diff options
Diffstat (limited to 'addons/web/static/src/js')
60 files changed, 18083 insertions, 18178 deletions
diff --git a/addons/web/static/src/js/action_manager.js b/addons/web/static/src/js/action_manager.js new file mode 100644 index 00000000000..4c49a7a5772 --- /dev/null +++ b/addons/web/static/src/js/action_manager.js @@ -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; + +}); diff --git a/addons/web/static/src/js/boot.js b/addons/web/static/src/js/boot.js index 5fad3d0a140..67834ebf6b2 100644 --- a/addons/web/static/src/js/boot.js +++ b/addons/web/static/src/js/boot.js @@ -6,50 +6,129 @@ * @name openerp * @namespace openerp */ + (function() { - // copy everything in the openerp namespace to openerp.web - openerp.web = _.clone(openerp); - - var inited = false; - - _.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; + "use strict"; + + var jobs = []; + var factories = Object.create(null); + var job_names = []; + var job_deps = []; + + 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"); + } } - 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]; + + 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++; } - fct(openerp, openerp[modules[i]]); + return deps[name]; } + + require.__require_calls = 0; + return require; } - openerp._modules = ['web'].concat(modules); - return openerp; } - }); -})(); + }; +})(); diff --git a/addons/web/static/src/js/chrome.js b/addons/web/static/src/js/chrome.js deleted file mode 100644 index 8eabafb5f2d..00000000000 --- a/addons/web/static/src/js/chrome.js +++ /dev/null @@ -1,1643 +0,0 @@ -/*--------------------------------------------------------- - * OpenERP Web chrome - *---------------------------------------------------------*/ -(function() { - -var instance = openerp; -openerp.web.chrome = {}; - -var QWeb = instance.web.qweb, - _t = instance.web._t, - _lt = instance.web._lt; - -instance.web.Notification = instance.web.Widget.extend({ - template: 'Notification', - init: function() { - this._super.apply(this, arguments); - instance.web.notification = this; - }, - 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); - } -}); - -var opened_modal = []; - -instance.web.action_notify = function(element, action) { - element.do_notify(action.params.title, action.params.text, action.params.sticky); -}; -instance.web.client_actions.add("action_notify", "instance.web.action_notify"); - -instance.web.action_warn = function(element, action) { - element.do_warn(action.params.title, action.params.text, action.params.sticky); -}; -instance.web.client_actions.add("action_warn", "instance.web.action_warn"); - -/** - 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. -*/ -instance.web.Dialog = instance.web.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) { - var self = this; - 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_modal.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(); - var self = this; - _.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_modal); - if (modal_list_index > -1){ - opened_modal.splice(modal_list_index,1)[0].remove(); - } - if (opened_modal.length > 0){ - //we still have other opened modal so we should focus it - opened_modal[opened_modal.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(); - } -}); - -instance.web.CrashManager = instance.web.Class.extend({ - init: function() { - this.active = true; - }, - - 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(){ - openerp.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 = instance.web.crash_manager_registry.get_object(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; - } - 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'), - }; - 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 instance.web.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 instance.web.Dialog(this, { - title: "Odoo " + _.str.capitalize(error.type), - buttons: buttons - }, QWeb.render('CrashManager.error', {session: instance.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. -*/ -instance.web.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() {}, -}; - -/** - The registry to handle exceptions. It associate a fully qualified python exception name with a class implementing - instance.web.ExceptionHandler. -*/ -instance.web.crash_manager_registry = new instance.web.Registry(); - -/** - * Handle redirection warnings, which behave more or less like a regular - * warning, with an additional redirection button. - */ -instance.web.RedirectWarningHandler = instance.web.Dialog.extend(instance.web.ExceptionHandler, { - init: function(parent, error) { - this._super(parent); - this.error = error; - }, - display: function() { - var self = this; - error = this.error; - error.data.message = error.data.arguments[0]; - - new instance.web.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(); - } -}); -instance.web.crash_manager_registry.add('openerp.exceptions.RedirectWarning', 'instance.web.RedirectWarningHandler'); - -instance.web.Loading = instance.web.Widget.extend({ - template: _t("Loading"), - init: function(parent) { - this._super(parent); - this.count = 0; - this.blocked_ui = false; - this.session.on("request", this, this.request_call); - this.session.on("response", this, this.response_call); - this.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; - instance.web.blockUI(); - }, 3000); - } - - this.count += increment; - if (this.count > 0) { - if (instance.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; - instance.web.unblockUI(); - } - this.$el.fadeOut(); - this.getParent().$el.removeClass('oe_wait'); - } - } -}); - -instance.web.DatabaseManager = instance.web.Widget.extend({ - init: function(parent) { - this._super(parent); - this.unblockUIFunction = instance.web.unblockUI; - $.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; - instance.webclient.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 () { - instance.web.blockUI(); - instance.web.unblockUI = function () {}; - }, - /** - * Reinstates $.unblockUI so third parties can play with blockUI, and - * unblocks the UI - */ - unblockUI: function () { - instance.web.unblockUI = this.unblockUIFunction; - instance.web.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 instance.web.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 self = this; - var fields = $(form).serializeArray(); - self.rpc("/web/database/create", {'fields': fields}).done(function(result) { - if (result) { - instance.web.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(); - instance.web.redirect('/web'); - } -}); -instance.web.client_actions.add("database_manager", "instance.web.DatabaseManager"); - -instance.web.login = function() { - instance.web.redirect('/web/login'); -}; -instance.web.client_actions.add("login", "instance.web.login"); - -instance.web.logout = function() { - instance.web.redirect('/web/session/logout'); -}; -instance.web.client_actions.add("logout", "instance.web.logout"); - - -/** - * Redirect to url by replacing window.location - * If wait is true, sleep 1s and wait for the server i.e. after a restart. - */ -instance.web.redirect = function(url, wait) { - // Dont display a dialog if some xmlhttprequest are in progress - if (instance.client && instance.client.crashmanager) { - instance.client.crashmanager.active = false; - } - - 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() { - instance.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 - */ -instance.web.Reload = function(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; - - instance.web.redirect(url, params.wait); -}; -instance.web.client_actions.add("reload", "instance.web.Reload"); - -/** - * Client action to refresh the session context (making sure - * HTTP requests will have the right one) then reload the - * whole interface. - */ -instance.web.ReloadContext = function(parent, action) { - // side-effect of get_session_info is to refresh the session context - instance.session.rpc("/web/session/get_session_info", {}).then(function() { - instance.web.Reload(parent, action); - }); -} -instance.web.client_actions.add("reload_context", "instance.web.ReloadContext"); - -/** - * Client action to go back in breadcrumb history. - * If can't go back in history stack, will go back to home. - */ -instance.web.HistoryBack = function(parent) { - parent.history_back().fail(function() { - instance.web.Home(parent); - }); -}; -instance.web.client_actions.add("history_back", "instance.web.HistoryBack"); - -/** - * Client action to go back home. - */ -instance.web.Home = function(parent, action) { - var url = '/' + (window.location.search || ''); - instance.web.redirect(url, action && action.params && action.params.wait); -}; -instance.web.client_actions.add("home", "instance.web.Home"); - -instance.web.ChangePassword = instance.web.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 { - instance.webclient.on_logout(); - } - }); - }); - }, - display_error: function (error) { - return new instance.web.Dialog(this, { - size: 'medium', - title: error.title, - buttons: [ - {text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }} - ] - }, $('<div>').html(error.error)).open(); - }, -}); -instance.web.client_actions.add("change_password", "instance.web.ChangePassword"); - -instance.web.SystrayItems = []; -instance.web.SystrayMenu = instance.web.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(instance.web.SystrayItems, function(widgetCls) { - var cur_systray_item = new widgetCls(self); - self.items.push(cur_systray_item.appendTo(self.$el)); - }); - }, -}); - -instance.web.Menu = instance.web.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); - } - }); - }, - 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); - instance.web.bus.on('resize', this, function() { - if ($(window).width() < 768 ) { - lazyreflow('all_outside'); - } else { - lazyreflow(); - } - }); - instance.web.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; - this.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); - }, -}); - -instance.web.UserMenu = instance.web.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 (!self.session.uid) - return; - var func = new instance.web.Model("res.users").get_func("read"); - return self.alive(func(self.session.uid, ["name", "company_id"])).then(function(res) { - var topbar_name = res.name; - if(instance.session.debug) - topbar_name = _.str.sprintf("%s (%s)", topbar_name, instance.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 (!instance.session.debug) { - topbar_name = _.str.sprintf("%s (%s)", topbar_name, instance.session.db); - } - var avatar_src = self.session.url('/web/binary/image', {model:'res.users', field: 'image_small', id: self.session.uid}); - $avatar.attr('src', avatar_src); - - openerp.web.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 = instance.session.uid; - self.getParent().action_manager.do_action(result); - }); - } - }, - on_menu_account: function() { - var self = this; - if (!this.getParent().has_uncommitted_changes()) { - var P = new instance.web.Model('ir.config_parameter'); - P.call('get_param', ['database.uuid']).then(function(dbuuid) { - var state = { - 'd': instance.session.db, - 'u': window.location.protocol + '//' + window.location.host, - }; - var params = { - response_type: 'token', - client_id: dbuuid || '', - state: JSON.stringify(state), - scope: 'userinfo', - }; - instance.web.redirect('https://accounts.odoo.com/oauth2/auth?'+$.param(params)); - }).fail(function(result, ev){ - ev.preventDefault(); - instance.web.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 instance.web.Dialog(this, { - size: 'medium', - dialogClass: 'oe_act_window', - title: _t("About"), - }, $help).open(); - }); - }, -}); - -instance.web.FullscreenWidget = instance.web.Widget.extend({ - /** - * Widgets extending the FullscreenWidget will be displayed fullscreen, - * and will have a fixed 1:1 zoom level on mobile devices. - */ - start: function(){ - if(!$('#oe-fullscreenwidget-viewport').length){ - $('head').append('<meta id="oe-fullscreenwidget-viewport" name="viewport" content="initial-scale=1.0; maximum-scale=1.0; user-scalable=0;">'); - } - instance.webclient.set_content_full_screen(true); - return this._super(); - }, - destroy: function(){ - instance.webclient.set_content_full_screen(false); - $('#oe-fullscreenwidget-viewport').remove(); - return this._super(); - }, - -}); - -instance.web.Client = instance.web.Widget.extend({ - init: function(parent, origin) { - instance.client = instance.webclient = this; - this.client_options = {}; - this._super(parent); - this.origin = origin; - }, - start: function() { - var self = this; - return instance.session.session_bind(this.origin).then(function() { - self.bind_events(); - return self.show_common(); - }); - }, - 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); - }); - instance.web.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'); - } - }); - }, - on_logo_click: function(ev){ - if (!this.has_uncommitted_changes()) { - return; - } else { - ev.preventDefault(); - } - }, - show_common: function() { - var self = this; - this.crashmanager = new instance.web.CrashManager(); - instance.session.on('error', this.crashmanager, this.crashmanager.rpc_error); - self.notification = new instance.web.Notification(this); - self.notification.appendTo(self.$el.find('.openerp')); - self.loading = new instance.web.Loading(self); - self.loading.appendTo(self.$('.openerp_webclient_container')); - self.action_manager = new instance.web.ActionManager(self); - self.action_manager.replace(self.$('.oe_application')); - }, - toggle_bars: function(value) { - this.$('tr:has(td.navbar),.oe_leftbar').toggle(value); - }, - has_uncommitted_changes: function() { - return false; - }, -}); - -instance.web.WebClient = instance.web.Client.extend({ - init: function(parent, client_options) { - this._super(parent); - if (client_options) { - _.extend(this.client_options, client_options); - } - this._current_state = null; - this.menu_dm = new instance.web.DropMisordered(); - this.action_mutex = new $.Mutex(); - this.set('title_part', {"zopenerp": "Odoo"}); - }, - start: function() { - var self = this; - this.on("change:title_part", this, this._title_changed); - this._title_changed(); - - return $.when(this._super()).then(function() { - if (jQuery.deparam !== undefined && jQuery.deparam(jQuery.param.querystring()).kitten !== undefined) { - self.to_kitten(); - } - if (self.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); - } - }); - }, - to_kitten: function() { - this.kitten = true; - $("body").addClass("kitten-mode-activated"); - $("body").css("background-image", "url(" + instance.session.origin + "/web/static/src/img/back-enable.jpg" + ")"); - if ($.blockUI) { - var imgkit = Math.floor(Math.random() * 2 + 1); - $.blockUI.defaults.message = '<img src="/web/static/src/img/k-waiting' + imgkit + '.gif" class="loading-kitten">'; - } - }, - /** - 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_common: function() { - var self = this; - this._super(); - window.onerror = function (message, file, line) { - self.crashmanager.show_error({ - type: _t("Client Error"), - message: message, - data: {debug: file + ':' + line} - }); - }; - }, - show_application: function() { - var self = this; - self.toggle_bars(true); - - self.update_logo(); - this.$('.oe_logo_edit_admin').click(function(ev) { - self.logo_edit(ev); - }); - this.$('.oe_logo img').click(function(ev) { - self.on_logo_click(ev); - }); - - // Menu is rendered server-side thus we don't want the widget to create any dom - self.menu = new instance.web.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 instance.web.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 instance.web.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 = this.session.company_id; - var img = this.session.url('/web/binary/company_logo' + '?db=' + this.session.db + (company ? '&company=' + company : '')); - this.$('.oe_logo img').attr('src', '').attr('src', img); - this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', this.session.uid === 1); - }, - logo_edit: function(ev) { - var self = this; - ev.preventDefault(); - self.alive(new instance.web.Model("res.users").get_func("read")(this.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 instance.web.Model('res.users').call('read', [[this.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: instance.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 () { - instance.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 instance.web.Model("res.users").call("read", [self.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 instance.web.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); - }, - has_uncommitted_changes: function() { - var $e = $.Event('clear_uncommitted_changes'); - instance.web.bus.trigger('clear_uncommitted_changes', $e); - if ($e.isDefaultPrevented()) { - return true; - } else { - return this._super.apply(this, arguments); - } - }, -}); - -instance.web.EmbeddedClient = instance.web.Client.extend({ - _template: 'EmbedClient', - init: function(parent, origin, dbname, login, key, action_id, options) { - this._super(parent, origin); - this.bind_credentials(dbname, login, key); - this.action_id = action_id; - this.options = options || {}; - }, - start: function() { - var self = this; - return $.when(this._super()).then(function() { - return self.authenticate().then(function() { - if (!self.action_id) { - return; - } - return self.rpc("/web/action/load", { action_id: self.action_id }).done(function(result) { - var action = result; - action.flags = _.extend({ - //views_switcher : false, - search_view : false, - action_buttons : false, - sidebar : false - //pager : false - }, self.options, action.flags || {}); - - self.do_action(action); - }); - }); - }); - }, - - do_action: function(/*...*/) { - var am = this.action_manager; - return am.do_action.apply(am, arguments); - }, - - authenticate: function() { - var s = instance.session; - if (s.session_is_valid() && s.db === this.dbname && s.login === this.login) { - return $.when(); - } - return instance.session.session_authenticate(this.dbname, this.login, this.key); - }, - - bind_credentials: function(dbname, login, key) { - this.dbname = dbname; - this.login = login; - this.key = key; - }, - -}); - -instance.web.embed = function (origin, dbname, login, key, action, options) { - $('head').append($('<link>', { - 'rel': 'stylesheet', - 'type': 'text/css', - 'href': origin +'/web/css/web.assets_webclient' - })); - var currentScript = document.currentScript; - if (!currentScript) { - var sc = document.getElementsByTagName('script'); - currentScript = sc[sc.length-1]; - } - var client = new instance.web.EmbeddedClient(null, origin, dbname, login, key, action, options); - client.insertAfter(currentScript); -}; - -})(); diff --git a/addons/web/static/src/js/core.js b/addons/web/static/src/js/core.js deleted file mode 100644 index ef7523ee440..00000000000 --- a/addons/web/static/src/js/core.js +++ /dev/null @@ -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(); - -})(); diff --git a/addons/web/static/src/js/formats.js b/addons/web/static/src/js/formats.js deleted file mode 100644 index 9fd9ff86b02..00000000000 --- a/addons/web/static/src/js/formats.js +++ /dev/null @@ -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; -}; - -})(); diff --git a/addons/web/static/src/js/framework/ajax.js b/addons/web/static/src/js/framework/ajax.js new file mode 100644 index 00000000000..00da4610f9c --- /dev/null +++ b/addons/web/static/src/js/framework/ajax.js @@ -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, +}; + +}); diff --git a/addons/web/static/src/js/framework/class.js b/addons/web/static/src/js/framework/class.js new file mode 100644 index 00000000000..4de1afc66d9 --- /dev/null +++ b/addons/web/static/src/js/framework/class.js @@ -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; +}); diff --git a/addons/web/static/src/js/framework/core.js b/addons/web/static/src/js/framework/core.js new file mode 100644 index 00000000000..5bfe975c7fd --- /dev/null +++ b/addons/web/static/src/js/framework/core.js @@ -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(), +}; + + +}); diff --git a/addons/web/static/src/js/framework/crash_manager.js b/addons/web/static/src/js/framework/crash_manager.js new file mode 100644 index 00000000000..416ef4b4136 --- /dev/null +++ b/addons/web/static/src/js/framework/crash_manager.js @@ -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; +}); diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/framework/data.js index 82edbc2f35b..f8f091be80d 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/framework/data.js @@ -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; - }, - - /** - * 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(); - }, - - /** - * 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); - - 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); + 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]; - request.onsuccess = function(){ - def.resolve(); - }; + 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); +} - request.onerror = function(event){ - def.reject(event.target.error); - }; - },function(){ - def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'}); - }); - return def.promise(); - }, -}); +return { + Query: Query, + DataSet: DataSet, + DataSetStatic: DataSetStatic, + DataSetSearch: DataSetSearch, + BufferedDataSet: BufferedDataSet, + ProxyDataSet: ProxyDataSet, + CompoundContext: CompoundContext, + CompoundDomain: CompoundDomain, + compute_domain: compute_domain, +}; -})(); +});
\ No newline at end of file diff --git a/addons/web/static/src/js/framework/dialog.js b/addons/web/static/src/js/framework/dialog.js new file mode 100644 index 00000000000..9464c452124 --- /dev/null +++ b/addons/web/static/src/js/framework/dialog.js @@ -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; + +}); diff --git a/addons/web/static/src/js/framework/formats.js b/addons/web/static/src/js/framework/formats.js new file mode 100644 index 00000000000..4126447c76a --- /dev/null +++ b/addons/web/static/src/js/framework/formats.js @@ -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, +}; + +}); diff --git a/addons/web/static/src/js/framework/misc.js b/addons/web/static/src/js/framework/misc.js new file mode 100644 index 00000000000..a3aaadbc46c --- /dev/null +++ b/addons/web/static/src/js/framework/misc.js @@ -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}); +}); + diff --git a/addons/web/static/src/js/framework/mixins.js b/addons/web/static/src/js/framework/mixins.js new file mode 100644 index 00000000000..102b75a9e4b --- /dev/null +++ b/addons/web/static/src/js/framework/mixins.js @@ -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, +}; + +}); + diff --git a/addons/web/static/src/js/framework/model.js b/addons/web/static/src/js/framework/model.js new file mode 100644 index 00000000000..f1a1555fd6f --- /dev/null +++ b/addons/web/static/src/js/framework/model.js @@ -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 || [] + }); + }, + +}); + +}); diff --git a/addons/web/static/src/js/framework/pyeval.js b/addons/web/static/src/js/framework/pyeval.js new file mode 100644 index 00000000000..18d43db318b --- /dev/null +++ b/addons/web/static/src/js/framework/pyeval.js @@ -0,0 +1,1018 @@ +odoo.define('web.pyeval', ['web.core', 'web.session', 'web.utils'], function (require) { +"use strict"; + +var core = require('web.core'); +var session = require('web.session'); +var utils = require('web.utils'); + +var _t = core._t; +var py = window.py; // to silence linters +/* + * py.js helpers and setup + */ + +var obj = function () {}; +obj.prototype = py.object; +var asJS = function (arg) { + if (arg instanceof obj) { + return arg.toJSON(); + } + return arg; +}; + +var datetime = py.PY_call(py.object); + +var zero = py.float.fromJSON(0); + +// Port from pypy/lib_pypy/datetime.py +var DAYS_IN_MONTH = [null, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +var DAYS_BEFORE_MONTH = [null]; +var dbm = 0; + +for (var i=1; i<DAYS_IN_MONTH.length; ++i) { + DAYS_BEFORE_MONTH.push(dbm); + dbm += DAYS_IN_MONTH[i]; +} + +function is_leap (year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function days_before_year (year) { + var y = year - 1; + return y*365 + Math.floor(y/4) - Math.floor(y/100) + Math.floor(y/400); +} + +function days_in_month (year, month) { + if (month === 2 && is_leap(year)) { + return 29; + } + return DAYS_IN_MONTH[month]; +} + +function days_before_month (year, month) { + var post_leap_feb = month > 2 && is_leap(year); + return DAYS_BEFORE_MONTH[month] + (post_leap_feb ? 1 : 0); +} + +function ymd2ord (year, month, day) { + var dim = days_in_month(year, month); + if (!(1 <= day && day <= dim)) { + throw new Error("ValueError: day must be in 1.." + dim); + } + return days_before_year(year) + + days_before_month(year, month) + + day; +} + +var DI400Y = days_before_year(401); +var DI100Y = days_before_year(101); +var DI4Y = days_before_year(5); + +function ord2ymd (n) { + --n; + var n400, n100, n4, n1, n0; + utils.divmod(n, DI400Y, function (_n400, n) { + n400 = _n400; + utils.divmod(n, DI100Y, function (_n100, n) { + n100 = _n100; + utils.divmod(n, DI4Y, function (_n4, n) { + n4 = _n4; + utils.divmod(n, 365, function (_n1, n) { + n1 = _n1; + n0 = n; + }); + }); + }); + }); + + n = n0; + var year = n400 * 400 + 1 + n100 * 100 + n4 * 4 + n1; + if (n1 == 4 || n100 == 100) { + utils.assert(n0 === 0); + return { + year: year - 1, + month: 12, + day: 31 + }; + } + + var leapyear = n1 === 3 && (n4 !== 24 || n100 == 3); + utils.assert(leapyear == is_leap(year)); + var month = (n + 50) >> 5; + var preceding = DAYS_BEFORE_MONTH[month] + ((month > 2 && leapyear) ? 1 : 0); + if (preceding > n) { + --month; + preceding -= DAYS_IN_MONTH[month] + ((month === 2 && leapyear) ? 1 : 0); + } + n -= preceding; + return { + year: year, + month: month, + day: n+1 + }; +} + +/** + * Converts the stuff passed in into a valid date, applying overflows as needed + */ +function tmxxx(year, month, day, hour, minute, second, microsecond) { + hour = hour || 0; minute = minute || 0; second = second || 0; + microsecond = microsecond || 0; + + if (microsecond < 0 || microsecond > 999999) { + utils.divmod(microsecond, 1000000, function (carry, ms) { + microsecond = ms; + second += carry; + }); + } + if (second < 0 || second > 59) { + utils.divmod(second, 60, function (carry, s) { + second = s; + minute += carry; + }); + } + if (minute < 0 || minute > 59) { + utils.divmod(minute, 60, function (carry, m) { + minute = m; + hour += carry; + }); + } + if (hour < 0 || hour > 23) { + utils.divmod(hour, 24, function (carry, h) { + hour = h; + day += carry; + }); + } + // That was easy. Now it gets muddy: the proper range for day + // can't be determined without knowing the correct month and year, + // but if day is, e.g., plus or minus a million, the current month + // and year values make no sense (and may also be out of bounds + // themselves). + // Saying 12 months == 1 year should be non-controversial. + if (month < 1 || month > 12) { + utils.divmod(month-1, 12, function (carry, m) { + month = m + 1; + year += carry; + }); + } + // Now only day can be out of bounds (year may also be out of bounds + // for a datetime object, but we don't care about that here). + // If day is out of bounds, what to do is arguable, but at least the + // method here is principled and explainable. + var dim = days_in_month(year, month); + if (day < 1 || day > dim) { + // Move day-1 days from the first of the month. First try to + // get off cheap if we're only one day out of range (adjustments + // for timezone alone can't be worse than that). + if (day === 0) { + --month; + if (month > 0) { + day = days_in_month(year, month); + } else { + --year; month=12; day=31; + } + } else if (day == dim + 1) { + ++month; + day = 1; + if (month > 12) { + month = 1; + ++year; + } + } else { + var r = ord2ymd(ymd2ord(year, month, 1) + (day - 1)); + year = r.year; + month = r.month; + day = r.day; + } + } + return { + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + }; +} + +datetime.timedelta = py.type('timedelta', null, { + __init__: function () { + var args = py.PY_parseArgs(arguments, [ + ['days', zero], ['seconds', zero], ['microseconds', zero], + ['milliseconds', zero], ['minutes', zero], ['hours', zero], + ['weeks', zero] + ]); + + var d = 0, s = 0, m = 0; + var days = args.days.toJSON() + args.weeks.toJSON() * 7; + var seconds = args.seconds.toJSON() + + args.minutes.toJSON() * 60 + + args.hours.toJSON() * 3600; + var microseconds = args.microseconds.toJSON() + + args.milliseconds.toJSON() * 1000; + + // Get rid of all fractions, and normalize s and us. + // Take a deep breath <wink>. + var daysecondsfrac = utils.modf(days, function (dayfrac, days) { + d = days; + if (dayfrac) { + return utils.modf(dayfrac * 24 * 3600, function (dsf, dsw) { + s = dsw; + return dsf; + }); + } + return 0; + }); + + var secondsfrac = utils.modf(seconds, function (sf, s) { + seconds = s; + return sf + daysecondsfrac; + }); + utils.divmod(seconds, 24*3600, function (days, seconds) { + d += days; + s += seconds; + }); + // seconds isn't referenced again before redefinition + + microseconds += secondsfrac * 1e6; + utils.divmod(microseconds, 1000000, function (seconds, microseconds) { + utils.divmod(seconds, 24*3600, function (days, seconds) { + d += days; + s += seconds; + m += Math.round(microseconds); + }); + }); + + // Carrying still possible here? + + this.days = d; + this.seconds = s; + this.microseconds = m; + }, + __str__: function () { + var hh, mm, ss; + utils.divmod(this.seconds, 60, function (m, s) { + utils.divmod(m, 60, function (h, m) { + hh = h; + mm = m; + ss = s; + }); + }); + var s = _.str.sprintf("%d:%02d:%02d", hh, mm, ss); + if (this.days) { + s = _.str.sprintf("%d day%s, %s", + this.days, + (this.days != 1 && this.days != -1) ? 's' : '', + s); + } + if (this.microseconds) { + s = _.str.sprintf("%s.%06d", s, this.microseconds); + } + return py.str.fromJSON(s); + }, + __eq__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.False; + } + + return (this.days === other.days + && this.seconds === other.seconds + && this.microseconds === other.microseconds) + ? py.True : py.False; + }, + __add__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(this.days + other.days), + py.float.fromJSON(this.seconds + other.seconds), + py.float.fromJSON(this.microseconds + other.microseconds) + ]); + }, + __radd__: function (other) { return this.__add__(other); }, + __sub__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(this.days - other.days), + py.float.fromJSON(this.seconds - other.seconds), + py.float.fromJSON(this.microseconds - other.microseconds) + ]); + }, + __rsub__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + return this.__neg__().__add__(other); + }, + __neg__: function () { + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(-this.days), + py.float.fromJSON(-this.seconds), + py.float.fromJSON(-this.microseconds) + ]); + }, + __pos__: function () { return this; }, + __mul__: function (other) { + if (!py.PY_isInstance(other, py.float)) { + return py.NotImplemented; + } + var n = other.toJSON(); + return py.PY_call(datetime.timedelta, [ + py.float.fromJSON(this.days * n), + py.float.fromJSON(this.seconds * n), + py.float.fromJSON(this.microseconds * n) + ]); + }, + __rmul__: function (other) { return this.__mul__(other); }, + __div__: function (other) { + if (!py.PY_isInstance(other, py.float)) { + return py.NotImplemented; + } + var usec = ((this.days * 24 * 3600) + this.seconds) * 1000000 + + this.microseconds; + return py.PY_call( + datetime.timedelta, [ + zero, zero, py.float.fromJSON(usec / other.toJSON())]); + }, + __floordiv__: function (other) { return this.__div__(other); }, + total_seconds: function () { + return py.float.fromJSON( + this.days * 86400 + + this.seconds + + this.microseconds / 1000000); + }, + __nonzero__: function () { + return (!!this.days || !!this.seconds || !!this.microseconds) + ? py.True + : py.False; + } +}); + +datetime.datetime = py.type('datetime', null, { + __init__: function () { + var zero = py.float.fromJSON(0); + var args = py.PY_parseArgs(arguments, [ + 'year', 'month', 'day', + ['hour', zero], ['minute', zero], ['second', zero], + ['microsecond', zero], ['tzinfo', py.None] + ]); + for(var key in args) { + if (!args.hasOwnProperty(key)) { continue; } + this[key] = asJS(args[key]); + } + }, + replace: function () { + var args = py.PY_parseArgs(arguments, [ + ['year', py.None], ['month', py.None], ['day', py.None], + ['hour', py.None], ['minute', py.None], ['second', py.None], + ['microsecond', py.None] // FIXME: tzinfo, can't use None as valid input + ]); + var params = {}; + for(var key in args) { + if (!args.hasOwnProperty(key)) { continue; } + + var arg = args[key]; + params[key] = (arg === py.None ? this[key] : asJS(arg)); + } + return py.PY_call(datetime.datetime, params); + }, + strftime: function () { + var self = this; + var args = py.PY_parseArgs(arguments, 'format'); + return py.str.fromJSON(args.format.toJSON() + .replace(/%([A-Za-z])/g, function (m, c) { + switch (c) { + case 'Y': return _.str.sprintf('%04d', self.year); + case 'm': return _.str.sprintf('%02d', self.month); + case 'd': return _.str.sprintf('%02d', self.day); + case 'H': return _.str.sprintf('%02d', self.hour); + case 'M': return _.str.sprintf('%02d', self.minute); + case 'S': return _.str.sprintf('%02d', self.second); + } + throw new Error('ValueError: No known conversion for ' + m); + })); + }, + now: py.classmethod.fromJSON(function () { + var d = new Date(); + return py.PY_call(datetime.datetime, [ + d.getFullYear(), d.getMonth() + 1, d.getDate(), + d.getHours(), d.getMinutes(), d.getSeconds(), + d.getMilliseconds() * 1000]); + }), + today: py.classmethod.fromJSON(function () { + var dt_class = py.PY_getAttr(datetime, 'datetime'); + return py.PY_call(py.PY_getAttr(dt_class, 'now')); + }), + utcnow: py.classmethod.fromJSON(function () { + var d = new Date(); + return py.PY_call(datetime.datetime, + [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), + d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), + d.getUTCMilliseconds() * 1000]); + }), + combine: py.classmethod.fromJSON(function () { + var args = py.PY_parseArgs(arguments, 'date time'); + return py.PY_call(datetime.datetime, [ + py.PY_getAttr(args.date, 'year'), + py.PY_getAttr(args.date, 'month'), + py.PY_getAttr(args.date, 'day'), + py.PY_getAttr(args.time, 'hour'), + py.PY_getAttr(args.time, 'minute'), + py.PY_getAttr(args.time, 'second') + ]); + }), + toJSON: function () { + return new Date( + this.year, + this.month - 1, + this.day, + this.hour, + this.minute, + this.second, + this.microsecond / 1000); + }, +}); + +datetime.date = py.type('date', null, { + __init__: function () { + var args = py.PY_parseArgs(arguments, 'year month day'); + this.year = asJS(args.year); + this.month = asJS(args.month); + this.day = asJS(args.day); + }, + strftime: function () { + var self = this; + var args = py.PY_parseArgs(arguments, 'format'); + return py.str.fromJSON(args.format.toJSON() + .replace(/%([A-Za-z])/g, function (m, c) { + switch (c) { + case 'Y': return self.year; + case 'm': return _.str.sprintf('%02d', self.month); + case 'd': return _.str.sprintf('%02d', self.day); + } + throw new Error('ValueError: No known conversion for ' + m); + })); + }, + __eq__: function (other) { + return (this.year === other.year + && this.month === other.month + && this.day === other.day) + ? py.True : py.False; + }, + replace: function () { + var args = py.PY_parseArgs(arguments, [ + ['year', py.None], ['month', py.None], ['day', py.None] + ]); + var params = {}; + for(var key in args) { + if (!args.hasOwnProperty(key)) { continue; } + + var arg = args[key]; + params[key] = (arg === py.None ? this[key] : asJS(arg)); + } + return py.PY_call(datetime.date, params); + }, + __add__: function (other) { + if (!py.PY_isInstance(other, datetime.timedelta)) { + return py.NotImplemented; + } + var s = tmxxx(this.year, this.month, this.day + other.days); + return datetime.date.fromJSON(s.year, s.month, s.day); + }, + __radd__: function (other) { return this.__add__(other); }, + __sub__: function (other) { + if (py.PY_isInstance(other, datetime.timedelta)) { + return py.PY_add(this, py.PY_negative(other)); + } + if (py.PY_isInstance(other, datetime.date)) { + // FIXME: getattr and sub API methods + return py.PY_call(datetime.timedelta, [ + py.PY_subtract( + py.PY_call(py.PY_getAttr(this, 'toordinal')), + py.PY_call(py.PY_getAttr(other, 'toordinal'))) + ]); + } + return py.NotImplemented; + }, + toordinal: function () { + return py.float.fromJSON(ymd2ord(this.year, this.month, this.day)); + }, + weekday: function () { + return py.float.fromJSON((this.toordinal().toJSON()+6)%7); + }, + fromJSON: function (year, month, day) { + return py.PY_call(datetime.date, [year, month, day]); + }, + today: py.classmethod.fromJSON(function () { + var d = new Date (); + return py.PY_call(datetime.date, [ + d.getFullYear(), d.getMonth() + 1, d.getDate()]); + }), +}); +/** + Returns the current local date, which means the date on the client (which can be different + compared to the date of the server). + + @return {datetime.date} +*/ +function context_today () { + var d = new Date(); + return py.PY_call( + datetime.date, [d.getFullYear(), d.getMonth() + 1, d.getDate()]); +} + +datetime.time = py.type('time', null, { + __init__: function () { + var zero = py.float.fromJSON(0); + var args = py.PY_parseArgs(arguments, [ + ['hour', zero], ['minute', zero], ['second', zero], ['microsecond', zero], + ['tzinfo', py.None] + ]); + + for(var k in args) { + if (!args.hasOwnProperty(k)) { continue; } + this[k] = asJS(args[k]); + } + } +}); + +var time = py.PY_call(py.object); +time.strftime = py.PY_def.fromJSON(function () { + var args = py.PY_parseArgs(arguments, 'format'); + var dt_class = py.PY_getAttr(datetime, 'datetime'); + var d = py.PY_call(py.PY_getAttr(dt_class, 'utcnow')); + return py.PY_call(py.PY_getAttr(d, 'strftime'), [args.format]); +}); + +var args = _.map(('year month day ' + + 'years months weeks days ' + + 'weekday leapdays yearday nlyearday').split(' '), function (arg) { + switch (arg) { + case 'years':case 'months':case 'days':case 'leapdays':case 'weeks': + return [arg, zero]; + case 'year':case 'month':case 'day':case 'weekday': + case 'yearday':case 'nlyearday': + return [arg, null]; + default: + throw new Error("Unknown relativedelta argument " + arg); + } +}); +args.unshift('*'); + +var _utils = { + monthrange: function (year, month) { + if (month < 1 || month > 12) { + throw new Error("Illegal month " + month); + } + + var day1 = this.weekday(year, month, 1); + var ndays = this.mdays[month] + (month == this.February && this.isleap(year)); + return [day1, ndays]; + }, + weekday: function (year, month, day) { + var date = py.PY_call(datetime.date, [year, month, day]); + return py.PY_call(py.PY_getAttr(date, 'weekday')); + }, + isleap: function (year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) + }, + mdays: [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], + January: 1, + February: 2 +}; + +var relativedelta = py.type('relativedelta', null, { + __init__: function () { + this.ops = py.PY_parseArgs(arguments, args); + this.ops.days = py.float.fromJSON( + asJS(this.ops.days) + asJS(this.ops.weeks) * 7 + ); + + var yday = zero; + if (this.ops.nlyearday) { + yday = this.ops.nlyearday; + } else if (this.ops.yearday) { + yday = this.ops.yearday; + if (asJS(this.ops.yearday) > 59) { + this.ops.leapdays = py.float.fromJS(-1); + } + } + if (py.PY_isTrue(yday)) { + var ydayidx = [31, 59, 90, 120, 151, 181, 212, + 243, 273, 304, 334, 366]; + for(var idx=0; idx<ydayidx.length; ++idx) { + var ydays = ydayidx[idx]; + if (asJS(yday) <= ydays) { + this.ops.month = py.float.fromJSON(idx+1); + if (!idx) { + this.ops.day = yday; + } else { + this.ops.day = py.PY_subtract( + yday, + py.float.fromJSON(ydayidx[idx-1]) + ); + } + break; + } + } + if (idx === ydayidx.length) { + throw new Error("Invalid year day (" + asJS(yday) + ")"); + } + } + this._fix(); + }, + _fix: function () { + var months = asJS(this.ops.months); + if (Math.abs(months) > 11) { + var s = months > 0 ? 1 : -1; + var r = utils.divmod(months * s, 12); + this.ops.months = py.float.fromJSON(r.mod*s); + this.ops.years = py.float.fromJSON( + asJS(this.ops.years) + r.div*s); + } + this._has_time = 0; + }, + __add__: function (other) { + if (!py.PY_isInstance(other, datetime.date)) { + return py.NotImplemented; + } + // TODO: test this whole mess + var year = (asJS(this.ops.year) || asJS(other.year)) + asJS(this.ops.years); + var month = asJS(this.ops.month) || asJS(other.month); + var months; + if (months = asJS(this.ops.months)) { + if (Math.abs(months) < 1 || Math.abs(months) > 12) { + throw new Error("Can only use relative months between -12 and +12"); + } + month += months; + if (month > 12) { + year += 1; + month -= 12; + } + if (month < 1) { + year -= 1; + month += 12; + } + } + + var day = Math.min(_utils.monthrange(year, month)[1], + asJS(this.ops.day) || asJS(other.day)); + + var repl = { + year: py.float.fromJSON(year), + month: py.float.fromJSON(month), + day: py.float.fromJSON(day) + }; + + var days = asJS(this.ops.days); + if (py.PY_isTrue(this.ops.leapdays) && month > 2 && _utils.isleap(year)) { + days += asJS(this.ops.leapdays); + } + + var ret = py.PY_add( + py.PY_call(py.PY_getAttr(other, 'replace'), repl), + py.PY_call(datetime.timedelta, { + days: py.float.fromJSON(days) + }) + ); + + if (this.ops.weekday) { + // FIXME: only handles numeric weekdays, not decorated + var weekday = asJS(this.ops.weekday), nth = 1; + var jumpdays = (Math.abs(nth) - 1) * 7; + + var ret_weekday = asJS(py.PY_call(py.PY_getAttr(ret, 'weekday'))); + if (nth > 0) { + jumpdays += (7-ret_weekday+weekday) % 7; + } else { + jumpdays += (ret_weekday - weekday) % 7; + jumpdays *= -1; + } + ret = py.PY_add( + ret, + py.PY_call(datetime.timedelta, { + days: py.float.fromJSON(jumpdays) + }) + ); + } + + return ret; + }, + __radd__: function (other) { + return this.__add__(other); + }, + __rsub__: function (other) { + return this.__neg__().__radd__(other); + }, + __neg__: function () { + return py.PY_call(relativedelta, { + years: py.PY_negative(this.ops.years), + months: py.PY_negative(this.ops.months), + days: py.PY_negative(this.ops.days), + leapdays: this.ops.leapdays, + year: this.ops.year, + month: this.ops.month, + day: this.ops.day, + weekday: this.ops.weekday + }); + } +}); + +// recursively wraps JS objects passed into the context to attributedicts +// which jsonify back to JS objects +function wrap(value) { + if (value === null) { return py.None; } + + switch (typeof value) { + case 'undefined': throw new Error("No conversion for undefined"); + case 'boolean': return py.bool.fromJSON(value); + case 'number': return py.float.fromJSON(value); + case 'string': return py.str.fromJSON(value); + } + + switch(value.constructor) { + case Object: return wrapping_dict.fromJSON(value); + case Array: return wrapping_list.fromJSON(value); + } + + throw new Error("ValueError: unable to wrap " + value); +} + +var wrapping_dict = py.type('wrapping_dict', null, { + __init__: function () { + this._store = {}; + }, + __getitem__: function (key) { + var k = key.toJSON(); + if (!(k in this._store)) { + throw new Error("KeyError: '" + k + "'"); + } + return wrap(this._store[k]); + }, + __getattr__: function (key) { + return this.__getitem__(py.str.fromJSON(key)); + }, + get: function () { + var args = py.PY_parseArgs(arguments, ['k', ['d', py.None]]); + + if (!(args.k.toJSON() in this._store)) { return args.d; } + return this.__getitem__(args.k); + }, + fromJSON: function (d) { + var instance = py.PY_call(wrapping_dict); + instance._store = d; + return instance; + }, + toJSON: function () { + return this._store; + }, +}); + +var wrapping_list = py.type('wrapping_list', null, { + __init__: function () { + this._store = []; + }, + __getitem__: function (index) { + return wrap(this._store[index.toJSON()]); + }, + fromJSON: function (ar) { + var instance = py.PY_call(wrapping_list); + instance._store = ar; + return instance; + }, + toJSON: function () { + return this._store; + }, +}); + +function wrap_context (context) { + for (var k in context) { + if (!context.hasOwnProperty(k)) { continue; } + var val = context[k]; + + if (val === null) { continue; } + if (val.constructor === Array) { + context[k] = wrapping_list.fromJSON(val); + } else if (val.constructor === Object + && !py.PY_isInstance(val, py.object)) { + context[k] = wrapping_dict.fromJSON(val); + } + } + return context; +} + +function eval_contexts (contexts, evaluation_context) { + evaluation_context = _.extend(pycontext(), evaluation_context || {}); + return _(contexts).reduce(function (result_context, ctx) { + // __eval_context evaluations can lead to some of `contexts`'s + // values being null, skip them as well as empty contexts + if (_.isEmpty(ctx)) { return result_context; } + if (_.isString(ctx)) { + // wrap raw strings in context + ctx = { __ref: 'context', __debug: ctx }; + } + var evaluated = ctx; + switch(ctx.__ref) { + case 'context': + evaluation_context.context = evaluation_context; + evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context)); + break; + case 'compound_context': + var eval_context = eval_contexts([ctx.__eval_context]); + evaluated = eval_contexts( + ctx.__contexts, _.extend({}, evaluation_context, eval_context)); + break; + } + // add newly evaluated context to evaluation context for following + // siblings + _.extend(evaluation_context, evaluated); + return _.extend(result_context, evaluated); + }, {}); +} + +function eval_domains (domains, evaluation_context) { + evaluation_context = _.extend(pycontext(), evaluation_context || {}); + var result_domain = []; + _(domains).each(function (domain) { + if (_.isString(domain)) { + // wrap raw strings in domain + domain = { __ref: 'domain', __debug: domain }; + } + switch(domain.__ref) { + case 'domain': + evaluation_context.context = evaluation_context; + result_domain.push.apply( + result_domain, py.eval(domain.__debug, wrap_context(evaluation_context))); + break; + case 'compound_domain': + var eval_context = eval_contexts([domain.__eval_context]); + result_domain.push.apply( + result_domain, eval_domains( + domain.__domains, _.extend( + {}, evaluation_context, eval_context))); + break; + default: + result_domain.push.apply(result_domain, domain); + } + }); + return result_domain; +} + +function eval_groupbys (contexts, evaluation_context) { + evaluation_context = _.extend(pycontext(), evaluation_context || {}); + var result_group = []; + _(contexts).each(function (ctx) { + if (_.isString(ctx)) { + // wrap raw strings in context + ctx = { __ref: 'context', __debug: ctx }; + } + var group; + var evaluated = ctx; + switch(ctx.__ref) { + case 'context': + evaluation_context.context = evaluation_context; + evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context)); + break; + case 'compound_context': + var eval_context = eval_contexts([ctx.__eval_context]); + evaluated = eval_contexts( + ctx.__contexts, _.extend({}, evaluation_context, eval_context)); + break; + } + group = evaluated.group_by; + if (!group) { return; } + if (typeof group === 'string') { + result_group.push(group); + } else if (group instanceof Array) { + result_group.push.apply(result_group, group); + } else { + throw new Error('Got invalid groupby {{' + + JSON.stringify(group) + '}}'); + } + _.extend(evaluation_context, evaluated); + }); + return result_group; +} + + +function pycontext () { + return _.extend({ + datetime: datetime, + context_today: context_today, + time: time, + relativedelta: relativedelta, + current_date: py.PY_call( + time.strftime, [py.str.fromJSON('%Y-%m-%d')]), + }, session.user_context); +} + +/** + * @param {String} type "domains", "contexts" or "groupbys" + * @param {Array} object domains or contexts to evaluate + * @param {Object} [context] evaluation context + */ +function pyeval (type, object, context, options) { + options = options || {}; + context = _.extend(pycontext(), context || {}); + + //noinspection FallthroughInSwitchStatementJS + switch(type) { + case 'context': + case 'contexts': + if (type === 'context') + object = [object]; + return eval_contexts((options.no_user_context ? [] : [session.user_context]).concat(object), context); + case 'domain': + case 'domains': + if (type === 'domain') + object = [object]; + return eval_domains(object, context); + case 'groupbys': + return eval_groupbys(object, context); + } + throw new Error("Unknow evaluation type " + type); +} + +function eval_arg (arg) { + if (typeof arg !== 'object' || !arg.__ref) { return arg; } + switch(arg.__ref) { + case 'domain': case 'compound_domain': + return pyeval('domains', [arg]); + case 'context': case 'compound_context': + return pyeval('contexts', [arg]); + default: + throw new Error(_t("Unknown nonliteral type " + arg.__ref)); + } +} + +/** + * If args or kwargs are unevaluated contexts or domains (compound or not), + * evaluated them in-place. + * + * Potentially mutates both parameters. + * + * @param args + * @param kwargs + */ +function ensure_evaluated (args, kwargs) { + for (var i=0; i<args.length; ++i) { + args[i] = eval_arg(args[i]); + } + for (var k in kwargs) { + if (!kwargs.hasOwnProperty(k)) { continue; } + kwargs[k] = eval_arg(kwargs[k]); + } +} + +function eval_domains_and_contexts (source) { + return new $.Deferred(function (d) {setTimeout(function () { + var result; + try { + result = sync_eval_domains_and_contexts(source); + } + catch (e) { + result = { error: { + code: 400, + message: _t("Evaluation Error"), + data: { + type: 'local_exception', + debug: _.str.sprintf( + _t("Local evaluation failure\n%s\n\n%s"), + e.message, JSON.stringify(source)) + } + }}; + } + d.resolve(result); + }, 0); }); +} + +function sync_eval_domains_and_contexts (source) { + var contexts = ([session.user_context] || []).concat(source.contexts); + // see Session.eval_context in Python + return { + context: pyeval('contexts', contexts), + domain: pyeval('domains', source.domains), + group_by: pyeval('groupbys', source.group_by_seq || []) + }; +} + +function py_eval (expr, context) { + return py.eval(expr, _.extend({}, context || {}, {"true": true, "false": false, "null": null})); +} + + +return { + context: pycontext, + ensure_evaluated: ensure_evaluated, + eval: pyeval, + eval_domains_and_contexts: eval_domains_and_contexts, + sync_eval_domains_and_contexts: sync_eval_domains_and_contexts, + py_eval: py_eval, +}; + +}); diff --git a/addons/web/static/src/js/framework/registry.js b/addons/web/static/src/js/framework/registry.js new file mode 100644 index 00000000000..c90614cf94a --- /dev/null +++ b/addons/web/static/src/js/framework/registry.js @@ -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; + +}); + diff --git a/addons/web/static/src/js/framework/session.js b/addons/web/static/src/js/framework/session.js new file mode 100644 index 00000000000..f81fec5ab54 --- /dev/null +++ b/addons/web/static/src/js/framework/session.js @@ -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; + +}); diff --git a/addons/web/static/src/js/framework/time.js b/addons/web/static/src/js/framework/time.js new file mode 100644 index 00000000000..7a13655c39b --- /dev/null +++ b/addons/web/static/src/js/framework/time.js @@ -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, +}; + +}); + diff --git a/addons/web/static/src/js/framework/translation.js b/addons/web/static/src/js/framework/translation.js new file mode 100644 index 00000000000..6f44a57b275 --- /dev/null +++ b/addons/web/static/src/js/framework/translation.js @@ -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, +}; + +}); + diff --git a/addons/web/static/src/js/framework/utils.js b/addons/web/static/src/js/framework/utils.js new file mode 100644 index 00000000000..d412a17bc9a --- /dev/null +++ b/addons/web/static/src/js/framework/utils.js @@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + if (human_readable) { + vattr = vattr.replace(/"/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, +}; + +}); + diff --git a/addons/web/static/src/js/framework/view.js b/addons/web/static/src/js/framework/view.js new file mode 100644 index 00000000000..c315e53d710 --- /dev/null +++ b/addons/web/static/src/js/framework/view.js @@ -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; + +}); diff --git a/addons/web/static/src/js/framework/widget.js b/addons/web/static/src/js/framework/widget.js new file mode 100644 index 00000000000..6d691f812e0 --- /dev/null +++ b/addons/web/static/src/js/framework/widget.js @@ -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; + +});
\ No newline at end of file diff --git a/addons/web/static/src/js/menu.js b/addons/web/static/src/js/menu.js new file mode 100644 index 00000000000..c7bf4b07828 --- /dev/null +++ b/addons/web/static/src/js/menu.js @@ -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; +}); diff --git a/addons/web/static/src/js/openerpframework.js b/addons/web/static/src/js/openerpframework.js deleted file mode 100644 index ce5decb6911..00000000000 --- a/addons/web/static/src/js/openerpframework.js +++ /dev/null @@ -1,1564 +0,0 @@ -/* - * Copyright (c) 2012, OpenERP S.A. - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - - /* - The only dependencies of this file are underscore >= 1.3.1, jQuery >= 1.8.3 and - QWeb >= 1.0.0 . No dependencies shall be added. - - This file must compile in EcmaScript 3 and work in IE7. - */ - -(function() { -/* jshint es3: true */ -"use strict"; - -function declare($, _, QWeb2) { -var openerp = {}; - -/** - * 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. - */ -(function() { - var initializing = false, - fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/; - // The web Class implementation (does nothing) - openerp.Class = function(){}; - - /** - * Subclass an existing class - * - * @param {Object} prop class-level properties (class attributes and instance methods) to set on the new class - */ - openerp.Class.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 !== openerp.Class){ - 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; - }; -})(); - -// Mixins - -/** - * 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. - */ -openerp.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 (!(predicate(ancestor)) && 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 = openerp.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). -*/ -openerp.EventDispatcherMixin = _.extend({}, openerp.ParentedMixin, { - __eventDispatcherMixin: true, - init: function() { - openerp.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(events) { - 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(); - openerp.ParentedMixin.destroy.call(this); - } -}); - -openerp.PropertiesMixin = _.extend({}, openerp.EventDispatcherMixin, { - init: function() { - openerp.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; - if (key === 'value' && self.field && self.field.type === 'float' && tmp && val){ - var precision = self.field.digits ? self.field.digits[1] : 2; - if (openerp.web.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]; - } -}); - -/** - * 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. - */ -openerp.Widget = openerp.Class.extend(openerp.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) { - openerp.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(); - } - openerp.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 = $(openerp.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); - }; - } -}); - -var genericJsonRpc = function(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 () { xhr.abort && xhr.abort(); }; - return result; -}; - -/** - * 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 openerp.datetime_to_str(value); -} - -openerp.jsonRpc = function(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, date_to_utc), - contentType: 'application/json' - })); - }); -}; - -openerp.jsonpRpc = function(url, fct_name, params, settings) { - settings = settings || {}; - return genericJsonRpc(fct_name, params, function(data) { - var payload_str = JSON.stringify(data, 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; - } - }); -}; - -openerp.loadCSS = function (url) { - if (!$('link[href="' + url + '"]').length) { - $('head').append($('<link>', { - 'href': url, - 'rel': 'stylesheet', - 'type': 'text/css' - })); - } -}; -openerp.loadJS = function (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; -}; -openerp.loadBundle = function (name) { - return $.when( - openerp.loadCSS('/web/css/' + name), - openerp.loadJS('/web/js/' + name) - ); -}; - -var realSetTimeout = function(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); -}; - -openerp.Session = openerp.Class.extend(openerp.PropertiesMixin, { - triggers: { - 'request': 'Request sent', - 'response': 'Response received', - 'response_failed': 'HTTP Error response or timeout received', - 'error': 'The received response is an JSON-RPC error' - }, - /** - @constructs openerp.Session - - @param parent The parent of the newly created object. - @param {String} origin Url of the OpenERP server to contact with this session 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) { - openerp.PropertiesMixin.init.call(this, parent); - options = options || {}; - 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); - }, - setup: function(origin) { - // 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; - }, - /** - * (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); - }); - }, - /** - * 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); - }); - }, - 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 = openerp.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 = openerp.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 = openerp.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 = openerp.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; - }, - model: function(model_name) { - return new openerp.Model(this, model_name); - } -}); - -openerp.Model = openerp.Class.extend({ - /** - new openerp.Model([session,] model_name) - - @constructs instance.Model - @extends instance.Class - - @param {openerp.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; - var args = _.toArray(arguments); - args.reverse(); - session = args.pop(); - if (session && ! (session instanceof openerp.Session)) { - model_name = session; - session = null; - } else { - model_name = args.pop(); - } - - this.name = model_name; - this._session = session; - }, - session: function() { - if (! this._session) - throw new Error("Not session specified"); - return this._session; - }, - /** - * 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 this.session().rpc(call_kw, { - model: this.name, - method: method, - args: args, - kwargs: kwargs - }, options); - } -}); - -/** OpenERP Translations */ -openerp.TranslationDataBase = openerp.Class.extend(/** @lends instance.TranslationDataBase# */{ - /** - * @constructs instance.TranslationDataBase - * @extends instance.Class - */ - 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; - } - }, - 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); - }); - } -}); - -openerp._t = new openerp.TranslationDataBase().build_translation_function(); - -openerp.get_cookie = function(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 ""; -}; - -openerp.qweb = new QWeb2.Engine(); - -openerp.qweb.default_dict = { - '_' : _, - 'JSON': JSON, - '_t' : openerp._t -}; - -openerp.Mutex = openerp.Class.extend({ - init: function() { - this.def = $.Deferred().resolve(); - }, - exec: function(action) { - var current = this.def; - var next = this.def = $.Deferred(); - return current.then(function() { - return $.when(action()).always(function() { - next.resolve(); - }); - }); - } -}); - -/** - * 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} - */ -openerp.str_to_datetime = function(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(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} - */ -openerp.str_to_date = function(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} - */ -openerp.str_to_time = function(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; -}; - -/* - * 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 - */ -var lpad = function(str, size) { - str = "" + str; - return new Array(size - str.length + 1).join('0') + str; -}; - -var rpad = function(str, size) { - str = "" + str; - return str + new Array(size - str.length + 1).join('0'); -}; - -/** - * 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. - */ -openerp.datetime_to_str = function(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. - */ -openerp.date_to_str = function(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. - */ -openerp.time_to_str = function(obj) { - if (!obj) { - return false; - } - return lpad(obj.getHours(),2) + ":" + lpad(obj.getMinutes(),2) + ":" - + lpad(obj.getSeconds(),2); -}; - -// jQuery custom plugins -jQuery.expr[":"].Contains = jQuery.expr.createPseudo(function(arg) { - return function( elem ) { - return jQuery(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0; - }; -}); - -openerp.declare = declare; - -return openerp; -} - -if (typeof(define) !== "undefined") { // amd - define(["jquery", "underscore", "qweb2"], declare); -} else { - window.openerp = declare($, _, QWeb2); -} - -})(); diff --git a/addons/web/static/src/js/pyeval.js b/addons/web/static/src/js/pyeval.js deleted file mode 100644 index c132b8365fb..00000000000 --- a/addons/web/static/src/js/pyeval.js +++ /dev/null @@ -1,1012 +0,0 @@ -/* - * py.js helpers and setup - */ -(function() { - - var instance = openerp; - - instance.web.pyeval = {}; - - var obj = function () {}; - obj.prototype = py.object; - var asJS = function (arg) { - if (arg instanceof obj) { - return arg.toJSON(); - } - return arg; - }; - - var datetime = py.PY_call(py.object); - - /** - * computes (Math.floor(a/b), a%b and passes that to the callback. - * - * returns the callback's result - */ - var divmod = function (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 - */ - var modf = function (x, fn) { - var mod = x%1; - if (mod < 0) { - mod += 1; - } - return fn(mod, Math.floor(x)); - }; - var zero = py.float.fromJSON(0); - - // Port from pypy/lib_pypy/datetime.py - var DAYS_IN_MONTH = [null, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - var DAYS_BEFORE_MONTH = [null]; - var dbm = 0; - for (var i=1; i<DAYS_IN_MONTH.length; ++i) { - DAYS_BEFORE_MONTH.push(dbm); - dbm += DAYS_IN_MONTH[i]; - } - var is_leap = function (year) { - return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - }; - var days_before_year = function (year) { - var y = year - 1; - return y*365 + Math.floor(y/4) - Math.floor(y/100) + Math.floor(y/400); - }; - var days_in_month = function (year, month) { - if (month === 2 && is_leap(year)) { - return 29; - } - return DAYS_IN_MONTH[month]; - }; - var days_before_month = function (year, month) { - var post_leap_feb = month > 2 && is_leap(year); - return DAYS_BEFORE_MONTH[month] - + (post_leap_feb ? 1 : 0); - }; - var ymd2ord = function (year, month, day) { - var dim = days_in_month(year, month); - if (!(1 <= day && day <= dim)) { - throw new Error("ValueError: day must be in 1.." + dim); - } - return days_before_year(year) - + days_before_month(year, month) - + day; - }; - var DI400Y = days_before_year(401); - var DI100Y = days_before_year(101); - var DI4Y = days_before_year(5); - var assert = function (bool) { - if (!bool) { - throw new Error("AssertionError"); - } - }; - var ord2ymd = function (n) { - --n; - var n400, n100, n4, n1, n0; - divmod(n, DI400Y, function (_n400, n) { - n400 = _n400; - divmod(n, DI100Y, function (_n100, n) { - n100 = _n100; - divmod(n, DI4Y, function (_n4, n) { - n4 = _n4; - divmod(n, 365, function (_n1, n) { - n1 = _n1; - n0 = n; - }); - }); - }); - }); - - n = n0; - var year = n400 * 400 + 1 + n100 * 100 + n4 * 4 + n1; - if (n1 == 4 || n100 == 100) { - assert(n0 === 0); - return { - year: year - 1, - month: 12, - day: 31 - }; - } - - var leapyear = n1 === 3 && (n4 !== 24 || n100 == 3); - assert(leapyear == is_leap(year)); - var month = (n + 50) >> 5; - var preceding = DAYS_BEFORE_MONTH[month] + ((month > 2 && leapyear) ? 1 : 0); - if (preceding > n) { - --month; - preceding -= DAYS_IN_MONTH[month] + ((month === 2 && leapyear) ? 1 : 0); - } - n -= preceding; - return { - year: year, - month: month, - day: n+1 - }; - }; - - /** - * Converts the stuff passed in into a valid date, applying overflows as needed - */ - var tmxxx = function (year, month, day, hour, minute, second, microsecond) { - hour = hour || 0; minute = minute || 0; second = second || 0; - microsecond = microsecond || 0; - - if (microsecond < 0 || microsecond > 999999) { - divmod(microsecond, 1000000, function (carry, ms) { - microsecond = ms; - second += carry; - }); - } - if (second < 0 || second > 59) { - divmod(second, 60, function (carry, s) { - second = s; - minute += carry; - }); - } - if (minute < 0 || minute > 59) { - divmod(minute, 60, function (carry, m) { - minute = m; - hour += carry; - }); - } - if (hour < 0 || hour > 23) { - divmod(hour, 24, function (carry, h) { - hour = h; - day += carry; - }); - } - // That was easy. Now it gets muddy: the proper range for day - // can't be determined without knowing the correct month and year, - // but if day is, e.g., plus or minus a million, the current month - // and year values make no sense (and may also be out of bounds - // themselves). - // Saying 12 months == 1 year should be non-controversial. - if (month < 1 || month > 12) { - divmod(month-1, 12, function (carry, m) { - month = m + 1; - year += carry; - }); - } - // Now only day can be out of bounds (year may also be out of bounds - // for a datetime object, but we don't care about that here). - // If day is out of bounds, what to do is arguable, but at least the - // method here is principled and explainable. - var dim = days_in_month(year, month); - if (day < 1 || day > dim) { - // Move day-1 days from the first of the month. First try to - // get off cheap if we're only one day out of range (adjustments - // for timezone alone can't be worse than that). - if (day === 0) { - --month; - if (month > 0) { - day = days_in_month(year, month); - } else { - --year; month=12; day=31; - } - } else if (day == dim + 1) { - ++month; - day = 1; - if (month > 12) { - month = 1; - ++year; - } - } else { - var r = ord2ymd(ymd2ord(year, month, 1) + (day - 1)); - year = r.year; - month = r.month; - day = r.day; - } - } - return { - year: year, - month: month, - day: day, - hour: hour, - minute: minute, - second: second, - microsecond: microsecond - }; - }; - datetime.timedelta = py.type('timedelta', null, { - __init__: function () { - var args = py.PY_parseArgs(arguments, [ - ['days', zero], ['seconds', zero], ['microseconds', zero], - ['milliseconds', zero], ['minutes', zero], ['hours', zero], - ['weeks', zero] - ]); - var d = 0, s = 0, m = 0; - var days = args.days.toJSON() + args.weeks.toJSON() * 7; - var seconds = args.seconds.toJSON() - + args.minutes.toJSON() * 60 - + args.hours.toJSON() * 3600; - var microseconds = args.microseconds.toJSON() - + args.milliseconds.toJSON() * 1000; - - // Get rid of all fractions, and normalize s and us. - // Take a deep breath <wink>. - var daysecondsfrac = modf(days, function (dayfrac, days) { - d = days; - if (dayfrac) { - return modf(dayfrac * 24 * 3600, function (dsf, dsw) { - s = dsw; - return dsf; - }); - } - return 0; - }); - - var secondsfrac = modf(seconds, function (sf, s) { - seconds = s; - return sf + daysecondsfrac; - }); - divmod(seconds, 24*3600, function (days, seconds) { - d += days; - s += seconds; - }); - // seconds isn't referenced again before redefinition - - microseconds += secondsfrac * 1e6; - divmod(microseconds, 1000000, function (seconds, microseconds) { - divmod(seconds, 24*3600, function (days, seconds) { - d += days; - s += seconds; - m += Math.round(microseconds); - }); - }); - - // Carrying still possible here? - - this.days = d; - this.seconds = s; - this.microseconds = m; - }, - __str__: function () { - var hh, mm, ss; - divmod(this.seconds, 60, function (m, s) { - divmod(m, 60, function (h, m) { - hh = h; - mm = m; - ss = s; - }); - }); - var s = _.str.sprintf("%d:%02d:%02d", hh, mm, ss); - if (this.days) { - s = _.str.sprintf("%d day%s, %s", - this.days, - (this.days != 1 && this.days != -1) ? 's' : '', - s); - } - if (this.microseconds) { - s = _.str.sprintf("%s.%06d", s, this.microseconds); - } - return py.str.fromJSON(s); - }, - __eq__: function (other) { - if (!py.PY_isInstance(other, datetime.timedelta)) { - return py.False; - } - return (this.days === other.days - && this.seconds === other.seconds - && this.microseconds === other.microseconds) - ? py.True : py.False; - }, - __add__: function (other) { - if (!py.PY_isInstance(other, datetime.timedelta)) { - return py.NotImplemented; - } - return py.PY_call(datetime.timedelta, [ - py.float.fromJSON(this.days + other.days), - py.float.fromJSON(this.seconds + other.seconds), - py.float.fromJSON(this.microseconds + other.microseconds) - ]); - }, - __radd__: function (other) { return this.__add__(other); }, - __sub__: function (other) { - if (!py.PY_isInstance(other, datetime.timedelta)) { - return py.NotImplemented; - } - return py.PY_call(datetime.timedelta, [ - py.float.fromJSON(this.days - other.days), - py.float.fromJSON(this.seconds - other.seconds), - py.float.fromJSON(this.microseconds - other.microseconds) - ]); - }, - __rsub__: function (other) { - if (!py.PY_isInstance(other, datetime.timedelta)) { - return py.NotImplemented; - } - return this.__neg__().__add__(other); - }, - __neg__: function () { - return py.PY_call(datetime.timedelta, [ - py.float.fromJSON(-this.days), - py.float.fromJSON(-this.seconds), - py.float.fromJSON(-this.microseconds) - ]); - }, - __pos__: function () { return this; }, - __mul__: function (other) { - if (!py.PY_isInstance(other, py.float)) { - return py.NotImplemented; - } - var n = other.toJSON(); - return py.PY_call(datetime.timedelta, [ - py.float.fromJSON(this.days * n), - py.float.fromJSON(this.seconds * n), - py.float.fromJSON(this.microseconds * n) - ]); - }, - __rmul__: function (other) { return this.__mul__(other); }, - __div__: function (other) { - if (!py.PY_isInstance(other, py.float)) { - return py.NotImplemented; - } - var usec = ((this.days * 24 * 3600) + this.seconds) * 1000000 - + this.microseconds; - return py.PY_call( - datetime.timedelta, [ - zero, zero, py.float.fromJSON(usec / other.toJSON())]); - }, - __floordiv__: function (other) { return this.__div__(other); }, - total_seconds: function () { - return py.float.fromJSON( - this.days * 86400 - + this.seconds - + this.microseconds / 1000000); - }, - __nonzero__: function () { - return (!!this.days || !!this.seconds || !!this.microseconds) - ? py.True - : py.False; - } - }); - datetime.datetime = py.type('datetime', null, { - __init__: function () { - var zero = py.float.fromJSON(0); - var args = py.PY_parseArgs(arguments, [ - 'year', 'month', 'day', - ['hour', zero], ['minute', zero], ['second', zero], - ['microsecond', zero], ['tzinfo', py.None] - ]); - for(var key in args) { - if (!args.hasOwnProperty(key)) { continue; } - this[key] = asJS(args[key]); - } - }, - replace: function () { - var args = py.PY_parseArgs(arguments, [ - ['year', py.None], ['month', py.None], ['day', py.None], - ['hour', py.None], ['minute', py.None], ['second', py.None], - ['microsecond', py.None] // FIXME: tzinfo, can't use None as valid input - ]); - var params = {}; - for(var key in args) { - if (!args.hasOwnProperty(key)) { continue; } - - var arg = args[key]; - params[key] = (arg === py.None ? this[key] : asJS(arg)); - } - return py.PY_call(datetime.datetime, params); - }, - strftime: function () { - var self = this; - var args = py.PY_parseArgs(arguments, 'format'); - return py.str.fromJSON(args.format.toJSON() - .replace(/%([A-Za-z])/g, function (m, c) { - switch (c) { - case 'Y': return _.str.sprintf('%04d', self.year); - case 'm': return _.str.sprintf('%02d', self.month); - case 'd': return _.str.sprintf('%02d', self.day); - case 'H': return _.str.sprintf('%02d', self.hour); - case 'M': return _.str.sprintf('%02d', self.minute); - case 'S': return _.str.sprintf('%02d', self.second); - } - throw new Error('ValueError: No known conversion for ' + m); - })); - }, - now: py.classmethod.fromJSON(function () { - var d = new Date; - return py.PY_call(datetime.datetime, [ - d.getFullYear(), d.getMonth() + 1, d.getDate(), - d.getHours(), d.getMinutes(), d.getSeconds(), - d.getMilliseconds() * 1000]); - }), - today: py.classmethod.fromJSON(function () { - var dt_class = py.PY_getAttr(datetime, 'datetime'); - return py.PY_call(py.PY_getAttr(dt_class, 'now')); - }), - utcnow: py.classmethod.fromJSON(function () { - var d = new Date(); - return py.PY_call(datetime.datetime, - [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), - d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), - d.getUTCMilliseconds() * 1000]); - }), - combine: py.classmethod.fromJSON(function () { - var args = py.PY_parseArgs(arguments, 'date time'); - return py.PY_call(datetime.datetime, [ - py.PY_getAttr(args.date, 'year'), - py.PY_getAttr(args.date, 'month'), - py.PY_getAttr(args.date, 'day'), - py.PY_getAttr(args.time, 'hour'), - py.PY_getAttr(args.time, 'minute'), - py.PY_getAttr(args.time, 'second') - ]); - }), - toJSON: function () { - return new Date( - this.year, - this.month - 1, - this.day, - this.hour, - this.minute, - this.second, - this.microsecond / 1000); - }, - }); - datetime.date = py.type('date', null, { - __init__: function () { - var args = py.PY_parseArgs(arguments, 'year month day'); - this.year = asJS(args.year); - this.month = asJS(args.month); - this.day = asJS(args.day); - }, - strftime: function () { - var self = this; - var args = py.PY_parseArgs(arguments, 'format'); - return py.str.fromJSON(args.format.toJSON() - .replace(/%([A-Za-z])/g, function (m, c) { - switch (c) { - case 'Y': return self.year; - case 'm': return _.str.sprintf('%02d', self.month); - case 'd': return _.str.sprintf('%02d', self.day); - } - throw new Error('ValueError: No known conversion for ' + m); - })); - }, - __eq__: function (other) { - return (this.year === other.year - && this.month === other.month - && this.day === other.day) - ? py.True : py.False; - }, - replace: function () { - var args = py.PY_parseArgs(arguments, [ - ['year', py.None], ['month', py.None], ['day', py.None] - ]); - var params = {}; - for(var key in args) { - if (!args.hasOwnProperty(key)) { continue; } - - var arg = args[key]; - params[key] = (arg === py.None ? this[key] : asJS(arg)); - } - return py.PY_call(datetime.date, params); - }, - __add__: function (other) { - if (!py.PY_isInstance(other, datetime.timedelta)) { - return py.NotImplemented; - } - - var s = tmxxx(this.year, this.month, this.day + other.days); - return datetime.date.fromJSON(s.year, s.month, s.day); - }, - __radd__: function (other) { return this.__add__(other); }, - __sub__: function (other) { - if (py.PY_isInstance(other, datetime.timedelta)) { - return py.PY_add(this, py.PY_negative(other)); - } - if (py.PY_isInstance(other, datetime.date)) { - // FIXME: getattr and sub API methods - return py.PY_call(datetime.timedelta, [ - py.PY_subtract( - py.PY_call(py.PY_getAttr(this, 'toordinal')), - py.PY_call(py.PY_getAttr(other, 'toordinal'))) - ]); - } - return py.NotImplemented; - }, - toordinal: function () { - return py.float.fromJSON(ymd2ord(this.year, this.month, this.day)); - }, - weekday: function () { - return py.float.fromJSON((this.toordinal().toJSON()+6)%7); - }, - fromJSON: function (year, month, day) { - return py.PY_call(datetime.date, [year, month, day]); - }, - today: py.classmethod.fromJSON(function () { - var d = new Date; - return py.PY_call(datetime.date, [ - d.getFullYear(), d.getMonth() + 1, d.getDate()]); - }), - }); - /** - Returns the current local date, which means the date on the client (which can be different - compared to the date of the server). - - @return {datetime.date} - */ - var context_today = function() { - var d = new Date(); - return py.PY_call( - datetime.date, [d.getFullYear(), d.getMonth() + 1, d.getDate()]); - }; - datetime.time = py.type('time', null, { - __init__: function () { - var zero = py.float.fromJSON(0); - var args = py.PY_parseArgs(arguments, [ - ['hour', zero], ['minute', zero], ['second', zero], ['microsecond', zero], - ['tzinfo', py.None] - ]); - - for(var k in args) { - if (!args.hasOwnProperty(k)) { continue; } - this[k] = asJS(args[k]); - } - } - }); - var time = py.PY_call(py.object); - time.strftime = py.PY_def.fromJSON(function () { - var args = py.PY_parseArgs(arguments, 'format'); - var dt_class = py.PY_getAttr(datetime, 'datetime'); - var d = py.PY_call(py.PY_getAttr(dt_class, 'utcnow')); - return py.PY_call(py.PY_getAttr(d, 'strftime'), [args.format]); - }); - - var args = _.map(('year month day ' - + 'years months weeks days ' - + 'weekday leapdays yearday nlyearday').split(' '), function (arg) { - switch (arg) { - case 'years':case 'months':case 'days':case 'leapdays':case 'weeks': - return [arg, zero]; - case 'year':case 'month':case 'day':case 'weekday': - case 'yearday':case 'nlyearday': - return [arg, null]; - default: - throw new Error("Unknown relativedelta argument " + arg); - } - }); - args.unshift('*'); - var utils = { - divmod: function (x, y) { - var rem = x % y; - return { - div: (x - rem) / y, - mod: rem - }; - }, - monthrange: function (year, month) { - if (month < 1 || month > 12) { - throw new Error("Illegal month " + month); - } - - var day1 = this.weekday(year, month, 1); - var ndays = this.mdays[month] + (month == this.February && this.isleap(year)); - return [day1, ndays]; - }, - weekday: function (year, month, day) { - var date = py.PY_call(datetime.date, [year, month, day]); - return py.PY_call(py.PY_getAttr(date, 'weekday')); - }, - isleap: function (year) { - return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) - }, - mdays: [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], - January: 1, - February: 2 - }; - var relativedelta = py.type('relativedelta', null, { - __init__: function () { - this.ops = py.PY_parseArgs(arguments, args); - this.ops.days = py.float.fromJSON( - asJS(this.ops.days) + asJS(this.ops.weeks) * 7 - ); - - var yday = zero; - if (this.ops.nlyearday) { - yday = this.ops.nlyearday; - } else if (this.ops.yearday) { - yday = this.ops.yearday; - if (asJS(this.ops.yearday) > 59) { - this.ops.leapdays = py.float.fromJS(-1); - } - } - if (py.PY_isTrue(yday)) { - var ydayidx = [31, 59, 90, 120, 151, 181, 212, - 243, 273, 304, 334, 366]; - for(var idx=0; idx<ydayidx.length; ++idx) { - var ydays = ydayidx[idx]; - if (asJS(yday) <= ydays) { - this.ops.month = py.float.fromJSON(idx+1); - if (!idx) { - this.ops.day = yday; - } else { - this.ops.day = py.PY_subtract( - yday, - py.float.fromJSON(ydayidx[idx-1]) - ); - } - break; - } - } - if (idx === ydayidx.length) { - throw new Error("Invalid year day (" + asJS(yday) + ")"); - } - } - this._fix(); - }, - _fix: function () { - var months = asJS(this.ops.months); - if (Math.abs(months) > 11) { - var s = months > 0 ? 1 : -1; - var r = utils.divmod(months * s, 12); - this.ops.months = py.float.fromJSON(r.mod*s); - this.ops.years = py.float.fromJSON( - asJS(this.ops.years) + r.div*s); - } - this._has_time = 0; - }, - __add__: function (other) { - if (!py.PY_isInstance(other, datetime.date)) { - return py.NotImplemented; - } - // TODO: test this whole mess - var year = (asJS(this.ops.year) || asJS(other.year)) + asJS(this.ops.years); - var month = asJS(this.ops.month) || asJS(other.month); - var months; - if (months = asJS(this.ops.months)) { - if (Math.abs(months) < 1 || Math.abs(months) > 12) { - throw new Error("Can only use relative months between -12 and +12"); - } - month += months; - if (month > 12) { - year += 1; - month -= 12; - } - if (month < 1) { - year -= 1; - month += 12; - } - } - - var day = Math.min(utils.monthrange(year, month)[1], - asJS(this.ops.day) || asJS(other.day)); - - var repl = { - year: py.float.fromJSON(year), - month: py.float.fromJSON(month), - day: py.float.fromJSON(day) - }; - - var days = asJS(this.ops.days); - if (py.PY_isTrue(this.ops.leapdays) && month > 2 && utils.isleap(year)) { - days += asJS(this.ops.leapdays); - } - - var ret = py.PY_add( - py.PY_call(py.PY_getAttr(other, 'replace'), repl), - py.PY_call(datetime.timedelta, { - days: py.float.fromJSON(days) - }) - ); - - if (this.ops.weekday) { - // FIXME: only handles numeric weekdays, not decorated - var weekday = asJS(this.ops.weekday), nth = 1; - var jumpdays = (Math.abs(nth) - 1) * 7; - - var ret_weekday = asJS(py.PY_call(py.PY_getAttr(ret, 'weekday'))); - if (nth > 0) { - jumpdays += (7-ret_weekday+weekday) % 7; - } else { - jumpdays += (ret_weekday - weekday) % 7; - jumpdays *= -1; - } - ret = py.PY_add( - ret, - py.PY_call(datetime.timedelta, { - days: py.float.fromJSON(jumpdays) - }) - ); - } - - return ret; - }, - __radd__: function (other) { - return this.__add__(other); - }, - __rsub__: function (other) { - return this.__neg__().__radd__(other); - }, - __neg__: function () { - return py.PY_call(relativedelta, { - years: py.PY_negative(this.ops.years), - months: py.PY_negative(this.ops.months), - days: py.PY_negative(this.ops.days), - leapdays: this.ops.leapdays, - year: this.ops.year, - month: this.ops.month, - day: this.ops.day, - weekday: this.ops.weekday - }); - } - }); - - // recursively wraps JS objects passed into the context to attributedicts - // which jsonify back to JS objects - var wrap = function (value) { - if (value === null) { return py.None; } - - switch (typeof value) { - case 'undefined': throw new Error("No conversion for undefined"); - case 'boolean': return py.bool.fromJSON(value); - case 'number': return py.float.fromJSON(value); - case 'string': return py.str.fromJSON(value); - } - - switch(value.constructor) { - case Object: return wrapping_dict.fromJSON(value); - case Array: return wrapping_list.fromJSON(value); - } - - throw new Error("ValueError: unable to wrap " + value); - }; - var wrapping_dict = py.type('wrapping_dict', null, { - __init__: function () { - this._store = {}; - }, - __getitem__: function (key) { - var k = key.toJSON(); - if (!(k in this._store)) { - throw new Error("KeyError: '" + k + "'"); - } - return wrap(this._store[k]); - }, - __getattr__: function (key) { - return this.__getitem__(py.str.fromJSON(key)); - }, - get: function () { - var args = py.PY_parseArgs(arguments, ['k', ['d', py.None]]); - - if (!(args.k.toJSON() in this._store)) { return args.d; } - return this.__getitem__(args.k); - }, - fromJSON: function (d) { - var instance = py.PY_call(wrapping_dict); - instance._store = d; - return instance; - }, - toJSON: function () { - return this._store; - }, - }); - var wrapping_list = py.type('wrapping_list', null, { - __init__: function () { - this._store = []; - }, - __getitem__: function (index) { - return wrap(this._store[index.toJSON()]); - }, - fromJSON: function (ar) { - var instance = py.PY_call(wrapping_list); - instance._store = ar; - return instance; - }, - toJSON: function () { - return this._store; - }, - }); - var wrap_context = function (context) { - for (var k in context) { - if (!context.hasOwnProperty(k)) { continue; } - var val = context[k]; - - if (val === null) { continue; } - if (val.constructor === Array) { - context[k] = wrapping_list.fromJSON(val); - } else if (val.constructor === Object - && !py.PY_isInstance(val, py.object)) { - context[k] = wrapping_dict.fromJSON(val); - } - } - return context; - }; - - var eval_contexts = function (contexts, evaluation_context) { - evaluation_context = _.extend(instance.web.pyeval.context(), evaluation_context || {}); - return _(contexts).reduce(function (result_context, ctx) { - // __eval_context evaluations can lead to some of `contexts`'s - // values being null, skip them as well as empty contexts - if (_.isEmpty(ctx)) { return result_context; } - if (_.isString(ctx)) { - // wrap raw strings in context - ctx = { __ref: 'context', __debug: ctx }; - } - var evaluated = ctx; - switch(ctx.__ref) { - case 'context': - evaluation_context.context = evaluation_context; - evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context)); - break; - case 'compound_context': - var eval_context = eval_contexts([ctx.__eval_context]); - evaluated = eval_contexts( - ctx.__contexts, _.extend({}, evaluation_context, eval_context)); - break; - } - // add newly evaluated context to evaluation context for following - // siblings - _.extend(evaluation_context, evaluated); - return _.extend(result_context, evaluated); - }, {}); - }; - var eval_domains = function (domains, evaluation_context) { - evaluation_context = _.extend(instance.web.pyeval.context(), evaluation_context || {}); - var result_domain = []; - _(domains).each(function (domain) { - if (_.isString(domain)) { - // wrap raw strings in domain - domain = { __ref: 'domain', __debug: domain }; - } - switch(domain.__ref) { - case 'domain': - evaluation_context.context = evaluation_context; - result_domain.push.apply( - result_domain, py.eval(domain.__debug, wrap_context(evaluation_context))); - break; - case 'compound_domain': - var eval_context = eval_contexts([domain.__eval_context]); - result_domain.push.apply( - result_domain, eval_domains( - domain.__domains, _.extend( - {}, evaluation_context, eval_context))); - break; - default: - result_domain.push.apply(result_domain, domain); - } - }); - return result_domain; - }; - var eval_groupbys = function (contexts, evaluation_context) { - evaluation_context = _.extend(instance.web.pyeval.context(), evaluation_context || {}); - var result_group = []; - _(contexts).each(function (ctx) { - if (_.isString(ctx)) { - // wrap raw strings in context - ctx = { __ref: 'context', __debug: ctx }; - } - var group; - var evaluated = ctx; - switch(ctx.__ref) { - case 'context': - evaluation_context.context = evaluation_context; - evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context)); - break; - case 'compound_context': - var eval_context = eval_contexts([ctx.__eval_context]); - evaluated = eval_contexts( - ctx.__contexts, _.extend({}, evaluation_context, eval_context)); - break; - } - group = evaluated.group_by; - if (!group) { return; } - if (typeof group === 'string') { - result_group.push(group); - } else if (group instanceof Array) { - result_group.push.apply(result_group, group); - } else { - throw new Error('Got invalid groupby {{' - + JSON.stringify(group) + '}}'); - } - _.extend(evaluation_context, evaluated); - }); - return result_group; - }; - - instance.web.pyeval.context = function () { - return _.extend({ - datetime: datetime, - context_today: context_today, - time: time, - relativedelta: relativedelta, - current_date: py.PY_call( - time.strftime, [py.str.fromJSON('%Y-%m-%d')]), - }, instance.session.user_context); - }; - - /** - * @param {String} type "domains", "contexts" or "groupbys" - * @param {Array} object domains or contexts to evaluate - * @param {Object} [context] evaluation context - */ - instance.web.pyeval.eval = function (type, object, context, options) { - options = options || {}; - context = _.extend(instance.web.pyeval.context(), context || {}); - - //noinspection FallthroughInSwitchStatementJS - switch(type) { - case 'context': - case 'contexts': - if (type === 'context') - object = [object]; - return eval_contexts((options.no_user_context ? [] : [instance.session.user_context]).concat(object), context); - case 'domain': - case 'domains': - if (type === 'domain') - object = [object]; - return eval_domains(object, context); - case 'groupbys': - return eval_groupbys(object, context); - } - throw new Error("Unknow evaluation type " + type); - }; - - var eval_arg = function (arg) { - if (typeof arg !== 'object' || !arg.__ref) { return arg; } - switch(arg.__ref) { - case 'domain': case 'compound_domain': - return instance.web.pyeval.eval('domains', [arg]); - case 'context': case 'compound_context': - return instance.web.pyeval.eval('contexts', [arg]); - default: - throw new Error(instance.web._t("Unknown nonliteral type " + arg.__ref)); - } - }; - /** - * If args or kwargs are unevaluated contexts or domains (compound or not), - * evaluated them in-place. - * - * Potentially mutates both parameters. - * - * @param args - * @param kwargs - */ - instance.web.pyeval.ensure_evaluated = function (args, kwargs) { - for (var i=0; i<args.length; ++i) { - args[i] = eval_arg(args[i]); - } - for (var k in kwargs) { - if (!kwargs.hasOwnProperty(k)) { continue; } - kwargs[k] = eval_arg(kwargs[k]); - } - }; - instance.web.pyeval.eval_domains_and_contexts = function (source) { - return new $.Deferred(function (d) {setTimeout(function () { - var result; - try { - result = instance.web.pyeval.sync_eval_domains_and_contexts(source); - } - catch (e) { - result = { error: { - code: 400, - message: instance.web._t("Evaluation Error"), - data: { - type: 'local_exception', - debug: _.str.sprintf( - instance.web._t("Local evaluation failure\n%s\n\n%s"), - e.message, JSON.stringify(source)) - } - }}; - } - d.resolve(result); - }, 0); }); - }; - instance.web.pyeval.sync_eval_domains_and_contexts = function (source) { - var contexts = ([instance.session.user_context] || []).concat(source.contexts); - // see Session.eval_context in Python - return { - context: instance.web.pyeval.eval('contexts', contexts), - domain: instance.web.pyeval.eval('domains', source.domains), - group_by: instance.web.pyeval.eval('groupbys', source.group_by_seq || []) - }; - }; -})(); diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js deleted file mode 100644 index f65cb3cb140..00000000000 --- a/addons/web/static/src/js/search.js +++ /dev/null @@ -1,2333 +0,0 @@ - -(function() { - -var instance = openerp; -openerp.web.search = {}; - -var QWeb = instance.web.qweb, - _t = instance.web._t, - _lt = instance.web._lt; - -/** @namespace */ -var my = instance.web.search = {}; - -var B = Backbone; -my.FacetValue = B.Model.extend({ - -}); -my.FacetValues = B.Collection.extend({ - model: my.FacetValue -}); -my.Facet = B.Model.extend({ - initialize: function (attrs) { - var values = attrs.values; - delete attrs.values; - - B.Model.prototype.initialize.apply(this, arguments); - - this.values = new my.FacetValues(values || []); - this.values.on('add remove change reset', function (_, options) { - this.trigger('change', this, options); - }, this); - }, - get: function (key) { - if (key !== 'values') { - return B.Model.prototype.get.call(this, key); - } - return this.values.toJSON(); - }, - set: function (key, value) { - if (key !== 'values') { - return B.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; - } -}); -my.SearchQuery = B.Collection.extend({ - model: my.Facet, - initialize: function () { - B.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; - } - B.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; - } -}); - -function assert(condition, message) { - if(!condition) { - throw new Error(message); - } -} -my.InputView = instance.web.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); - } - assert(range.startContainer === root, - "selection should be in the input view"); - 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 my.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); - } -}); -my.FacetView = instance.web.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 my.FacetValueView(self, value).appendTo($e); - })); - }); - }, - model_changed: function () { - this.$el.text(this.$el.text() + '*'); - } -}); -my.FacetValueView = instance.web.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() + '*'); - } -}); - -instance.web.SearchView = instance.web.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 my.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 = instance.web.fields_view_get({ - model: this.dataset._model, - 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 self = this; - 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 instance.web.Model(this.dataset.model).call('fields_get', { - context: this.dataset.context - }); - - this.groupby_menu = new my.GroupByMenu(this, this.groupbys, fields_def); - this.filter_menu = new my.FilterMenu(this, this.filters, fields_def); - this.favorite_menu = new my.FavoriteMenu(this, this.query, this.dataset.model, this.action_id); - - this.filter_menu.appendTo(this.$buttons); - this.groupby_menu.appendTo(this.$buttons); - var 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); - 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; - 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 my.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 my.InputView(this); - started.push(i.appendTo(this.$facets_container)); - this.input_subviews.push(i); - this.query.each(function (facet) { - var f = new my.FacetView(this, facet); - started.push(f.appendTo(self.$facets_container)); - self.input_subviews.push(f); - - var i = new my.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 = instance.web.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 my.Filter(filter.item, self)); - } - if (current_group.length) { - var group = new my.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 = my.fields.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 my.Filter(filter.item, self)); - } - current_category = filter.category; - }); - }, -}); - -/** - * Registry of search fields, called by :js:class:`instance.web.SearchView` to - * find and instantiate its field widgets. - */ -instance.web.search.fields = new instance.web.Registry({ - 'char': 'instance.web.search.CharField', - 'text': 'instance.web.search.CharField', - 'html': 'instance.web.search.CharField', - 'boolean': 'instance.web.search.BooleanField', - 'integer': 'instance.web.search.IntegerField', - 'id': 'instance.web.search.IntegerField', - 'float': 'instance.web.search.FloatField', - 'selection': 'instance.web.search.SelectionField', - 'datetime': 'instance.web.search.DateTimeField', - 'date': 'instance.web.search.DateField', - 'many2one': 'instance.web.search.ManyToOneField', - 'many2many': 'instance.web.search.CharField', - 'one2many': 'instance.web.search.CharField' -}); - -instance.web.search.Input = instance.web.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; - }, -}); -instance.web.search.FilterGroup = instance.web.search.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 instance.web.search.GroupbyGroup) && - _(filters).all(function (f) { - if (!f.attrs.context) { return false; } - var c = instance.web.pyeval.eval('context', f.attrs.context); - return !_.isEmpty(c.group_by);})) { - return new instance.web.search.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 instance.web.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 instance.web.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]) - }; - })); - } -}); -instance.web.search.GroupbyGroup = instance.web.search.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 - }; - } -}); -instance.web.search.Filter = instance.web.search.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 () { }, -}); - -instance.web.search.Field = instance.web.search.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 instance.web.CompoundContext(context) - .set_eval_context({self: self.value_from(facetValue)}); - }); - - if (contexts.length === 1) { return contexts[0]; } - - return _.extend(new instance.web.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 instance.web.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 instance.web.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 - */ -instance.web.search.CharField = instance.web.search.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}] - } - }]); - } -}); -instance.web.search.NumberField = instance.web.search.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 - */ -instance.web.search.IntegerField = instance.web.search.NumberField.extend(/** @lends instance.web.search.IntegerField# */{ - error_message: _t("not a valid integer"), - parse: function (value) { - try { - return instance.web.parse_value(value, {'widget': 'integer'}); - } catch (e) { - return NaN; - } - } -}); -/** - * @class - * @extends instance.web.search.NumberField - */ -instance.web.search.FloatField = instance.web.search.NumberField.extend(/** @lends instance.web.search.FloatField# */{ - error_message: _t("not a valid number"), - parse: function (value) { - try { - return instance.web.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 - */ -instance.web.search.SelectionField = instance.web.search.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)); - } -}); -instance.web.search.BooleanField = instance.web.search.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 - */ -instance.web.search.DateField = instance.web.search.Field.extend(/** @lends instance.web.search.DateField# */{ - value_from: function (facetValue) { - return instance.web.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 = instance.web.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 - */ -instance.web.search.DateTimeField = instance.web.search.DateField.extend(/** @lends instance.web.search.DateTimeField# */{ - value_from: function (facetValue) { - return instance.web.datetime_to_str(facetValue.get('value')); - } -}); -instance.web.search.ManyToOneField = instance.web.search.CharField.extend({ - default_operator: {}, - init: function (view_section, field, parent) { - this._super(view_section, field, parent); - this.model = new instance.web.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 = instance.web.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)); - } - 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); - } -}); - -instance.web.search.FilterMenu = instance.web.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) { - var self = this; - 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 instance.web.search.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 my.Filter(filter, this); - }), - filter_group = new my.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); - }, -}); - -instance.web.search.GroupByMenu = instance.web.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 (event) { - 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 my.Filter({attrs:{ - context:"{'group_by':'" + field_name + "''}", - name: this.groupable_fields[field_name].string, - }}, this.searchview); - var group = new my.FilterGroup([filter], this.searchview), - divider = this.$('.divider').show(); - group.insertBefore(divider); - group.toggle(filter); - }, -}); - -instance.web.search.FavoriteMenu = instance.web.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 instance.web.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) { - return a instanceof instance.web.ViewManager - }), - view_context = view_manager ? view_manager.active_view.controller.get_context() : {}, - results = instance.web.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(instance.session.user_context)).each(function (key) { - delete ctx[key]; - }); - var filter = { - name: filter_name, - user_id: shared_filter ? false : instance.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(); - } - }); - }, -}); - -instance.web.search.ExtendedSearchProposition = instance.web.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 = instance.web.search.custom_filters.get_object(type); - if(!Field) { - Field = instance.web.search.custom_filters.get_object("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', - }; - }, -}); - -instance.web.search.ExtendedSearchProposition.Field = instance.web.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(); - } -}); -instance.web.search.ExtendedSearchProposition.Char = instance.web.search.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(); - } -}); -instance.web.search.ExtendedSearchProposition.DateTime = instance.web.search.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")} - ], - /** - * Date widgets live in view_form which is not yet loaded when this is - * initialized -_- - */ - widget: function () { return instance.web.DateTimeWidget; }, - get_value: function() { - return this.datewidget.get_value(); - }, - toString: function () { - return instance.web.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; - } -}); -instance.web.search.ExtendedSearchProposition.Date = instance.web.search.ExtendedSearchProposition.DateTime.extend({ - widget: function () { return instance.web.DateWidget; }, - toString: function () { - return instance.web.format_value(this.get_value(), { type:"date" }); - } -}); -instance.web.search.ExtendedSearchProposition.Integer = instance.web.search.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 instance.web.parse_value(val === "" ? 0 : val, {'widget': 'integer'}); - } catch (e) { - return ""; - } - } -}); -instance.web.search.ExtendedSearchProposition.Id = instance.web.search.ExtendedSearchProposition.Integer.extend({ - operators: [{value: "=", text: _lt("is")}] -}); -instance.web.search.ExtendedSearchProposition.Float = instance.web.search.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 instance.web.parse_value(val === "" ? 0.0 : val, {'widget': 'float'}); - } catch (e) { - return ""; - } - } -}); -instance.web.search.ExtendedSearchProposition.Selection = instance.web.search.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(); - } -}); -instance.web.search.ExtendedSearchProposition.Boolean = instance.web.search.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; - } -}); - -instance.web.search.custom_filters = new instance.web.Registry({ - 'char': 'instance.web.search.ExtendedSearchProposition.Char', - 'text': 'instance.web.search.ExtendedSearchProposition.Char', - 'one2many': 'instance.web.search.ExtendedSearchProposition.Char', - 'many2one': 'instance.web.search.ExtendedSearchProposition.Char', - 'many2many': 'instance.web.search.ExtendedSearchProposition.Char', - - 'datetime': 'instance.web.search.ExtendedSearchProposition.DateTime', - 'date': 'instance.web.search.ExtendedSearchProposition.Date', - 'integer': 'instance.web.search.ExtendedSearchProposition.Integer', - 'float': 'instance.web.search.ExtendedSearchProposition.Float', - 'boolean': 'instance.web.search.ExtendedSearchProposition.Boolean', - 'selection': 'instance.web.search.ExtendedSearchProposition.Selection', - - 'id': 'instance.web.search.ExtendedSearchProposition.Id' -}); - -instance.web.search.AutoComplete = instance.web.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 (ev) {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; - }, -}); - -})(); diff --git a/addons/web/static/src/js/test_support.js b/addons/web/static/src/js/test_support.js deleted file mode 100644 index 006ccc580ed..00000000000 --- a/addons/web/static/src/js/test_support.js +++ /dev/null @@ -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); - }); - } -}; - -})(); diff --git a/addons/web/static/src/js/testing.js b/addons/web/static/src/js/testing.js deleted file mode 100644 index 5e9483addc1..00000000000 --- a/addons/web/static/src/js/testing.js +++ /dev/null @@ -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); diff --git a/addons/web/static/src/js/tour.js b/addons/web/static/src/js/tour.js index efc46c00d6e..7ee3e43f049 100644 --- a/addons/web/static/src/js/tour.js +++ b/addons/web/static/src/js/tour.js @@ -1,24 +1,29 @@ -(function () { - 'use strict'; +odoo.define('web.Tour', ['web.core'], function (require) { +"use strict"; -// 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); -} +var core = require('web.core'); -var website = openerp.website; +var _t = core._t; +var qweb = core.qweb; -// don't rewrite T in test mode -if (typeof openerp.Tour !== "undefined") { - return; -} +// 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); +// } + + +// // 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; + +}); diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js deleted file mode 100644 index bdd1ecb6225..00000000000 --- a/addons/web/static/src/js/view_form.js +++ /dev/null @@ -1,6443 +0,0 @@ -(function() { - -var instance = openerp; -var _t = instance.web._t, - _lt = instance.web._lt; -var QWeb = instance.web.qweb; - -/** @namespace */ -instance.web.form = {}; - -/** - * Interface implemented by the form view or any other object - * able to provide the features necessary for the fields to work. - * - * Properties: - * - display_invalid_fields : if true, all fields where is_valid() return true should - * be displayed as invalid. - * - actual_mode : the current mode of the field manager. Can be "view", "edit" or "create". - * Events: - * - view_content_has_changed : when the values of the fields have changed. When - * this event is triggered all fields should reprocess their modifiers. - * - field_changed:<field_name> : when the value of a field change, an event is triggered - * named "field_changed:<field_name>" with <field_name> replaced by the name of the field. - * This event is not related to the on_change mechanism of OpenERP and is always called - * when the value of a field is setted or changed. This event is only triggered when the - * value of the field is syntactically valid, but it can be triggered when the value - * is sematically invalid (ie, when a required field is false). It is possible that an event - * about a precise field is never triggered even if that field exists in the view, in that - * case the value of the field is assumed to be false. - */ -instance.web.form.FieldManagerMixin = { - /** - * Must return the asked field as in fields_get. - */ - get_field_desc: function(field_name) {}, - /** - * Returns the current value of a field present in the view. See the get_value() method - * method in FieldInterface for further information. - */ - get_field_value: function(field_name) {}, - /** - Gives new values for the fields contained in the view. The new values could not be setted - right after the call to this method. Setting new values can trigger on_changes. - - @param {Object} values A dictonary with key = field name and value = new value. - @return {$.Deferred} Is resolved after all the values are setted. - */ - set_values: function(values) {}, - /** - Computes an OpenERP domain. - - @param {Array} expression An OpenERP domain. - @return {boolean} The computed value of the domain. - */ - compute_domain: function(expression) {}, - /** - Builds an evaluation context for the resolution of the fields' contexts. Please note - the field are only supposed to use this context to evualuate their own, they should not - extend it. - - @return {CompoundContext} An OpenERP context. - */ - build_eval_context: function() {}, -}; - -instance.web.views.add('form', 'instance.web.FormView'); -/** - * Properties: - * - actual_mode: always "view", "edit" or "create". Read-only property. Determines - * the mode used by the view. - */ -instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerMixin, { - /** - * Indicates that this view is not searchable, and thus that no search - * view should be displayed (if there is one active). - */ - searchable: false, - template: "FormView", - display_name: _lt('Form'), - view_type: "form", - /** - * @constructs instance.web.FormView - * @extends instance.web.View - * - * @param {instance.web.Session} session the current openerp session - * @param {instance.web.DataSet} dataset the dataset this view will work with - * @param {String} view_id the identifier of the OpenERP view object - * - * @property {instance.web.Registry} registry=instance.web.form.widgets widgets registry for this form view instance - */ - init: function(parent, dataset, view_id, options) { - var self = this; - this._super(parent); - this.ViewManager = parent; - this.set_default_options(options); - this.dataset = dataset; - this.model = dataset.model; - this.view_id = view_id || false; - this.fields_view = {}; - this.fields = {}; - this.fields_order = []; - this.datarecord = {}; - this._onchange_specs = {}; - this.onchanges_mutex = new $.Mutex(); - this.default_focus_field = null; - this.default_focus_button = null; - this.fields_registry = instance.web.form.widgets; - this.tags_registry = instance.web.form.tags; - this.widgets_registry = instance.web.form.custom_widgets; - this.has_been_loaded = $.Deferred(); - this.translatable_fields = []; - _.defaults(this.options, { - "not_interactible_on_create": false, - "initial_mode": "view", - "disable_autofocus": false, - "footer_to_buttons": false, - }); - this.is_initialized = $.Deferred(); - this.mutating_mutex = new $.Mutex(); - this.save_list = []; - this.render_value_defs = []; - this.reload_mutex = new $.Mutex(); - this.__clicked_inside = false; - this.__blur_timeout = null; - this.rendering_engine = new instance.web.form.FormRenderingEngine(this); - self.set({actual_mode: self.options.initial_mode}); - this.has_been_loaded.done(function() { - self._build_onchange_specs(); - self.on("change:actual_mode", self, self.check_actual_mode); - self.check_actual_mode(); - self.on("change:actual_mode", self, self.init_pager); - self.init_pager(); - }); - self.on("load_record", self, self.load_record); - instance.web.bus.on('clear_uncommitted_changes', this, function(e) { - if (!this.can_be_discarded()) { - e.preventDefault(); - } - }); - }, - view_loading: function(r) { - return this.load_form(r); - }, - destroy: function() { - _.each(this.get_widgets(), function(w) { - w.off('focused blurred'); - w.destroy(); - }); - if (this.$el) { - this.$el.off('.formBlur'); - } - this._super(); - }, - load_form: function(data) { - var self = this; - if (!data) { - throw new Error(_t("No data provided.")); - } - if (this.arch) { - throw "Form view does not support multiple calls to load_form"; - } - this.fields_order = []; - this.fields_view = data; - - this.rendering_engine.set_fields_registry(this.fields_registry); - this.rendering_engine.set_tags_registry(this.tags_registry); - this.rendering_engine.set_widgets_registry(this.widgets_registry); - this.rendering_engine.set_fields_view(data); - var $dest = this.$el.hasClass("oe_form_container") ? this.$el : this.$el.find('.oe_form_container'); - this.rendering_engine.render_to($dest); - - this.$el.on('mousedown.formBlur', function () { - self.__clicked_inside = true; - }); - - this.$buttons = $(QWeb.render("FormView.buttons", {'widget':self})); - if (this.options.$buttons) { - this.$buttons.appendTo(this.options.$buttons); - } else { - this.$el.find('.oe_form_buttons').replaceWith(this.$buttons); - } - this.$buttons.on('click', '.oe_form_button_create', - this.guard_active(this.on_button_create)); - this.$buttons.on('click', '.oe_form_button_edit', - this.guard_active(this.on_button_edit)); - this.$buttons.on('click', '.oe_form_button_save', - this.guard_active(this.on_button_save)); - this.$buttons.on('click', '.oe_form_button_cancel', - this.guard_active(this.on_button_cancel)); - if (this.options.footer_to_buttons) { - this.$el.find('footer').appendTo(this.$buttons); - } - - this.$sidebar = this.options.$sidebar || this.$el.find('.oe_form_sidebar'); - if (!this.sidebar && this.options.$sidebar) { - this.sidebar = new instance.web.Sidebar(this); - this.sidebar.appendTo(this.$sidebar); - if (this.fields_view.toolbar) { - this.sidebar.add_toolbar(this.fields_view.toolbar); - } - this.sidebar.add_items('other', _.compact([ - self.is_action_enabled('delete') && { label: _t('Delete'), callback: self.on_button_delete }, - self.is_action_enabled('create') && { label: _t('Duplicate'), callback: self.on_button_duplicate } - ])); - } - - this.has_been_loaded.resolve(); - - // Add bounce effect on button 'Edit' when click on readonly page view. - this.$el.find(".oe_form_group_row,.oe_form_field,label,h1,.oe_title,.oe_notebook_page, .oe_list_content").on('click', function (e) { - if(self.get("actual_mode") == "view") { - var $button = self.options.$buttons.find(".oe_form_button_edit"); - $button.openerpBounce(); - e.stopPropagation(); - instance.web.bus.trigger('click', e); - } - }); - //bounce effect on red button when click on statusbar. - this.$el.find(".oe_form_field_status:not(.oe_form_status_clickable)").on('click', function (e) { - if((self.get("actual_mode") == "view")) { - var $button = self.$el.find(".oe_highlight:not(.oe_form_invisible)").css({'float':'left','clear':'none'}); - $button.openerpBounce(); - e.stopPropagation(); - } - }); - this.trigger('form_view_loaded', data); - return $.when(); - }, - widgetFocused: function() { - // Clear click flag if used to focus a widget - this.__clicked_inside = false; - if (this.__blur_timeout) { - clearTimeout(this.__blur_timeout); - this.__blur_timeout = null; - } - }, - widgetBlurred: function() { - if (this.__clicked_inside) { - // clicked in an other section of the form (than the currently - // focused widget) => just ignore the blurring entirely? - this.__clicked_inside = false; - return; - } - var self = this; - // clear timeout, if any - this.widgetFocused(); - this.__blur_timeout = setTimeout(function () { - self.trigger('blurred'); - }, 0); - }, - - do_load_state: function(state, warm) { - if (state.id && this.datarecord.id != state.id) { - if (this.dataset.get_id_index(state.id) === null) { - this.dataset.ids.push(state.id); - } - this.dataset.select_id(state.id); - this.do_show(); - } - }, - /** - * - * @param {Object} [options] - * @param {Boolean} [mode=undefined] If specified, switch the form to specified mode. Can be "edit" or "view". - * @param {Boolean} [reload=true] whether the form should reload its content on show, or use the currently loaded record - * @return {$.Deferred} - */ - do_show: function (options) { - var self = this; - options = options || {}; - if (this.sidebar) { - this.sidebar.$el.show(); - } - if (this.$buttons) { - this.$buttons.show(); - } - this.$el.show().css({ - opacity: '0', - filter: 'alpha(opacity = 0)' - }); - this.$el.add(this.$buttons).removeClass('oe_form_dirty'); - - var shown = this.has_been_loaded; - if (options.reload !== false) { - shown = shown.then(function() { - if (self.dataset.index === null) { - // null index means we should start a new record - return self.on_button_new(); - } - var fields = _.keys(self.fields_view.fields); - fields.push('display_name'); - return self.dataset.read_index(fields, { - context: { 'bin_size': true, 'future_display_name' : true } - }).then(function(r) { - self.trigger('load_record', r); - }); - }); - } - return $.when(shown, this._super()).then(function() { - self._actualize_mode(options.mode || self.options.initial_mode); - self.$el.css({ - opacity: '1', - filter: 'alpha(opacity = 100)' - }); - instance.web.bus.trigger('form_view_shown', self); - }); - }, - do_hide: function () { - if (this.sidebar) { - this.sidebar.$el.hide(); - } - if (this.$pager) { - this.$pager.hide(); - } - this._super(); - }, - load_record: function(record) { - var self = this, set_values = []; - if (!record) { - this.set({ 'title' : undefined }); - this.do_warn(_t("Form"), _t("The record could not be found in the database."), true); - return $.Deferred().reject(); - } - this.datarecord = record; - this._actualize_mode(); - this.set({ 'title' : record.id ? record.display_name : _t("New") }); - - _(this.fields).each(function (field, f) { - field._dirty_flag = false; - field._inhibit_on_change_flag = true; - var result = field.set_value(self.datarecord[f] || false); - field._inhibit_on_change_flag = false; - set_values.push(result); - }); - return $.when.apply(null, set_values).then(function() { - if (!record.id) { - // trigger onchanges - self.do_onchange(null); - } - self.on_form_changed(); - self.rendering_engine.init_fields(); - self.is_initialized.resolve(); - self.do_update_pager(record.id === null || record.id === undefined); - if (self.sidebar) { - self.sidebar.do_attachement_update(self.dataset, self.datarecord.id); - } - if (record.id) { - self.do_push_state({id:record.id}); - } else { - self.do_push_state({}); - } - self.$el.add(self.$buttons).removeClass('oe_form_dirty'); - self.autofocus(); - }); - }, - /** - * Loads and sets up the default values for the model as the current - * record - * - * @return {$.Deferred} - */ - load_defaults: function () { - var self = this; - var keys = _.keys(this.fields_view.fields); - if (keys.length) { - return this.dataset.default_get(keys).then(function(r) { - self.trigger('load_record', r); - }); - } - return self.trigger('load_record', {}); - }, - on_form_changed: function() { - this.trigger("view_content_has_changed"); - }, - do_notify_change: function() { - this.$el.add(this.$buttons).addClass('oe_form_dirty'); - }, - execute_pager_action: function(action) { - if (this.can_be_discarded()) { - switch (action) { - case 'first': - this.dataset.index = 0; - break; - case 'previous': - this.dataset.previous(); - break; - case 'next': - this.dataset.next(); - break; - case 'last': - this.dataset.index = this.dataset.ids.length - 1; - break; - } - var def = this.reload(); - this.trigger('pager_action_executed'); - return def; - } - return $.when(); - }, - init_pager: function() { - var self = this; - if (this.$pager) - this.$pager.remove(); - if (this.get("actual_mode") === "create") - return; - this.$pager = $(QWeb.render("FormView.pager", {'widget':self})); - if (this.options.$pager) { - this.$pager.appendTo(this.options.$pager); - } else { - this.$el.find('.oe_form_pager').replaceWith(this.$pager); - } - this.$pager.on('click','a[data-pager-action]',function() { - var $el = $(this); - if ($el.attr("disabled")) - return; - var action = $el.data('pager-action'); - var def = $.when(self.execute_pager_action(action)); - $el.attr("disabled"); - def.always(function() { - $el.removeAttr("disabled"); - }); - }); - this.do_update_pager(); - }, - do_update_pager: function(hide_index) { - this.$pager.toggle(this.dataset.ids.length > 1); - if (hide_index) { - $(".oe_form_pager_state", this.$pager).html(""); - } else { - $(".oe_form_pager_state", this.$pager).html(_.str.sprintf(_t("%d / %d"), this.dataset.index + 1, this.dataset.ids.length)); - } - }, - - _build_onchange_specs: function() { - var self = this; - var find = function(field_name, root) { - var fields = [root]; - while (fields.length) { - var node = fields.pop(); - if (!node) { - continue; - } - if (node.tag === 'field' && node.attrs.name === field_name) { - return node.attrs.on_change || ""; - } - fields = _.union(fields, node.children); - } - return ""; - }; - - self._onchange_fields = []; - self._onchange_specs = {}; - _.each(this.fields, function(field, name) { - self._onchange_fields.push(name); - self._onchange_specs[name] = find(name, field.node); - _.each(field.field.views, function(view) { - _.each(view.fields, function(_, subname) { - self._onchange_specs[name + '.' + subname] = find(subname, view.arch); - }); - }); - }); - }, - _get_onchange_values: function() { - var field_values = this.get_fields_values(); - if (field_values.id.toString().match(instance.web.BufferedDataSet.virtual_id_regex)) { - delete field_values.id; - } - if (this.dataset.parent_view) { - // this belongs to a parent view: add parent field if possible - var parent_view = this.dataset.parent_view; - var child_name = this.dataset.child_name; - var parent_name = parent_view.get_field_desc(child_name).relation_field; - if (parent_name) { - // consider all fields except the inverse of the parent field - var parent_values = parent_view.get_fields_values(); - delete parent_values[child_name]; - field_values[parent_name] = parent_values; - } - } - return field_values; - }, - - do_onchange: function(widget) { - var self = this; - var onchange_specs = self._onchange_specs; - try { - var def = $.when({}); - var change_spec = widget ? onchange_specs[widget.name] : null; - if (!widget || (!_.isEmpty(change_spec) && change_spec !== "0")) { - var ids = [], - trigger_field_name = widget ? widget.name : self._onchange_fields, - values = self._get_onchange_values(), - context = new instance.web.CompoundContext(self.dataset.get_context()); - - if (widget && widget.build_context()) { - context.add(widget.build_context()); - } - if (self.dataset.parent_view) { - var parent_name = self.dataset.parent_view.get_field_desc(self.dataset.child_name).relation_field; - context.add({field_parent: parent_name}); - } - - if (self.datarecord.id && !instance.web.BufferedDataSet.virtual_id_regex.test(self.datarecord.id)) { - // In case of a o2m virtual id, we should pass an empty ids list - ids.push(self.datarecord.id); - } - def = self.alive(new instance.web.Model(self.dataset.model).call( - "onchange", [ids, values, trigger_field_name, onchange_specs, context])); - } - this.onchanges_mutex.exec(function(){ - return def.then(function(response) { - var fields = {}; - if (widget){ - fields[widget.name] = widget.field; - } - else{ - fields = self.fields_view.fields; - } - var defs = []; - _.each(fields, function(field, fieldname){ - if (field && field.change_default) { - var value_; - if (response.value && (fieldname in response.value)) { - // Use value from onchange if onchange executed - value_ = response.value[fieldname]; - } else { - // otherwise get form value for field - value_ = self.fields[fieldname].get_value(); - } - var condition = fieldname + '=' + value_; - - if (value_) { - defs.push(self.alive(new instance.web.Model('ir.values').call( - 'get_defaults', [self.model, condition] - )).then(function (results) { - if (!results.length) { - return response; - } - if (!response.value) { - response.value = {}; - } - for(var i=0; i<results.length; ++i) { - // [whatever, key, value] - var triplet = results[i]; - response.value[triplet[1]] = triplet[2]; - } - return response; - })); - } - } - }); - return _.isEmpty(defs) ? response : $.when.apply(null, defs); - }).then(function(response) { - return self.on_processed_onchange(response); - }); - }); - return this.onchanges_mutex.def; - } catch(e) { - console.error(e); - instance.webclient.crashmanager.show_message(e); - return $.Deferred().reject(); - } - }, - on_processed_onchange: function(result) { - try { - var fields = this.fields; - _(result.domain).each(function (domain, fieldname) { - var field = fields[fieldname]; - if (!field) { return; } - field.node.attrs.domain = domain; - }); - - if (!_.isEmpty(result.value)) { - this._internal_set_values(result.value); - } - // FIXME XXX a list of warnings? - if (!_.isEmpty(result.warning)) { - new instance.web.Dialog(this, { - size: 'medium', - title:result.warning.title, - buttons: [ - {text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }} - ] - }, QWeb.render("CrashManager.warning", result.warning)).open(); - } - - return $.Deferred().resolve(); - } catch(e) { - console.error(e); - instance.webclient.crashmanager.show_message(e); - return $.Deferred().reject(); - } - }, - _process_operations: function() { - var self = this; - return this.mutating_mutex.exec(function() { - function iterate() { - - var mutex = new $.Mutex(); - _.each(self.fields, function(field) { - self.onchanges_mutex.def.then(function(){ - mutex.exec(function(){ - return field.commit_value(); - }); - }); - }); - - var args = _.toArray(arguments); - return $.when.apply(null, [mutex.def, self.onchanges_mutex.def]).then(function() { - var save_obj = self.save_list.pop(); - if (save_obj) { - return self._process_save(save_obj).then(function() { - save_obj.ret = _.toArray(arguments); - return iterate(); - }, function() { - save_obj.error = true; - }); - } - return $.when(); - }).fail(function() { - self.save_list.pop(); - return $.when(); - }); - } - return iterate(); - }); - }, - _internal_set_values: function(values) { - for (var f in values) { - if (!values.hasOwnProperty(f)) { continue; } - var field = this.fields[f]; - // If field is not defined in the view, just ignore it - if (field) { - var value_ = values[f]; - if (field.get_value() != value_) { - field._inhibit_on_change_flag = true; - field.set_value(value_); - field._inhibit_on_change_flag = false; - field._dirty_flag = true; - } - } - } - this.on_form_changed(); - }, - set_values: function(values) { - var self = this; - return this.mutating_mutex.exec(function() { - self._internal_set_values(values); - }); - }, - /** - * Ask the view to switch to view mode if possible. The view may not do it - * if the current record is not yet saved. It will then stay in create mode. - */ - to_view_mode: function() { - this._actualize_mode("view"); - }, - /** - * Ask the view to switch to edit mode if possible. The view may not do it - * if the current record is not yet saved. It will then stay in create mode. - */ - to_edit_mode: function() { - this.onchanges_mutex = new $.Mutex(); - this._actualize_mode("edit"); - }, - /** - * Ask the view to switch to a precise mode if possible. The view is free to - * not respect this command if the state of the dataset is not compatible with - * the new mode. For example, it is not possible to switch to edit mode if - * the current record is not yet saved in database. - * - * @param {string} [new_mode] Can be "edit", "view", "create" or undefined. If - * undefined the view will test the actual mode to check if it is still consistent - * with the dataset state. - */ - _actualize_mode: function(switch_to) { - var mode = switch_to || this.get("actual_mode"); - if (! this.datarecord.id) { - mode = "create"; - } else if (mode === "create") { - mode = "edit"; - } - this.render_value_defs = []; - this.set({actual_mode: mode}); - }, - check_actual_mode: function(source, options) { - var self = this; - if(this.get("actual_mode") === "view") { - self.$el.removeClass('oe_form_editable').addClass('oe_form_readonly'); - self.$buttons.find('.oe_form_buttons_edit').hide(); - self.$buttons.find('.oe_form_buttons_view').show(); - self.$sidebar.show(); - } else { - self.$el.removeClass('oe_form_readonly').addClass('oe_form_editable'); - self.$buttons.find('.oe_form_buttons_edit').show(); - self.$buttons.find('.oe_form_buttons_view').hide(); - self.$sidebar.hide(); - this.autofocus(); - } - }, - autofocus: function() { - if (this.get("actual_mode") !== "view" && !this.options.disable_autofocus) { - var fields_order = this.fields_order.slice(0); - if (this.default_focus_field) { - fields_order.unshift(this.default_focus_field.name); - } - for (var i = 0; i < fields_order.length; i += 1) { - var field = this.fields[fields_order[i]]; - if (!field.get('effective_invisible') && !field.get('effective_readonly') && field.$label) { - if (field.focus() !== false) { - break; - } - } - } - } - }, - on_button_save: function(e) { - var self = this; - $(e.target).attr("disabled", true); - return this.save().done(function(result) { - self.trigger("save", result); - self.reload().then(function() { - self.to_view_mode(); - var menu = instance.webclient.menu; - if (menu) { - menu.do_reload_needaction(); - } - instance.web.bus.trigger('form_view_saved', self); - }); - }).always(function(){ - $(e.target).attr("disabled", false); - }); - }, - on_button_cancel: function(event) { - var self = this; - if (this.can_be_discarded()) { - if (this.get('actual_mode') === 'create') { - this.trigger('history_back'); - } else { - this.to_view_mode(); - $.when.apply(null, this.render_value_defs).then(function(){ - self.trigger('load_record', self.datarecord); - }); - } - } - this.trigger('on_button_cancel'); - return false; - }, - on_button_new: function() { - var self = this; - this.to_edit_mode(); - return $.when(this.has_been_loaded).then(function() { - if (self.can_be_discarded()) { - return self.load_defaults(); - } - }); - }, - on_button_edit: function() { - return this.to_edit_mode(); - }, - on_button_create: function() { - this.dataset.index = null; - this.do_show(); - }, - on_button_duplicate: function() { - var self = this; - return this.has_been_loaded.then(function() { - return self.dataset.call('copy', [self.datarecord.id, {}, self.dataset.context]).then(function(new_id) { - self.record_created(new_id); - self.to_edit_mode(); - }); - }); - }, - on_button_delete: function() { - var self = this; - var def = $.Deferred(); - this.has_been_loaded.done(function() { - if (self.datarecord.id && confirm(_t("Do you really want to delete this record?"))) { - self.dataset.unlink([self.datarecord.id]).done(function() { - if (self.dataset.size()) { - self.execute_pager_action('next'); - } else { - self.do_action('history_back'); - } - def.resolve(); - }); - } else { - $.async_when().done(function () { - def.reject(); - }); - } - }); - return def.promise(); - }, - can_be_discarded: function() { - if (this.$el.is('.oe_form_dirty')) { - if (!confirm(_t("Warning, the record has been modified, your changes will be discarded.\n\nAre you sure you want to leave this page ?"))) { - return false; - } - this.$el.removeClass('oe_form_dirty'); - } - return true; - }, - /** - * Triggers saving the form's record. Chooses between creating a new - * record or saving an existing one depending on whether the record - * already has an id property. - * - * @param {Boolean} [prepend_on_create=false] if ``save`` creates a new - * record, should that record be inserted at the start of the dataset (by - * default, records are added at the end) - */ - save: function(prepend_on_create) { - var self = this; - var save_obj = {prepend_on_create: prepend_on_create, ret: null}; - this.save_list.push(save_obj); - return self._process_operations().then(function() { - if (save_obj.error) - return $.Deferred().reject(); - return $.when.apply($, save_obj.ret); - }).done(function(result) { - self.$el.removeClass('oe_form_dirty'); - }); - }, - _process_save: function(save_obj) { - var self = this; - var prepend_on_create = save_obj.prepend_on_create; - try { - var form_invalid = false, - values = {}, - first_invalid_field = null, - readonly_values = {}; - for (var f in self.fields) { - if (!self.fields.hasOwnProperty(f)) { continue; } - f = self.fields[f]; - if (!f.is_valid()) { - form_invalid = true; - if (!first_invalid_field) { - first_invalid_field = f; - } - } else if (f.name !== 'id' && (!self.datarecord.id || f._dirty_flag)) { - // Special case 'id' field, do not save this field - // on 'create' : save all non readonly fields - // on 'edit' : save non readonly modified fields - if (!f.get("readonly")) { - values[f.name] = f.get_value(true); - } else { - readonly_values[f.name] = f.get_value(true); - } - } - } - if (form_invalid) { - self.set({'display_invalid_fields': true}); - first_invalid_field.focus(); - self.on_invalid(); - return $.Deferred().reject(); - } else { - self.set({'display_invalid_fields': false}); - var save_deferral; - if (!self.datarecord.id) { - // Creation save - save_deferral = self.dataset.create(values, {readonly_fields: readonly_values}).then(function(r) { - return self.record_created(r, prepend_on_create); - }, null); - } else if (_.isEmpty(values)) { - // Not dirty, noop save - save_deferral = $.Deferred().resolve({}).promise(); - } else { - // Write save - save_deferral = self.dataset.write(self.datarecord.id, values, {readonly_fields: readonly_values}).then(function(r) { - return self.record_saved(r); - }, null); - } - return save_deferral; - } - } catch (e) { - console.error(e); - return $.Deferred().reject(); - } - }, - on_invalid: function() { - var warnings = _(this.fields).chain() - .filter(function (f) { return !f.is_valid(); }) - .map(function (f) { - return _.str.sprintf('<li>%s</li>', - _.escape(f.string)); - }).value(); - warnings.unshift('<ul>'); - warnings.push('</ul>'); - this.do_warn(_t("The following fields are invalid:"), warnings.join('')); - }, - /** - * Reload the form after saving - * - * @param {Object} r result of the write function. - */ - record_saved: function(r) { - this.trigger('record_saved', r); - if (!r) { - // should not happen in the server, but may happen for internal purpose - return $.Deferred().reject(); - } - return r; - }, - /** - * Updates the form' dataset to contain the new record: - * - * * Adds the newly created record to the current dataset (at the end by - * default) - * * Selects that record (sets the dataset's index to point to the new - * record's id). - * * Updates the pager and sidebar displays - * - * @param {Object} r - * @param {Boolean} [prepend_on_create=false] adds the newly created record - * at the beginning of the dataset instead of the end - */ - record_created: function(r, prepend_on_create) { - var self = this; - if (!r) { - // should not happen in the server, but may happen for internal purpose - this.trigger('record_created', r); - return $.Deferred().reject(); - } else { - this.datarecord.id = r; - if (!prepend_on_create) { - this.dataset.alter_ids(this.dataset.ids.concat([this.datarecord.id])); - this.dataset.index = this.dataset.ids.length - 1; - } else { - this.dataset.alter_ids([this.datarecord.id].concat(this.dataset.ids)); - this.dataset.index = 0; - } - this.do_update_pager(); - if (this.sidebar) { - this.sidebar.do_attachement_update(this.dataset, this.datarecord.id); - } - //openerp.log("The record has been created with id #" + this.datarecord.id); - return $.when(this.reload()).then(function () { - self.trigger('record_created', r); - return _.extend(r, {created: true}); - }); - } - }, - on_action: function (action) { - console.debug('Executing action', action); - }, - reload: function() { - var self = this; - return this.reload_mutex.exec(function() { - if (self.dataset.index === null || self.dataset.index === undefined) { - self.trigger("previous_view"); - return $.Deferred().reject().promise(); - } - if (self.dataset.index < 0) { - return $.when(self.on_button_new()); - } else { - var fields = _.keys(self.fields_view.fields); - fields.push('display_name'); - return self.dataset.read_index(fields, - { - context: { - 'bin_size': true, - 'future_display_name': true - }, - check_access_rule: true - }).then(function(r) { - self.trigger('load_record', r); - }).fail(function (){ - self.do_action('history_back'); - }); - } - }); - }, - get_widgets: function() { - return _.filter(this.getChildren(), function(obj) { - return obj instanceof instance.web.form.FormWidget; - }); - }, - get_fields_values: function() { - var values = {}; - var ids = this.get_selected_ids(); - values["id"] = ids.length > 0 ? ids[0] : false; - _.each(this.fields, function(value_, key) { - values[key] = value_.get_value(); - }); - return values; - }, - get_selected_ids: function() { - var id = this.dataset.ids[this.dataset.index]; - return id ? [id] : []; - }, - recursive_save: function() { - var self = this; - return $.when(this.save()).then(function(res) { - if (self.dataset.parent_view) - return self.dataset.parent_view.recursive_save(); - }); - }, - recursive_reload: function() { - var self = this; - var pre = $.when(); - if (self.dataset.parent_view) - pre = self.dataset.parent_view.recursive_reload(); - return pre.then(function() { - return self.reload(); - }); - }, - is_dirty: function() { - return _.any(this.fields, function (value_) { - return value_._dirty_flag; - }); - }, - is_interactible_record: function() { - var id = this.datarecord.id; - if (!id) { - if (this.options.not_interactible_on_create) - return false; - } else if (typeof(id) === "string") { - if(instance.web.BufferedDataSet.virtual_id_regex.test(id)) - return false; - } - return true; - }, - sidebar_eval_context: function () { - return $.when(this.build_eval_context()); - }, - open_defaults_dialog: function () { - var self = this; - var display = function (field, value) { - if (!value) { return value; } - if (field instanceof instance.web.form.FieldSelection) { - return _(field.get('values')).find(function (option) { - return option[0] === value; - })[1]; - } else if (field instanceof instance.web.form.FieldMany2One) { - return field.get_displayed(); - } - return value; - }; - var fields = _.chain(this.fields) - .map(function (field) { - var value = field.get_value(); - // ignore fields which are empty, invisible, readonly, o2m - // or m2m - if (!value - || field.get('invisible') - || field.get("readonly") - || field.field.type === 'one2many' - || field.field.type === 'many2many' - || field.field.type === 'binary' - || field.password) { - return false; - } - - return { - name: field.name, - string: field.string, - value: value, - displayed: display(field, value), - }; - }) - .compact() - .sortBy(function (field) { return field.string; }) - .value(); - var conditions = _.chain(self.fields) - .filter(function (field) { return field.field.change_default; }) - .map(function (field) { - var value = field.get_value(); - return { - name: field.name, - string: field.string, - value: value, - displayed: display(field, value), - }; - }) - .value(); - var d = new instance.web.Dialog(this, { - title: _t("Set Default"), - args: { - fields: fields, - conditions: conditions - }, - buttons: [ - {text: _t("Close"), click: function () { d.close(); }}, - {text: _t("Save default"), click: function () { - var $defaults = d.$el.find('#formview_default_fields'); - var field_to_set = $defaults.val(); - if (!field_to_set) { - $defaults.parent().addClass('oe_form_invalid'); - return; - } - var condition = d.$el.find('#formview_default_conditions').val(), - all_users = d.$el.find('#formview_default_all').is(':checked'); - new instance.web.DataSet(self, 'ir.values').call( - 'set_default', [ - self.dataset.model, - field_to_set, - self.fields[field_to_set].get_value(), - all_users, - true, - condition || false - ]).done(function () { d.close(); }); - }} - ] - }); - d.template = 'FormView.set_default'; - d.open(); - }, - register_field: function(field, name) { - this.fields[name] = field; - this.fields_order.push(name); - if (JSON.parse(field.node.attrs.default_focus || "0")) { - this.default_focus_field = field; - } - - field.on('focused', null, this.proxy('widgetFocused')) - .on('blurred', null, this.proxy('widgetBlurred')); - if (this.get_field_desc(name).translate) { - this.translatable_fields.push(field); - } - field.on('changed_value', this, function() { - if (field.is_syntax_valid()) { - this.trigger('field_changed:' + name); - } - if (field._inhibit_on_change_flag) { - return; - } - field._dirty_flag = true; - if (field.is_syntax_valid()) { - this.do_onchange(field); - this.on_form_changed(true); - this.do_notify_change(); - } - }); - }, - get_field_desc: function(field_name) { - return this.fields_view.fields[field_name]; - }, - get_field_value: function(field_name) { - return this.fields[field_name].get_value(); - }, - compute_domain: function(expression) { - return instance.web.form.compute_domain(expression, this.fields); - }, - _build_view_fields_values: function() { - var a_dataset = this.dataset; - var fields_values = this.get_fields_values(); - var active_id = a_dataset.ids[a_dataset.index]; - _.extend(fields_values, { - active_id: active_id || false, - active_ids: active_id ? [active_id] : [], - active_model: a_dataset.model, - parent: {} - }); - if (a_dataset.parent_view) { - fields_values.parent = a_dataset.parent_view.get_fields_values(); - } - return fields_values; - }, - build_eval_context: function() { - var a_dataset = this.dataset; - return new instance.web.CompoundContext(a_dataset.get_context(), this._build_view_fields_values()); - }, -}); - -/** - * Interface to be implemented by rendering engines for the form view. - */ -instance.web.form.FormRenderingEngineInterface = instance.web.Class.extend({ - set_fields_view: function(fields_view) {}, - set_fields_registry: function(fields_registry) {}, - render_to: function($el) {}, -}); - -/** - * Default rendering engine for the form view. - * - * It is necessary to set the view using set_view() before usage. - */ -instance.web.form.FormRenderingEngine = instance.web.form.FormRenderingEngineInterface.extend({ - init: function(view) { - this.view = view; - }, - set_fields_view: function(fvg) { - this.fvg = fvg; - this.version = parseFloat(this.fvg.arch.attrs.version); - if (isNaN(this.version)) { - this.version = 7.0; - } - }, - set_tags_registry: function(tags_registry) { - this.tags_registry = tags_registry; - }, - set_fields_registry: function(fields_registry) { - this.fields_registry = fields_registry; - }, - set_widgets_registry: function(widgets_registry) { - this.widgets_registry = widgets_registry; - }, - // Backward compatibility tools, current default version: v7 - process_version: function() { - if (this.version < 7.0) { - this.$form.find('form:first').wrapInner('<group col="4"/>'); - this.$form.find('page').each(function() { - if (!$(this).parents('field').length) { - $(this).wrapInner('<group col="4"/>'); - } - }); - } - }, - get_arch_fragment: function() { - var doc = $.parseXML(instance.web.json_node_to_xml(this.fvg.arch)).documentElement; - // IE won't allow custom button@type and will revert it to spec default : 'submit' - $('button', doc).each(function() { - $(this).attr('data-button-type', $(this).attr('type')).attr('type', 'button'); - }); - // IE's html parser is also a css parser. How convenient... - $('board', doc).each(function() { - $(this).attr('layout', $(this).attr('style')); - }); - return $('<div class="oe_form"/>').append(instance.web.xml_to_str(doc)); - }, - render_to: function($target) { - var self = this; - this.$target = $target; - - this.$form = this.get_arch_fragment(); - - this.process_version(); - - this.fields_to_init = []; - this.tags_to_init = []; - this.widgets_to_init = []; - this.labels = {}; - this.process(this.$form); - - this.$form.appendTo(this.$target); - - this.to_replace = []; - - _.each(this.fields_to_init, function($elem) { - var name = $elem.attr("name"); - if (!self.fvg.fields[name]) { - throw new Error(_.str.sprintf(_t("Field '%s' specified in view could not be found."), name)); - } - var obj = self.fields_registry.get_any([$elem.attr('widget'), self.fvg.fields[name].type]); - if (!obj) { - throw new Error(_.str.sprintf(_t("Widget type '%s' is not implemented"), $elem.attr('widget'))); - } - var w = new (obj)(self.view, instance.web.xml_to_json($elem[0])); - var $label = self.labels[$elem.attr("name")]; - if ($label) { - w.set_input_id($label.attr("for")); - } - self.alter_field(w); - self.view.register_field(w, $elem.attr("name")); - self.to_replace.push([w, $elem]); - }); - _.each(this.tags_to_init, function($elem) { - var tag_name = $elem[0].tagName.toLowerCase(); - var obj = self.tags_registry.get_object(tag_name); - var w = new (obj)(self.view, instance.web.xml_to_json($elem[0])); - self.to_replace.push([w, $elem]); - }); - _.each(this.widgets_to_init, function($elem) { - var widget_type = $elem.attr("type"); - var obj = self.widgets_registry.get_object(widget_type); - var w = new (obj)(self.view, instance.web.xml_to_json($elem[0])); - self.to_replace.push([w, $elem]); - }); - }, - init_fields: function() { - var defs = []; - _.each(this.to_replace, function(el) { - defs.push(el[0].replace(el[1])); - if (el[1].children().length) { - el[0].$el.append(el[1].children()); - } - }); - this.to_replace = []; - return $.when.apply($, defs); - }, - render_element: function(template /* dictionaries */) { - var dicts = [].slice.call(arguments).slice(1); - var dict = _.extend.apply(_, dicts); - dict['classnames'] = dict['class'] || ''; // class is a reserved word and might caused problem to Safari when used from QWeb - return $(QWeb.render(template, dict)); - }, - alter_field: function(field) { - }, - toggle_layout_debugging: function() { - if (!this.$target.has('.oe_layout_debug_cell:first').length) { - this.$target.find('[title]').removeAttr('title'); - this.$target.find('.oe_form_group_cell').each(function() { - var text = 'W:' + ($(this).attr('width') || '') + ' - C:' + $(this).attr('colspan'); - $(this).attr('title', text); - }); - } - this.$target.toggleClass('oe_layout_debugging'); - }, - process: function($tag) { - var self = this; - var tagname = $tag[0].nodeName.toLowerCase(); - if (this.tags_registry.contains(tagname)) { - this.tags_to_init.push($tag); - return (tagname === 'button') ? this.process_button($tag) : $tag; - } - var fn = self['process_' + tagname]; - if (fn) { - var args = [].slice.call(arguments); - args[0] = $tag; - return fn.apply(self, args); - } else { - // generic tag handling, just process children - $tag.children().each(function() { - self.process($(this)); - }); - self.handle_common_properties($tag, $tag); - $tag.removeAttr("modifiers"); - return $tag; - } - }, - process_button: function ($button) { - var self = this; - $button.children().each(function() { - self.process($(this)); - }); - return $button; - }, - process_widget: function($widget) { - this.widgets_to_init.push($widget); - return $widget; - }, - process_sheet: function($sheet) { - var $new_sheet = this.render_element('FormRenderingSheet', $sheet.getAttributes()); - this.handle_common_properties($new_sheet, $sheet); - var $dst = $new_sheet.find('.oe_form_sheet'); - $sheet.contents().appendTo($dst); - $sheet.before($new_sheet).remove(); - this.process($new_sheet); - }, - process_form: function($form) { - if ($form.find('> sheet').length === 0) { - $form.addClass('oe_form_nosheet'); - } - var $new_form = this.render_element('FormRenderingForm', $form.getAttributes()); - this.handle_common_properties($new_form, $form); - $form.contents().appendTo($new_form); - if ($form[0] === this.$form[0]) { - // If root element, replace it - this.$form = $new_form; - } else { - $form.before($new_form).remove(); - } - this.process($new_form); - }, - /* - * Used by direct <field> children of a <group> tag only - * This method will add the implicit <label...> for every field - * in the <group> - */ - preprocess_field: function($field) { - var self = this; - var name = $field.attr('name'), - field_colspan = parseInt($field.attr('colspan'), 10), - field_modifiers = JSON.parse($field.attr('modifiers') || '{}'); - - if ($field.attr('nolabel') === '1') - return; - $field.attr('nolabel', '1'); - var found = false; - this.$form.find('label[for="' + name + '"]').each(function(i ,el) { - $(el).parents().each(function(unused, tag) { - var name = tag.tagName.toLowerCase(); - if (name === "field" || name in self.tags_registry.map) - found = true; - }); - }); - if (found) - return; - - var $label = $('<label/>').attr({ - 'for' : name, - "modifiers": JSON.stringify({invisible: field_modifiers.invisible}), - "string": $field.attr('string'), - "help": $field.attr('help'), - "class": $field.attr('class'), - }); - $label.insertBefore($field); - if (field_colspan > 1) { - $field.attr('colspan', field_colspan - 1); - } - return $label; - }, - process_field: function($field) { - if ($field.parent().is('group')) { - // No implicit labels for normal fields, only for <group> direct children - var $label = this.preprocess_field($field); - if ($label) { - this.process($label); - } - } - this.fields_to_init.push($field); - return $field; - }, - process_group: function($group) { - var self = this; - $group.children('field').each(function() { - self.preprocess_field($(this)); - }); - var $new_group = this.render_element('FormRenderingGroup', $group.getAttributes()); - var $table; - if ($new_group.first().is('table.oe_form_group')) { - $table = $new_group; - } else if ($new_group.filter('table.oe_form_group').length) { - $table = $new_group.filter('table.oe_form_group').first(); - } else { - $table = $new_group.find('table.oe_form_group').first(); - } - - var $tr, $td, - cols = parseInt($group.attr('col') || 2, 10), - row_cols = cols; - - var children = []; - $group.children().each(function(a,b,c) { - var $child = $(this); - var colspan = parseInt($child.attr('colspan') || 1, 10); - var tagName = $child[0].tagName.toLowerCase(); - var $td = $('<td/>').addClass('oe_form_group_cell').attr('colspan', colspan); - var newline = tagName === 'newline'; - - // Note FME: those classes are used in layout debug mode - if ($tr && row_cols > 0 && (newline || row_cols < colspan)) { - $tr.addClass('oe_form_group_row_incomplete'); - if (newline) { - $tr.addClass('oe_form_group_row_newline'); - } - } - if (newline) { - $tr = null; - return; - } - if (!$tr || row_cols < colspan) { - $tr = $('<tr/>').addClass('oe_form_group_row').appendTo($table); - row_cols = cols; - } else if (tagName==='group') { - // When <group> <group/><group/> </group>, we need a spacing between the two groups - $td.addClass('oe_group_right'); - } - row_cols -= colspan; - - // invisibility transfer - var field_modifiers = JSON.parse($child.attr('modifiers') || '{}'); - var invisible = field_modifiers.invisible; - self.handle_common_properties($td, $("<dummy>").attr("modifiers", JSON.stringify({invisible: invisible}))); - - $tr.append($td.append($child)); - children.push($child[0]); - }); - if (row_cols && $td) { - $td.attr('colspan', parseInt($td.attr('colspan'), 10) + row_cols); - } - $group.before($new_group).remove(); - - $table.find('> tbody > tr').each(function() { - var to_compute = [], - row_cols = cols, - total = 100; - $(this).children().each(function() { - var $td = $(this), - $child = $td.children(':first'); - if ($child.attr('cell-class')) { - $td.addClass($child.attr('cell-class')); - } - switch ($child[0].tagName.toLowerCase()) { - case 'separator': - break; - case 'label': - if ($child.attr('for')) { - $td.attr('width', '1%').addClass('oe_form_group_cell_label'); - row_cols-= $td.attr('colspan') || 1; - total--; - } - break; - default: - var width = _.str.trim($child.attr('width') || ''), - iwidth = parseInt(width, 10); - if (iwidth) { - if (width.substr(-1) === '%') { - total -= iwidth; - width = iwidth + '%'; - } else { - // Absolute width - $td.css('min-width', width + 'px'); - } - $td.attr('width', width); - $child.removeAttr('width'); - row_cols-= $td.attr('colspan') || 1; - } else { - to_compute.push($td); - } - - } - }); - if (row_cols) { - var unit = Math.floor(total / row_cols); - if (!$(this).is('.oe_form_group_row_incomplete')) { - _.each(to_compute, function($td, i) { - var width = parseInt($td.attr('colspan'), 10) * unit; - $td.attr('width', width + '%'); - total -= width; - }); - } - } - }); - _.each(children, function(el) { - self.process($(el)); - }); - this.handle_common_properties($new_group, $group); - return $new_group; - }, - process_notebook: function($notebook) { - var self = this; - - // Extract useful info from the notebook xml declaration - var pages = []; - $notebook.find('> page').each(function() { - var $page = $(this); - var page_attrs = $page.getAttributes(); - page_attrs.id = _.uniqueId('notebook_page_'); - page_attrs.contents = $page.html(); - page_attrs.ref = $page; // reference to the current page node in the xml declaration - pages.push(page_attrs); - }); - - // Render the notebook and replace $notebook with it - var $new_notebook = self.render_element('FormRenderingNotebook', {'pages': pages}); - $notebook.before($new_notebook).remove(); - - // Bind the invisibility changers and find the page to display - var pageid_to_display; - _.each(pages, function(page) { - var $tab = $new_notebook.find('a[href=#' + page.id + ']').parent(); - var $content = $new_notebook.find('#' + page.id); - - // Case: <page autofocus="autofocus">; - // We attach the autofocus attribute to the node because it can be useful during the - // page execution - self.attach_node_attr($tab, page.ref, 'autofocus'); - self.attach_node_attr($content, page.ref, 'autofocus'); - if (!pageid_to_display && page.autofocus) { - // If multiple autofocus, keep the first one - pageid_to_display = page.id; - } - - // Case: <page attrs="{'invisible': domain}">; - self.handle_common_properties($tab, page.ref, instance.web.form.NotebookInvisibilityChanger); - self.handle_common_properties($content, page.ref, instance.web.form.NotebookInvisibilityChanger); - }); - if (!pageid_to_display) { - pageid_to_display = $new_notebook.find('div[role="tabpanel"]:not(.oe_form_invisible):first').attr('id'); - } - - // Display page. Note: we can't use bootstrap's show function because it is looking for - // document attached DOM, and the form view is only attached when everything is processed - $new_notebook.find('a[href=#' + pageid_to_display + ']').parent().addClass('active'); - $new_notebook.find('#' + pageid_to_display).addClass('active'); - - self.process($new_notebook.children()); - self.handle_common_properties($new_notebook, $notebook); - - return $new_notebook; - }, - process_separator: function($separator) { - var $new_separator = this.render_element('FormRenderingSeparator', $separator.getAttributes()); - $separator.before($new_separator).remove(); - this.handle_common_properties($new_separator, $separator); - return $new_separator; - }, - process_label: function($label) { - var name = $label.attr("for"), - field_orm = this.fvg.fields[name]; - var dict = { - string: $label.attr('string') || (field_orm || {}).string || '', - help: $label.attr('help') || (field_orm || {}).help || '', - _for: name ? _.uniqueId('oe-field-input-') : undefined, - }; - var align = parseFloat(dict.align); - if (isNaN(align) || align === 1) { - align = 'right'; - } else if (align === 0) { - align = 'left'; - } else { - align = 'center'; - } - dict.align = align; - var $new_label = this.render_element('FormRenderingLabel', dict); - $label.before($new_label).remove(); - this.handle_common_properties($new_label, $label); - if (name) { - this.labels[name] = $new_label; - } - return $new_label; - }, - handle_common_properties: function($new_element, $node, InvisibilityChanger) { - var str_modifiers = $node.attr("modifiers") || "{}"; - var modifiers = JSON.parse(str_modifiers); - var ic = null; - if (modifiers.invisible !== undefined) { - var InvisibilityChangerCls = InvisibilityChanger || instance.web.form.InvisibilityChanger; - ic = new InvisibilityChangerCls(this.view, this.view, modifiers.invisible, $new_element); - } - $new_element.addClass($node.attr("class") || ""); - $new_element.attr('style', $node.attr('style')); - return {invisibility_changer: ic,}; - }, - /** - * Attach a node attribute to an html element to be able to use it after - * the processing of the page. - */ - attach_node_attr: function($new_element, $node, attr) { - $new_element.data(attr, $node.attr(attr)); - } -}); - -/** - Welcome. - - If you read this documentation, it probably means that you were asked to use a form view widget outside of - a form view. Before going further, you must understand that those fields were never really created for - that usage. Don't think that this class will hold the answer to all your problems, at best it will allow - you to hack the system with more style. -*/ -instance.web.form.DefaultFieldManager = instance.web.Widget.extend({ - init: function(parent, eval_context) { - this._super(parent); - this.field_descs = {}; - this.eval_context = eval_context || {}; - this.set({ - display_invalid_fields: false, - actual_mode: 'create', - }); - }, - get_field_desc: function(field_name) { - if (this.field_descs[field_name] === undefined) { - this.field_descs[field_name] = { - string: field_name, - }; - } - return this.field_descs[field_name]; - }, - extend_field_desc: function(fields) { - var self = this; - _.each(fields, function(v, k) { - _.extend(self.get_field_desc(k), v); - }); - }, - get_field_value: function(field_name) { - return false; - }, - set_values: function(values) { - // nothing - }, - compute_domain: function(expression) { - return instance.web.form.compute_domain(expression, {}); - }, - build_eval_context: function() { - return new instance.web.CompoundContext(this.eval_context); - }, -}); - -instance.web.form.compute_domain = function(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))); - } - } - - 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]; - - 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); -}; - -instance.web.form.is_bin_size = function(v) { - return (/^\d+(\.\d*)? \w+$/).test(v); -}; - -/** - * Must be applied over an class already possessing the PropertiesMixin. - * - * Apply the result of the "invisible" domain to this.$el. - */ -instance.web.form.InvisibilityChangerMixin = { - init: function(field_manager, invisible_domain) { - var self = this; - this._ic_field_manager = field_manager; - this._ic_invisible_modifier = invisible_domain; - this._ic_field_manager.on("view_content_has_changed", this, function() { - var result = self._ic_invisible_modifier === undefined ? false : - self._ic_field_manager.compute_domain(self._ic_invisible_modifier); - self.set({"invisible": result}); - }); - this.set({invisible: this._ic_invisible_modifier === true, force_invisible: false}); - var check = function() { - if (self.get("invisible") || self.get('force_invisible')) { - self.set({"effective_invisible": true}); - } else { - self.set({"effective_invisible": false}); - } - }; - this.on('change:invisible', this, check); - this.on('change:force_invisible', this, check); - check.call(this); - }, - start: function() { - this.on("change:effective_invisible", this, this._check_visibility); - this._check_visibility(); - }, - _check_visibility: function() { - this.$el.toggleClass('oe_form_invisible', this.get("effective_invisible")); - }, -}; - -instance.web.form.InvisibilityChanger = instance.web.Class.extend(instance.web.PropertiesMixin, instance.web.form.InvisibilityChangerMixin, { - init: function(parent, field_manager, invisible_domain, $el) { - this.setParent(parent); - instance.web.PropertiesMixin.init.call(this); - instance.web.form.InvisibilityChangerMixin.init.call(this, field_manager, invisible_domain); - this.$el = $el; - this.start(); - }, -}); - -// Specialization of InvisibilityChanger for the `notebook` form element to handle more -// elegantly its special cases (i.e. activate the closest visible sibling) -instance.web.form.NotebookInvisibilityChanger = instance.web.form.InvisibilityChanger.extend({ - // Override start so that it does not call _check_visibility since it will be - // called again when view content will be loaded (event view_content_has_changed) - start: function() { - this.on("change:effective_invisible", this, this._check_visibility); - }, - _check_visibility: function() { - this._super(); - if (this.get("effective_invisible") === true) { - // Switch to invisible - // Remove this element as active and set a visible sibling active (if there is one) - if (this.$el.hasClass('active')) { - this.$el.removeClass('active'); - var visible_siblings = this.$el.siblings(':not(.oe_form_invisible)'); - if (visible_siblings.length) { - $(visible_siblings[0]).addClass('active'); - } - } - } else { - // Switch to visible - // If there is no visible active sibling, set this element as active, - // otherwise if that sibling hasn't autofocus and if we are in edit mode, - // remove that sibling as active and set this element as active - var visible_active_sibling = this.$el.siblings(':not(.oe_form_invisible).active'); - if (!(visible_active_sibling.length)) { - this.$el.addClass('active'); - } else if (!$(visible_active_sibling[0]).data('autofocus') && this._ic_field_manager.get('actual_mode') === "edit") { - this.$el.addClass('active'); - $(visible_active_sibling[0]).removeClass('active'); - } - } - }, -}); - - -/** - Base class for all fields, custom widgets and buttons to be displayed in the form view. - - Properties: - - effective_readonly: when it is true, the widget is displayed as readonly. Vary depending - the values of the "readonly" property and the "mode" property on the field manager. -*/ -instance.web.form.FormWidget = instance.web.Widget.extend(instance.web.form.InvisibilityChangerMixin, { - /** - * @constructs instance.web.form.FormWidget - * @extends instance.web.Widget - * - * @param field_manager - * @param node - */ - init: function(field_manager, node) { - this._super(field_manager); - this.field_manager = field_manager; - if (this.field_manager instanceof instance.web.FormView) - this.view = this.field_manager; - this.node = node; - this.modifiers = JSON.parse(this.node.attrs.modifiers || '{}'); - instance.web.form.InvisibilityChangerMixin.init.call(this, this.field_manager, this.modifiers.invisible); - - this.field_manager.on("view_content_has_changed", this, this.process_modifiers); - - this.set({ - required: false, - readonly: false, - }); - // some events to make the property "effective_readonly" sync automatically with "readonly" and - // "mode" on field_manager - var self = this; - var test_effective_readonly = function() { - self.set({"effective_readonly": self.get("readonly") || self.field_manager.get("actual_mode") === "view"}); - }; - this.on("change:readonly", this, test_effective_readonly); - this.field_manager.on("change:actual_mode", this, test_effective_readonly); - test_effective_readonly.call(this); - }, - renderElement: function() { - this.process_modifiers(); - this._super(); - this.$el.addClass(this.node.attrs["class"] || ""); - }, - destroy: function() { - $.fn.tooltip('destroy'); - this._super.apply(this, arguments); - }, - /** - * Sets up blur/focus forwarding from DOM elements to a widget (`this`). - * - * This method is an utility method that is meant to be called by child classes. - * - * @param {jQuery} $e jQuery object of elements to bind focus/blur on - */ - setupFocus: function ($e) { - var self = this; - $e.on({ - focus: function () { self.trigger('focused'); }, - blur: function () { self.trigger('blurred'); } - }); - }, - process_modifiers: function() { - var to_set = {}; - for (var a in this.modifiers) { - if (!this.modifiers.hasOwnProperty(a)) { continue; } - if (!_.include(["invisible"], a)) { - var val = this.field_manager.compute_domain(this.modifiers[a]); - to_set[a] = val; - } - } - this.set(to_set); - }, - do_attach_tooltip: function(widget, trigger, options) { - widget = widget || this; - trigger = trigger || this.$el; - options = _.extend({ - delay: { show: 500, hide: 0 }, - title: function() { - var template = widget.template + '.tooltip'; - if (!QWeb.has_template(template)) { - template = 'WidgetLabel.tooltip'; - } - return QWeb.render(template, { - debug: instance.session.debug, - widget: widget - }); - }, - }, options || {}); - //only show tooltip if we are in debug or if we have a help to show, otherwise it will display - //as empty - if (instance.session.debug || widget.node.attrs.help || (widget.field && widget.field.help)){ - $(trigger).tooltip(options); - } - }, - /** - * Builds a new context usable for operations related to fields by merging - * the fields'context with the action's context. - */ - build_context: function() { - // only use the model's context if there is not context on the node - var v_context = this.node.attrs.context; - if (! v_context) { - v_context = (this.field || {}).context || {}; - } - - if (v_context.__ref || true) { //TODO: remove true - var fields_values = this.field_manager.build_eval_context(); - v_context = new instance.web.CompoundContext(v_context).set_eval_context(fields_values); - } - return v_context; - }, - build_domain: function() { - var f_domain = this.field.domain || []; - var n_domain = this.node.attrs.domain || null; - // if there is a domain on the node, overrides the model's domain - var final_domain = n_domain !== null ? n_domain : f_domain; - if (!(final_domain instanceof Array) || true) { //TODO: remove true - var fields_values = this.field_manager.build_eval_context(); - final_domain = new instance.web.CompoundDomain(final_domain).set_eval_context(fields_values); - } - return final_domain; - } -}); - -instance.web.form.WidgetButton = instance.web.form.FormWidget.extend({ - template: 'WidgetButton', - init: function(field_manager, node) { - node.attrs.type = node.attrs['data-button-type']; - this.is_stat_button = /\boe_stat_button\b/.test(node.attrs['class']); - this.icon_class = node.attrs.icon && "stat_button_icon fa " + node.attrs.icon + " fa-fw"; - this._super(field_manager, node); - this.force_disabled = false; - this.string = (this.node.attrs.string || '').replace(/_/g, ''); - if (JSON.parse(this.node.attrs.default_focus || "0")) { - // TODO fme: provide enter key binding to widgets - this.view.default_focus_button = this; - } - if (this.node.attrs.icon && (! /\//.test(this.node.attrs.icon))) { - this.node.attrs.icon = '/web/static/src/img/icons/' + this.node.attrs.icon + '.png'; - } - }, - start: function() { - this._super.apply(this, arguments); - this.view.on('view_content_has_changed', this, this.check_disable); - this.check_disable(); - this.$el.click(this.on_click); - if (this.node.attrs.help || instance.session.debug) { - this.do_attach_tooltip(); - } - this.setupFocus(this.$el); - }, - on_click: function() { - var self = this; - this.force_disabled = true; - this.check_disable(); - this.execute_action().always(function() { - self.force_disabled = false; - self.check_disable(); - }); - }, - execute_action: function() { - var self = this; - var exec_action = function() { - if (self.node.attrs.confirm) { - var def = $.Deferred(); - var dialog = new instance.web.Dialog(this, { - title: _t('Confirm'), - buttons: [ - {text: _t("Cancel"), click: function() { - this.parents('.modal').modal('hide'); - } - }, - {text: _t("Ok"), click: function() { - var self2 = this; - self.on_confirmed().always(function() { - self2.parents('.modal').modal('hide'); - }); - } - } - ], - }, $('<div/>').text(self.node.attrs.confirm)).open(); - dialog.on("closing", null, function() {def.resolve();}); - return def.promise(); - } else { - return self.on_confirmed(); - } - }; - if (!this.node.attrs.special) { - return this.view.recursive_save().then(exec_action); - } else { - return exec_action(); - } - }, - on_confirmed: function() { - var self = this; - - var context = this.build_context(); - return this.view.do_execute_action( - _.extend({}, this.node.attrs, {context: context}), - this.view.dataset, this.view.datarecord.id, function (reason) { - if (!_.isObject(reason)) { - self.view.recursive_reload(); - } - }); - }, - check_disable: function() { - var disabled = (this.force_disabled || !this.view.is_interactible_record()); - this.$el.prop('disabled', disabled); - this.$el.css('color', disabled ? 'grey' : ''); - } -}); - -/** - * Interface to be implemented by fields. - * - * Events: - * - changed_value: triggered when the value of the field has changed. This can be due - * to a user interaction or a call to set_value(). - * - */ -instance.web.form.FieldInterface = { - /** - * Constructor takes 2 arguments: - * - field_manager: Implements FieldManagerMixin - * - node: the "<field>" node in json form - */ - init: function(field_manager, node) {}, - /** - * Called by the form view to indicate the value of the field. - * - * Multiple calls to set_value() can occur at any time and must be handled correctly by the implementation, - * regardless of any asynchronous operation currently running. Calls to set_value() can and will also occur - * before the widget is inserted into the DOM. - * - * set_value() must be able, at any moment, to handle the syntax returned by the "read" method of the - * osv class in the OpenERP server as well as the syntax used by the set_value() (see below). It must - * also be able to handle any other format commonly used in the _defaults key on the models in the addons - * as well as any format commonly returned in a on_change. It must be able to autodetect those formats as - * no information is ever given to know which format is used. - */ - set_value: function(value_) {}, - /** - * Get the current value of the widget. - * - * Must always return a syntactically correct value to be passed to the "write" method of the osv class in - * the OpenERP server, although it is not assumed to respect the constraints applied to the field. - * For example if the field is marked as "required", a call to get_value() can return false. - * - * get_value() can also be called *before* a call to set_value() and, in that case, is supposed to - * return a default value according to the type of field. - * - * This method is always assumed to perform synchronously, it can not return a promise. - * - * If there was no user interaction to modify the value of the field, it is always assumed that - * get_value() return the same semantic value than the one passed in the last call to set_value(), - * although the syntax can be different. This can be the case for type of fields that have a different - * syntax for "read" and "write" (example: m2o: set_value([0, "Administrator"]), get_value() => 0). - */ - get_value: function() {}, - /** - * Inform the current object of the id it should use to match a html <label> that exists somewhere in the - * view. - */ - set_input_id: function(id) {}, - /** - * Returns true if is_syntax_valid() returns true and the value is semantically - * valid too according to the semantic restrictions applied to the field. - */ - is_valid: function() {}, - /** - * Returns true if the field holds a value which is syntactically correct, ignoring - * the potential semantic restrictions applied to the field. - */ - is_syntax_valid: function() {}, - /** - * Must set the focus on the field. Return false if field is not focusable. - */ - focus: function() {}, - /** - * Called when the translate button is clicked. - */ - on_translate: function() {}, - /** - This method is called by the form view before reading on_change values and before saving. It tells - the field to save its value before reading it using get_value(). Must return a promise. - */ - commit_value: function() {}, -}; - -/** - * Abstract class for classes implementing FieldInterface. - * - * Properties: - * - value: useful property to hold the value of the field. By default, set_value() and get_value() - * set and retrieve the value property. Changing the value property also triggers automatically - * a 'changed_value' event that inform the view to trigger on_changes. - * - */ -instance.web.form.AbstractField = instance.web.form.FormWidget.extend(instance.web.form.FieldInterface, { - /** - * @constructs instance.web.form.AbstractField - * @extends instance.web.form.FormWidget - * - * @param field_manager - * @param node - */ - init: function(field_manager, node) { - var self = this; - this._super(field_manager, node); - this.name = this.node.attrs.name; - this.field = this.field_manager.get_field_desc(this.name); - this.widget = this.node.attrs.widget; - this.string = this.node.attrs.string || this.field.string || this.name; - this.options = instance.web.py_eval(this.node.attrs.options || '{}'); - this.set({'value': false}); - - this.on("change:value", this, function() { - this.trigger('changed_value'); - this._check_css_flags(); - }); - }, - renderElement: function() { - var self = this; - this._super(); - if (this.field.translate && this.view) { - this.$el.addClass('oe_form_field_translatable'); - this.$el.find('.oe_field_translate').click(this.on_translate); - } - this.$label = this.view ? this.view.$el.find('label[for=' + this.id_for_label + ']') : $(); - this.do_attach_tooltip(this, this.$label[0] || this.$el); - if (instance.session.debug) { - this.$label.off('dblclick').on('dblclick', function() { - console.log("Field '%s' of type '%s' in View: %o", self.name, (self.node.attrs.widget || self.field.type), self.view); - window.w = self; - console.log("window.w =", window.w); - }); - } - if (!this.disable_utility_classes) { - this.off("change:required", this, this._set_required); - this.on("change:required", this, this._set_required); - this._set_required(); - } - this._check_visibility(); - this.field_manager.off("change:display_invalid_fields", this, this._check_css_flags); - this.field_manager.on("change:display_invalid_fields", this, this._check_css_flags); - this._check_css_flags(); - }, - start: function() { - var tmp = this._super(); - this.on("change:value", this, function() { - if (! this.no_rerender) - this.render_value(); - }); - this.render_value(); - }, - /** - * Private. Do not use. - */ - _set_required: function() { - this.$el.toggleClass('oe_form_required', this.get("required")); - }, - set_value: function(value_) { - this.set({'value': value_}); - }, - get_value: function() { - return this.get('value'); - }, - /** - Utility method that all implementations should use to change the - value without triggering a re-rendering. - */ - internal_set_value: function(value_) { - var tmp = this.no_rerender; - this.no_rerender = true; - this.set({'value': value_}); - this.no_rerender = tmp; - }, - /** - This method is called each time the value is modified. - */ - render_value: function() {}, - is_valid: function() { - return this.is_syntax_valid() && !(this.get('required') && this.is_false()); - }, - is_syntax_valid: function() { - return true; - }, - /** - * Method useful to implement to ease validity testing. Must return true if the current - * value is similar to false in OpenERP. - */ - is_false: function() { - return this.get('value') === false; - }, - _check_css_flags: function() { - if (this.field.translate) { - this.$el.find('.oe_field_translate').toggle(this.field_manager.get('actual_mode') !== "create"); - } - if (!this.disable_utility_classes) { - if (this.field_manager.get('display_invalid_fields')) { - this.$el.toggleClass('oe_form_invalid', !this.is_valid()); - } - } - }, - focus: function() { - return false; - }, - set_input_id: function(id) { - this.id_for_label = id; - }, - on_translate: function() { - var self = this; - var trans = new instance.web.DataSet(this, 'ir.translation'); - return trans.call_button('translate_fields', [this.view.dataset.model, this.view.datarecord.id, this.name, this.view.dataset.get_context()]).done(function(r) { - self.do_action(r); - }); - }, - - set_dimensions: function (height, width) { - this.$el.css({ - width: width, - minHeight: height - }); - }, - commit_value: function() { - return $.when(); - }, -}); - -/** - * A mixin to apply on any FormWidget that has to completely re-render when its readonly state - * switch. - */ -instance.web.form.ReinitializeWidgetMixin = { - /** - * Default implementation of, you should not override it, use initialize_field() instead. - */ - start: function() { - this.initialize_field(); - this._super(); - }, - initialize_field: function() { - this.on("change:effective_readonly", this, this.reinitialize); - this.initialize_content(); - }, - reinitialize: function() { - this.destroy_content(); - this.renderElement(); - this.initialize_content(); - }, - /** - * Called to destroy anything that could have been created previously, called before a - * re-initialization. - */ - destroy_content: function() {}, - /** - * Called to initialize the content. - */ - initialize_content: function() {}, -}; - -/** - * A mixin to apply on any field that has to completely re-render when its readonly state - * switch. - */ -instance.web.form.ReinitializeFieldMixin = _.extend({}, instance.web.form.ReinitializeWidgetMixin, { - reinitialize: function() { - instance.web.form.ReinitializeWidgetMixin.reinitialize.call(this); - var res = this.render_value(); - if (this.view && this.view.render_value_defs){ - this.view.render_value_defs.push(res); - } - }, -}); - -/** - 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(); - } - }); -} - -instance.web.form.FieldChar = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: 'FieldChar', - widget_class: 'oe_form_field_char', - events: { - 'change input': 'store_dom_value', - }, - init: function (field_manager, node) { - this._super(field_manager, node); - this.password = this.node.attrs.password === 'True' || this.node.attrs.password === '1'; - }, - initialize_content: function() { - this.setupFocus(this.$('input')); - }, - store_dom_value: function () { - if (!this.get('effective_readonly') - && this.$('input').length - && this.is_syntax_valid()) { - this.internal_set_value( - this.parse_value( - this.$('input').val())); - } - }, - commit_value: function () { - this.store_dom_value(); - return this._super(); - }, - render_value: function() { - var show_value = this.format_value(this.get('value'), ''); - if (!this.get("effective_readonly")) { - this.$el.find('input').val(show_value); - } else { - if (this.password) { - show_value = new Array(show_value.length + 1).join('*'); - } - this.$(".oe_form_char_content").text(show_value); - } - }, - is_syntax_valid: function() { - if (!this.get("effective_readonly") && this.$("input").size() > 0) { - try { - this.parse_value(this.$('input').val(), ''); - return true; - } catch(e) { - return false; - } - } - return true; - }, - parse_value: function(val, def) { - return instance.web.parse_value(val, this, def); - }, - format_value: function(val, def) { - return instance.web.format_value(val, this, def); - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function() { - var input = this.$('input:first')[0]; - return input ? input.focus() : false; - }, - set_dimensions: function (height, width) { - this._super(height, width); - this.$('input').css({ - height: height, - width: width - }); - } -}); - -instance.web.form.KanbanSelection = instance.web.form.FieldChar.extend({ - init: function (field_manager, node) { - this._super(field_manager, node); - }, - start: function () { - var self = this; - this.states = []; - this._super.apply(this, arguments); - // hook on form view content changed: recompute the states, because it may be related to the current stage - this.getParent().on('view_content_has_changed', self, function () { - self.render_value(); - }); - }, - prepare_dropdown_selection: function() { - var self = this; - var data = []; - var selection = this.field.selection || []; - var stage_id = _.isArray(this.view.datarecord.stage_id) ? this.view.datarecord.stage_id[0] : this.view.datarecord.stage_id; - var legend_field = this.options && this.options.states_legend_field || false; - var fields_to_read = _.map( - this.options && this.options.states_legend || {}, - function (value, key, list) { return value; }); - if (legend_field && fields_to_read && stage_id) { - var fetch_stage = new openerp.web.DataSet( - this, - self.view.fields[legend_field].field.relation).read_ids([stage_id], - fields_to_read); - } - else { var fetch_stage = $.Deferred().resolve(false); } - return $.when(fetch_stage).then(function (stage_data) { - _.map(selection, function(res) { - var value = { - 'name': res[0], - 'tooltip': res[1], - 'state_name': res[1], - } - if (stage_data && stage_data[0][self.options.states_legend[res[0]]]) { - value['state_name'] = stage_data[0][self.options.states_legend[res[0]]]; - value['tooltip'] = stage_data[0][self.options.states_legend[res[0]]]; - } - if (res[0] == 'normal') { value['state_class'] = 'oe_kanban_status'; } - else if (res[0] == 'done') { value['state_class'] = 'oe_kanban_status oe_kanban_status_green'; } - else { value['state_class'] = 'oe_kanban_status oe_kanban_status_red'; } - data.push(value); - }); - return data; - }); - }, - render_value: function() { - var self = this; - var dd_fetched = this.prepare_dropdown_selection();; - return $.when(dd_fetched).then(function (states) { - self.states = states; - self.$el.html(QWeb.render("KanbanSelection", {'widget': self})); - self.$el.find('li').on('click', self.set_kanban_selection.bind(self)); - }) - }, - /* setting the value: in view mode, perform an asynchronous call and reload - the form view; in edit mode, use set_value to save the new value that will - be written when saving the record. */ - set_kanban_selection: function (ev) { - var self = this; - var li = $(ev.target).closest('li'); - if (li.length) { - var value = String(li.data('value')); - if (this.view.get('actual_mode') == 'view') { - var write_values = {} - write_values[self.name] = value; - return this.view.dataset._model.call( - 'write', [ - [this.view.datarecord.id], - write_values, - self.view.dataset.get_context() - ]).done(self.reload_record.bind(self)); - } - else { - return this.set_value(value); - } - } - }, - reload_record: function() { - this.view.reload(); - }, -}); - -instance.web.form.Priority = instance.web.form.FieldChar.extend({ - init: function (field_manager, node) { - this._super(field_manager, node); - }, - prepare_priority: function() { - var self = this; - var selection = this.field.selection || []; - var init_value = selection && selection[0][0] || 0; - var data = _.map(selection.slice(1), function(element, index) { - var value = { - 'value': element[0], - 'name': element[1], - 'click_value': element[0], - } - if (index == 0 && self.get('value') == element[0]) { - value['click_value'] = init_value; - } - return value; - }); - return data; - }, - render_value: function() { - var self = this; - this.priorities = this.prepare_priority(); - this.$el.html(QWeb.render("Priority", {'widget': this})); - if (!this.get('readonly')){ - this.$el.find('li').on('click', this.set_priority.bind(this)); - } - }, - /* setting the value: in view mode, perform an asynchronous call and reload - the form view; in edit mode, use set_value to save the new value that will - be written when saving the record. */ - set_priority: function (ev) { - var self = this; - var li = $(ev.target).closest('li'); - if (li.length) { - var value = String(li.data('value')); - if (this.view.get('actual_mode') == 'view') { - var write_values = {} - write_values[self.name] = value; - return this.view.dataset._model.call( - 'write', [ - [this.view.datarecord.id], - write_values, - self.view.dataset.get_context() - ]).done(self.reload_record.bind(self)); - } - else { - return this.set_value(value); - } - } - - }, - reload_record: function() { - this.view.reload(); - }, -}); - -instance.web.form.FieldID = instance.web.form.FieldChar.extend({ - process_modifiers: function () { - this._super(); - this.set({ readonly: true }); - }, -}); - -instance.web.form.FieldEmail = instance.web.form.FieldChar.extend({ - template: 'FieldEmail', - initialize_content: function() { - this._super(); - var $button = this.$el.find('button'); - $button.click(this.on_button_clicked); - this.setupFocus($button); - }, - render_value: function() { - if (!this.get("effective_readonly")) { - this._super(); - } else { - this.$el.find('a') - .attr('href', 'mailto:' + this.get('value')) - .text(this.get('value') || ''); - } - }, - on_button_clicked: function() { - if (!this.get('value') || !this.is_syntax_valid()) { - this.do_warn(_t("E-mail Error"), _t("Can't send email to invalid e-mail address")); - } else { - location.href = 'mailto:' + this.get('value'); - } - } -}); - -instance.web.form.FieldUrl = instance.web.form.FieldChar.extend({ - template: 'FieldUrl', - initialize_content: function() { - this._super(); - var $button = this.$el.find('button'); - $button.click(this.on_button_clicked); - this.setupFocus($button); - }, - render_value: function() { - if (!this.get("effective_readonly")) { - this._super(); - } else { - var tmp = this.get('value'); - var s = /(\w+):(.+)|^\.{0,2}\//.exec(tmp); - if (!s) { - tmp = "http://" + this.get('value'); - } - var text = this.get('value') ? this.node.attrs.text || tmp : ''; - this.$el.find('a').attr('href', tmp).text(text); - } - }, - on_button_clicked: function() { - if (!this.get('value')) { - this.do_warn(_t("Resource Error"), _t("This resource is empty")); - } else { - var url = $.trim(this.get('value')); - if(/^www\./i.test(url)) - url = 'http://'+url; - window.open(url); - } - } -}); - -instance.web.form.FieldFloat = instance.web.form.FieldChar.extend({ - is_field_number: true, - widget_class: 'oe_form_field_float', - init: function (field_manager, node) { - this._super(field_manager, node); - this.internal_set_value(0); - if (this.node.attrs.digits) { - this.digits = this.node.attrs.digits; - } else { - this.digits = this.field.digits; - } - }, - set_value: function(value_) { - if (value_ === false || value_ === undefined) { - // As in GTK client, floats default to 0 - value_ = 0; - } - if (this.digits !== undefined && this.digits.length === 2) { - value_ = instance.web.round_decimals(value_, this.digits[1]); - } - this._super.apply(this, [value_]); - }, - focus: function () { - var $input = this.$('input:first'); - return $input.length ? $input.select() : false; - } -}); - - -instance.web.form.FieldCharDomain = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - render_value: function() { - var self = this; - this.$el.html(instance.web.qweb.render("FieldCharDomain", {widget: this})); - if (this.get('value')) { - var model = this.options.model || this.field_manager.get_field_value(this.options.model_field); - var domain = instance.web.pyeval.eval('domain', this.get('value')); - var ds = new instance.web.DataSetStatic(self, model, self.build_context()); - ds.call('search_count', [domain]).then(function (results) { - $('.oe_domain_count', self.$el).text(results + ' records selected'); - if (self.get('effective_readonly')) { - $('button span', self.$el).text(' See selection'); - } - else { - $('button span', self.$el).text(' Change selection'); - } - }); - } else { - $('.oe_domain_count', this.$el).text('0 record selected'); - $('button span', this.$el).text(' Select records'); - }; - this.$('.select_records').on('click', self.on_click); - }, - on_click: function(event) { - event.preventDefault(); - var self = this; - var model = this.options.model || this.field_manager.get_field_value(this.options.model_field); - - var options = { - title: this.get('effective_readonly') ? 'Selected records' : 'Select records...', - readonly: this.get('effective_readonly'), - disable_multiple_selection: this.get('effective_readonly'), - no_create: this.get('effective_readonly'), - }; - var domain = this.get('value'); - popup = new instance.web.form.DomainEditorPopup(this); - popup.select_element(model, options, domain, {}); - popup.on("elements_selected", self, function(selected_ids) { - if (!self.get('effective_readonly')) { - self.set_value(popup.get_domain(selected_ids)); - } - }); - }, -}); - - -instance.web.DateTimeWidget = instance.web.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 self = this; - 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 : instance.web.normalize_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'] = instance.web.normalize_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 instance.web.parse_value(v, {"widget": this.type_of_date}); - }, - format_client: function(v) { - return instance.web.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') { - value = moment().second(0); - if (this.$input.val().length !== 0 && this.is_valid_()){ - var 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(); - }, -}); - -instance.web.DateWidget = instance.web.DateTimeWidget.extend({ - type_of_date: "date" -}); - -instance.web.form.FieldDatetime = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: "FieldDatetime", - build_widget: function() { - return new instance.web.DateTimeWidget(this); - }, - destroy_content: function() { - if (this.datewidget) { - this.datewidget.destroy(); - this.datewidget = undefined; - } - }, - initialize_content: function() { - if (!this.get("effective_readonly")) { - this.datewidget = this.build_widget(); - this.datewidget.on('datetime_changed', this, _.bind(function() { - this.internal_set_value(this.datewidget.get_value()); - }, this)); - this.datewidget.appendTo(this.$el); - this.setupFocus(this.datewidget.$input); - } - }, - render_value: function() { - if (!this.get("effective_readonly")) { - this.datewidget.set_value(this.get('value')); - } else { - this.$el.text(instance.web.format_value(this.get('value'), this, '')); - } - }, - is_syntax_valid: function() { - if (!this.get("effective_readonly") && this.datewidget) { - return this.datewidget.is_valid_(); - } - return true; - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function() { - var input = this.datewidget && this.datewidget.$input[0]; - return input ? input.focus() : false; - }, - set_dimensions: function (height, width) { - this._super(height, width); - if (!this.get("effective_readonly")) { - this.datewidget.$input.css('height', height); - } - } -}); - -instance.web.form.FieldDate = instance.web.form.FieldDatetime.extend({ - template: "FieldDate", - build_widget: function() { - return new instance.web.DateWidget(this); - } -}); - -instance.web.form.FieldText = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: 'FieldText', - events: { - 'keyup': function (e) { - if (e.which === $.ui.keyCode.ENTER) { - e.stopPropagation(); - } - }, - 'keypress': function (e) { - if (e.which === $.ui.keyCode.ENTER) { - e.stopPropagation(); - } - }, - 'change textarea': 'store_dom_value', - }, - initialize_content: function() { - var self = this; - if (! this.get("effective_readonly")) { - this.$textarea = this.$el.find('textarea'); - this.auto_sized = false; - this.default_height = this.$textarea.css('height'); - if (this.default_height === '0px') this.default_height = '90px'; - if (this.get("effective_readonly")) { - this.$textarea.attr('disabled', 'disabled'); - } - this.setupFocus(this.$textarea); - } else { - this.$textarea = undefined; - } - }, - commit_value: function () { - if (! this.get("effective_readonly") && this.$textarea) { - this.store_dom_value(); - } - return this._super(); - }, - store_dom_value: function () { - this.internal_set_value(instance.web.parse_value(this.$textarea.val(), this)); - }, - render_value: function() { - if (! this.get("effective_readonly")) { - var show_value = instance.web.format_value(this.get('value'), this, ''); - if (show_value === '') { - this.$textarea.css('height', parseInt(this.default_height, 10)+"px"); - } - this.$textarea.val(show_value); - if (! this.auto_sized) { - this.auto_sized = true; - this.$textarea.autosize(); - } else { - this.$textarea.trigger("autosize"); - } - } else { - var txt = this.get("value") || ''; - this.$(".oe_form_text_content").text(txt); - } - }, - is_syntax_valid: function() { - if (!this.get("effective_readonly") && this.$textarea) { - try { - instance.web.parse_value(this.$textarea.val(), this, ''); - return true; - } catch(e) { - return false; - } - } - return true; - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function($el) { - var input = !this.get("effective_readonly") && this.$textarea && this.$textarea[0]; - return input ? input.focus() : false; - }, - set_dimensions: function (height, width) { - this._super(height, width); - if (!this.get("effective_readonly") && this.$textarea) { - this.$textarea.css({ - width: width, - minHeight: height - }); - } - }, -}); - -/** - * FieldTextHtml Widget - * Intended for FieldText widgets meant to display HTML content. This - * widget will instantiate the CLEditor (see cleditor in static/src/lib) - * To find more information about CLEditor configutation: go to - * http://premiumsoftware.net/cleditor/docs/GettingStarted.html - */ -instance.web.form.FieldTextHtml = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: 'FieldTextHtml', - init: function() { - this._super.apply(this, arguments); - }, - initialize_content: function() { - var self = this; - if (! this.get("effective_readonly")) { - self._updating_editor = false; - this.$textarea = this.$el.find('textarea'); - var width = ((this.node.attrs || {}).editor_width || 'calc(100% - 4px)'); - var height = ((this.node.attrs || {}).editor_height || 250); - this.$textarea.cleditor({ - width: width, // width not including margins, borders or padding - height: height, // height not including margins, borders or padding - controls: // controls to add to the toolbar - "bold italic underline strikethrough " + - "| removeformat | bullets numbering | outdent " + - "indent | link unlink | source", - bodyStyle: // style to assign to document body contained within the editor - "margin:4px; color:#4c4c4c; font-size:13px; font-family:'Lucida Grande',Helvetica,Verdana,Arial,sans-serif; cursor:text" - }); - this.$cleditor = this.$textarea.cleditor()[0]; - this.$cleditor.change(function() { - if (! self._updating_editor) { - self.$cleditor.updateTextArea(); - self.internal_set_value(self.$textarea.val()); - } - }); - if (this.field.translate) { - var $img = $('<img class="oe_field_translate oe_input_icon" src="/web/static/src/img/icons/terp-translate.png" width="16" height="16" border="0"/>') - .click(this.on_translate); - this.$cleditor.$toolbar.append($img); - } - } - }, - render_value: function() { - if (! this.get("effective_readonly")) { - this.$textarea.val(this.get('value') || ''); - this._updating_editor = true; - this.$cleditor.updateFrame(); - this._updating_editor = false; - } else { - this.$el.html(this.get('value')); - } - }, -}); - -instance.web.form.FieldBoolean = instance.web.form.AbstractField.extend({ - template: 'FieldBoolean', - start: function() { - var self = this; - this.$checkbox = $("input", this.$el); - this.setupFocus(this.$checkbox); - this.$el.click(_.bind(function() { - this.internal_set_value(this.$checkbox.is(':checked')); - }, this)); - var check_readonly = function() { - self.$checkbox.prop('disabled', self.get("effective_readonly")); - self.click_disabled_boolean(); - }; - this.on("change:effective_readonly", this, check_readonly); - check_readonly.call(this); - this._super.apply(this, arguments); - }, - render_value: function() { - this.$checkbox[0].checked = this.get('value'); - }, - focus: function() { - var input = this.$checkbox && this.$checkbox[0]; - return input ? input.focus() : false; - }, - click_disabled_boolean: function(){ - var $disabled = this.$el.find('input[type=checkbox]:disabled'); - $disabled.each(function (){ - $(this).next('div').remove(); - $(this).closest("span").append($('<div class="boolean"></div>')); - }); - } -}); - -/** - The progressbar field expect a float from 0 to 100. -*/ -instance.web.form.FieldProgressBar = instance.web.form.AbstractField.extend({ - template: 'FieldProgressBar', - render_value: function() { - this.$el.progressbar({ - value: this.get('value') || 0, - disabled: this.get("effective_readonly") - }); - var formatted_value = instance.web.format_value(this.get('value') || 0, { type : 'float' }); - this.$('span').html(formatted_value + '%'); - } -}); - -/** - The PercentPie field expect a float from 0 to 100. -*/ -instance.web.form.FieldPercentPie = instance.web.form.AbstractField.extend({ - template: 'FieldPercentPie', - - render_value: function() { - var value = this.get('value'), - formatted_value = Math.round(value || 0) + '%', - svg = this.$('svg')[0]; - - svg.innerHTML = ""; - nv.addGraph(function() { - var width = 42, height = 42; - var chart = nv.models.pieChart() - .width(width) - .height(height) - .margin({top: 0, right: 0, bottom: 0, left: 0}) - .donut(true) - .showLegend(false) - .showLabels(false) - .tooltips(false) - .color(['#7C7BAD','#DDD']) - .donutRatio(0.62); - - d3.select(svg) - .datum([{'x': 'value', 'y': value}, {'x': 'complement', 'y': 100 - value}]) - .transition() - .call(chart) - .attr('style', 'width: ' + width + 'px; height:' + height + 'px;'); - - d3.select(svg) - .append("text") - .attr({x: width/2, y: height/2 + 3, 'text-anchor': 'middle'}) - .style({"font-size": "10px", "font-weight": "bold"}) - .text(formatted_value); - - return chart; - }); - - } -}); - -/** - The FieldBarChart expectsa list of values (indeed) -*/ -instance.web.form.FieldBarChart = instance.web.form.AbstractField.extend({ - template: 'FieldBarChart', - - render_value: function() { - var value = JSON.parse(this.get('value')); - var svg = this.$('svg')[0]; - svg.innerHTML = ""; - nv.addGraph(function() { - var width = 34, height = 34; - var chart = nv.models.discreteBarChart() - .x(function (d) { return d.tooltip }) - .y(function (d) { return d.value }) - .width(width) - .height(height) - .margin({top: 0, right: 0, bottom: 0, left: 0}) - .tooltips(false) - .showValues(false) - .transitionDuration(350) - .showXAxis(false) - .showYAxis(false); - - d3.select(svg) - .datum([{key: 'values', values: value}]) - .transition() - .call(chart) - .attr('style', 'width: ' + (width + 4) + 'px; height: ' + (height + 8) + 'px;'); - - nv.utils.windowResize(chart.update); - - return chart; - }); - - } -}); - - -instance.web.form.FieldSelection = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: 'FieldSelection', - events: { - 'change select': 'store_dom_value', - }, - init: function(field_manager, node) { - var self = this; - this._super(field_manager, node); - this.set("value", false); - this.set("values", []); - this.records_orderer = new instance.web.DropMisordered(); - this.field_manager.on("view_content_has_changed", this, function() { - var domain = new openerp.web.CompoundDomain(this.build_domain()).eval(); - if (! _.isEqual(domain, this.get("domain"))) { - this.set("domain", domain); - } - }); - }, - initialize_field: function() { - instance.web.form.ReinitializeFieldMixin.initialize_field.call(this); - this.on("change:domain", this, this.query_values); - this.set("domain", new openerp.web.CompoundDomain(this.build_domain()).eval()); - this.on("change:values", this, this.render_value); - }, - query_values: function() { - var self = this; - var def; - if (this.field.type === "many2one") { - var model = new openerp.Model(openerp.session, this.field.relation); - def = model.call("name_search", ['', this.get("domain")], {"context": this.build_context()}); - } else { - var values = _.reject(this.field.selection, function (v) { return v[0] === false && v[1] === ''; }); - def = $.when(values); - } - this.records_orderer.add(def).then(function(values) { - if (! _.isEqual(values, self.get("values"))) { - self.set("values", values); - } - }); - }, - initialize_content: function() { - // Flag indicating whether we're in an event chain containing a change - // event on the select, in order to know what to do on keyup[RETURN]: - // * If the user presses [RETURN] as part of changing the value of a - // selection, we should just let the value change and not let the - // event broadcast further (e.g. to validating the current state of - // the form in editable list view, which would lead to saving the - // current row or switching to the next one) - // * If the user presses [RETURN] with a select closed (side-effect: - // also if the user opened the select and pressed [RETURN] without - // changing the selected value), takes the action as validating the - // row - var ischanging = false; - var $select = this.$el.find('select') - .change(function () { ischanging = true; }) - .click(function () { ischanging = false; }) - .keyup(function (e) { - if (e.which !== 13 || !ischanging) { return; } - e.stopPropagation(); - ischanging = false; - }); - this.setupFocus($select); - }, - commit_value: function () { - this.store_dom_value(); - return this._super(); - }, - store_dom_value: function () { - if (!this.get('effective_readonly') && this.$('select').length) { - var val = JSON.parse(this.$('select').val()); - this.internal_set_value(val); - } - }, - set_value: function(value_) { - value_ = value_ === null ? false : value_; - value_ = value_ instanceof Array ? value_[0] : value_; - this._super(value_); - }, - render_value: function() { - var values = this.get("values"); - values = [[false, this.node.attrs.placeholder || '']].concat(values); - var found = _.find(values, function(el) { return el[0] === this.get("value"); }, this); - if (! found) { - found = [this.get("value"), _t('Unknown')]; - values = [found].concat(values); - } - if (! this.get("effective_readonly")) { - this.$().html(QWeb.render("FieldSelectionSelect", {widget: this, values: values})); - this.$("select").val(JSON.stringify(found[0])); - } else { - this.$el.text(found[1]); - } - }, - focus: function() { - var input = this.$('select:first')[0]; - return input ? input.focus() : false; - }, - set_dimensions: function (height, width) { - this._super(height, width); - this.$('select').css({ - height: height, - width: width - }); - } -}); - -instance.web.form.FieldRadio = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: 'FieldRadio', - events: { - 'click input': 'click_change_value' - }, - init: function(field_manager, node) { - /* Radio button widget: Attributes options: - * - "horizontal" to display in column - * - "no_radiolabel" don't display text values - */ - this._super(field_manager, node); - this.selection = _.clone(this.field.selection) || []; - this.domain = false; - this.uniqueId = _.uniqueId("radio"); - }, - initialize_content: function () { - this.on("change:effective_readonly", this, this.render_value); - this.field_manager.on("view_content_has_changed", this, this.get_selection); - this.get_selection(); - }, - click_change_value: function (event) { - var val = $(event.target).val(); - val = this.field.type == "selection" ? val : +val; - if (val !== this.get_value()) { - this.set_value(val); - } - }, - /** Get the selection and render it - * selection: [[identifier, value_to_display], ...] - * For selection fields: this is directly given by this.field.selection - * For many2one fields: perform a search on the relation of the many2one field - */ - get_selection: function() { - var self = this; - var selection = []; - var def = $.Deferred(); - if (self.field.type == "many2one") { - var domain = instance.web.pyeval.eval('domain', this.build_domain()) || []; - if (! _.isEqual(self.domain, domain)) { - self.domain = domain; - var ds = new instance.web.DataSetStatic(self, self.field.relation, self.build_context()); - ds.call('search', [self.domain]) - .then(function (records) { - ds.name_get(records).then(function (records) { - selection = records; - def.resolve(); - }); - }); - } else { - selection = self.selection; - def.resolve(); - } - } - else if (self.field.type == "selection") { - selection = self.field.selection || []; - def.resolve(); - } - return def.then(function () { - if (! _.isEqual(selection, self.selection)) { - self.selection = _.clone(selection); - self.renderElement(); - self.render_value(); - } - }); - }, - set_value: function (value_) { - if (this.field.type == "selection") { - value_ = _.find(this.field.selection, function (sel) { return sel[0] == value_;}); - } - else if (!this.selection.length) { - this.selection = [value_]; - } - - this._super(value_); - }, - get_value: function () { - var value = this.get('value'); - return value instanceof Array ? value[0] : value; - }, - render_value: function () { - var self = this; - this.$el.toggleClass("oe_readonly", this.get('effective_readonly')); - this.$("input").filter(function () {return this.value == self.get_value();}).prop("checked", true); - this.$(".oe_radio_readonly").text(this.get('value') ? this.get('value')[1] : ""); - } -}); - -// jquery autocomplete tweak to allow html and classnames -(function() { - 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); - } - }); -})(); - -/** - A mixin containing some useful methods to handle completion inputs. - - The widget containing this option can have these arguments in its widget options: - - no_quick_create: if true, it will disable the quick create -*/ -instance.web.form.CompletionFieldMixin = { - init: function() { - this.limit = 7; - this.orderer = new instance.web.DropMisordered(); - this.can_create = this.node.attrs.can_create || true; - this.can_write = this.node.attrs.can_write || true; - }, - /** - * Call this method to search using a string. - */ - get_search_result: function(search_val) { - var self = this; - - var dataset = new instance.web.DataSet(this, this.field.relation, self.build_context()); - this.last_query = search_val; - var exclusion_domain = [], ids_blacklist = this.get_search_blacklist(); - if (!_(ids_blacklist).isEmpty()) { - exclusion_domain.push(['id', 'not in', ids_blacklist]); - } - - return this.orderer.add(dataset.name_search( - search_val, new instance.web.CompoundDomain(self.build_domain(), exclusion_domain), - 'ilike', this.limit + 1, self.build_context())).then(function(data) { - self.last_search = data; - // possible selections for the m2o - var values = _.map(data, function(x) { - x[1] = x[1].split("\n")[0]; - return { - label: _.str.escapeHTML(x[1]), - value: x[1], - name: x[1], - id: x[0], - }; - }); - - // search more... if more results that max - if (values.length > self.limit) { - values = values.slice(0, self.limit); - values.push({ - label: _t("Search More..."), - action: function() { - dataset.name_search(search_val, self.build_domain(), 'ilike', 160).done(function(data) { - self._search_create_popup("search", data); - }); - }, - classname: 'oe_m2o_dropdown_option' - }); - } - // quick create - var raw_result = _(data.result).map(function(x) {return x[1];}); - if (search_val.length > 0 && !_.include(raw_result, search_val) && - ! (self.options && (self.options.no_create || self.options.no_quick_create))) { - self.can_create && values.push({ - label: _.str.sprintf(_t('Create "<strong>%s</strong>"'), - $('<span />').text(search_val).html()), - action: function() { - self._quick_create(search_val); - }, - classname: 'oe_m2o_dropdown_option' - }); - } - // create... - if (!(self.options && (self.options.no_create || self.options.no_create_edit)) && self.can_create){ - values.push({ - label: _t("Create and Edit..."), - action: function() { - self._search_create_popup("form", undefined, self._create_context(search_val)); - }, - classname: 'oe_m2o_dropdown_option' - }); - } - else if (values.length == 0) - values.push({ - label: _t("No results to show..."), - action: function() {}, - classname: 'oe_m2o_dropdown_option' - }); - - return values; - }); - }, - get_search_blacklist: function() { - return []; - }, - _quick_create: function(name) { - var self = this; - var slow_create = function () { - self._search_create_popup("form", undefined, self._create_context(name)); - }; - if (self.options.quick_create === undefined || self.options.quick_create) { - new instance.web.DataSet(this, this.field.relation, self.build_context()) - .name_create(name).done(function(data) { - if (!self.get('effective_readonly')) - self.add_id(data[0]); - }).fail(function(error, event) { - event.preventDefault(); - slow_create(); - }); - } else - slow_create(); - }, - // all search/create popup handling - _search_create_popup: function(view, ids, context) { - var self = this; - var pop = new instance.web.form.SelectCreatePopup(this); - pop.select_element( - self.field.relation, - { - title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string, - initial_ids: ids ? _.map(ids, function(x) {return x[0];}) : undefined, - initial_view: view, - disable_multiple_selection: true - }, - self.build_domain(), - new instance.web.CompoundContext(self.build_context(), context || {}) - ); - pop.on("elements_selected", self, function(element_ids) { - self.add_id(element_ids[0]); - self.focus(); - }); - }, - /** - * To implement. - */ - add_id: function(id) {}, - _create_context: function(name) { - var tmp = {}; - var field = (this.options || {}).create_name_field; - if (field === undefined) - field = "name"; - if (field !== false && name && (this.options || {}).quick_create !== false) - tmp["default_" + field] = name; - return tmp; - }, -}; - -instance.web.form.M2ODialog = instance.web.Dialog.extend({ - template: "M2ODialog", - init: function(parent) { - this.name = parent.string; - this._super(parent, { - title: _.str.sprintf(_t("Create a %s"), parent.string), - size: 'medium', - }); - }, - start: function() { - var self = this; - var text = _.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), self.name); - this.$("p").text( text ); - this.$buttons.html(QWeb.render("M2ODialog.buttons")); - this.$("input").val(this.getParent().$input.val()); - this.$buttons.find(".oe_form_m2o_qc_button").click(function(e){ - if (self.$("input").val() != ''){ - self.getParent()._quick_create(self.$("input").val()); - self.destroy(); - } else{ - e.preventDefault(); - self.$("input").focus(); - } - }); - this.$buttons.find(".oe_form_m2o_sc_button").click(function(){ - self.getParent()._search_create_popup("form", undefined, self.getParent()._create_context(self.$("input").val())); - self.destroy(); - }); - this.$buttons.find(".oe_form_m2o_cancel_button").click(function(){ - self.destroy(); - }); - }, -}); - -instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instance.web.form.CompletionFieldMixin, instance.web.form.ReinitializeFieldMixin, { - template: "FieldMany2One", - events: { - 'keydown input': function (e) { - switch (e.which) { - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - e.stopPropagation(); - } - }, - }, - init: function(field_manager, node) { - this._super(field_manager, node); - instance.web.form.CompletionFieldMixin.init.call(this); - this.set({'value': false}); - this.display_value = {}; - this.display_value_backup = {}; - this.last_search = []; - this.floating = false; - this.current_display = null; - this.is_started = false; - this.ignore_focusout = false; - }, - reinit_value: function(val) { - this.internal_set_value(val); - this.floating = false; - if (this.is_started && !this.no_rerender) - this.render_value(); - }, - initialize_field: function() { - this.is_started = true; - instance.web.bus.on('click', this, function() { - if (!this.get("effective_readonly") && this.$input && this.$input.autocomplete('widget').is(':visible')) { - this.$input.autocomplete("close"); - } - }); - instance.web.form.ReinitializeFieldMixin.initialize_field.call(this); - }, - initialize_content: function() { - if (!this.get("effective_readonly")) - this.render_editable(); - }, - destroy_content: function () { - if (this.$drop_down) { - this.$drop_down.off('click'); - delete this.$drop_down; - } - if (this.$input) { - this.$input.closest(".modal .modal-content").off('scroll'); - this.$input.off('keyup blur autocompleteclose autocompleteopen ' + - 'focus focusout change keydown'); - delete this.$input; - } - if (this.$follow_button) { - this.$follow_button.off('blur focus click'); - delete this.$follow_button; - } - }, - destroy: function () { - this.destroy_content(); - return this._super(); - }, - init_error_displayer: function() { - // nothing - }, - hide_error_displayer: function() { - // doesn't work - }, - show_error_displayer: function() { - new instance.web.form.M2ODialog(this).open(); - }, - render_editable: function() { - var self = this; - this.$input = this.$el.find("input"); - - this.init_error_displayer(); - - self.$input.on('focus', function() { - self.hide_error_displayer(); - }); - - this.$drop_down = this.$el.find(".oe_m2o_drop_down_button"); - this.$follow_button = $(".oe_m2o_cm_button", this.$el); - - this.$follow_button.click(function(ev) { - ev.preventDefault(); - if (!self.get('value')) { - self.focus(); - return; - } - var pop = new instance.web.form.FormOpenPopup(self); - var context = self.build_context().eval(); - var model_obj = new instance.web.Model(self.field.relation); - model_obj.call('get_formview_id', [self.get("value"), context]).then(function(view_id){ - pop.show_element( - self.field.relation, - self.get("value"), - self.build_context(), - { - title: _t("Open: ") + self.string, - view_id: view_id, - readonly: !self.can_write - } - ); - pop.on('write_completed', self, function(){ - self.display_value = {}; - self.display_value_backup = {}; - self.render_value(); - self.focus(); - self.trigger('changed_value'); - }); - }); - }); - - // some behavior for input - var input_changed = function() { - if (self.current_display !== self.$input.val()) { - self.current_display = self.$input.val(); - if (self.$input.val() === "") { - self.internal_set_value(false); - self.floating = false; - } else { - self.floating = true; - } - } - }; - this.$input.keydown(input_changed); - this.$input.change(input_changed); - this.$drop_down.click(function() { - self.$input.focus(); - if (self.$input.autocomplete("widget").is(":visible")) { - self.$input.autocomplete("close"); - } else { - if (self.get("value") && ! self.floating) { - self.$input.autocomplete("search", ""); - } else { - self.$input.autocomplete("search"); - } - } - }); - - // Autocomplete close on dialog content scroll - var close_autocomplete = _.debounce(function() { - if (self.$input.autocomplete("widget").is(":visible")) { - self.$input.autocomplete("close"); - } - }, 50); - this.$input.closest(".modal .modal-content").on('scroll', this, close_autocomplete); - - self.ed_def = $.Deferred(); - self.uned_def = $.Deferred(); - var ed_delay = 200; - var ed_duration = 15000; - var anyoneLoosesFocus = function (e) { - if (self.ignore_focusout) { return; } - var used = false; - if (self.floating) { - if (self.last_search.length > 0) { - if (self.last_search[0][0] != self.get("value")) { - self.display_value = {}; - self.display_value_backup = {}; - self.display_value["" + self.last_search[0][0]] = self.last_search[0][1]; - self.reinit_value(self.last_search[0][0]); - self.last_search = [] - } else { - used = true; - self.render_value(); - } - } else { - used = true; - } - self.floating = false; - } - if (used && self.get("value") === false && ! self.no_ed && ! (self.options && (self.options.no_create || self.options.no_quick_create))) { - self.ed_def.reject(); - self.uned_def.reject(); - self.ed_def = $.Deferred(); - self.ed_def.done(function() { - self.can_create && self.show_error_displayer(); - ignore_blur = false; - self.trigger('focused'); - }); - ignore_blur = true; - setTimeout(function() { - self.ed_def.resolve(); - self.uned_def.reject(); - self.uned_def = $.Deferred(); - self.uned_def.done(function() { - self.hide_error_displayer(); - }); - setTimeout(function() {self.uned_def.resolve();}, ed_duration); - }, ed_delay); - } else { - self.no_ed = false; - self.ed_def.reject(); - } - }; - var ignore_blur = false; - this.$input.on({ - focusout: anyoneLoosesFocus, - focus: function () { self.trigger('focused'); }, - autocompleteopen: function () { ignore_blur = true; }, - autocompleteclose: function () { setTimeout(function() {ignore_blur = false;},0); }, - blur: function () { - // autocomplete open - if (ignore_blur) { $(this).focus(); return; } - if (_(self.getChildren()).any(function (child) { - return child instanceof instance.web.form.AbstractFormPopup; - })) { return; } - self.trigger('blurred'); - } - }); - - var isSelecting = false; - // autocomplete - this.$input.autocomplete({ - source: function(req, resp) { - self.get_search_result(req.term).done(function(result) { - resp(result); - }); - }, - select: function(event, ui) { - isSelecting = true; - var item = ui.item; - if (item.id) { - self.display_value = {}; - self.display_value_backup = {}; - self.display_value["" + item.id] = item.name; - self.reinit_value(item.id); - } else if (item.action) { - item.action(); - // Cancel widget blurring, to avoid form blur event - self.trigger('focused'); - return false; - } - }, - focus: function(e, ui) { - e.preventDefault(); - }, - html: true, - // disabled to solve a bug, but may cause others - //close: anyoneLoosesFocus, - minLength: 0, - delay: 200, - }); - var appendTo = this.$input.parents('.oe-view-manager-content, .modal-dialog').last(); - if (appendTo.length === 0) { - appendTo = '.oe_application > *'; - } - this.$input.autocomplete({ - appendTo: appendTo - }); - // set position for list of suggestions box - this.$input.autocomplete( "option", "position", { my : "left top", at: "left bottom" } ); - this.$input.autocomplete("widget").openerpClass(); - // used to correct a bug when selecting an element by pushing 'enter' in an editable list - this.$input.keyup(function(e) { - if (e.which === 13) { // ENTER - if (isSelecting) - e.stopPropagation(); - } - isSelecting = false; - }); - this.setupFocus(this.$follow_button); - }, - render_value: function(no_recurse) { - var self = this; - if (! this.get("value")) { - this.display_string(""); - return; - } - var display = this.display_value["" + this.get("value")]; - if (display) { - this.display_string(display); - return; - } - if (! no_recurse) { - var dataset = new instance.web.DataSetStatic(this, this.field.relation, self.build_context()); - var def = this.alive(dataset.name_get([self.get("value")])).done(function(data) { - if (!data[0]) { - self.do_warn(_t("Render"), _t("No value found for the field "+self.field.string+" for value "+self.get("value"))); - return; - } - self.display_value["" + self.get("value")] = data[0][1]; - self.render_value(true); - }).fail( function (data, event) { - // avoid displaying crash errors as many2One should be name_get compliant - event.preventDefault(); - self.display_value["" + self.get("value")] = self.display_value_backup["" + self.get("value")]; - self.render_value(true); - }); - if (this.view && this.view.render_value_defs){ - this.view.render_value_defs.push(def); - } - } - }, - display_string: function(str) { - var self = this; - if (!this.get("effective_readonly")) { - this.$input.val(str.split("\n")[0]); - this.current_display = this.$input.val(); - if (this.is_false()) { - this.$('.oe_m2o_cm_button').css({'display':'none'}); - } else { - this.$('.oe_m2o_cm_button').css({'display':'inline'}); - } - } else { - var lines = _.escape(str).split("\n"); - var link = ""; - var follow = ""; - link = lines[0]; - follow = _.rest(lines).join("<br />"); - if (follow) - link += "<br />"; - var $link = this.$el.find('.oe_form_uri') - .unbind('click') - .html(link); - if (! this.options.no_open) - $link.click(function () { - var context = self.build_context().eval(); - var model_obj = new instance.web.Model(self.field.relation); - model_obj.call('get_formview_action', [self.get("value"), context]).then(function(action){ - self.do_action(action); - }); - return false; - }); - $(".oe_form_m2o_follow", this.$el).html(follow); - } - }, - set_value: function(value_) { - var self = this; - if (value_ instanceof Array) { - this.display_value = {}; - this.display_value_backup = {}; - if (! this.options.always_reload) { - this.display_value["" + value_[0]] = value_[1]; - } - else { - this.display_value_backup["" + value_[0]] = value_[1]; - } - value_ = value_[0]; - } - value_ = value_ || false; - this.reinit_value(value_); - }, - get_displayed: function() { - return this.display_value["" + this.get("value")]; - }, - add_id: function(id) { - this.display_value = {}; - this.display_value_backup = {}; - this.reinit_value(id); - }, - is_false: function() { - return ! this.get("value"); - }, - focus: function () { - var input = !this.get('effective_readonly') && this.$input && this.$input[0]; - return input ? input.focus() : false; - }, - _quick_create: function() { - this.no_ed = true; - this.ed_def.reject(); - return instance.web.form.CompletionFieldMixin._quick_create.apply(this, arguments); - }, - _search_create_popup: function() { - this.no_ed = true; - this.ed_def.reject(); - this.ignore_focusout = true; - this.reinit_value(false); - var res = instance.web.form.CompletionFieldMixin._search_create_popup.apply(this, arguments); - this.ignore_focusout = false; - this.no_ed = false; - return res; - }, - set_dimensions: function (height, width) { - this._super(height, width); - if (!this.get("effective_readonly") && this.$input) - this.$input.css('height', height); - } -}); - -instance.web.form.Many2OneButton = instance.web.form.AbstractField.extend({ - template: 'Many2OneButton', - init: function(field_manager, node) { - this._super.apply(this, arguments); - }, - start: function() { - this._super.apply(this, arguments); - this.set_button(); - }, - set_button: function() { - var self = this; - if (this.$button) { - this.$button.remove(); - } - this.string = ''; - this.node.attrs.icon = this.get('value') ? '/web/static/src/img/icons/gtk-yes.png' : '/web/static/src/img/icons/gtk-no.png'; - this.$button = $(QWeb.render('WidgetButton', {'widget': this})); - this.$button.addClass('oe_link').css({'padding':'4px'}); - this.$el.append(this.$button); - this.$button.on('click', self.on_click); - }, - on_click: function(ev) { - var self = this; - this.popup = new instance.web.form.FormOpenPopup(this); - this.popup.show_element( - this.field.relation, - this.get('value'), - this.build_context(), - {title: this.string} - ); - this.popup.on('create_completed', self, function(r) { - self.set_value(r); - }); - }, - set_value: function(value_) { - var self = this; - if (value_ instanceof Array) { - value_ = value_[0]; - } - value_ = value_ || false; - this.set('value', value_); - this.set_button(); - }, -}); - -/** - * Abstract-ish ListView.List subclass adding an "Add an item" row to replace - * the big ugly button in the header. - * - * Requires the implementation of a ``is_readonly`` method (usually a proxy to - * the corresponding field's readonly or effective_readonly property) to - * decide whether the special row should or should not be inserted. - * - * Optionally an ``_add_row_class`` attribute can be set for the class(es) to - * set on the insertion row. - */ -instance.web.form.AddAnItemList = instance.web.ListView.List.extend({ - pad_table_to: function (count) { - if (!this.view.is_action_enabled('create') || this.is_readonly()) { - this._super(count); - return; - } - - this._super(count > 0 ? count - 1 : 0); - - var self = this; - var columns = _(this.columns).filter(function (column) { - return column.invisible !== '1'; - }).length; - if (this.options.selectable) { columns++; } - if (this.options.deletable) { columns++; } - - var $cell = $('<td>', { - colspan: columns, - 'class': this._add_row_class || '' - }).append( - $('<a>', {href: '#'}).text(_t("Add an item")) - .mousedown(function () { - // FIXME: needs to be an official API somehow - if (self.view.editor.is_editing()) { - self.view.__ignore_blur = true; - } - }) - .click(function (e) { - e.preventDefault(); - e.stopPropagation(); - // FIXME: there should also be an API for that one - if (self.view.editor.form.__blur_timeout) { - clearTimeout(self.view.editor.form.__blur_timeout); - self.view.editor.form.__blur_timeout = false; - } - self.view.ensure_saved().done(function () { - self.view.do_add_record(); - }); - })); - - var $padding = this.$current.find('tr:not([data-id]):first'); - var $newrow = $('<tr>').append($cell); - if ($padding.length) { - $padding.before($newrow); - } else { - this.$current.append($newrow) - } - } -}); - -/* -# Values: (0, 0, { fields }) create -# (1, ID, { fields }) update -# (2, ID) remove (delete) -# (3, ID) unlink one (target id or target of relation) -# (4, ID) link -# (5) unlink all (only valid for one2many) -*/ -var commands = { - // (0, _, {values}) - CREATE: 0, - 'create': function (values) { - return [commands.CREATE, false, values]; - }, - // (1, id, {values}) - UPDATE: 1, - 'update': function (id, values) { - return [commands.UPDATE, id, values]; - }, - // (2, id[, _]) - DELETE: 2, - 'delete': function (id) { - return [commands.DELETE, id, false]; - }, - // (3, id[, _]) removes relation, but not linked record itself - FORGET: 3, - 'forget': function (id) { - return [commands.FORGET, id, false]; - }, - // (4, id[, _]) - LINK_TO: 4, - 'link_to': function (id) { - return [commands.LINK_TO, id, false]; - }, - // (5[, _[, _]]) - DELETE_ALL: 5, - 'delete_all': function () { - return [5, false, false]; - }, - // (6, _, ids) replaces all linked records with provided ids - REPLACE_WITH: 6, - 'replace_with': function (ids) { - return [6, false, ids]; - } -}; -instance.web.form.FieldOne2Many = instance.web.form.AbstractField.extend({ - multi_selection: false, - disable_utility_classes: true, - init: function(field_manager, node) { - this._super(field_manager, node); - this.is_loaded = $.Deferred(); - this.initial_is_loaded = this.is_loaded; - this.form_last_update = $.Deferred(); - this.init_form_last_update = this.form_last_update; - this.is_started = false; - this.dataset = new instance.web.form.One2ManyDataSet(this, this.field.relation); - this.dataset.o2m = this; - this.dataset.parent_view = this.view; - this.dataset.child_name = this.name; - var self = this; - this.dataset.on('dataset_changed', this, function() { - self.trigger_on_change(); - }); - this.set_value([]); - }, - start: function() { - this._super.apply(this, arguments); - this.$el.addClass('oe_form_field oe_form_field_one2many'); - - var self = this; - - self.load_views(); - var destroy = function() { - self.is_loaded = self.is_loaded.then(function() { - self.viewmanager.destroy(); - return $.when(self.load_views()).done(function() { - self.reload_current_view(); - }); - }); - }; - this.is_loaded.done(function() { - self.on("change:effective_readonly", self, destroy); - }); - this.view.on("on_button_cancel", self, destroy); - this.is_started = true; - this.reload_current_view(); - }, - trigger_on_change: function() { - this.trigger('changed_value'); - }, - load_views: function() { - var self = this; - - var modes = this.node.attrs.mode; - modes = !!modes ? modes.split(",") : ["tree"]; - var views = []; - _.each(modes, function(mode) { - if (! _.include(["list", "tree", "graph", "kanban"], mode)) { - throw new Error(_.str.sprintf(_t("View type '%s' is not supported in One2Many."), mode)); - } - var view = { - view_id: false, - view_type: mode == "tree" ? "list" : mode, - options: {} - }; - if (self.field.views && self.field.views[mode]) { - view.embedded_view = self.field.views[mode]; - } - if(view.view_type === "list") { - _.extend(view.options, { - addable: null, - selectable: self.multi_selection, - sortable: true, - import_enabled: false, - deletable: true - }); - if (self.get("effective_readonly")) { - _.extend(view.options, { - deletable: null, - reorderable: false, - }); - } - } else if (view.view_type === "form") { - if (self.get("effective_readonly")) { - view.view_type = 'form'; - } - _.extend(view.options, { - not_interactible_on_create: true, - }); - } else if (view.view_type === "kanban") { - _.extend(view.options, { - confirm_on_delete: false, - }); - if (self.get("effective_readonly")) { - _.extend(view.options, { - action_buttons: false, - quick_creatable: false, - creatable: false, - read_only_mode: true, - }); - } - } - views.push(view); - }); - this.views = views; - - this.viewmanager = new instance.web.form.One2ManyViewManager(this, this.dataset, views, {}); - this.viewmanager.o2m = self; - var once = $.Deferred().done(function() { - self.init_form_last_update.resolve(); - }); - var def = $.Deferred().done(function() { - self.initial_is_loaded.resolve(); - }); - this.viewmanager.on("controller_inited", self, function(view_type, controller) { - controller.o2m = self; - if (view_type == "list") { - if (self.get("effective_readonly")) { - controller.on('edit:before', self, function (e) { - e.cancel = true; - }); - _(controller.columns).find(function (column) { - if (!(column instanceof instance.web.list.Handle)) { - return false; - } - column.modifiers.invisible = true; - return true; - }); - } - } else if (view_type === "form") { - if (self.get("effective_readonly")) { - $(".oe_form_buttons", controller.$el).children().remove(); - } - controller.on("load_record", self, function(){ - once.resolve(); - }); - controller.on('pager_action_executed',self,self.save_any_view); - } else if (view_type == "graph") { - self.reload_current_view(); - } - def.resolve(); - }); - this.viewmanager.on("switch_mode", self, function(n_mode, b, c, d, e) { - $.when(self.save_any_view()).done(function() { - if (n_mode === "list") { - $.async_when().done(function() { - self.reload_current_view(); - }); - } - }); - }); - $.async_when().done(function () { - self.viewmanager.appendTo(self.$el); - }); - return def; - }, - reload_current_view: function() { - var self = this; - self.is_loaded = self.is_loaded.then(function() { - var view = self.get_active_view(); - if (view.type === "list") { - return view.controller.reload_content(); - } else if (view.type === "form") { - if (self.dataset.index === null && self.dataset.ids.length >= 1) { - self.dataset.index = 0; - } - var act = function() { - return view.controller.do_show(); - }; - self.form_last_update = self.form_last_update.then(act, act); - return self.form_last_update; - } else if (view.controller.do_search) { - return view.controller.do_search(self.build_domain(), self.dataset.get_context(), []); - } - }, undefined); - return self.is_loaded; - }, - get_active_view: function () { - /** - * Returns the current active view if any. - */ - return (this.viewmanager && this.viewmanager.active_view); - }, - set_value: function(value_) { - value_ = value_ || []; - var self = this; - var view = this.get_active_view(); - this.dataset.reset_ids([]); - var ids; - if(value_.length >= 1 && value_[0] instanceof Array) { - ids = []; - _.each(value_, function(command) { - var obj = {values: command[2]}; - switch (command[0]) { - case commands.CREATE: - obj['id'] = _.uniqueId(self.dataset.virtual_id_prefix); - obj.defaults = {}; - self.dataset.to_create.push(obj); - self.dataset.cache.push(_.extend(_.clone(obj), {values: _.clone(command[2])})); - ids.push(obj.id); - return; - case commands.UPDATE: - obj['id'] = command[1]; - self.dataset.to_write.push(obj); - self.dataset.cache.push(_.extend(_.clone(obj), {values: _.clone(command[2])})); - ids.push(obj.id); - return; - case commands.DELETE: - self.dataset.to_delete.push({id: command[1]}); - return; - case commands.LINK_TO: - ids.push(command[1]); - return; - case commands.DELETE_ALL: - self.dataset.delete_all = true; - return; - } - }); - this._super(ids); - this.dataset.set_ids(ids); - } else if (value_.length >= 1 && typeof(value_[0]) === "object") { - ids = []; - this.dataset.delete_all = true; - _.each(value_, function(command) { - var obj = {values: command}; - obj['id'] = _.uniqueId(self.dataset.virtual_id_prefix); - obj.defaults = {}; - self.dataset.to_create.push(obj); - self.dataset.cache.push(_.clone(obj)); - ids.push(obj.id); - }); - this._super(ids); - this.dataset.set_ids(ids); - } else { - this._super(value_); - this.dataset.reset_ids(value_); - } - if (this.dataset.index === null && this.dataset.ids.length > 0) { - this.dataset.index = 0; - } - this.trigger_on_change(); - if (this.is_started && !this.no_rerender) { - return self.reload_current_view(); - } else { - return $.when(); - } - }, - get_value: function() { - var self = this; - if (!this.dataset) - return []; - var val = this.dataset.delete_all ? [commands.delete_all()] : []; - val = val.concat(_.map(this.dataset.ids, function(id) { - var alter_order = _.detect(self.dataset.to_create, function(x) {return x.id === id;}); - if (alter_order) { - return commands.create(alter_order.values); - } - alter_order = _.detect(self.dataset.to_write, function(x) {return x.id === id;}); - if (alter_order) { - return commands.update(alter_order.id, alter_order.values); - } - return commands.link_to(id); - })); - return val.concat(_.map( - this.dataset.to_delete, function(x) { - return commands['delete'](x.id);})); - }, - commit_value: function() { - return this.save_any_view(); - }, - save_any_view: function() { - var view = this.get_active_view(); - if (view) { - if (this.viewmanager.active_view.type === "form") { - if (view.controller.is_initialized.state() !== 'resolved') { - return $.when(false); - } - return $.when(view.controller.save()); - } else if (this.viewmanager.active_view.type === "list") { - return $.when(view.controller.ensure_saved()); - } - } - return $.when(false); - }, - is_syntax_valid: function() { - var view = this.get_active_view(); - if (!view){ - return true; - } - switch (this.viewmanager.active_view.type) { - case 'form': - return _(view.controller.fields).chain() - .invoke('is_valid') - .all(_.identity) - .value(); - case 'list': - return view.controller.is_valid(); - } - return true; - }, -}); - -instance.web.form.One2ManyViewManager = instance.web.ViewManager.extend({ - template: 'One2Many.viewmanager', - init: function(parent, dataset, views, flags) { - this._super(parent, dataset, views, _.extend({}, flags, {$sidebar: false})); - this.registry = instance.web.views.extend({ - list: 'instance.web.form.One2ManyListView', - form: 'instance.web.form.One2ManyFormView', - }); - this.__ignore_blur = false; - }, - switch_mode: function(mode, unused) { - if (mode !== 'form') { - return this._super(mode, unused); - } - var self = this; - var id = self.o2m.dataset.index !== null ? self.o2m.dataset.ids[self.o2m.dataset.index] : null; - var pop = new instance.web.form.FormOpenPopup(this); - pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), { - title: _t("Open: ") + self.o2m.string, - create_function: function(data, options) { - return self.o2m.dataset.create(data, options).done(function(r) { - self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r])); - self.o2m.dataset.trigger("dataset_changed", r); - }); - }, - write_function: function(id, data, options) { - return self.o2m.dataset.write(id, data, {}).done(function() { - self.o2m.reload_current_view(); - }); - }, - alternative_form_view: self.o2m.field.views ? self.o2m.field.views["form"] : undefined, - parent_view: self.o2m.view, - child_name: self.o2m.name, - read_function: function() { - return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); - }, - form_view_options: {'not_interactible_on_create':true}, - readonly: self.o2m.get("effective_readonly") - }); - pop.on("elements_selected", self, function() { - self.o2m.reload_current_view(); - }); - }, -}); - -instance.web.form.One2ManyDataSet = instance.web.BufferedDataSet.extend({ - get_context: function() { - this.context = this.o2m.build_context(); - return this.context; - } -}); - -instance.web.form.One2ManyListView = instance.web.ListView.extend({ - _template: 'One2Many.listview', - init: function (parent, dataset, view_id, options) { - this._super(parent, dataset, view_id, _.extend(options || {}, { - GroupsType: instance.web.form.One2ManyGroups, - ListType: instance.web.form.One2ManyList - })); - this.on('edit:after', this, this.proxy('_after_edit')); - this.on('save:before cancel:before', this, this.proxy('_before_unedit')); - - this.records - .bind('add', this.proxy("changed_records")) - .bind('remove', this.proxy("changed_records")); - this.on('save:after', this, this.proxy("changed_records")); - }, - start: function () { - var ret = this._super(); - this.$el - .off('mousedown.handleButtons') - .on('mousedown.handleButtons', 'table button, div a.oe_m2o_cm_button', this.proxy('_button_down')); - return ret; - }, - changed_records: function () { - this.o2m.trigger_on_change(); - }, - is_valid: function () { - var self = this; - if (!this.fields_view || !this.editable()){ - return true; - } - if (_.isEmpty(this.records.records)){ - return true; - } - current_values = {}; - _.each(this.editor.form.fields, function(field){ - field._inhibit_on_change_flag = true; - field.no_rerender = true; - current_values[field.name] = field.get('value'); - }); - var valid = _.every(this.records.records, function(record){ - _.each(self.editor.form.fields, function(field){ - field.set_value(record.attributes[field.name]); - }); - return _.every(self.editor.form.fields, function(field){ - field.process_modifiers(); - field._check_css_flags(); - return field.is_valid(); - }); - }); - _.each(this.editor.form.fields, function(field){ - field.set('value', current_values[field.name]); - field._inhibit_on_change_flag = false; - field.no_rerender = false; - }); - return valid; - }, - do_add_record: function () { - if (this.editable()) { - this._super.apply(this, arguments); - } else { - var self = this; - var pop = new instance.web.form.SelectCreatePopup(this); - pop.select_element( - self.o2m.field.relation, - { - title: _t("Create: ") + self.o2m.string, - initial_view: "form", - alternative_form_view: self.o2m.field.views ? self.o2m.field.views["form"] : undefined, - create_function: function(data, options) { - return self.o2m.dataset.create(data, options).done(function(r) { - self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r])); - self.o2m.dataset.trigger("dataset_changed", r); - }); - }, - read_function: function() { - return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); - }, - parent_view: self.o2m.view, - child_name: self.o2m.name, - form_view_options: {'not_interactible_on_create':true} - }, - self.o2m.build_domain(), - self.o2m.build_context() - ); - pop.on("elements_selected", self, function() { - self.o2m.reload_current_view(); - }); - } - }, - do_activate_record: function(index, id) { - var self = this; - var pop = new instance.web.form.FormOpenPopup(self); - pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), { - title: _t("Open: ") + self.o2m.string, - write_function: function(id, data) { - return self.o2m.dataset.write(id, data, {}).done(function() { - self.o2m.reload_current_view(); - }); - }, - alternative_form_view: self.o2m.field.views ? self.o2m.field.views["form"] : undefined, - parent_view: self.o2m.view, - child_name: self.o2m.name, - read_function: function() { - return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); - }, - form_view_options: {'not_interactible_on_create':true}, - readonly: !this.is_action_enabled('edit') || self.o2m.get("effective_readonly") - }); - }, - do_button_action: function (name, id, callback) { - if (!_.isNumber(id)) { - instance.webclient.notification.warn( - _t("Action Button"), - _t("The o2m record must be saved before an action can be used")); - return; - } - var parent_form = this.o2m.view; - var self = this; - this.ensure_saved().then(function () { - if (parent_form) - return parent_form.save(); - else - return $.when(); - }).done(function () { - var ds = self.o2m.dataset; - var cached_records = _.any([ds.to_create, ds.to_delete, ds.to_write], function(value) { - return value.length; - }); - if (!self.o2m.options.reload_on_button && !cached_records) { - self.handle_button(name, id, callback); - }else { - self.handle_button(name, id, function(){ - self.o2m.view.reload(); - }); - } - }); - }, - - _after_edit: function () { - this.__ignore_blur = false; - this.editor.form.on('blurred', this, this._on_form_blur); - - // The form's blur thing may be jiggered during the edition setup, - // potentially leading to the o2m instasaving the row. Cancel any - // blurring triggered the edition startup here - this.editor.form.widgetFocused(); - }, - _before_unedit: function () { - this.editor.form.off('blurred', this, this._on_form_blur); - }, - _button_down: function () { - // If a button is clicked (usually some sort of action button), it's - // the button's responsibility to ensure the editable list is in the - // correct state -> ignore form blurring - this.__ignore_blur = true; - }, - /** - * Handles blurring of the nested form (saves the currently edited row), - * unless the flag to ignore the event is set to ``true`` - * - * Makes the internal form go away - */ - _on_form_blur: function () { - if (this.__ignore_blur) { - this.__ignore_blur = false; - return; - } - // FIXME: why isn't there an API for this? - if (this.editor.form.$el.hasClass('oe_form_dirty')) { - this.ensure_saved(); - return; - } - this.cancel_edition(); - }, - keypress_ENTER: function () { - // blurring caused by hitting the [Return] key, should skip the - // autosave-on-blur and let the handler for [Return] do its thing (save - // the current row *anyway*, then create a new one/edit the next one) - this.__ignore_blur = true; - this._super.apply(this, arguments); - }, - do_delete: function (ids) { - var confirm = window.confirm; - window.confirm = function () { return true; }; - try { - return this._super(ids); - } finally { - window.confirm = confirm; - } - }, - reload_record: function (record, options) { - if (!options || !options['do_not_evict']) { - // Evict record.id from cache to ensure it will be reloaded correctly - this.dataset.evict_record(record.get('id')); - } - - return this._super(record); - } -}); -instance.web.form.One2ManyGroups = instance.web.ListView.Groups.extend({ - setup_resequence_rows: function () { - if (!this.view.o2m.get('effective_readonly')) { - this._super.apply(this, arguments); - } - } -}); -instance.web.form.One2ManyList = instance.web.form.AddAnItemList.extend({ - _add_row_class: 'oe_form_field_one2many_list_row_add', - is_readonly: function () { - return this.view.o2m.get('effective_readonly'); - }, -}); - -instance.web.form.One2ManyFormView = instance.web.FormView.extend({ - form_template: 'One2Many.formview', - load_form: function(data) { - this._super(data); - var self = this; - this.$buttons.find('button.oe_form_button_create').click(function() { - self.save().done(self.on_button_new); - }); - }, - do_notify_change: function() { - if (this.dataset.parent_view) { - this.dataset.parent_view.do_notify_change(); - } else { - this._super.apply(this, arguments); - } - } -}); - -instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(instance.web.form.CompletionFieldMixin, instance.web.form.ReinitializeFieldMixin, { - template: "FieldMany2ManyTags", - tag_template: "FieldMany2ManyTag", - init: function() { - this._super.apply(this, arguments); - instance.web.form.CompletionFieldMixin.init.call(this); - this.set({"value": []}); - this._display_orderer = new instance.web.DropMisordered(); - this._drop_shown = false; - }, - initialize_texttext: function(){ - var self = this; - return { - plugins : 'tags arrow autocomplete', - autocomplete: { - render: function(suggestion) { - return $('<span class="text-label"/>'). - data('index', suggestion['index']).html(suggestion['label']); - } - }, - ext: { - autocomplete: { - selectFromDropdown: function() { - this.trigger('hideDropdown'); - var index = Number(this.selectedSuggestionElement().children().children().data('index')); - var data = self.search_result[index]; - if (data.id) { - self.add_id(data.id); - } else { - self.ignore_blur = true; - data.action(); - } - this.trigger('setSuggestions', {result : []}); - }, - }, - tags: { - isTagAllowed: function(tag) { - return !!tag.name; - - }, - removeTag: function(tag) { - var id = tag.data("id"); - self.set({"value": _.without(self.get("value"), id)}); - }, - renderTag: function(stuff) { - return $.fn.textext.TextExtTags.prototype.renderTag. - call(this, stuff).data("id", stuff.id); - }, - }, - itemManager: { - itemToString: function(item) { - return item.name; - }, - }, - core: { - onSetInputData: function(e, data) { - if (data === '') { - this._plugins.autocomplete._suggestions = null; - } - this.input().val(data); - }, - }, - }, - } - }, - initialize_content: function() { - if (this.get("effective_readonly")) - return; - var self = this; - self.ignore_blur = false; - self.$text = this.$("textarea"); - self.$text.textext(self.initialize_texttext()).bind('getSuggestions', function(e, data) { - var _this = this; - var str = !!data ? data.query || '' : ''; - self.get_search_result(str).done(function(result) { - self.search_result = result; - $(_this).trigger('setSuggestions', {result : _.map(result, function(el, i) { - return _.extend(el, {index:i}); - })}); - }); - }).bind('hideDropdown', function() { - self._drop_shown = false; - }).bind('showDropdown', function() { - self._drop_shown = true; - }); - self.tags = self.$text.textext()[0].tags(); - self.$text - .focusin(function () { - self.trigger('focused'); - self.ignore_blur = false; - }) - .focusout(function() { - self.$text.trigger("setInputData", ""); - if (!self.ignore_blur) { - self.trigger('blurred'); - } - }).keydown(function(e) { - if (e.which === $.ui.keyCode.TAB && self._drop_shown) { - self.$text.textext()[0].autocomplete().selectFromDropdown(); - } - }); - }, - // WARNING: duplicated in 4 other M2M widgets - set_value: function(value_) { - value_ = value_ || []; - if (value_.length >= 1 && value_[0] instanceof Array) { - // value_ is a list of m2m commands. We only process - // LINK_TO and REPLACE_WITH in this context - var val = []; - _.each(value_, function (command) { - if (command[0] === commands.LINK_TO) { - val.push(command[1]); // (4, id[, _]) - } else if (command[0] === commands.REPLACE_WITH) { - val = command[2]; // (6, _, ids) - } - }); - value_ = val; - } - this._super(value_); - }, - is_false: function() { - return _(this.get("value")).isEmpty(); - }, - get_value: function() { - var tmp = [commands.replace_with(this.get("value"))]; - return tmp; - }, - get_search_blacklist: function() { - return this.get("value"); - }, - map_tag: function(data){ - return _.map(data, function(el) {return {name: el[1], id:el[0]};}) - }, - get_render_data: function(ids){ - var self = this; - var dataset = new instance.web.DataSetStatic(this, this.field.relation, self.build_context()); - return dataset.name_get(ids); - }, - render_tag: function(data) { - var self = this; - if (! self.get("effective_readonly")) { - self.tags.containerElement().children().remove(); - self.$('textarea').css("padding-left", "3px"); - self.tags.addTags(self.map_tag(data)); - } else { - self.$el.html(QWeb.render(self.tag_template, {elements: data})); - } - }, - render_value: function() { - var self = this; - var dataset = new instance.web.DataSetStatic(this, this.field.relation, self.build_context()); - var values = self.get("value"); - var handle_names = function(data) { - if (self.isDestroyed()) - return; - var indexed = {}; - _.each(data, function(el) { - indexed[el[0]] = el; - }); - data = _.map(values, function(el) { return indexed[el]; }); - self.render_tag(data); - } - if (! values || values.length > 0) { - return this._display_orderer.add(self.get_render_data(values)).done(handle_names); - } else { - handle_names([]); - } - }, - add_id: function(id) { - this.set({'value': _.uniq(this.get('value').concat([id]))}); - }, - focus: function () { - var input = this.$text && this.$text[0]; - return input ? input.focus() : false; - }, - set_dimensions: function (height, width) { - this._super(height, width); - this.$("textarea").css({ - width: width, - minHeight: height - }); - }, - _search_create_popup: function() { - self.ignore_blur = true; - return instance.web.form.CompletionFieldMixin._search_create_popup.apply(this, arguments); - }, -}); - -/** - widget options: - - reload_on_button: Reload the whole form view if click on a button in a list view. - If you see this options, do not use it, it's basically a dirty hack to make one - precise o2m to behave the way we want. -*/ -instance.web.form.FieldMany2Many = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - multi_selection: false, - disable_utility_classes: true, - init: function(field_manager, node) { - this._super(field_manager, node); - this.is_loaded = $.Deferred(); - this.dataset = new instance.web.form.Many2ManyDataSet(this, this.field.relation); - this.dataset.m2m = this; - var self = this; - this.dataset.on('unlink', self, function(ids) { - self.dataset_changed(); - }); - this.set_value([]); - this.list_dm = new instance.web.DropMisordered(); - this.render_value_dm = new instance.web.DropMisordered(); - }, - initialize_content: function() { - var self = this; - - this.$el.addClass('oe_form_field oe_form_field_many2many'); - - this.list_view = new instance.web.form.Many2ManyListView(this, this.dataset, false, { - 'addable': null, - 'deletable': this.get("effective_readonly") ? false : true, - 'selectable': this.multi_selection, - 'sortable': false, - 'reorderable': false, - 'import_enabled': false, - }); - var embedded = (this.field.views || {}).tree; - if (embedded) { - this.list_view.set_embedded_view(embedded); - } - this.list_view.m2m_field = this; - var loaded = $.Deferred(); - this.list_view.on("list_view_loaded", this, function() { - loaded.resolve(); - }); - this.list_view.appendTo(this.$el); - - var old_def = self.is_loaded; - self.is_loaded = $.Deferred().done(function() { - old_def.resolve(); - }); - this.list_dm.add(loaded).then(function() { - self.is_loaded.resolve(); - }); - }, - destroy_content: function() { - this.list_view.destroy(); - this.list_view = undefined; - }, - // WARNING: duplicated in 4 other M2M widgets - set_value: function(value_) { - value_ = value_ || []; - if (value_.length >= 1 && value_[0] instanceof Array) { - // value_ is a list of m2m commands. We only process - // LINK_TO and REPLACE_WITH in this context - var val = []; - _.each(value_, function (command) { - if (command[0] === commands.LINK_TO) { - val.push(command[1]); // (4, id[, _]) - } else if (command[0] === commands.REPLACE_WITH) { - val = command[2]; // (6, _, ids) - } - }); - value_ = val; - } - this._super(value_); - }, - get_value: function() { - return [commands.replace_with(this.get('value'))]; - }, - is_false: function () { - return _(this.get("value")).isEmpty(); - }, - render_value: function() { - var self = this; - this.dataset.set_ids(this.get("value")); - this.render_value_dm.add(this.is_loaded).then(function() { - return self.list_view.reload_content(); - }); - }, - dataset_changed: function() { - this.internal_set_value(this.dataset.ids); - }, -}); - -instance.web.form.Many2ManyDataSet = instance.web.DataSetStatic.extend({ - get_context: function() { - this.context = this.m2m.build_context(); - return this.context; - } -}); - -/** - * @class - * @extends instance.web.ListView - */ -instance.web.form.Many2ManyListView = instance.web.ListView.extend(/** @lends instance.web.form.Many2ManyListView# */{ - init: function (parent, dataset, view_id, options) { - this._super(parent, dataset, view_id, _.extend(options || {}, { - ListType: instance.web.form.Many2ManyList, - })); - }, - do_add_record: function () { - var pop = new instance.web.form.SelectCreatePopup(this); - pop.select_element( - this.model, - { - title: _t("Add: ") + this.m2m_field.string, - alternative_form_view: this.m2m_field.field.views ? this.m2m_field.field.views["form"] : undefined, - no_create: this.m2m_field.options.no_create, - }, - new instance.web.CompoundDomain(this.m2m_field.build_domain(), ["!", ["id", "in", this.m2m_field.dataset.ids]]), - this.m2m_field.build_context() - ); - var self = this; - pop.on("elements_selected", self, function(element_ids) { - var reload = false; - _(element_ids).each(function (id) { - if(! _.detect(self.dataset.ids, function(x) {return x == id;})) { - self.dataset.set_ids(self.dataset.ids.concat([id])); - self.m2m_field.dataset_changed(); - reload = true; - } - }); - if (reload) { - self.reload_content(); - } - }); - }, - do_activate_record: function(index, id) { - var self = this; - var pop = new instance.web.form.FormOpenPopup(this); - pop.show_element(this.dataset.model, id, this.m2m_field.build_context(), { - title: _t("Open: ") + this.m2m_field.string, - alternative_form_view: this.m2m_field.field.views ? this.m2m_field.field.views["form"] : undefined, - readonly: this.getParent().get("effective_readonly") - }); - pop.on('write_completed', self, self.reload_content); - }, - do_button_action: function(name, id, callback) { - var self = this; - var _sup = _.bind(this._super, this); - if (! this.m2m_field.options.reload_on_button) { - return _sup(name, id, callback); - } else { - return this.m2m_field.view.save().then(function() { - return _sup(name, id, function() { - self.m2m_field.view.reload(); - }); - }); - } - }, - is_action_enabled: function () { return true; }, -}); -instance.web.form.Many2ManyList = instance.web.form.AddAnItemList.extend({ - _add_row_class: 'oe_form_field_many2many_list_row_add', - is_readonly: function () { - return this.view.m2m_field.get('effective_readonly'); - } -}); - -instance.web.form.FieldMany2ManyKanban = instance.web.form.AbstractField.extend(instance.web.form.CompletionFieldMixin, { - disable_utility_classes: true, - init: function(field_manager, node) { - this._super(field_manager, node); - instance.web.form.CompletionFieldMixin.init.call(this); - m2m_kanban_lazy_init(); - this.is_loaded = $.Deferred(); - this.initial_is_loaded = this.is_loaded; - - var self = this; - this.dataset = new instance.web.form.Many2ManyDataSet(this, this.field.relation); - this.dataset.m2m = this; - this.dataset.on('unlink', self, function(ids) { - self.dataset_changed(); - }); - }, - start: function() { - this._super.apply(this, arguments); - - var self = this; - - self.load_view(); - self.on("change:effective_readonly", self, function() { - self.is_loaded = self.is_loaded.then(function() { - self.kanban_view.destroy(); - return $.when(self.load_view()).done(function() { - self.render_value(); - }); - }); - }); - }, - // WARNING: duplicated in 4 other M2M widgets - set_value: function(value_) { - value_ = value_ || []; - if (value_.length >= 1 && value_[0] instanceof Array) { - // value_ is a list of m2m commands. We only process - // LINK_TO and REPLACE_WITH in this context - var val = []; - _.each(value_, function (command) { - if (command[0] === commands.LINK_TO) { - val.push(command[1]); // (4, id[, _]) - } else if (command[0] === commands.REPLACE_WITH) { - val = command[2]; // (6, _, ids) - } - }); - value_ = val; - } - this._super(value_); - }, - get_value: function() { - return [commands.replace_with(this.get('value'))]; - }, - load_view: function() { - var self = this; - this.kanban_view = new instance.web.form.Many2ManyKanbanView(this, this.dataset, false, { - 'create_text': _t("Add"), - 'creatable': self.get("effective_readonly") ? false : true, - 'quick_creatable': self.get("effective_readonly") ? false : true, - 'read_only_mode': self.get("effective_readonly") ? true : false, - 'confirm_on_delete': false, - }); - var embedded = (this.field.views || {}).kanban; - if (embedded) { - this.kanban_view.set_embedded_view(embedded); - } - this.kanban_view.m2m = this; - var loaded = $.Deferred(); - this.kanban_view.on("kanban_view_loaded",self,function() { - self.initial_is_loaded.resolve(); - loaded.resolve(); - }); - this.kanban_view.on('switch_mode', this, this.open_popup); - $.async_when().done(function () { - self.kanban_view.appendTo(self.$el); - }); - return loaded; - }, - render_value: function() { - var self = this; - this.dataset.set_ids(this.get("value")); - this.is_loaded = this.is_loaded.then(function() { - return self.kanban_view.do_search(self.build_domain(), self.dataset.get_context(), []); - }); - }, - dataset_changed: function() { - this.set({'value': this.dataset.ids}); - }, - open_popup: function(type, unused) { - if (type !== "form") - return; - var self = this; - var pop; - if (this.dataset.index === null) { - pop = new instance.web.form.SelectCreatePopup(this); - pop.select_element( - this.field.relation, - { - title: _t("Add: ") + this.string - }, - new instance.web.CompoundDomain(this.build_domain(), ["!", ["id", "in", this.dataset.ids]]), - this.build_context() - ); - pop.on("elements_selected", self, function(element_ids) { - _.each(element_ids, function(one_id) { - if(! _.detect(self.dataset.ids, function(x) {return x == one_id;})) { - self.dataset.set_ids([].concat(self.dataset.ids, [one_id])); - self.dataset_changed(); - self.render_value(); - } - }); - }); - } else { - var id = self.dataset.ids[self.dataset.index]; - pop = new instance.web.form.FormOpenPopup(this); - pop.show_element(self.field.relation, id, self.build_context(), { - title: _t("Open: ") + self.string, - write_function: function(id, data, options) { - return self.dataset.write(id, data, {}).done(function() { - self.render_value(); - }); - }, - alternative_form_view: self.field.views ? self.field.views["form"] : undefined, - parent_view: self.view, - child_name: self.name, - readonly: self.get("effective_readonly") - }); - } - }, - add_id: function(id) { - this.quick_create.add_id(id); - }, -}); - -function m2m_kanban_lazy_init() { -if (instance.web.form.Many2ManyKanbanView) - return; -instance.web.form.Many2ManyKanbanView = instance.web_kanban.KanbanView.extend({ - quick_create_class: 'instance.web.form.Many2ManyQuickCreate', - _is_quick_create_enabled: function() { - return this._super() && ! this.group_by; - }, -}); -instance.web.form.Many2ManyQuickCreate = instance.web.Widget.extend({ - template: 'Many2ManyKanban.quick_create', - - /** - * close_btn: If true, the widget will display a "Close" button able to trigger - * a "close" event. - */ - init: function(parent, dataset, context, buttons) { - this._super(parent); - this.m2m = this.getParent().view.m2m; - this.m2m.quick_create = this; - this._dataset = dataset; - this._buttons = buttons || false; - this._context = context || {}; - }, - start: function () { - var self = this; - self.$text = this.$el.find('input').css("width", "200px"); - self.$text.textext({ - plugins : 'arrow autocomplete', - autocomplete: { - render: function(suggestion) { - return $('<span class="text-label"/>'). - data('index', suggestion['index']).html(suggestion['label']); - } - }, - ext: { - autocomplete: { - selectFromDropdown: function() { - $(this).trigger('hideDropdown'); - var index = Number(this.selectedSuggestionElement().children().children().data('index')); - var data = self.search_result[index]; - if (data.id) { - self.add_id(data.id); - } else { - data.action(); - } - }, - }, - itemManager: { - itemToString: function(item) { - return item.name; - }, - }, - }, - }).bind('getSuggestions', function(e, data) { - var _this = this; - var str = !!data ? data.query || '' : ''; - self.m2m.get_search_result(str).done(function(result) { - self.search_result = result; - $(_this).trigger('setSuggestions', {result : _.map(result, function(el, i) { - return _.extend(el, {index:i}); - })}); - }); - }); - self.$text.focusout(function() { - self.$text.val(""); - }); - }, - focus: function() { - this.$text[0].focus(); - }, - add_id: function(id) { - var self = this; - self.$text.val(""); - self.trigger('added', id); - this.m2m.dataset_changed(); - }, -}); -} - -/** - * Class with everything which is common between FormOpenPopup and SelectCreatePopup. - */ -instance.web.form.AbstractFormPopup = instance.web.Widget.extend({ - template: "AbstractFormPopup.render", - /** - * options: - * -readonly: only applicable when not in creation mode, default to false - * - alternative_form_view - * - view_id - * - write_function - * - read_function - * - create_function - * - parent_view - * - child_name - * - form_view_options - */ - init_popup: function(model, row_id, domain, context, options) { - this.row_id = row_id; - this.model = model; - this.domain = domain || []; - this.context = context || {}; - this.options = options; - _.defaults(this.options, {}); - }, - init_dataset: function() { - var self = this; - this.created_elements = []; - this.dataset = new instance.web.ProxyDataSet(this, this.model, this.context); - this.dataset.read_function = this.options.read_function; - this.dataset.create_function = function(data, options, sup) { - var fct = self.options.create_function || sup; - return fct.call(this, data, options).done(function(r) { - self.trigger('create_completed saved', r); - self.created_elements.push(r); - }); - }; - this.dataset.write_function = function(id, data, options, sup) { - var fct = self.options.write_function || sup; - return fct.call(this, id, data, options).done(function(r) { - self.trigger('write_completed saved', r); - }); - }; - this.dataset.parent_view = this.options.parent_view; - this.dataset.child_name = this.options.child_name; - }, - display_popup: function() { - var self = this; - this.renderElement(); - var dialog = new instance.web.Dialog(this, { - dialogClass: 'oe_act_window', - title: this.options.title || "", - }, this.$el).open(); - dialog.on('closing', this, function (e){ - self.check_exit(true); - }); - this.$buttonpane = dialog.$buttons; - this.start(); - }, - setup_form_view: function() { - var self = this; - if (this.row_id) { - this.dataset.ids = [this.row_id]; - this.dataset.index = 0; - } else { - this.dataset.index = null; - } - var options = _.clone(self.options.form_view_options) || {}; - if (this.row_id !== null) { - options.initial_mode = this.options.readonly ? "view" : "edit"; - } - _.extend(options, { - $buttons: this.$buttonpane, - }); - this.view_form = new instance.web.FormView(this, this.dataset, this.options.view_id || false, options); - if (this.options.alternative_form_view) { - this.view_form.set_embedded_view(this.options.alternative_form_view); - } - this.view_form.appendTo(this.$(".oe_popup_form").show()); - this.view_form.on("form_view_loaded", self, function() { - var multi_select = self.row_id === null && ! self.options.disable_multiple_selection; - self.$buttonpane.html(QWeb.render("AbstractFormPopup.buttons", { - multi_select: multi_select, - readonly: self.row_id !== null && self.options.readonly, - })); - var $snbutton = self.$buttonpane.find(".oe_abstractformpopup-form-save-new"); - $snbutton.click(function() { - $.when(self.view_form.save()).done(function() { - self.view_form.reload_mutex.exec(function() { - self.view_form.on_button_new(); - }); - }); - }); - var $sbutton = self.$buttonpane.find(".oe_abstractformpopup-form-save"); - $sbutton.click(function() { - $.when(self.view_form.save()).done(function() { - self.view_form.reload_mutex.exec(function() { - self.check_exit(); - }); - }); - }); - var $cbutton = self.$buttonpane.find(".oe_abstractformpopup-form-close"); - $cbutton.click(function() { - self.view_form.trigger('on_button_cancel'); - self.check_exit(); - }); - self.view_form.do_show(); - }); - }, - select_elements: function(element_ids) { - this.trigger("elements_selected", element_ids); - }, - check_exit: function(no_destroy) { - if (this.created_elements.length > 0) { - this.select_elements(this.created_elements); - this.created_elements = []; - } - this.trigger('closed'); - this.destroy(); - }, - destroy: function () { - this.trigger('closed'); - if (this.$el.is(":data(bs.modal)")) { - this.$el.parents('.modal').modal('hide'); - } - this._super(); - }, -}); - -/** - * Class to display a popup containing a form view. - */ -instance.web.form.FormOpenPopup = instance.web.form.AbstractFormPopup.extend({ - show_element: function(model, row_id, context, options) { - this.init_popup(model, row_id, [], context, options); - _.defaults(this.options, { - }); - this.display_popup(); - }, - start: function() { - this._super(); - this.init_dataset(); - this.setup_form_view(); - }, -}); - -/** - * Class to display a popup to display a list to search a row. It also allows - * to switch to a form view to create a new row. - */ -instance.web.form.SelectCreatePopup = instance.web.form.AbstractFormPopup.extend({ - /** - * options: - * - initial_ids - * - initial_view: form or search (default search) - * - disable_multiple_selection - * - list_view_options - */ - select_element: function(model, options, domain, context) { - this.init_popup(model, null, domain, context, options); - var self = this; - _.defaults(this.options, { - initial_view: "search", - }); - this.initial_ids = this.options.initial_ids; - this.display_popup(); - }, - start: function() { - this.init_dataset(); - if (this.options.initial_view == "search") { - var context = instance.web.pyeval.sync_eval_domains_and_contexts({ - domains: [], - contexts: [this.context] - }).context; - var search_defaults = {}; - _.each(context, function (value_, key) { - var match = /^search_default_(.*)$/.exec(key); - if (match) { - search_defaults[match[1]] = value_; - } - }); - this.setup_search_view(search_defaults); - } else { // "form" - this.new_object(); - } - }, - setup_search_view: function(search_defaults) { - var self = this; - if (this.searchview) { - this.searchview.destroy(); - } - var $buttons = this.$('.o-search-options'); - this.searchview = new instance.web.SearchView(this, - this.dataset, false, search_defaults, {$buttons: $buttons}); - this.searchview.on('search_data', self, function(domains, contexts, groupbys) { - if (self.initial_ids) { - self.do_search(domains.concat([[["id", "in", self.initial_ids]], self.domain]), - contexts.concat(self.context), groupbys); - self.initial_ids = undefined; - } else { - self.do_search(domains.concat([self.domain]), contexts.concat(self.context), groupbys); - } - }); - this.searchview.appendTo(this.$(".o-popup-search")).done(function() { - self.searchview.toggle_visibility(true); - self.view_list = new instance.web.form.SelectCreateListView(self, - self.dataset, false, - _.extend({'deletable': false, - 'selectable': !self.options.disable_multiple_selection, - 'import_enabled': false, - '$buttons': self.$buttonpane, - 'disable_editable_mode': true, - '$pager': self.$('.oe_popup_list_pager'), - }, self.options.list_view_options || {})); - self.view_list.on('edit:before', self, function (e) { - e.cancel = true; - }); - self.view_list.popup = self; - self.view_list.appendTo(self.$(".oe_popup_list").show()).then(function() { - self.view_list.do_show(); - }).then(function() { - if (self.options.initial_facet) { - self.searchview.query.reset([self.options.initial_facet], { - preventSearch: true, - }); - } - self.searchview.do_search(); - }); - self.view_list.on("list_view_loaded", self, function() { - self.$buttonpane.html(QWeb.render("SelectCreatePopup.search.buttons", {widget:self})); - var $cbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-close"); - $cbutton.click(function() { - self.destroy(); - }); - var $sbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-select"); - $sbutton.click(function() { - self.select_elements(self.selected_ids); - self.destroy(); - }); - $cbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-create"); - $cbutton.click(function() { - self.new_object(); - }); - }); - }); - }, - do_search: function(domains, contexts, groupbys) { - var results = instance.web.pyeval.sync_eval_domains_and_contexts({ - domains: domains || [], - contexts: contexts || [], - group_by_seq: groupbys || [] - }); - this.view_list.do_search(results.domain, results.context, results.group_by); - }, - on_click_element: function(ids) { - var self = this; - this.selected_ids = ids || []; - if(this.selected_ids.length > 0) { - self.$buttonpane.find(".oe_selectcreatepopup-search-select").removeAttr('disabled'); - } else { - self.$buttonpane.find(".oe_selectcreatepopup-search-select").attr('disabled', "disabled"); - } - }, - new_object: function() { - if (this.searchview) { - this.searchview.do_hide(); - } - if (this.view_list) { - this.view_list.do_hide(); - this.view_list.$el.parent().hide(); - } - this.setup_form_view(); - }, -}); - -instance.web.form.DomainEditorPopup = instance.web.form.SelectCreatePopup.extend({ - select_element: function (model, options, domain, context) { - if (options.readonly) { - return this._super(model, options, domain, context); - } - options.initial_facet = { - category: _t("Custom Filter"), - icon: 'fa-star', - field: { - get_context: function () { return context; }, - get_groupby: function () { return []; }, - get_domain: function () { return domain; }, - }, - values: [{label: _t("Selected domain"), value: null}], - }; - this._super(model, options, [], context); - }, - get_domain: function (selected_ids) { - var group_domain = [], - domain; - if (this.$('input.oe_list_record_selector').prop('checked')) { - if (this.view_list.grouped) { - var group_domain = _.chain(_.values(this.view_list.groups.children)) - .filter(function (child) { return child.records.length; }) - .map(function (c) { return c.datagroup.domain;}) - .value(); - group_domain = _.flatten(group_domain, true); - group_domain = _.times(group_domain.length - 1, _.constant('|')).concat(group_domain); - } - var search_data = this.searchview.build_search_data(); - domain = instance.web.pyeval.sync_eval_domains_and_contexts({ - domains: search_data.domains, - contexts: search_data.contexts, - group_by_seq: search_data.groupbys || [] - }).domain; - } - else { - domain = [["id", "in", selected_ids]]; - } - return this.dataset.domain.concat(group_domain).concat(domain || []); - }, -}); - -instance.web.form.SelectCreateListView = instance.web.ListView.extend({ - do_add_record: function () { - this.popup.new_object(); - }, - select_record: function(index) { - this.popup.select_elements([this.dataset.ids[index]]); - this.popup.destroy(); - }, - do_select: function(ids, records) { - this._super(ids, records); - this.popup.on_click_element(ids); - } -}); - -instance.web.form.FieldReference = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: 'FieldReference', - init: function(field_manager, node) { - this._super(field_manager, node); - this.reference_ready = true; - }, - destroy_content: function() { - if (this.fm) { - this.fm.destroy(); - this.fm = undefined; - } - }, - initialize_content: function() { - var self = this; - var fm = new instance.web.form.DefaultFieldManager(this); - this.fm = fm; - fm.extend_field_desc({ - "selection": { - selection: this.field_manager.get_field_desc(this.name).selection, - type: "selection", - }, - "m2o": { - relation: null, - type: "many2one", - }, - }); - this.selection = new instance.web.form.FieldSelection(fm, { attrs: { - name: 'selection', - modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), - }}); - this.selection.on("change:value", this, this.on_selection_changed); - this.selection.appendTo(this.$(".oe_form_view_reference_selection")); - this.selection - .on('focused', null, function () {self.trigger('focused');}) - .on('blurred', null, function () {self.trigger('blurred');}); - - this.m2o = new instance.web.form.FieldMany2One(fm, { attrs: { - name: 'Referenced Document', - modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), - }}); - this.m2o.on("change:value", this, this.data_changed); - this.m2o.appendTo(this.$(".oe_form_view_reference_m2o")); - this.m2o - .on('focused', null, function () {self.trigger('focused');}) - .on('blurred', null, function () {self.trigger('blurred');}); - }, - on_selection_changed: function() { - if (this.reference_ready) { - this.internal_set_value([this.selection.get_value(), false]); - this.render_value(); - } - }, - data_changed: function() { - if (this.reference_ready) { - this.internal_set_value([this.selection.get_value(), this.m2o.get_value()]); - } - }, - set_value: function(val) { - if (val) { - val = val.split(','); - val[0] = val[0] || false; - val[1] = val[0] ? (val[1] ? parseInt(val[1], 10) : val[1]) : false; - } - this._super(val || [false, false]); - }, - get_value: function() { - return this.get('value')[0] && this.get('value')[1] ? (this.get('value')[0] + ',' + this.get('value')[1]) : false; - }, - render_value: function() { - this.reference_ready = false; - if (!this.get("effective_readonly")) { - this.selection.set_value(this.get('value')[0]); - } - this.m2o.field.relation = this.get('value')[0]; - this.m2o.set_value(this.get('value')[1]); - this.m2o.$el.toggle(!!this.get('value')[0]); - this.reference_ready = true; - }, -}); - -instance.web.form.FieldBinary = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - init: function(field_manager, node) { - var self = this; - this._super(field_manager, node); - this.binary_value = false; - this.useFileAPI = !!window.FileReader; - this.max_upload_size = 25 * 1024 * 1024; // 25Mo - if (!this.useFileAPI) { - this.fileupload_id = _.uniqueId('oe_fileupload'); - $(window).on(this.fileupload_id, function() { - var args = [].slice.call(arguments).slice(1); - self.on_file_uploaded.apply(self, args); - }); - } - }, - stop: function() { - if (!this.useFileAPI) { - $(window).off(this.fileupload_id); - } - this._super.apply(this, arguments); - }, - initialize_content: function() { - var self= this; - this.$el.find('input.oe_form_binary_file').change(this.on_file_change); - this.$el.find('button.oe_form_binary_file_save').click(this.on_save_as); - this.$el.find('.oe_form_binary_file_clear').click(this.on_clear); - this.$el.find('.oe_form_binary_file_edit').click(function(event){ - self.$el.find('input.oe_form_binary_file').click(); - }); - }, - on_file_change: function(e) { - var self = this; - var file_node = e.target; - if ((this.useFileAPI && file_node.files.length) || (!this.useFileAPI && $(file_node).val() !== '')) { - if (this.useFileAPI) { - var file = file_node.files[0]; - if (file.size > this.max_upload_size) { - var msg = _t("The selected file exceed the maximum file size of %s."); - instance.webclient.notification.warn(_t("File upload"), _.str.sprintf(msg, instance.web.human_size(this.max_upload_size))); - return false; - } - var filereader = new FileReader(); - filereader.readAsDataURL(file); - filereader.onloadend = function(upload) { - var data = upload.target.result; - data = data.split(',')[1]; - self.on_file_uploaded(file.size, file.name, file.type, data); - }; - } else { - this.$el.find('form.oe_form_binary_form input[name=session_id]').val(this.session.session_id); - this.$el.find('form.oe_form_binary_form').submit(); - } - this.$el.find('.oe_form_binary_progress').show(); - this.$el.find('.oe_form_binary').hide(); - } - }, - on_file_uploaded: function(size, name, content_type, file_base64) { - if (size === false) { - this.do_warn(_t("File Upload"), _t("There was a problem while uploading your file")); - // TODO: use openerp web crashmanager - console.warn("Error while uploading file : ", name); - } else { - this.filename = name; - this.on_file_uploaded_and_valid.apply(this, arguments); - } - this.$el.find('.oe_form_binary_progress').hide(); - this.$el.find('.oe_form_binary').show(); - }, - on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { - }, - on_save_as: function(ev) { - var value = this.get('value'); - if (!value) { - this.do_warn(_t("Save As..."), _t("The field is empty, there's nothing to save !")); - ev.stopPropagation(); - } else { - instance.web.blockUI(); - var c = instance.webclient.crashmanager; - var filename_fieldname = this.node.attrs.filename; - var filename_field = this.view.fields && this.view.fields[filename_fieldname]; - this.session.get_file({ - url: '/web/binary/saveas_ajax', - data: {data: JSON.stringify({ - model: this.view.dataset.model, - id: (this.view.datarecord.id || ''), - field: this.name, - filename_field: (filename_fieldname || ''), - data: instance.web.form.is_bin_size(value) ? null : value, - filename: filename_field ? filename_field.get('value') : null, - context: this.view.dataset.get_context() - })}, - complete: instance.web.unblockUI, - error: c.rpc_error.bind(c) - }); - ev.stopPropagation(); - return false; - } - }, - set_filename: function(value) { - var filename = this.node.attrs.filename; - if (filename) { - var tmp = {}; - tmp[filename] = value; - this.field_manager.set_values(tmp); - } - }, - on_clear: function() { - if (this.get('value') !== false) { - this.binary_value = false; - this.internal_set_value(false); - } - return false; - } -}); - -instance.web.form.FieldBinaryFile = instance.web.form.FieldBinary.extend({ - template: 'FieldBinaryFile', - initialize_content: function() { - this._super(); - if (this.get("effective_readonly")) { - var self = this; - this.$el.find('a').click(function(ev) { - if (self.get('value')) { - self.on_save_as(ev); - } - return false; - }); - } - }, - render_value: function() { - var show_value; - if (!this.get("effective_readonly")) { - if (this.node.attrs.filename) { - show_value = this.view.datarecord[this.node.attrs.filename] || ''; - } else { - show_value = (this.get('value') !== null && this.get('value') !== undefined && this.get('value') !== false) ? this.get('value') : ''; - } - this.$el.find('input').eq(0).val(show_value); - } else { - this.$el.find('a').toggle(!!this.get('value')); - if (this.get('value')) { - show_value = _t("Download"); - if (this.view) - show_value += " " + (this.view.datarecord[this.node.attrs.filename] || ''); - this.$el.find('a').text(show_value); - } - } - }, - on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { - this.binary_value = true; - this.internal_set_value(file_base64); - var show_value = name + " (" + instance.web.human_size(size) + ")"; - this.$el.find('input').eq(0).val(show_value); - this.set_filename(name); - }, - on_clear: function() { - this._super.apply(this, arguments); - this.$el.find('input').eq(0).val(''); - this.set_filename(''); - } -}); - -instance.web.form.FieldBinaryImage = instance.web.form.FieldBinary.extend({ - template: 'FieldBinaryImage', - placeholder: "/web/static/src/img/placeholder.png", - render_value: function() { - var self = this; - var url; - if (this.get('value') && !instance.web.form.is_bin_size(this.get('value'))) { - url = 'data:image/png;base64,' + this.get('value'); - } else if (this.get('value')) { - var id = JSON.stringify(this.view.datarecord.id || null); - var field = this.name; - if (this.options.preview_image) - field = this.options.preview_image; - url = this.session.url('/web/binary/image', { - model: this.view.dataset.model, - id: id, - field: field, - t: (new Date().getTime()), - }); - } else { - url = this.placeholder; - } - var $img = $(QWeb.render("FieldBinaryImage-img", { widget: this, url: url })); - $($img).click(function(e) { - if(self.view.get("actual_mode") == "view") { - var $button = $(".oe_form_button_edit"); - $button.openerpBounce(); - e.stopPropagation(); - } - }); - this.$el.find('> img').remove(); - this.$el.prepend($img); - $img.load(function() { - if (! self.options.size) - return; - $img.css("max-width", "" + self.options.size[0] + "px"); - $img.css("max-height", "" + self.options.size[1] + "px"); - }); - $img.on('error', function() { - $img.attr('src', self.placeholder); - instance.webclient.notification.warn(_t("Image"), _t("Could not display the selected image.")); - }); - }, - on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { - this.internal_set_value(file_base64); - this.binary_value = true; - this.render_value(); - this.set_filename(name); - }, - on_clear: function() { - this._super.apply(this, arguments); - this.render_value(); - this.set_filename(''); - }, - set_value: function(value_){ - var changed = value_ !== this.get_value(); - this._super.apply(this, arguments); - // By default, on binary images read, the server returns the binary size - // This is possible that two images have the exact same size - // Therefore we trigger the change in case the image value hasn't changed - // So the image is re-rendered correctly - if (!changed){ - this.trigger("change:value", this, { - oldValue: value_, - newValue: value_ - }); - } - } -}); - -/** - * Widget for (many2many field) to upload one or more file in same time and display in list. - * The user can delete his files. - * Options on attribute ; "blockui" {Boolean} block the UI or not - * during the file is uploading - */ -instance.web.form.FieldMany2ManyBinaryMultiFiles = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - template: "FieldBinaryFileUploader", - init: function(field_manager, node) { - this._super(field_manager, node); - this.field_manager = field_manager; - this.node = node; - if(this.field.type != "many2many" || this.field.relation != 'ir.attachment') { - throw _.str.sprintf(_t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model."), this.field.string); - } - this.data = {}; - this.set_value([]); - this.ds_file = new instance.web.DataSetSearch(this, 'ir.attachment'); - this.fileupload_id = _.uniqueId('oe_fileupload_temp'); - $(window).on(this.fileupload_id, _.bind(this.on_file_loaded, this)); - }, - initialize_content: function() { - this.$el.on('change', 'input.oe_form_binary_file', this.on_file_change ); - }, - // WARNING: duplicated in 4 other M2M widgets - set_value: function(value_) { - value_ = value_ || []; - if (value_.length >= 1 && value_[0] instanceof Array) { - // value_ is a list of m2m commands. We only process - // LINK_TO and REPLACE_WITH in this context - var val = []; - _.each(value_, function (command) { - if (command[0] === commands.LINK_TO) { - val.push(command[1]); // (4, id[, _]) - } else if (command[0] === commands.REPLACE_WITH) { - val = command[2]; // (6, _, ids) - } - }); - value_ = val; - } - this._super(value_); - }, - get_value: function() { - var tmp = [commands.replace_with(this.get("value"))]; - return tmp; - }, - get_file_url: function (attachment) { - return this.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'datas_fname', id: attachment['id']}); - }, - read_name_values : function () { - var self = this; - // don't reset know values - var ids = this.get('value'); - var _value = _.filter(ids, function (id) { return typeof self.data[id] == 'undefined'; } ); - // send request for get_name - if (_value.length) { - return this.ds_file.call('read', [_value, ['id', 'name', 'datas_fname']]).then(function (datas) { - _.each(datas, function (data) { - data.no_unlink = true; - data.url = self.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'datas_fname', id: data.id}); - self.data[data.id] = data; - }); - return ids; - }); - } else { - return $.when(ids); - } - }, - render_value: function () { - var self = this; - this.read_name_values().then(function (ids) { - var render = $(instance.web.qweb.render('FieldBinaryFileUploader.files', {'widget': self, 'values': ids})); - render.on('click', '.oe_delete', _.bind(self.on_file_delete, self)); - self.$('.oe_placeholder_files, .oe_attachments').replaceWith( render ); - - // reinit input type file - var $input = self.$('input.oe_form_binary_file'); - $input.after($input.clone(true)).remove(); - self.$(".oe_fileupload").show(); - - }); - }, - on_file_change: function (event) { - event.stopPropagation(); - var self = this; - var $target = $(event.target); - if ($target.val() !== '') { - var filename = $target.val().replace(/.*[\\\/]/,''); - // don't uplode more of one file in same time - if (self.data[0] && self.data[0].upload ) { - return false; - } - for (var id in this.get('value')) { - // if the files exits, delete the file before upload (if it's a new file) - if (self.data[id] && (self.data[id].filename || self.data[id].name) == filename && !self.data[id].no_unlink ) { - self.ds_file.unlink([id]); - } - } - - // block UI or not - if(this.node.attrs.blockui>0) { - instance.web.blockUI(); - } - - // TODO : unactivate send on wizard and form - - // submit file - this.$('form.oe_form_binary_form').submit(); - this.$(".oe_fileupload").hide(); - // add file on data result - this.data[0] = { - 'id': 0, - 'name': filename, - 'filename': filename, - 'url': '', - 'upload': true - }; - } - }, - on_file_loaded: function (event, result) { - var files = this.get('value'); - - // unblock UI - if(this.node.attrs.blockui>0) { - instance.web.unblockUI(); - } - - if (result.error || !result.id ) { - this.do_warn( _t('Uploading Error'), result.error); - delete this.data[0]; - } else { - if (this.data[0] && this.data[0].filename == result.filename && this.data[0].upload) { - delete this.data[0]; - this.data[result.id] = { - 'id': result.id, - 'name': result.name, - 'filename': result.filename, - 'url': this.get_file_url(result) - }; - } else { - this.data[result.id] = { - 'id': result.id, - 'name': result.name, - 'filename': result.filename, - 'url': this.get_file_url(result) - }; - } - var values = _.clone(this.get('value')); - values.push(result.id); - this.set({'value': values}); - } - this.render_value(); - }, - on_file_delete: function (event) { - event.stopPropagation(); - var file_id=$(event.target).data("id"); - if (file_id) { - var files = _.filter(this.get('value'), function (id) {return id != file_id;}); - if(!this.data[file_id].no_unlink) { - this.ds_file.unlink([file_id]); - } - this.set({'value': files}); - } - }, -}); - -instance.web.form.FieldStatus = instance.web.form.AbstractField.extend({ - template: "FieldStatus", - init: function(field_manager, node) { - this._super(field_manager, node); - this.options.clickable = this.options.clickable || (this.node.attrs || {}).clickable || false; - this.options.visible = this.options.visible || (this.node.attrs || {}).statusbar_visible || false; - this.set({value: false}); - this.selection = {'unfolded': [], 'folded': []}; - this.set("selection", {'unfolded': [], 'folded': []}); - this.selection_dm = new instance.web.DropMisordered(); - this.dataset = new instance.web.DataSetStatic(this, this.field.relation, this.build_context()); - }, - start: function() { - this.field_manager.on("view_content_has_changed", this, this.calc_domain); - this.calc_domain(); - this.on("change:value", this, this.get_selection); - this.on("change:evaluated_selection_domain", this, this.get_selection); - this.on("change:selection", this, function() { - this.selection = this.get("selection"); - this.render_value(); - }); - this.get_selection(); - if (this.options.clickable) { - this.$el.on('click','li[data-id]',this.on_click_stage); - } - if (this.$el.parent().is('header')) { - this.$el.after('<div class="oe_clear"/>'); - } - this._super(); - }, - set_value: function(value_) { - if (value_ instanceof Array) { - value_ = value_[0]; - } - this._super(value_); - }, - render_value: function() { - var self = this; - var content = QWeb.render("FieldStatus.content", { - 'widget': self, - 'value_folded': _.find(self.selection.folded, function(i){return i[0] === self.get('value');}) - }); - self.$el.html(content); - }, - calc_domain: function() { - var d = instance.web.pyeval.eval('domain', this.build_domain()); - var domain = []; //if there is no domain defined, fetch all the records - - if (d.length) { - domain = ['|',['id', '=', this.get('value')]].concat(d); - } - - if (! _.isEqual(domain, this.get("evaluated_selection_domain"))) { - this.set("evaluated_selection_domain", domain); - } - }, - /** Get the selection and render it - * selection: [[identifier, value_to_display], ...] - * For selection fields: this is directly given by this.field.selection - * For many2one fields: perform a search on the relation of the many2one field - */ - get_selection: function() { - var self = this; - var selection_unfolded = []; - var selection_folded = []; - var fold_field = this.options.fold_field; - - var calculation = _.bind(function() { - if (this.field.type == "many2one") { - return self.get_distant_fields().then(function (fields) { - return new instance.web.DataSetSearch(self, self.field.relation, self.build_context(), self.get("evaluated_selection_domain")) - .read_slice(_.union(_.keys(self.distant_fields), ['id']), {}).then(function (records) { - var ids = _.pluck(records, 'id'); - return self.dataset.name_get(ids).then(function (records_name) { - _.each(records, function (record) { - var name = _.find(records_name, function (val) {return val[0] == record.id;})[1]; - if (fold_field && record[fold_field] && record.id != self.get('value')) { - selection_folded.push([record.id, name]); - } else { - selection_unfolded.push([record.id, name]); - } - }); - }); - }); - }); - } else { - // For field type selection filter values according to - // statusbar_visible attribute of the field. For example: - // statusbar_visible="draft,open". - var select = this.field.selection; - for(var i=0; i < select.length; i++) { - var key = select[i][0]; - if(key == this.get('value') || !this.options.visible || this.options.visible.indexOf(key) != -1) { - selection_unfolded.push(select[i]); - } - } - return $.when(); - } - }, this); - this.selection_dm.add(calculation()).then(function () { - var selection = {'unfolded': selection_unfolded, 'folded': selection_folded}; - if (! _.isEqual(selection, self.get("selection"))) { - self.set("selection", selection); - } - }); - }, - /* - * :deprecated: this feature will probably be removed with OpenERP v8 - */ - get_distant_fields: function() { - var self = this; - if (! this.options.fold_field) { - this.distant_fields = {} - } - if (this.distant_fields) { - return $.when(this.distant_fields); - } - return new instance.web.Model(self.field.relation).call("fields_get", [[this.options.fold_field]]).then(function(fields) { - self.distant_fields = fields; - return fields; - }); - }, - on_click_stage: function (ev) { - var self = this; - var $li = $(ev.currentTarget); - var val; - if (this.field.type == "many2one") { - val = parseInt($li.data("id"), 10); - } - else { - val = $li.data("id"); - } - if (val != self.get('value')) { - this.view.recursive_save().done(function() { - var change = {}; - change[self.name] = val; - self.view.dataset.write(self.view.datarecord.id, change).done(function() { - self.view.reload(); - }); - }); - } - }, -}); - -instance.web.form.FieldMonetary = instance.web.form.FieldFloat.extend({ - template: "FieldMonetary", - widget_class: 'oe_form_field_float oe_form_field_monetary', - init: function() { - this._super.apply(this, arguments); - this.set({"currency": false}); - if (this.options.currency_field) { - this.field_manager.on("field_changed:" + this.options.currency_field, this, function() { - this.set({"currency": this.field_manager.get_field_value(this.options.currency_field)}); - }); - } - this.on("change:currency", this, this.get_currency_info); - this.get_currency_info(); - this.ci_dm = new instance.web.DropMisordered(); - }, - start: function() { - var tmp = this._super(); - this.on("change:currency_info", this, this.reinitialize); - return tmp; - }, - get_currency_info: function() { - var self = this; - if (this.get("currency") === false) { - this.set({"currency_info": null}); - return; - } - return this.ci_dm.add(self.alive(new instance.web.Model("res.currency").query(["symbol", "position"]) - .filter([["id", "=", self.get("currency")]]).first())).then(function(res) { - self.set({"currency_info": res}); - }); - }, - parse_value: function(val, def) { - return instance.web.parse_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def); - }, - format_value: function(val, def) { - return instance.web.format_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def); - }, -}); - -/* - This type of field display a list of checkboxes. It works only with m2ms. This field will display one checkbox for each - record existing in the model targeted by the relation, according to the given domain if one is specified. Checked records - will be added to the relation. -*/ -instance.web.form.FieldMany2ManyCheckBoxes = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - className: "oe_form_many2many_checkboxes", - init: function() { - this._super.apply(this, arguments); - this.set("value", {}); - this.set("records", []); - this.field_manager.on("view_content_has_changed", this, function() { - var domain = new openerp.web.CompoundDomain(this.build_domain()).eval(); - if (! _.isEqual(domain, this.get("domain"))) { - this.set("domain", domain); - } - }); - this.records_orderer = new instance.web.DropMisordered(); - }, - initialize_field: function() { - instance.web.form.ReinitializeFieldMixin.initialize_field.call(this); - this.on("change:domain", this, this.query_records); - this.set("domain", new openerp.web.CompoundDomain(this.build_domain()).eval()); - this.on("change:records", this, this.render_value); - }, - query_records: function() { - var self = this; - var model = new openerp.Model(openerp.session, this.field.relation); - this.records_orderer.add(model.call("search", [this.get("domain")], {"context": this.build_context()}).then(function(record_ids) { - return model.call("name_get", [record_ids] , {"context": self.build_context()}); - })).then(function(res) { - self.set("records", res); - }); - }, - render_value: function() { - this.$().html(QWeb.render("FieldMany2ManyCheckBoxes", {widget: this, selected: this.get("value")})); - var inputs = this.$("input"); - inputs.change(_.bind(this.from_dom, this)); - if (this.get("effective_readonly")) - inputs.attr("disabled", "true"); - }, - from_dom: function() { - var new_value = {}; - this.$("input").each(function() { - var elem = $(this); - new_value[elem.data("record-id")] = elem.is(':checked') ? true : undefined; - }); - if (! _.isEqual(new_value, this.get("value"))) - this.internal_set_value(new_value); - }, - // WARNING: (mostly) duplicated in 4 other M2M widgets - set_value: function(value_) { - value_ = value_ || []; - if (value_.length >= 1 && value_[0] instanceof Array) { - // value_ is a list of m2m commands. We only process - // LINK_TO and REPLACE_WITH in this context - var val = []; - _.each(value_, function (command) { - if (command[0] === commands.LINK_TO) { - val.push(command[1]); // (4, id[, _]) - } else if (command[0] === commands.REPLACE_WITH) { - val = command[2]; // (6, _, ids) - } - }); - value_ = val; - } - var formatted = {}; - _.each(value_, function(el) { - formatted[JSON.stringify(el)] = true; - }); - this._super(formatted); - }, - get_value: function() { - var value = _.filter(_.keys(this.get("value")), function(el) { - return this.get("value")[el]; - }, this); - value = _.map(value, function(el) { - return JSON.parse(el); - }); - return [commands.replace_with(value)]; - }, -}); - -/** - This field can be applied on many2many and one2many. It is a read-only field that will display a single link whose name is - "<number of linked records> <label of the field>". When the link is clicked, it will redirect to another act_window - action on the model of the relation and show only the linked records. - - Widget options: - - * views: The views to display in the act_window action. Must be a list of tuples whose first element is the id of the view - to display (or False to take the default one) and the second element is the type of the view. Defaults to - [[false, "tree"], [false, "form"]] . -*/ -instance.web.form.X2ManyCounter = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { - className: "oe_form_x2many_counter", - init: function() { - this._super.apply(this, arguments); - this.set("value", []); - _.defaults(this.options, { - "views": [[false, "tree"], [false, "form"]], - }); - }, - render_value: function() { - var text = _.str.sprintf("%d %s", this.val().length, this.string); - this.$().html(QWeb.render("X2ManyCounter", {text: text})); - this.$("a").click(_.bind(this.go_to, this)); - }, - go_to: function() { - return this.view.recursive_save().then(_.bind(function() { - var val = this.val(); - var context = {}; - if (this.field.type === "one2many") { - context["default_" + this.field.relation_field] = this.view.datarecord.id; - } - var domain = [["id", "in", val]]; - return this.do_action({ - type: 'ir.actions.act_window', - name: this.string, - res_model: this.field.relation, - views: this.options.views, - target: 'current', - context: context, - domain: domain, - }); - }, this)); - }, - val: function() { - var value = this.get("value") || []; - if (value.length >= 1 && value[0] instanceof Array) { - value = value[0][2]; - } - return value; - } -}); - -/** - This widget is intended to be used on stat button numeric fields. It will display - the value many2many and one2many. It is a read-only field that will - display a simple string "<value of field> <label of the field>" -*/ -instance.web.form.StatInfo = instance.web.form.AbstractField.extend({ - is_field_number: true, - init: function() { - this._super.apply(this, arguments); - this.internal_set_value(0); - }, - set_value: function(value_) { - if (value_ === false || value_ === undefined) { - value_ = 0; - } - this._super.apply(this, [value_]); - }, - render_value: function() { - var options = { - value: this.get("value") || 0, - }; - if (! this.node.attrs.nolabel) { - if(this.options.label_field && this.view.datarecord[this.options.label_field]) { - options.text = this.view.datarecord[this.options.label_field]; - } - else { - options.text = this.string; - } - } - this.$el.html(QWeb.render("StatInfo", options)); - }, - -}); - -/** - This widget is intended to be used on boolean fields. It toggles a button - switching between a green bullet / gray bullet. -*/ -instance.web.form.FieldToggleBoolean = instance.web.form.AbstractField.extend({ - render_value: function () { - this.icon = this.get_value() ? 'gtk-yes.png' : 'gtk-normal.png'; - this.$el.html(QWeb.render("FieldToggleBoolean", {'widget': this})); - this.$('.oe_toggle_button').on('click', this.set_toggle_button.bind(this)); - }, - set_toggle_button: function () { - var self = this; - var toggle_value = this.get_value() === false ? true: false; - if (this.view.get('actual_mode') == 'view') { - var rec_values = {}; - rec_values[self.node.attrs.name] = toggle_value; - return this.view.dataset._model.call( - 'write', [ - [this.view.datarecord.id], - rec_values, - self.view.dataset.get_context() - ]).done(function () { self.reload_record(); }); - } - else { - this.set_value(toggle_value); - } - }, - reload_record: function () { - this.view.reload(); - }, -}); - -/** - * Registry of form fields, called by :js:`instance.web.FormView`. - * - * All referenced classes must implement FieldInterface. Those represent the classes whose instances - * will substitute to the <field> tags as defined in OpenERP's views. - */ -instance.web.form.widgets = new instance.web.Registry({ - 'char' : 'instance.web.form.FieldChar', - 'id' : 'instance.web.form.FieldID', - 'email' : 'instance.web.form.FieldEmail', - 'url' : 'instance.web.form.FieldUrl', - 'text' : 'instance.web.form.FieldText', - 'html' : 'instance.web.form.FieldTextHtml', - 'char_domain': 'instance.web.form.FieldCharDomain', - 'date' : 'instance.web.form.FieldDate', - 'datetime' : 'instance.web.form.FieldDatetime', - 'selection' : 'instance.web.form.FieldSelection', - 'radio' : 'instance.web.form.FieldRadio', - 'many2one' : 'instance.web.form.FieldMany2One', - 'many2onebutton' : 'instance.web.form.Many2OneButton', - 'many2many' : 'instance.web.form.FieldMany2Many', - 'many2many_tags' : 'instance.web.form.FieldMany2ManyTags', - 'many2many_kanban' : 'instance.web.form.FieldMany2ManyKanban', - 'one2many' : 'instance.web.form.FieldOne2Many', - 'one2many_list' : 'instance.web.form.FieldOne2Many', - 'reference' : 'instance.web.form.FieldReference', - 'boolean' : 'instance.web.form.FieldBoolean', - 'toggle_button' : 'instance.web.form.FieldToggleBoolean', - 'float' : 'instance.web.form.FieldFloat', - 'percentpie': 'instance.web.form.FieldPercentPie', - 'barchart': 'instance.web.form.FieldBarChart', - 'integer': 'instance.web.form.FieldFloat', - 'float_time': 'instance.web.form.FieldFloat', - 'progressbar': 'instance.web.form.FieldProgressBar', - 'image': 'instance.web.form.FieldBinaryImage', - 'binary': 'instance.web.form.FieldBinaryFile', - 'many2many_binary': 'instance.web.form.FieldMany2ManyBinaryMultiFiles', - 'statusbar': 'instance.web.form.FieldStatus', - 'monetary': 'instance.web.form.FieldMonetary', - 'many2many_checkboxes': 'instance.web.form.FieldMany2ManyCheckBoxes', - 'x2many_counter': 'instance.web.form.X2ManyCounter', - 'priority':'instance.web.form.Priority', - 'kanban_state_selection':'instance.web.form.KanbanSelection', - 'statinfo': 'instance.web.form.StatInfo', -}); - -/** - * Registry of widgets usable in the form view that can substitute to any possible - * tags defined in OpenERP's form views. - * - * Every referenced class should extend FormWidget. - */ -instance.web.form.tags = new instance.web.Registry({ - 'button' : 'instance.web.form.WidgetButton', -}); - -instance.web.form.custom_widgets = new instance.web.Registry({ -}); - -})(); diff --git a/addons/web/static/src/js/view_list_editable.js b/addons/web/static/src/js/view_list_editable.js deleted file mode 100644 index f5bda4bce11..00000000000 --- a/addons/web/static/src/js/view_list_editable.js +++ /dev/null @@ -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; - } - }); -})(); diff --git a/addons/web/static/src/js/view_manager.js b/addons/web/static/src/js/view_manager.js new file mode 100644 index 00000000000..dfef449fe9e --- /dev/null +++ b/addons/web/static/src/js/view_manager.js @@ -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; + +}); diff --git a/addons/web/static/src/js/views.js b/addons/web/static/src/js/views.js deleted file mode 100644 index e58753e1efa..00000000000 --- a/addons/web/static/src/js/views.js +++ /dev/null @@ -1,1593 +0,0 @@ -/*--------------------------------------------------------- - * OpenERP web library - *---------------------------------------------------------*/ - -(function() { -"use strict"; - -var instance = openerp; -openerp.web.views = {}; -var QWeb = instance.web.qweb, - _t = instance.web._t; - -instance.web.ActionManager = instance.web.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 instance.web.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 instance.web.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 instance.web.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 instance.web.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) && instance.web.client_actions.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() { - instance.webclient.menu.is_bound.done(function() { - if (self.inner_action && self.inner_action.id) { - instance.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) && instance.web.client_actions.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); - }); - } - - instance.web.bus.trigger('action', action); - - // Ensure context & domain are evaluated and can be manipulated/used - var ncontext = new instance.web.CompoundContext(options.additional_context, action.context || {}); - action.context = instance.web.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 = instance.web.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 instance.web.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 instance.web.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 instance.web.ViewManager(self, null, null, null, action); - }, - action: action, - klass: 'oe_act_window', - }, options); - }, - ir_actions_client: function (action, options) { - var self = this; - var ClientWidget = instance.web.client_actions.get_object(action.tag); - if (!ClientWidget) { - return self.do_warn("Action Error", "Could not find client action '" + action.tag + "'."); - } - - if (!(ClientWidget.prototype instanceof instance.web.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; - instance.web.blockUI(); - action = _.clone(action); - var eval_contexts = ([instance.session.user_context] || []).concat([action.context]); - action.context = instance.web.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); - instance.web.unblockUI(); - $('<a href="'+url+'" target="_blank"></a>')[0].click(); - return; - } - var c = instance.webclient.crashmanager; - return $.Deferred(function (d) { - self.session.get_file({ - url: '/web/report', - data: {action: JSON.stringify(action)}, - complete: instance.web.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') { - instance.web.redirect(action.url); - } else { - window.open(action.url, '_blank'); - } - return $.when(); - }, -}); - -instance.web.ViewManager = instance.web.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; - dataset = new instance.web.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 = instance.web.views; - this.title = this.action && this.action.name; - - _.each(views, function (view) { - var view_type = view[1] || view.view_type, - View = instance.web.views.get_object(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.session.debug) { - self.$('.oe_debug_view').html(QWeb.render('ViewManagerDebug', { - view: self.active_view.controller, - view_manager: self, - })); - } - }); - }, - 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_object(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, - }; - var SearchView = instance.web.SearchView; - 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(); - instance.web.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 instance.web.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 instance.web.Dialog(this, { title: _t("Fields View Get") }).open(); - $('<pre>').text(instance.web.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 instance.web.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 : instance.web.format_value - })).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 instance.web.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) { - instance.web.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: instance.web.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, - } - }); - }, -}); - -instance.web.Sidebar = instance.web.Widget.extend({ - init: function(parent) { - var self = this; - this._super(parent); - var view = this.getParent(); - 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); - instance.web.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) { - var self = this; - 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 instance.web.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 = instance.web.pyeval.eval('context', - new instance.web.CompoundContext( - sidebar_eval_context, active_ids_context)); - - self.rpc("/web/action/load", { - action_id: item.action.id, - context: new instance.web.CompoundContext( - dataset.get_context(), active_ids_context).eval() - }).done(function(result) { - result.context = new instance.web.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) { - var self = this; - 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 instance.web.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 items = []; - var prefix = this.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...')); - instance.web.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 instance.web.DataSet(this, 'ir.attachment')).unlink([parseInt($e.attr('data-id'), 10)]).done(function() { - self.do_attachement_update(self.dataset, self.model_id); - }); - } - } -}); - -instance.web.View = instance.web.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(context) { - 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 = instance.web.fields_view_get({ - "model": this.dataset._model, - "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 instance.web.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 instance.web.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 () { - if (instance.webclient) { - instance.webclient.menu.do_reload_needaction(); - } - }); - } else if (action_data.type=="action") { - return this.rpc('/web/action/load', { - action_id: action_data.name, - context: _.extend(instance.web.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); - - instance.web.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) { - }, - on_sidebar_export: function() { - new instance.web.DataExport(this, this.dataset).open(); - }, - 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 {}; - }, -}); - -/** - * 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 - */ -instance.web.fields_view_get = function(args) { - function postprocess(fvg) { - var doc = $.parseXML(fvg.arch).documentElement; - fvg.arch = instance.web.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; - } - args = _.defaults(args, { - toolbar: false, - }); - var model = args.model; - if (typeof model === 'string') { - model = new instance.web.Model(args.model, args.context); - } - return args.model.call('fields_view_get', { - view_id: args.view_id, - view_type: args.view_type, - context: args.context, - toolbar: args.toolbar - }).then(function(fvg) { - return postprocess(fvg); - }); -}; - -instance.web.xml_to_json = function(node, strip_whitespace) { - switch (node.nodeType) { - case 9: - return instance.web.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 instance.web.xml_to_json(node, strip_whitespace); - })), - }; - } -}; - -instance.web.json_node_to_xml = function(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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); - if (human_readable) { - vattr = vattr.replace(/"/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(instance.web.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 + '/>'; - } -}; -instance.web.xml_to_str = function(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; -}; - -/** - * Registry for all the main views - */ -instance.web.views = new instance.web.Registry(); - -})(); diff --git a/addons/web/static/src/js/views/form_common.js b/addons/web/static/src/js/views/form_common.js new file mode 100644 index 00000000000..c1f679fa4f3 --- /dev/null +++ b/addons/web/static/src/js/views/form_common.js @@ -0,0 +1,1135 @@ +odoo.define('web.form_common', ['web.core', 'web.data', 'web.Dialog', 'web.ListView', 'web.pyeval', 'web.SearchView', 'web.session', 'web.utils', 'web.Widget'], function (require) { +"use strict"; + +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var ListView = require('web.ListView'); +var pyeval = require('web.pyeval'); +var SearchView = require('web.SearchView'); +var session = require('web.session'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +/** + * Interface implemented by the form view or any other object + * able to provide the features necessary for the fields to work. + * + * Properties: + * - display_invalid_fields : if true, all fields where is_valid() return true should + * be displayed as invalid. + * - actual_mode : the current mode of the field manager. Can be "view", "edit" or "create". + * Events: + * - view_content_has_changed : when the values of the fields have changed. When + * this event is triggered all fields should reprocess their modifiers. + * - field_changed:<field_name> : when the value of a field change, an event is triggered + * named "field_changed:<field_name>" with <field_name> replaced by the name of the field. + * This event is not related to the on_change mechanism of OpenERP and is always called + * when the value of a field is setted or changed. This event is only triggered when the + * value of the field is syntactically valid, but it can be triggered when the value + * is sematically invalid (ie, when a required field is false). It is possible that an event + * about a precise field is never triggered even if that field exists in the view, in that + * case the value of the field is assumed to be false. + */ +var FieldManagerMixin = { + /** + * Must return the asked field as in fields_get. + */ + get_field_desc: function(field_name) {}, + /** + * Returns the current value of a field present in the view. See the get_value() method + * method in FieldInterface for further information. + */ + get_field_value: function(field_name) {}, + /** + Gives new values for the fields contained in the view. The new values could not be setted + right after the call to this method. Setting new values can trigger on_changes. + + @param {Object} values A dictonary with key = field name and value = new value. + @return {$.Deferred} Is resolved after all the values are setted. + */ + set_values: function(values) {}, + /** + Computes an OpenERP domain. + + @param {Array} expression An OpenERP domain. + @return {boolean} The computed value of the domain. + */ + compute_domain: function(expression) {}, + /** + Builds an evaluation context for the resolution of the fields' contexts. Please note + the field are only supposed to use this context to evualuate their own, they should not + extend it. + + @return {CompoundContext} An OpenERP context. + */ + build_eval_context: function() {}, +}; + +/** + Welcome. + + If you read this documentation, it probably means that you were asked to use a form view widget outside of + a form view. Before going further, you must understand that those fields were never really created for + that usage. Don't think that this class will hold the answer to all your problems, at best it will allow + you to hack the system with more style. +*/ +var DefaultFieldManager = Widget.extend({ + init: function(parent, eval_context) { + this._super(parent); + this.field_descs = {}; + this.eval_context = eval_context || {}; + this.set({ + display_invalid_fields: false, + actual_mode: 'create', + }); + }, + get_field_desc: function(field_name) { + if (this.field_descs[field_name] === undefined) { + this.field_descs[field_name] = { + string: field_name, + }; + } + return this.field_descs[field_name]; + }, + extend_field_desc: function(fields) { + var self = this; + _.each(fields, function(v, k) { + _.extend(self.get_field_desc(k), v); + }); + }, + get_field_value: function(field_name) { + return false; + }, + set_values: function(values) { + // nothing + }, + compute_domain: function(expression) { + return data.compute_domain(expression, {}); + }, + build_eval_context: function() { + return new data.CompoundContext(this.eval_context); + }, +}); + + +/** + * A mixin to apply on any FormWidget that has to completely re-render when its readonly state + * switch. + */ +var ReinitializeWidgetMixin = { + /** + * Default implementation of, you should not override it, use initialize_field() instead. + */ + start: function() { + this.initialize_field(); + this._super(); + }, + initialize_field: function() { + this.on("change:effective_readonly", this, this.reinitialize); + this.initialize_content(); + }, + reinitialize: function() { + this.destroy_content(); + this.renderElement(); + this.initialize_content(); + }, + /** + * Called to destroy anything that could have been created previously, called before a + * re-initialization. + */ + destroy_content: function() {}, + /** + * Called to initialize the content. + */ + initialize_content: function() {}, +}; + +/** + * A mixin to apply on any field that has to completely re-render when its readonly state + * switch. + */ +var ReinitializeFieldMixin = _.extend({}, ReinitializeWidgetMixin, { + reinitialize: function() { + ReinitializeWidgetMixin.reinitialize.call(this); + var res = this.render_value(); + if (this.view && this.view.render_value_defs){ + this.view.render_value_defs.push(res); + } + }, +}); + +/** + A mixin containing some useful methods to handle completion inputs. + + The widget containing this option can have these arguments in its widget options: + - no_quick_create: if true, it will disable the quick create +*/ +var CompletionFieldMixin = { + init: function() { + this.limit = 7; + this.orderer = new utils.DropMisordered(); + this.can_create = this.node.attrs.can_create || true; + this.can_write = this.node.attrs.can_write || true; + }, + /** + * Call this method to search using a string. + */ + get_search_result: function(search_val) { + var self = this; + + var dataset = new data.DataSet(this, this.field.relation, self.build_context()); + this.last_query = search_val; + var exclusion_domain = [], ids_blacklist = this.get_search_blacklist(); + if (!_(ids_blacklist).isEmpty()) { + exclusion_domain.push(['id', 'not in', ids_blacklist]); + } + + return this.orderer.add(dataset.name_search( + search_val, new data.CompoundDomain(self.build_domain(), exclusion_domain), + 'ilike', this.limit + 1, self.build_context())).then(function(data) { + self.last_search = data; + // possible selections for the m2o + var values = _.map(data, function(x) { + x[1] = x[1].split("\n")[0]; + return { + label: _.str.escapeHTML(x[1]), + value: x[1], + name: x[1], + id: x[0], + }; + }); + + // search more... if more results that max + if (values.length > self.limit) { + values = values.slice(0, self.limit); + values.push({ + label: _t("Search More..."), + action: function() { + dataset.name_search(search_val, self.build_domain(), 'ilike', 160).done(function(data) { + self._search_create_popup("search", data); + }); + }, + classname: 'oe_m2o_dropdown_option' + }); + } + // quick create + var raw_result = _(data.result).map(function(x) {return x[1];}); + if (search_val.length > 0 && !_.include(raw_result, search_val) && + ! (self.options && (self.options.no_create || self.options.no_quick_create))) { + self.can_create && values.push({ + label: _.str.sprintf(_t('Create "<strong>%s</strong>"'), + $('<span />').text(search_val).html()), + action: function() { + self._quick_create(search_val); + }, + classname: 'oe_m2o_dropdown_option' + }); + } + // create... + if (!(self.options && (self.options.no_create || self.options.no_create_edit)) && self.can_create){ + values.push({ + label: _t("Create and Edit..."), + action: function() { + self._search_create_popup("form", undefined, self._create_context(search_val)); + }, + classname: 'oe_m2o_dropdown_option' + }); + } + else if (values.length === 0) { + values.push({ + label: _t("No results to show..."), + action: function() {}, + classname: 'oe_m2o_dropdown_option' + }); + } + + return values; + }); + }, + get_search_blacklist: function() { + return []; + }, + _quick_create: function(name) { + var self = this; + var slow_create = function () { + self._search_create_popup("form", undefined, self._create_context(name)); + }; + if (self.options.quick_create === undefined || self.options.quick_create) { + new data.DataSet(this, this.field.relation, self.build_context()) + .name_create(name).done(function(data) { + if (!self.get('effective_readonly')) + self.add_id(data[0]); + }).fail(function(error, event) { + event.preventDefault(); + slow_create(); + }); + } else + slow_create(); + }, + // all search/create popup handling + _search_create_popup: function(view, ids, context) { + var self = this; + var pop = new SelectCreatePopup(this); + pop.select_element( + self.field.relation, + { + title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string, + initial_ids: ids ? _.map(ids, function(x) {return x[0];}) : undefined, + initial_view: view, + disable_multiple_selection: true + }, + self.build_domain(), + new data.CompoundContext(self.build_context(), context || {}) + ); + pop.on("elements_selected", self, function(element_ids) { + self.add_id(element_ids[0]); + self.focus(); + }); + }, + /** + * To implement. + */ + add_id: function(id) {}, + _create_context: function(name) { + var tmp = {}; + var field = (this.options || {}).create_name_field; + if (field === undefined) + field = "name"; + if (field !== false && name && (this.options || {}).quick_create !== false) + tmp["default_" + field] = name; + return tmp; + }, +}; + + + +/** + * Must be applied over an class already possessing the PropertiesMixin. + * + * Apply the result of the "invisible" domain to this.$el. + */ +var InvisibilityChangerMixin = { + init: function(field_manager, invisible_domain) { + var self = this; + this._ic_field_manager = field_manager; + this._ic_invisible_modifier = invisible_domain; + this._ic_field_manager.on("view_content_has_changed", this, function() { + var result = self._ic_invisible_modifier === undefined ? false : + self._ic_field_manager.compute_domain(self._ic_invisible_modifier); + self.set({"invisible": result}); + }); + this.set({invisible: this._ic_invisible_modifier === true, force_invisible: false}); + var check = function() { + if (self.get("invisible") || self.get('force_invisible')) { + self.set({"effective_invisible": true}); + } else { + self.set({"effective_invisible": false}); + } + }; + this.on('change:invisible', this, check); + this.on('change:force_invisible', this, check); + check.call(this); + }, + start: function() { + this.on("change:effective_invisible", this, this._check_visibility); + this._check_visibility(); + }, + _check_visibility: function() { + this.$el.toggleClass('oe_form_invisible', this.get("effective_invisible")); + }, +}; + +var InvisibilityChanger = core.Class.extend(core.mixins.PropertiesMixin, InvisibilityChangerMixin, { + init: function(parent, field_manager, invisible_domain, $el) { + this.setParent(parent); + core.mixins.PropertiesMixin.init.call(this); + InvisibilityChangerMixin.init.call(this, field_manager, invisible_domain); + this.$el = $el; + this.start(); + }, +}); + +// Specialization of InvisibilityChanger for the `notebook` form element to handle more +// elegantly its special cases (i.e. activate the closest visible sibling) +var NotebookInvisibilityChanger = InvisibilityChanger.extend({ + // Override start so that it does not call _check_visibility since it will be + // called again when view content will be loaded (event view_content_has_changed) + start: function() { + this.on("change:effective_invisible", this, this._check_visibility); + }, + _check_visibility: function() { + this._super(); + if (this.get("effective_invisible") === true) { + // Switch to invisible + // Remove this element as active and set a visible sibling active (if there is one) + if (this.$el.hasClass('active')) { + this.$el.removeClass('active'); + var visible_siblings = this.$el.siblings(':not(.oe_form_invisible)'); + if (visible_siblings.length) { + $(visible_siblings[0]).addClass('active'); + } + } + } else { + // Switch to visible + // If there is no visible active sibling, set this element as active, + // otherwise if that sibling hasn't autofocus and if we are in edit mode, + // remove that sibling as active and set this element as active + var visible_active_sibling = this.$el.siblings(':not(.oe_form_invisible).active'); + if (!(visible_active_sibling.length)) { + this.$el.addClass('active'); + } else if (!$(visible_active_sibling[0]).data('autofocus') && this._ic_field_manager.get('actual_mode') === "edit") { + this.$el.addClass('active'); + $(visible_active_sibling[0]).removeClass('active'); + } + } + }, +}); + + +/** + Base class for all fields, custom widgets and buttons to be displayed in the form view. + + Properties: + - effective_readonly: when it is true, the widget is displayed as readonly. Vary depending + the values of the "readonly" property and the "mode" property on the field manager. +*/ +var FormWidget = Widget.extend(InvisibilityChangerMixin, { + /** + * @constructs instance.web.form.FormWidget + * @extends instance.web.Widget + * + * @param field_manager + * @param node + */ + init: function(field_manager, node) { + this._super(field_manager); + this.field_manager = field_manager; + this.view = this.field_manager; + this.node = node; + this.session = session; + this.modifiers = JSON.parse(this.node.attrs.modifiers || '{}'); + InvisibilityChangerMixin.init.call(this, this.field_manager, this.modifiers.invisible); + + this.field_manager.on("view_content_has_changed", this, this.process_modifiers); + + this.set({ + required: false, + readonly: false, + }); + // some events to make the property "effective_readonly" sync automatically with "readonly" and + // "mode" on field_manager + var self = this; + var test_effective_readonly = function() { + self.set({"effective_readonly": self.get("readonly") || self.field_manager.get("actual_mode") === "view"}); + }; + this.on("change:readonly", this, test_effective_readonly); + this.field_manager.on("change:actual_mode", this, test_effective_readonly); + test_effective_readonly.call(this); + }, + renderElement: function() { + this.process_modifiers(); + this._super(); + this.$el.addClass(this.node.attrs["class"] || ""); + }, + destroy: function() { + $.fn.tooltip('destroy'); + this._super.apply(this, arguments); + }, + /** + * Sets up blur/focus forwarding from DOM elements to a widget (`this`). + * + * This method is an utility method that is meant to be called by child classes. + * + * @param {jQuery} $e jQuery object of elements to bind focus/blur on + */ + setupFocus: function ($e) { + var self = this; + $e.on({ + focus: function () { self.trigger('focused'); }, + blur: function () { self.trigger('blurred'); } + }); + }, + process_modifiers: function() { + var to_set = {}; + for (var a in this.modifiers) { + if (!this.modifiers.hasOwnProperty(a)) { continue; } + if (!_.include(["invisible"], a)) { + var val = this.field_manager.compute_domain(this.modifiers[a]); + to_set[a] = val; + } + } + this.set(to_set); + }, + do_attach_tooltip: function(widget, trigger, options) { + widget = widget || this; + trigger = trigger || this.$el; + options = _.extend({ + delay: { show: 500, hide: 0 }, + title: function() { + var template = widget.template + '.tooltip'; + if (!QWeb.has_template(template)) { + template = 'WidgetLabel.tooltip'; + } + return QWeb.render(template, { + debug: session.debug, + widget: widget + }); + }, + }, options || {}); + //only show tooltip if we are in debug or if we have a help to show, otherwise it will display + //as empty + if (session.debug || widget.node.attrs.help || (widget.field && widget.field.help)){ + $(trigger).tooltip(options); + } + }, + /** + * Builds a new context usable for operations related to fields by merging + * the fields'context with the action's context. + */ + build_context: function() { + // only use the model's context if there is not context on the node + var v_context = this.node.attrs.context; + if (! v_context) { + v_context = (this.field || {}).context || {}; + } + + if (v_context.__ref || true) { //TODO: remove true + var fields_values = this.field_manager.build_eval_context(); + v_context = new data.CompoundContext(v_context).set_eval_context(fields_values); + } + return v_context; + }, + build_domain: function() { + var f_domain = this.field.domain || []; + var n_domain = this.node.attrs.domain || null; + // if there is a domain on the node, overrides the model's domain + var final_domain = n_domain !== null ? n_domain : f_domain; + if (!(final_domain instanceof Array) || true) { //TODO: remove true + var fields_values = this.field_manager.build_eval_context(); + final_domain = new data.CompoundDomain(final_domain).set_eval_context(fields_values); + } + return final_domain; + } +}); + +/* +# Values: (0, 0, { fields }) create +# (1, ID, { fields }) update +# (2, ID) remove (delete) +# (3, ID) unlink one (target id or target of relation) +# (4, ID) link +# (5) unlink all (only valid for one2many) +*/ +var commands = { + // (0, _, {values}) + CREATE: 0, + 'create': function (values) { + return [commands.CREATE, false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + 'update': function (id, values) { + return [commands.UPDATE, id, values]; + }, + // (2, id[, _]) + DELETE: 2, + 'delete': function (id) { + return [commands.DELETE, id, false]; + }, + // (3, id[, _]) removes relation, but not linked record itself + FORGET: 3, + 'forget': function (id) { + return [commands.FORGET, id, false]; + }, + // (4, id[, _]) + LINK_TO: 4, + 'link_to': function (id) { + return [commands.LINK_TO, id, false]; + }, + // (5[, _[, _]]) + DELETE_ALL: 5, + 'delete_all': function () { + return [5, false, false]; + }, + // (6, _, ids) replaces all linked records with provided ids + REPLACE_WITH: 6, + 'replace_with': function (ids) { + return [6, false, ids]; + } +}; + +/** + * Interface to be implemented by fields. + * + * Events: + * - changed_value: triggered when the value of the field has changed. This can be due + * to a user interaction or a call to set_value(). + * + */ +var FieldInterface = { + /** + * Constructor takes 2 arguments: + * - field_manager: Implements FieldManagerMixin + * - node: the "<field>" node in json form + */ + init: function(field_manager, node) {}, + /** + * Called by the form view to indicate the value of the field. + * + * Multiple calls to set_value() can occur at any time and must be handled correctly by the implementation, + * regardless of any asynchronous operation currently running. Calls to set_value() can and will also occur + * before the widget is inserted into the DOM. + * + * set_value() must be able, at any moment, to handle the syntax returned by the "read" method of the + * osv class in the OpenERP server as well as the syntax used by the set_value() (see below). It must + * also be able to handle any other format commonly used in the _defaults key on the models in the addons + * as well as any format commonly returned in a on_change. It must be able to autodetect those formats as + * no information is ever given to know which format is used. + */ + set_value: function(value_) {}, + /** + * Get the current value of the widget. + * + * Must always return a syntactically correct value to be passed to the "write" method of the osv class in + * the OpenERP server, although it is not assumed to respect the constraints applied to the field. + * For example if the field is marked as "required", a call to get_value() can return false. + * + * get_value() can also be called *before* a call to set_value() and, in that case, is supposed to + * return a default value according to the type of field. + * + * This method is always assumed to perform synchronously, it can not return a promise. + * + * If there was no user interaction to modify the value of the field, it is always assumed that + * get_value() return the same semantic value than the one passed in the last call to set_value(), + * although the syntax can be different. This can be the case for type of fields that have a different + * syntax for "read" and "write" (example: m2o: set_value([0, "Administrator"]), get_value() => 0). + */ + get_value: function() {}, + /** + * Inform the current object of the id it should use to match a html <label> that exists somewhere in the + * view. + */ + set_input_id: function(id) {}, + /** + * Returns true if is_syntax_valid() returns true and the value is semantically + * valid too according to the semantic restrictions applied to the field. + */ + is_valid: function() {}, + /** + * Returns true if the field holds a value which is syntactically correct, ignoring + * the potential semantic restrictions applied to the field. + */ + is_syntax_valid: function() {}, + /** + * Must set the focus on the field. Return false if field is not focusable. + */ + focus: function() {}, + /** + * Called when the translate button is clicked. + */ + on_translate: function() {}, + /** + This method is called by the form view before reading on_change values and before saving. It tells + the field to save its value before reading it using get_value(). Must return a promise. + */ + commit_value: function() {}, +}; + +/** + * Abstract class for classes implementing FieldInterface. + * + * Properties: + * - value: useful property to hold the value of the field. By default, set_value() and get_value() + * set and retrieve the value property. Changing the value property also triggers automatically + * a 'changed_value' event that inform the view to trigger on_changes. + * + */ +var AbstractField = FormWidget.extend(FieldInterface, { + /** + * @constructs instance.web.form.AbstractField + * @extends instance.web.form.FormWidget + * + * @param field_manager + * @param node + */ + init: function(field_manager, node) { + this._super(field_manager, node); + this.name = this.node.attrs.name; + this.field = this.field_manager.get_field_desc(this.name); + this.widget = this.node.attrs.widget; + this.string = this.node.attrs.string || this.field.string || this.name; + this.options = pyeval.py_eval(this.node.attrs.options || '{}'); + this.set({'value': false}); + + this.on("change:value", this, function() { + this.trigger('changed_value'); + this._check_css_flags(); + }); + }, + renderElement: function() { + var self = this; + this._super(); + if (this.field.translate && this.view) { + this.$el.addClass('oe_form_field_translatable'); + this.$el.find('.oe_field_translate').click(this.on_translate); + } + this.$label = this.view ? this.view.$el.find('label[for=' + this.id_for_label + ']') : $(); + this.do_attach_tooltip(this, this.$label[0] || this.$el); + if (session.debug) { + this.$label.off('dblclick').on('dblclick', function() { + console.log("Field '%s' of type '%s' in View: %o", self.name, (self.node.attrs.widget || self.field.type), self.view); + window.w = self; + console.log("window.w =", window.w); + }); + } + if (!this.disable_utility_classes) { + this.off("change:required", this, this._set_required); + this.on("change:required", this, this._set_required); + this._set_required(); + } + this._check_visibility(); + this.field_manager.off("change:display_invalid_fields", this, this._check_css_flags); + this.field_manager.on("change:display_invalid_fields", this, this._check_css_flags); + this._check_css_flags(); + }, + start: function() { + this._super(); + this.on("change:value", this, function() { + if (! this.no_rerender) + this.render_value(); + }); + this.render_value(); + }, + /** + * Private. Do not use. + */ + _set_required: function() { + this.$el.toggleClass('oe_form_required', this.get("required")); + }, + set_value: function(value_) { + this.set({'value': value_}); + }, + get_value: function() { + return this.get('value'); + }, + /** + Utility method that all implementations should use to change the + value without triggering a re-rendering. + */ + internal_set_value: function(value_) { + var tmp = this.no_rerender; + this.no_rerender = true; + this.set({'value': value_}); + this.no_rerender = tmp; + }, + /** + This method is called each time the value is modified. + */ + render_value: function() {}, + is_valid: function() { + return this.is_syntax_valid() && !(this.get('required') && this.is_false()); + }, + is_syntax_valid: function() { + return true; + }, + /** + * Method useful to implement to ease validity testing. Must return true if the current + * value is similar to false in OpenERP. + */ + is_false: function() { + return this.get('value') === false; + }, + _check_css_flags: function() { + if (this.field.translate) { + this.$el.find('.oe_field_translate').toggle(this.field_manager.get('actual_mode') !== "create"); + } + if (!this.disable_utility_classes) { + if (this.field_manager.get('display_invalid_fields')) { + this.$el.toggleClass('oe_form_invalid', !this.is_valid()); + } + } + }, + focus: function() { + return false; + }, + set_input_id: function(id) { + this.id_for_label = id; + }, + on_translate: function() { + var self = this; + var trans = new data.DataSet(this, 'ir.translation'); + return trans.call_button('translate_fields', [this.view.dataset.model, this.view.datarecord.id, this.name, this.view.dataset.get_context()]).done(function(r) { + self.do_action(r); + }); + }, + + set_dimensions: function (height, width) { + this.$el.css({ + width: width, + minHeight: height + }); + }, + commit_value: function() { + return $.when(); + }, +}); + +/** + * Class with everything which is common between FormOpenPopup and SelectCreatePopup. + */ +var AbstractFormPopup = Widget.extend({ + template: "AbstractFormPopup.render", + /** + * options: + * -readonly: only applicable when not in creation mode, default to false + * - alternative_form_view + * - view_id + * - write_function + * - read_function + * - create_function + * - parent_view + * - child_name + * - form_view_options + */ + init_popup: function(model, row_id, domain, context, options) { + this.row_id = row_id; + this.model = model; + this.domain = domain || []; + this.context = context || {}; + this.options = options; + _.defaults(this.options, {}); + }, + init_dataset: function() { + var self = this; + this.created_elements = []; + this.dataset = new data.ProxyDataSet(this, this.model, this.context); + this.dataset.read_function = this.options.read_function; + this.dataset.create_function = function(data, options, sup) { + var fct = self.options.create_function || sup; + return fct.call(this, data, options).done(function(r) { + self.trigger('create_completed saved', r); + self.created_elements.push(r); + }); + }; + this.dataset.write_function = function(id, data, options, sup) { + var fct = self.options.write_function || sup; + return fct.call(this, id, data, options).done(function(r) { + self.trigger('write_completed saved', r); + }); + }; + this.dataset.parent_view = this.options.parent_view; + this.dataset.child_name = this.options.child_name; + }, + display_popup: function() { + var self = this; + this.renderElement(); + var dialog = new Dialog(this, { + dialogClass: 'oe_act_window', + title: this.options.title || "", + }, this.$el).open(); + dialog.on('closing', this, function (e){ + self.check_exit(true); + }); + this.$buttonpane = dialog.$buttons; + this.start(); + }, + setup_form_view: function() { + var self = this; + if (this.row_id) { + this.dataset.ids = [this.row_id]; + this.dataset.index = 0; + } else { + this.dataset.index = null; + } + var options = _.clone(self.options.form_view_options) || {}; + if (this.row_id !== null) { + options.initial_mode = this.options.readonly ? "view" : "edit"; + } + _.extend(options, { + $buttons: this.$buttonpane, + }); + var FormView = core.view_registry.get('form'); + this.view_form = new FormView(this, this.dataset, this.options.view_id || false, options); + if (this.options.alternative_form_view) { + this.view_form.set_embedded_view(this.options.alternative_form_view); + } + this.view_form.appendTo(this.$(".oe_popup_form").show()); + this.view_form.on("form_view_loaded", self, function() { + var multi_select = self.row_id === null && ! self.options.disable_multiple_selection; + self.$buttonpane.html(QWeb.render("AbstractFormPopup.buttons", { + multi_select: multi_select, + readonly: self.row_id !== null && self.options.readonly, + })); + var $snbutton = self.$buttonpane.find(".oe_abstractformpopup-form-save-new"); + $snbutton.click(function() { + $.when(self.view_form.save()).done(function() { + self.view_form.reload_mutex.exec(function() { + self.view_form.on_button_new(); + }); + }); + }); + var $sbutton = self.$buttonpane.find(".oe_abstractformpopup-form-save"); + $sbutton.click(function() { + $.when(self.view_form.save()).done(function() { + self.view_form.reload_mutex.exec(function() { + self.check_exit(); + }); + }); + }); + var $cbutton = self.$buttonpane.find(".oe_abstractformpopup-form-close"); + $cbutton.click(function() { + self.view_form.trigger('on_button_cancel'); + self.check_exit(); + }); + self.view_form.do_show(); + }); + }, + select_elements: function(element_ids) { + this.trigger("elements_selected", element_ids); + }, + check_exit: function(no_destroy) { + if (this.created_elements.length > 0) { + this.select_elements(this.created_elements); + this.created_elements = []; + } + this.trigger('closed'); + this.destroy(); + }, + destroy: function () { + this.trigger('closed'); + if (this.$el.is(":data(bs.modal)")) { + this.$el.parents('.modal').modal('hide'); + } + this._super(); + }, +}); + +/** + * Class to display a popup containing a form view. + */ +var FormOpenPopup = AbstractFormPopup.extend({ + show_element: function(model, row_id, context, options) { + this.init_popup(model, row_id, [], context, options); + _.defaults(this.options, { + }); + this.display_popup(); + }, + start: function() { + this._super(); + this.init_dataset(); + this.setup_form_view(); + }, +}); + +var SelectCreateListView = ListView.extend({ + do_add_record: function () { + this.popup.new_object(); + }, + select_record: function(index) { + this.popup.select_elements([this.dataset.ids[index]]); + this.popup.destroy(); + }, + do_select: function(ids, records) { + this._super(ids, records); + this.popup.on_click_element(ids); + } +}); + +/** + * Class to display a popup to display a list to search a row. It also allows + * to switch to a form view to create a new row. + */ +var SelectCreatePopup = AbstractFormPopup.extend({ + /** + * options: + * - initial_ids + * - initial_view: form or search (default search) + * - disable_multiple_selection + * - list_view_options + */ + select_element: function(model, options, domain, context) { + this.init_popup(model, null, domain, context, options); + _.defaults(this.options, { + initial_view: "search", + }); + this.initial_ids = this.options.initial_ids; + this.display_popup(); + }, + start: function() { + this.init_dataset(); + if (this.options.initial_view == "search") { + var context = pyeval.sync_eval_domains_and_contexts({ + domains: [], + contexts: [this.context] + }).context; + var search_defaults = {}; + _.each(context, function (value_, key) { + var match = /^search_default_(.*)$/.exec(key); + if (match) { + search_defaults[match[1]] = value_; + } + }); + this.setup_search_view(search_defaults); + } else { // "form" + this.new_object(); + } + }, + setup_search_view: function(search_defaults) { + var self = this; + if (this.searchview) { + this.searchview.destroy(); + } + var $buttons = this.$('.o-search-options'); + this.searchview = new SearchView(this, + this.dataset, false, search_defaults, {$buttons: $buttons}); + this.searchview.on('search_data', self, function(domains, contexts, groupbys) { + if (self.initial_ids) { + self.do_search(domains.concat([[["id", "in", self.initial_ids]], self.domain]), + contexts.concat(self.context), groupbys); + self.initial_ids = undefined; + } else { + self.do_search(domains.concat([self.domain]), contexts.concat(self.context), groupbys); + } + }); + this.searchview.appendTo(this.$(".o-popup-search")).done(function() { + self.searchview.toggle_visibility(true); + self.view_list = new SelectCreateListView(self, + self.dataset, false, + _.extend({'deletable': false, + 'selectable': !self.options.disable_multiple_selection, + 'import_enabled': false, + '$buttons': self.$buttonpane, + 'disable_editable_mode': true, + '$pager': self.$('.oe_popup_list_pager'), + }, self.options.list_view_options || {})); + self.view_list.on('edit:before', self, function (e) { + e.cancel = true; + }); + self.view_list.popup = self; + self.view_list.appendTo(self.$(".oe_popup_list").show()).then(function() { + self.view_list.do_show(); + }).then(function() { + if (self.options.initial_facet) { + self.searchview.query.reset([self.options.initial_facet], { + preventSearch: true, + }); + } + self.searchview.do_search(); + }); + self.view_list.on("list_view_loaded", self, function() { + self.$buttonpane.html(QWeb.render("SelectCreatePopup.search.buttons", {widget:self})); + var $cbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-close"); + $cbutton.click(function() { + self.destroy(); + }); + var $sbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-select"); + $sbutton.click(function() { + self.select_elements(self.selected_ids); + self.destroy(); + }); + $cbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-create"); + $cbutton.click(function() { + self.new_object(); + }); + }); + }); + }, + do_search: function(domains, contexts, groupbys) { + var results = pyeval.sync_eval_domains_and_contexts({ + domains: domains || [], + contexts: contexts || [], + group_by_seq: groupbys || [] + }); + this.view_list.do_search(results.domain, results.context, results.group_by); + }, + on_click_element: function(ids) { + var self = this; + this.selected_ids = ids || []; + if(this.selected_ids.length > 0) { + self.$buttonpane.find(".oe_selectcreatepopup-search-select").removeAttr('disabled'); + } else { + self.$buttonpane.find(".oe_selectcreatepopup-search-select").attr('disabled', "disabled"); + } + }, + new_object: function() { + if (this.searchview) { + this.searchview.do_hide(); + } + if (this.view_list) { + this.view_list.do_hide(); + this.view_list.$el.parent().hide(); + } + this.setup_form_view(); + }, +}); + +var DomainEditorPopup = SelectCreatePopup.extend({ + select_element: function (model, options, domain, context) { + if (options.readonly) { + return this._super(model, options, domain, context); + } + options.initial_facet = { + category: _t("Custom Filter"), + icon: 'fa-star', + field: { + get_context: function () { return context; }, + get_groupby: function () { return []; }, + get_domain: function () { return domain; }, + }, + values: [{label: _t("Selected domain"), value: null}], + }; + this._super(model, options, [], context); + }, + get_domain: function (selected_ids) { + var group_domain = [], + domain; + if (this.$('input.oe_list_record_selector').prop('checked')) { + if (this.view_list.grouped) { + var group_domain = _.chain(_.values(this.view_list.groups.children)) + .filter(function (child) { return child.records.length; }) + .map(function (c) { return c.datagroup.domain;}) + .value(); + group_domain = _.flatten(group_domain, true); + group_domain = _.times(group_domain.length - 1, _.constant('|')).concat(group_domain); + } + var search_data = this.searchview.build_search_data(); + domain = pyeval.sync_eval_domains_and_contexts({ + domains: search_data.domains, + contexts: search_data.contexts, + group_by_seq: search_data.groupbys || [] + }).domain; + } + else { + domain = [["id", "in", selected_ids]]; + } + return this.dataset.domain.concat(group_domain).concat(domain || []); + }, +}); + +return { + // mixins + FieldManagerMixin: FieldManagerMixin, + ReinitializeWidgetMixin: ReinitializeWidgetMixin, + ReinitializeFieldMixin: ReinitializeFieldMixin, + CompletionFieldMixin: CompletionFieldMixin, + + // misc + FormWidget: FormWidget, + DefaultFieldManager: DefaultFieldManager, + InvisibilityChanger: InvisibilityChanger, + NotebookInvisibilityChanger: NotebookInvisibilityChanger, + commands: commands, + AbstractField: AbstractField, + + // popup dialogs + AbstractFormPopup: AbstractFormPopup, + FormOpenPopup: FormOpenPopup, + SelectCreatePopup: SelectCreatePopup, + DomainEditorPopup: DomainEditorPopup, +}; + +});
\ No newline at end of file diff --git a/addons/web/static/src/js/views/form_relational_widgets.js b/addons/web/static/src/js/views/form_relational_widgets.js new file mode 100644 index 00000000000..19ca1903048 --- /dev/null +++ b/addons/web/static/src/js/views/form_relational_widgets.js @@ -0,0 +1,2028 @@ +odoo.define('web.form_relational', ['web.core', 'web.data', 'web.Dialog', 'web.form_common', 'web.FormView', 'web.ListView', 'web.Model', 'web.session', 'web.utils', 'web.ViewManager'], function (require) { +"use strict"; + +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var common = require('web.form_common'); +var FormView = require('web.FormView'); +var ListView = require('web.ListView'); +var Model = require('web.Model'); +var session = require('web.session'); +var utils = require('web.utils'); +var ViewManager = require('web.ViewManager'); + + +var _t = core._t; +var QWeb = core.qweb; +var commands = common.commands; +var list_widget_registry = core.list_widget_registry; + +var M2ODialog = Dialog.extend({ + template: "M2ODialog", + init: function(parent) { + this.name = parent.string; + this._super(parent, { + title: _.str.sprintf(_t("Create a %s"), parent.string), + size: 'medium', + }); + }, + start: function() { + var self = this; + var text = _.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), self.name); + this.$("p").text( text ); + this.$buttons.html(QWeb.render("M2ODialog.buttons")); + this.$("input").val(this.getParent().$input.val()); + this.$buttons.find(".oe_form_m2o_qc_button").click(function(e){ + if (self.$("input").val() !== ''){ + self.getParent()._quick_create(self.$("input").val()); + self.destroy(); + } else{ + e.preventDefault(); + self.$("input").focus(); + } + }); + this.$buttons.find(".oe_form_m2o_sc_button").click(function(){ + self.getParent()._search_create_popup("form", undefined, self.getParent()._create_context(self.$("input").val())); + self.destroy(); + }); + this.$buttons.find(".oe_form_m2o_cancel_button").click(function(){ + self.destroy(); + }); + }, +}); + +var FieldMany2One = common.AbstractField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, { + template: "FieldMany2One", + events: { + 'keydown input': function (e) { + switch (e.which) { + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + e.stopPropagation(); + } + }, + }, + init: function(field_manager, node) { + this._super(field_manager, node); + common.CompletionFieldMixin.init.call(this); + this.set({'value': false}); + this.display_value = {}; + this.display_value_backup = {}; + this.last_search = []; + this.floating = false; + this.current_display = null; + this.is_started = false; + this.ignore_focusout = false; + }, + reinit_value: function(val) { + this.internal_set_value(val); + this.floating = false; + if (this.is_started && !this.no_rerender) { + this.render_value(); + } + }, + initialize_field: function() { + this.is_started = true; + core.bus.on('click', this, function() { + if (!this.get("effective_readonly") && this.$input && this.$input.autocomplete('widget').is(':visible')) { + this.$input.autocomplete("close"); + } + }); + common.ReinitializeFieldMixin.initialize_field.call(this); + }, + initialize_content: function() { + if (!this.get("effective_readonly")) + this.render_editable(); + }, + destroy_content: function () { + if (this.$drop_down) { + this.$drop_down.off('click'); + delete this.$drop_down; + } + if (this.$input) { + this.$input.closest(".modal .modal-content").off('scroll'); + this.$input.off('keyup blur autocompleteclose autocompleteopen ' + + 'focus focusout change keydown'); + delete this.$input; + } + if (this.$follow_button) { + this.$follow_button.off('blur focus click'); + delete this.$follow_button; + } + }, + destroy: function () { + this.destroy_content(); + return this._super(); + }, + init_error_displayer: function() { + // nothing + }, + hide_error_displayer: function() { + // doesn't work + }, + show_error_displayer: function() { + new M2ODialog(this).open(); + }, + render_editable: function() { + var self = this; + this.$input = this.$el.find("input"); + + this.init_error_displayer(); + + self.$input.on('focus', function() { + self.hide_error_displayer(); + }); + + this.$drop_down = this.$el.find(".oe_m2o_drop_down_button"); + this.$follow_button = $(".oe_m2o_cm_button", this.$el); + + this.$follow_button.click(function(ev) { + ev.preventDefault(); + if (!self.get('value')) { + self.focus(); + return; + } + var pop = new common.FormOpenPopup(self); + var context = self.build_context().eval(); + var model_obj = new Model(self.field.relation); + model_obj.call('get_formview_id', [self.get("value"), context]).then(function(view_id){ + pop.show_element( + self.field.relation, + self.get("value"), + self.build_context(), + { + title: _t("Open: ") + self.string, + view_id: view_id, + readonly: !self.can_write + } + ); + pop.on('write_completed', self, function(){ + self.display_value = {}; + self.display_value_backup = {}; + self.render_value(); + self.focus(); + self.trigger('changed_value'); + }); + }); + }); + + // some behavior for input + var input_changed = function() { + if (self.current_display !== self.$input.val()) { + self.current_display = self.$input.val(); + if (self.$input.val() === "") { + self.internal_set_value(false); + self.floating = false; + } else { + self.floating = true; + } + } + }; + this.$input.keydown(input_changed); + this.$input.change(input_changed); + this.$drop_down.click(function() { + self.$input.focus(); + if (self.$input.autocomplete("widget").is(":visible")) { + self.$input.autocomplete("close"); + } else { + if (self.get("value") && ! self.floating) { + self.$input.autocomplete("search", ""); + } else { + self.$input.autocomplete("search"); + } + } + }); + + // Autocomplete close on dialog content scroll + var close_autocomplete = _.debounce(function() { + if (self.$input.autocomplete("widget").is(":visible")) { + self.$input.autocomplete("close"); + } + }, 50); + this.$input.closest(".modal .modal-content").on('scroll', this, close_autocomplete); + + self.ed_def = $.Deferred(); + self.uned_def = $.Deferred(); + var ed_delay = 200; + var ed_duration = 15000; + var anyoneLoosesFocus = function (e) { + if (self.ignore_focusout) { return; } + var used = false; + if (self.floating) { + if (self.last_search.length > 0) { + if (self.last_search[0][0] != self.get("value")) { + self.display_value = {}; + self.display_value_backup = {}; + self.display_value["" + self.last_search[0][0]] = self.last_search[0][1]; + self.reinit_value(self.last_search[0][0]); + self.last_search = []; + } else { + used = true; + self.render_value(); + } + } else { + used = true; + } + self.floating = false; + } + if (used && self.get("value") === false && ! self.no_ed && ! (self.options && (self.options.no_create || self.options.no_quick_create))) { + self.ed_def.reject(); + self.uned_def.reject(); + self.ed_def = $.Deferred(); + self.ed_def.done(function() { + self.can_create && self.show_error_displayer(); + ignore_blur = false; + self.trigger('focused'); + }); + ignore_blur = true; + setTimeout(function() { + self.ed_def.resolve(); + self.uned_def.reject(); + self.uned_def = $.Deferred(); + self.uned_def.done(function() { + self.hide_error_displayer(); + }); + setTimeout(function() {self.uned_def.resolve();}, ed_duration); + }, ed_delay); + } else { + self.no_ed = false; + self.ed_def.reject(); + } + }; + var ignore_blur = false; + this.$input.on({ + focusout: anyoneLoosesFocus, + focus: function () { self.trigger('focused'); }, + autocompleteopen: function () { ignore_blur = true; }, + autocompleteclose: function () { setTimeout(function() {ignore_blur = false;},0); }, + blur: function () { + // autocomplete open + if (ignore_blur) { $(this).focus(); return; } + if (_(self.getChildren()).any(function (child) { + return child instanceof common.AbstractFormPopup; + })) { return; } + self.trigger('blurred'); + } + }); + + var isSelecting = false; + // autocomplete + this.$input.autocomplete({ + source: function(req, resp) { + self.get_search_result(req.term).done(function(result) { + resp(result); + }); + }, + select: function(event, ui) { + isSelecting = true; + var item = ui.item; + if (item.id) { + self.display_value = {}; + self.display_value_backup = {}; + self.display_value["" + item.id] = item.name; + self.reinit_value(item.id); + } else if (item.action) { + item.action(); + // Cancel widget blurring, to avoid form blur event + self.trigger('focused'); + return false; + } + }, + focus: function(e, ui) { + e.preventDefault(); + }, + html: true, + // disabled to solve a bug, but may cause others + //close: anyoneLoosesFocus, + minLength: 0, + delay: 200, + }); + var appendTo = this.$input.parents('.oe-view-manager-content, .modal-dialog').last(); + if (appendTo.length === 0) { + appendTo = '.oe_application > *'; + } + this.$input.autocomplete({ + appendTo: appendTo + }); + // set position for list of suggestions box + this.$input.autocomplete( "option", "position", { my : "left top", at: "left bottom" } ); + this.$input.autocomplete("widget").openerpClass(); + // used to correct a bug when selecting an element by pushing 'enter' in an editable list + this.$input.keyup(function(e) { + if (e.which === 13) { // ENTER + if (isSelecting) + e.stopPropagation(); + } + isSelecting = false; + }); + this.setupFocus(this.$follow_button); + }, + render_value: function(no_recurse) { + var self = this; + if (! this.get("value")) { + this.display_string(""); + return; + } + var display = this.display_value["" + this.get("value")]; + if (display) { + this.display_string(display); + return; + } + if (! no_recurse) { + var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context()); + var def = this.alive(dataset.name_get([self.get("value")])).done(function(data) { + if (!data[0]) { + self.do_warn(_t("Render"), _t("No value found for the field "+self.field.string+" for value "+self.get("value"))); + return; + } + self.display_value["" + self.get("value")] = data[0][1]; + self.render_value(true); + }).fail( function (data, event) { + // avoid displaying crash errors as many2One should be name_get compliant + event.preventDefault(); + self.display_value["" + self.get("value")] = self.display_value_backup["" + self.get("value")]; + self.render_value(true); + }); + if (this.view && this.view.render_value_defs){ + this.view.render_value_defs.push(def); + } + } + }, + display_string: function(str) { + var self = this; + if (!this.get("effective_readonly")) { + this.$input.val(str.split("\n")[0]); + this.current_display = this.$input.val(); + if (this.is_false()) { + this.$('.oe_m2o_cm_button').css({'display':'none'}); + } else { + this.$('.oe_m2o_cm_button').css({'display':'inline'}); + } + } else { + var lines = _.escape(str).split("\n"); + var link = ""; + var follow = ""; + link = lines[0]; + follow = _.rest(lines).join("<br />"); + if (follow) + link += "<br />"; + var $link = this.$el.find('.oe_form_uri') + .unbind('click') + .html(link); + if (! this.options.no_open) + $link.click(function () { + var context = self.build_context().eval(); + var model_obj = new Model(self.field.relation); + model_obj.call('get_formview_action', [self.get("value"), context]).then(function(action){ + self.do_action(action); + }); + return false; + }); + $(".oe_form_m2o_follow", this.$el).html(follow); + } + }, + set_value: function(value_) { + if (value_ instanceof Array) { + this.display_value = {}; + this.display_value_backup = {}; + if (! this.options.always_reload) { + this.display_value["" + value_[0]] = value_[1]; + } + else { + this.display_value_backup["" + value_[0]] = value_[1]; + } + value_ = value_[0]; + } + value_ = value_ || false; + this.reinit_value(value_); + }, + get_displayed: function() { + return this.display_value["" + this.get("value")]; + }, + add_id: function(id) { + this.display_value = {}; + this.display_value_backup = {}; + this.reinit_value(id); + }, + is_false: function() { + return ! this.get("value"); + }, + focus: function () { + var input = !this.get('effective_readonly') && this.$input && this.$input[0]; + return input ? input.focus() : false; + }, + _quick_create: function() { + this.no_ed = true; + this.ed_def.reject(); + return common.CompletionFieldMixin._quick_create.apply(this, arguments); + }, + _search_create_popup: function() { + this.no_ed = true; + this.ed_def.reject(); + this.ignore_focusout = true; + this.reinit_value(false); + var res = common.CompletionFieldMixin._search_create_popup.apply(this, arguments); + this.ignore_focusout = false; + this.no_ed = false; + return res; + }, + set_dimensions: function (height, width) { + this._super(height, width); + if (!this.get("effective_readonly") && this.$input) + this.$input.css('height', height); + } +}); + +var Many2OneButton = common.AbstractField.extend({ + template: 'Many2OneButton', + init: function(field_manager, node) { + this._super.apply(this, arguments); + }, + start: function() { + this._super.apply(this, arguments); + this.set_button(); + }, + set_button: function() { + var self = this; + if (this.$button) { + this.$button.remove(); + } + this.string = ''; + this.node.attrs.icon = this.get('value') ? '/web/static/src/img/icons/gtk-yes.png' : '/web/static/src/img/icons/gtk-no.png'; + this.$button = $(QWeb.render('WidgetButton', {'widget': this})); + this.$button.addClass('oe_link').css({'padding':'4px'}); + this.$el.append(this.$button); + this.$button.on('click', self.on_click); + }, + on_click: function(ev) { + var self = this; + this.popup = new common.FormOpenPopup(this); + this.popup.show_element( + this.field.relation, + this.get('value'), + this.build_context(), + {title: this.string} + ); + this.popup.on('create_completed', self, function(r) { + self.set_value(r); + }); + }, + set_value: function(value_) { + if (value_ instanceof Array) { + value_ = value_[0]; + } + value_ = value_ || false; + this.set('value', value_); + this.set_button(); + }, +}); + +/** + * Abstract-ish ListView.List subclass adding an "Add an item" row to replace + * the big ugly button in the header. + * + * Requires the implementation of a ``is_readonly`` method (usually a proxy to + * the corresponding field's readonly or effective_readonly property) to + * decide whether the special row should or should not be inserted. + * + * Optionally an ``_add_row_class`` attribute can be set for the class(es) to + * set on the insertion row. + */ +var AddAnItemList = ListView.List.extend({ + pad_table_to: function (count) { + if (!this.view.is_action_enabled('create') || this.is_readonly()) { + this._super(count); + return; + } + + this._super(count > 0 ? count - 1 : 0); + + var self = this; + var columns = _(this.columns).filter(function (column) { + return column.invisible !== '1'; + }).length; + if (this.options.selectable) { columns++; } + if (this.options.deletable) { columns++; } + + var $cell = $('<td>', { + colspan: columns, + 'class': this._add_row_class || '' + }).append( + $('<a>', {href: '#'}).text(_t("Add an item")) + .mousedown(function () { + // FIXME: needs to be an official API somehow + if (self.view.editor.is_editing()) { + self.view.__ignore_blur = true; + } + }) + .click(function (e) { + e.preventDefault(); + e.stopPropagation(); + // FIXME: there should also be an API for that one + if (self.view.editor.form.__blur_timeout) { + clearTimeout(self.view.editor.form.__blur_timeout); + self.view.editor.form.__blur_timeout = false; + } + self.view.ensure_saved().done(function () { + self.view.do_add_record(); + }); + })); + + var $padding = this.$current.find('tr:not([data-id]):first'); + var $newrow = $('<tr>').append($cell); + if ($padding.length) { + $padding.before($newrow); + } else { + this.$current.append($newrow); + } + } +}); + + +var FieldOne2Many = common.AbstractField.extend({ + multi_selection: false, + disable_utility_classes: true, + init: function(field_manager, node) { + this._super(field_manager, node); + this.is_loaded = $.Deferred(); + this.initial_is_loaded = this.is_loaded; + this.form_last_update = $.Deferred(); + this.init_form_last_update = this.form_last_update; + this.is_started = false; + this.dataset = new One2ManyDataSet(this, this.field.relation); + this.dataset.o2m = this; + this.dataset.parent_view = this.view; + this.dataset.child_name = this.name; + var self = this; + this.dataset.on('dataset_changed', this, function() { + self.trigger_on_change(); + }); + this.set_value([]); + }, + start: function() { + this._super.apply(this, arguments); + this.$el.addClass('oe_form_field oe_form_field_one2many'); + + var self = this; + + self.load_views(); + var destroy = function() { + self.is_loaded = self.is_loaded.then(function() { + self.viewmanager.destroy(); + return $.when(self.load_views()).done(function() { + self.reload_current_view(); + }); + }); + }; + this.is_loaded.done(function() { + self.on("change:effective_readonly", self, destroy); + }); + this.view.on("on_button_cancel", self, destroy); + this.is_started = true; + this.reload_current_view(); + }, + trigger_on_change: function() { + this.trigger('changed_value'); + }, + load_views: function() { + var self = this; + + var modes = this.node.attrs.mode; + modes = !!modes ? modes.split(",") : ["tree"]; + var views = []; + _.each(modes, function(mode) { + if (! _.include(["list", "tree", "graph", "kanban"], mode)) { + throw new Error(_.str.sprintf(_t("View type '%s' is not supported in One2Many."), mode)); + } + var view = { + view_id: false, + view_type: mode == "tree" ? "list" : mode, + options: {} + }; + if (self.field.views && self.field.views[mode]) { + view.embedded_view = self.field.views[mode]; + } + if(view.view_type === "list") { + _.extend(view.options, { + addable: null, + selectable: self.multi_selection, + sortable: true, + import_enabled: false, + deletable: true + }); + if (self.get("effective_readonly")) { + _.extend(view.options, { + deletable: null, + reorderable: false, + }); + } + } else if (view.view_type === "form") { + if (self.get("effective_readonly")) { + view.view_type = 'form'; + } + _.extend(view.options, { + not_interactible_on_create: true, + }); + } else if (view.view_type === "kanban") { + _.extend(view.options, { + confirm_on_delete: false, + }); + if (self.get("effective_readonly")) { + _.extend(view.options, { + action_buttons: false, + quick_creatable: false, + creatable: false, + read_only_mode: true, + }); + } + } + views.push(view); + }); + this.views = views; + + this.viewmanager = new One2ManyViewManager(this, this.dataset, views, {}); + this.viewmanager.o2m = self; + var once = $.Deferred().done(function() { + self.init_form_last_update.resolve(); + }); + var def = $.Deferred().done(function() { + self.initial_is_loaded.resolve(); + }); + this.viewmanager.on("controller_inited", self, function(view_type, controller) { + controller.o2m = self; + if (view_type == "list") { + if (self.get("effective_readonly")) { + controller.on('edit:before', self, function (e) { + e.cancel = true; + }); + _(controller.columns).find(function (column) { + if (!(column instanceof list_widget_registry.get('field.handle'))) { + return false; + } + column.modifiers.invisible = true; + return true; + }); + } + } else if (view_type === "form") { + if (self.get("effective_readonly")) { + $(".oe_form_buttons", controller.$el).children().remove(); + } + controller.on("load_record", self, function(){ + once.resolve(); + }); + controller.on('pager_action_executed',self,self.save_any_view); + } else if (view_type == "graph") { + self.reload_current_view(); + } + def.resolve(); + }); + this.viewmanager.on("switch_mode", self, function(n_mode) { + $.when(self.save_any_view()).done(function() { + if (n_mode === "list") { + $.async_when().done(function() { + self.reload_current_view(); + }); + } + }); + }); + $.async_when().done(function () { + self.viewmanager.appendTo(self.$el); + }); + return def; + }, + reload_current_view: function() { + var self = this; + self.is_loaded = self.is_loaded.then(function() { + var view = self.get_active_view(); + if (view.type === "list") { + return view.controller.reload_content(); + } else if (view.type === "form") { + if (self.dataset.index === null && self.dataset.ids.length >= 1) { + self.dataset.index = 0; + } + var act = function() { + return view.controller.do_show(); + }; + self.form_last_update = self.form_last_update.then(act, act); + return self.form_last_update; + } else if (view.controller.do_search) { + return view.controller.do_search(self.build_domain(), self.dataset.get_context(), []); + } + }, undefined); + return self.is_loaded; + }, + get_active_view: function () { + /** + * Returns the current active view if any. + */ + return (this.viewmanager && this.viewmanager.active_view); + }, + set_value: function(value_) { + value_ = value_ || []; + var self = this; + var view = this.get_active_view(); + this.dataset.reset_ids([]); + var ids; + if(value_.length >= 1 && value_[0] instanceof Array) { + ids = []; + _.each(value_, function(command) { + var obj = {values: command[2]}; + switch (command[0]) { + case commands.CREATE: + obj.id = _.uniqueId(self.dataset.virtual_id_prefix); + obj.defaults = {}; + self.dataset.to_create.push(obj); + self.dataset.cache.push(_.extend(_.clone(obj), {values: _.clone(command[2])})); + ids.push(obj.id); + return; + case commands.UPDATE: + obj.id = command[1]; + self.dataset.to_write.push(obj); + self.dataset.cache.push(_.extend(_.clone(obj), {values: _.clone(command[2])})); + ids.push(obj.id); + return; + case commands.DELETE: + self.dataset.to_delete.push({id: command[1]}); + return; + case commands.LINK_TO: + ids.push(command[1]); + return; + case commands.DELETE_ALL: + self.dataset.delete_all = true; + return; + } + }); + this._super(ids); + this.dataset.set_ids(ids); + } else if (value_.length >= 1 && typeof(value_[0]) === "object") { + ids = []; + this.dataset.delete_all = true; + _.each(value_, function(command) { + var obj = {values: command}; + obj.id = _.uniqueId(self.dataset.virtual_id_prefix); + obj.defaults = {}; + self.dataset.to_create.push(obj); + self.dataset.cache.push(_.clone(obj)); + ids.push(obj.id); + }); + this._super(ids); + this.dataset.set_ids(ids); + } else { + this._super(value_); + this.dataset.reset_ids(value_); + } + if (this.dataset.index === null && this.dataset.ids.length > 0) { + this.dataset.index = 0; + } + this.trigger_on_change(); + if (this.is_started && !this.no_rerender) { + return self.reload_current_view(); + } else { + return $.when(); + } + }, + get_value: function() { + var self = this; + if (!this.dataset) + return []; + var val = this.dataset.delete_all ? [commands.delete_all()] : []; + val = val.concat(_.map(this.dataset.ids, function(id) { + var alter_order = _.detect(self.dataset.to_create, function(x) {return x.id === id;}); + if (alter_order) { + return commands.create(alter_order.values); + } + alter_order = _.detect(self.dataset.to_write, function(x) {return x.id === id;}); + if (alter_order) { + return commands.update(alter_order.id, alter_order.values); + } + return commands.link_to(id); + })); + return val.concat(_.map( + this.dataset.to_delete, function(x) { + return commands['delete'](x.id);})); + }, + commit_value: function() { + return this.save_any_view(); + }, + save_any_view: function() { + var view = this.get_active_view(); + if (view) { + if (this.viewmanager.active_view.type === "form") { + if (view.controller.is_initialized.state() !== 'resolved') { + return $.when(false); + } + return $.when(view.controller.save()); + } else if (this.viewmanager.active_view.type === "list") { + return $.when(view.controller.ensure_saved()); + } + } + return $.when(false); + }, + is_syntax_valid: function() { + var view = this.get_active_view(); + if (!view){ + return true; + } + switch (this.viewmanager.active_view.type) { + case 'form': + return _(view.controller.fields).chain() + .invoke('is_valid') + .all(_.identity) + .value(); + case 'list': + return view.controller.is_valid(); + } + return true; + }, +}); + +var One2ManyDataSet = data.BufferedDataSet.extend({ + get_context: function() { + this.context = this.o2m.build_context(); + return this.context; + } +}); + +var One2ManyListView = ListView.extend({ + _template: 'One2Many.listview', + init: function (parent, dataset, view_id, options) { + this._super(parent, dataset, view_id, _.extend(options || {}, { + GroupsType: One2ManyGroups, + ListType: One2ManyList + })); + this.on('edit:after', this, this.proxy('_after_edit')); + this.on('save:before cancel:before', this, this.proxy('_before_unedit')); + + this.records + .bind('add', this.proxy("changed_records")) + .bind('remove', this.proxy("changed_records")); + this.on('save:after', this, this.proxy("changed_records")); + }, + start: function () { + var ret = this._super(); + this.$el + .off('mousedown.handleButtons') + .on('mousedown.handleButtons', 'table button, div a.oe_m2o_cm_button', this.proxy('_button_down')); + return ret; + }, + changed_records: function () { + this.o2m.trigger_on_change(); + }, + is_valid: function () { + var self = this; + if (!this.fields_view || !this.editable()){ + return true; + } + if (_.isEmpty(this.records.records)){ + return true; + } + var current_values = {}; + _.each(this.editor.form.fields, function(field){ + field._inhibit_on_change_flag = true; + field.no_rerender = true; + current_values[field.name] = field.get('value'); + }); + var valid = _.every(this.records.records, function(record){ + _.each(self.editor.form.fields, function(field){ + field.set_value(record.attributes[field.name]); + }); + return _.every(self.editor.form.fields, function(field){ + field.process_modifiers(); + field._check_css_flags(); + return field.is_valid(); + }); + }); + _.each(this.editor.form.fields, function(field){ + field.set('value', current_values[field.name]); + field._inhibit_on_change_flag = false; + field.no_rerender = false; + }); + return valid; + }, + do_add_record: function () { + if (this.editable()) { + this._super.apply(this, arguments); + } else { + var self = this; + var pop = new common.SelectCreatePopup(this); + pop.select_element( + self.o2m.field.relation, + { + title: _t("Create: ") + self.o2m.string, + initial_view: "form", + alternative_form_view: self.o2m.field.views ? self.o2m.field.views.form : undefined, + create_function: function(data, options) { + return self.o2m.dataset.create(data, options).done(function(r) { + self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r])); + self.o2m.dataset.trigger("dataset_changed", r); + }); + }, + read_function: function() { + return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); + }, + parent_view: self.o2m.view, + child_name: self.o2m.name, + form_view_options: {'not_interactible_on_create':true} + }, + self.o2m.build_domain(), + self.o2m.build_context() + ); + pop.on("elements_selected", self, function() { + self.o2m.reload_current_view(); + }); + } + }, + do_activate_record: function(index, id) { + var self = this; + var pop = new common.FormOpenPopup(self); + pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), { + title: _t("Open: ") + self.o2m.string, + write_function: function(id, data) { + return self.o2m.dataset.write(id, data, {}).done(function() { + self.o2m.reload_current_view(); + }); + }, + alternative_form_view: self.o2m.field.views ? self.o2m.field.views.form : undefined, + parent_view: self.o2m.view, + child_name: self.o2m.name, + read_function: function() { + return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); + }, + form_view_options: {'not_interactible_on_create':true}, + readonly: !this.is_action_enabled('edit') || self.o2m.get("effective_readonly") + }); + }, + do_button_action: function (name, id, callback) { + if (!_.isNumber(id)) { + core.bus.trigger('display_notification_warning', + _t("Action Button"), + _t("The o2m record must be saved before an action can be used")); + return; + } + var parent_form = this.o2m.view; + var self = this; + this.ensure_saved().then(function () { + if (parent_form) + return parent_form.save(); + else + return $.when(); + }).done(function () { + var ds = self.o2m.dataset; + var cached_records = _.any([ds.to_create, ds.to_delete, ds.to_write], function(value) { + return value.length; + }); + if (!self.o2m.options.reload_on_button && !cached_records) { + self.handle_button(name, id, callback); + }else { + self.handle_button(name, id, function(){ + self.o2m.view.reload(); + }); + } + }); + }, + + _after_edit: function () { + this.__ignore_blur = false; + this.editor.form.on('blurred', this, this._on_form_blur); + + // The form's blur thing may be jiggered during the edition setup, + // potentially leading to the o2m instasaving the row. Cancel any + // blurring triggered the edition startup here + this.editor.form.widgetFocused(); + }, + _before_unedit: function () { + this.editor.form.off('blurred', this, this._on_form_blur); + }, + _button_down: function () { + // If a button is clicked (usually some sort of action button), it's + // the button's responsibility to ensure the editable list is in the + // correct state -> ignore form blurring + this.__ignore_blur = true; + }, + /** + * Handles blurring of the nested form (saves the currently edited row), + * unless the flag to ignore the event is set to ``true`` + * + * Makes the internal form go away + */ + _on_form_blur: function () { + if (this.__ignore_blur) { + this.__ignore_blur = false; + return; + } + // FIXME: why isn't there an API for this? + if (this.editor.form.$el.hasClass('oe_form_dirty')) { + this.ensure_saved(); + return; + } + this.cancel_edition(); + }, + keypress_ENTER: function () { + // blurring caused by hitting the [Return] key, should skip the + // autosave-on-blur and let the handler for [Return] do its thing (save + // the current row *anyway*, then create a new one/edit the next one) + this.__ignore_blur = true; + this._super.apply(this, arguments); + }, + do_delete: function (ids) { + var confirm = window.confirm; + window.confirm = function () { return true; }; + try { + return this._super(ids); + } finally { + window.confirm = confirm; + } + }, + reload_record: function (record, options) { + if (!options || !options.do_not_evict) { + // Evict record.id from cache to ensure it will be reloaded correctly + this.dataset.evict_record(record.get('id')); + } + + return this._super(record); + } +}); + +var One2ManyGroups = ListView.Groups.extend({ + setup_resequence_rows: function () { + if (!this.view.o2m.get('effective_readonly')) { + this._super.apply(this, arguments); + } + } +}); + +var One2ManyList = AddAnItemList.extend({ + _add_row_class: 'oe_form_field_one2many_list_row_add', + is_readonly: function () { + return this.view.o2m.get('effective_readonly'); + }, +}); + +var One2ManyFormView = FormView.extend({ + form_template: 'One2Many.formview', + load_form: function(data) { + this._super(data); + var self = this; + this.$buttons.find('button.oe_form_button_create').click(function() { + self.save().done(self.on_button_new); + }); + }, + do_notify_change: function() { + if (this.dataset.parent_view) { + this.dataset.parent_view.do_notify_change(); + } else { + this._super.apply(this, arguments); + } + } +}); + +var One2ManyViewManager = ViewManager.extend({ + template: 'One2Many.viewmanager', + init: function(parent, dataset, views, flags) { + this._super(parent, dataset, views, _.extend({}, flags, {$sidebar: false})); + this.registry = core.view_registry.extend({ + list: One2ManyListView, + form: One2ManyFormView, + }); + this.__ignore_blur = false; + }, + switch_mode: function(mode, unused) { + if (mode !== 'form') { + return this._super(mode, unused); + } + var self = this; + var id = self.o2m.dataset.index !== null ? self.o2m.dataset.ids[self.o2m.dataset.index] : null; + var pop = new common.FormOpenPopup(this); + pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), { + title: _t("Open: ") + self.o2m.string, + create_function: function(data, options) { + return self.o2m.dataset.create(data, options).done(function(r) { + self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r])); + self.o2m.dataset.trigger("dataset_changed", r); + }); + }, + write_function: function(id, data, options) { + return self.o2m.dataset.write(id, data, {}).done(function() { + self.o2m.reload_current_view(); + }); + }, + alternative_form_view: self.o2m.field.views ? self.o2m.field.views.form : undefined, + parent_view: self.o2m.view, + child_name: self.o2m.name, + read_function: function() { + return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); + }, + form_view_options: {'not_interactible_on_create':true}, + readonly: self.o2m.get("effective_readonly") + }); + pop.on("elements_selected", self, function() { + self.o2m.reload_current_view(); + }); + }, +}); + +var FieldMany2ManyTags = common.AbstractField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, { + template: "FieldMany2ManyTags", + tag_template: "FieldMany2ManyTag", + init: function() { + this._super.apply(this, arguments); + common.CompletionFieldMixin.init.call(this); + this.set({"value": []}); + this._display_orderer = new utils.DropMisordered(); + this._drop_shown = false; + }, + initialize_texttext: function(){ + var self = this; + return { + plugins : 'tags arrow autocomplete', + autocomplete: { + render: function(suggestion) { + return $('<span class="text-label"/>'). + data('index', suggestion.index).html(suggestion.label); + } + }, + ext: { + autocomplete: { + selectFromDropdown: function() { + this.trigger('hideDropdown'); + var index = Number(this.selectedSuggestionElement().children().children().data('index')); + var data = self.search_result[index]; + if (data.id) { + self.add_id(data.id); + } else { + self.ignore_blur = true; + data.action(); + } + this.trigger('setSuggestions', {result : []}); + }, + }, + tags: { + isTagAllowed: function(tag) { + return !!tag.name; + + }, + removeTag: function(tag) { + var id = tag.data("id"); + self.set({"value": _.without(self.get("value"), id)}); + }, + renderTag: function(stuff) { + return $.fn.textext.TextExtTags.prototype.renderTag. + call(this, stuff).data("id", stuff.id); + }, + }, + itemManager: { + itemToString: function(item) { + return item.name; + }, + }, + core: { + onSetInputData: function(e, data) { + if (data === '') { + this._plugins.autocomplete._suggestions = null; + } + this.input().val(data); + }, + }, + }, + }; + }, + initialize_content: function() { + if (this.get("effective_readonly")) + return; + var self = this; + self.ignore_blur = false; + self.$text = this.$("textarea"); + self.$text.textext(self.initialize_texttext()).bind('getSuggestions', function(e, data) { + var _this = this; + var str = !!data ? data.query || '' : ''; + self.get_search_result(str).done(function(result) { + self.search_result = result; + $(_this).trigger('setSuggestions', {result : _.map(result, function(el, i) { + return _.extend(el, {index:i}); + })}); + }); + }).bind('hideDropdown', function() { + self._drop_shown = false; + }).bind('showDropdown', function() { + self._drop_shown = true; + }); + self.tags = self.$text.textext()[0].tags(); + self.$text + .focusin(function () { + self.trigger('focused'); + self.ignore_blur = false; + }) + .focusout(function() { + self.$text.trigger("setInputData", ""); + if (!self.ignore_blur) { + self.trigger('blurred'); + } + }).keydown(function(e) { + if (e.which === $.ui.keyCode.TAB && self._drop_shown) { + self.$text.textext()[0].autocomplete().selectFromDropdown(); + } + }); + }, + // WARNING: duplicated in 4 other M2M widgets + set_value: function(value_) { + value_ = value_ || []; + if (value_.length >= 1 && value_[0] instanceof Array) { + // value_ is a list of m2m commands. We only process + // LINK_TO and REPLACE_WITH in this context + var val = []; + _.each(value_, function (command) { + if (command[0] === commands.LINK_TO) { + val.push(command[1]); // (4, id[, _]) + } else if (command[0] === commands.REPLACE_WITH) { + val = command[2]; // (6, _, ids) + } + }); + value_ = val; + } + this._super(value_); + }, + is_false: function() { + return _(this.get("value")).isEmpty(); + }, + get_value: function() { + var tmp = [commands.replace_with(this.get("value"))]; + return tmp; + }, + get_search_blacklist: function() { + return this.get("value"); + }, + map_tag: function(data){ + return _.map(data, function(el) {return {name: el[1], id:el[0]};}); + }, + get_render_data: function(ids){ + var self = this; + var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context()); + return dataset.name_get(ids); + }, + render_tag: function(data) { + var self = this; + if (! self.get("effective_readonly")) { + self.tags.containerElement().children().remove(); + self.$('textarea').css("padding-left", "3px"); + self.tags.addTags(self.map_tag(data)); + } else { + self.$el.html(QWeb.render(self.tag_template, {elements: data})); + } + }, + render_value: function() { + var self = this; + var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context()); + var values = self.get("value"); + var handle_names = function(data) { + if (self.isDestroyed()) + return; + var indexed = {}; + _.each(data, function(el) { + indexed[el[0]] = el; + }); + data = _.map(values, function(el) { return indexed[el]; }); + self.render_tag(data); + }; + if (! values || values.length > 0) { + return this._display_orderer.add(self.get_render_data(values)).done(handle_names); + } else { + handle_names([]); + } + }, + add_id: function(id) { + this.set({'value': _.uniq(this.get('value').concat([id]))}); + }, + focus: function () { + var input = this.$text && this.$text[0]; + return input ? input.focus() : false; + }, + set_dimensions: function (height, width) { + this._super(height, width); + this.$("textarea").css({ + width: width, + minHeight: height + }); + }, + _search_create_popup: function() { + this.ignore_blur = true; + return common.CompletionFieldMixin._search_create_popup.apply(this, arguments); + }, +}); + +/** + widget options: + - reload_on_button: Reload the whole form view if click on a button in a list view. + If you see this options, do not use it, it's basically a dirty hack to make one + precise o2m to behave the way we want. +*/ +var FieldMany2Many = common.AbstractField.extend(common.ReinitializeFieldMixin, { + multi_selection: false, + disable_utility_classes: true, + init: function(field_manager, node) { + this._super(field_manager, node); + this.is_loaded = $.Deferred(); + this.dataset = new Many2ManyDataSet(this, this.field.relation); + this.dataset.m2m = this; + var self = this; + this.dataset.on('unlink', self, function(ids) { + self.dataset_changed(); + }); + this.set_value([]); + this.list_dm = new utils.DropMisordered(); + this.render_value_dm = new utils.DropMisordered(); + }, + initialize_content: function() { + var self = this; + + this.$el.addClass('oe_form_field oe_form_field_many2many'); + + this.list_view = new Many2ManyListView(this, this.dataset, false, { + 'addable': null, + 'deletable': this.get("effective_readonly") ? false : true, + 'selectable': this.multi_selection, + 'sortable': false, + 'reorderable': false, + 'import_enabled': false, + }); + var embedded = (this.field.views || {}).tree; + if (embedded) { + this.list_view.set_embedded_view(embedded); + } + this.list_view.m2m_field = this; + var loaded = $.Deferred(); + this.list_view.on("list_view_loaded", this, function() { + loaded.resolve(); + }); + this.list_view.appendTo(this.$el); + + var old_def = self.is_loaded; + self.is_loaded = $.Deferred().done(function() { + old_def.resolve(); + }); + this.list_dm.add(loaded).then(function() { + self.is_loaded.resolve(); + }); + }, + destroy_content: function() { + this.list_view.destroy(); + this.list_view = undefined; + }, + // WARNING: duplicated in 4 other M2M widgets + set_value: function(value_) { + value_ = value_ || []; + if (value_.length >= 1 && value_[0] instanceof Array) { + // value_ is a list of m2m commands. We only process + // LINK_TO and REPLACE_WITH in this context + var val = []; + _.each(value_, function (command) { + if (command[0] === commands.LINK_TO) { + val.push(command[1]); // (4, id[, _]) + } else if (command[0] === commands.REPLACE_WITH) { + val = command[2]; // (6, _, ids) + } + }); + value_ = val; + } + this._super(value_); + }, + get_value: function() { + return [commands.replace_with(this.get('value'))]; + }, + is_false: function () { + return _(this.get("value")).isEmpty(); + }, + render_value: function() { + var self = this; + this.dataset.set_ids(this.get("value")); + this.render_value_dm.add(this.is_loaded).then(function() { + return self.list_view.reload_content(); + }); + }, + dataset_changed: function() { + this.internal_set_value(this.dataset.ids); + }, +}); + +var Many2ManyDataSet = data.DataSetStatic.extend({ + get_context: function() { + this.context = this.m2m.build_context(); + return this.context; + } +}); + +/** + * @class + * @extends instance.web.ListView + */ +var Many2ManyListView = ListView.extend(/** @lends instance.web.form.Many2ManyListView# */{ + init: function (parent, dataset, view_id, options) { + this._super(parent, dataset, view_id, _.extend(options || {}, { + ListType: Many2ManyList, + })); + }, + do_add_record: function () { + var pop = new common.SelectCreatePopup(this); + pop.select_element( + this.model, + { + title: _t("Add: ") + this.m2m_field.string, + alternative_form_view: this.m2m_field.field.views ? this.m2m_field.field.views.form : undefined, + no_create: this.m2m_field.options.no_create, + }, + new data.CompoundDomain(this.m2m_field.build_domain(), ["!", ["id", "in", this.m2m_field.dataset.ids]]), + this.m2m_field.build_context() + ); + var self = this; + pop.on("elements_selected", self, function(element_ids) { + var reload = false; + _(element_ids).each(function (id) { + if(! _.detect(self.dataset.ids, function(x) {return x == id;})) { + self.dataset.set_ids(self.dataset.ids.concat([id])); + self.m2m_field.dataset_changed(); + reload = true; + } + }); + if (reload) { + self.reload_content(); + } + }); + }, + do_activate_record: function(index, id) { + var self = this; + var pop = new common.FormOpenPopup(this); + pop.show_element(this.dataset.model, id, this.m2m_field.build_context(), { + title: _t("Open: ") + this.m2m_field.string, + alternative_form_view: this.m2m_field.field.views ? this.m2m_field.field.views.form : undefined, + readonly: this.getParent().get("effective_readonly") + }); + pop.on('write_completed', self, self.reload_content); + }, + do_button_action: function(name, id, callback) { + var self = this; + var _sup = _.bind(this._super, this); + if (! this.m2m_field.options.reload_on_button) { + return _sup(name, id, callback); + } else { + return this.m2m_field.view.save().then(function() { + return _sup(name, id, function() { + self.m2m_field.view.reload(); + }); + }); + } + }, + is_action_enabled: function () { return true; }, +}); +var Many2ManyList = AddAnItemList.extend({ + _add_row_class: 'oe_form_field_many2many_list_row_add', + is_readonly: function () { + return this.view.m2m_field.get('effective_readonly'); + } +}); + +var FieldMany2ManyKanban = common.AbstractField.extend(common.CompletionFieldMixin, { + disable_utility_classes: true, + init: function(field_manager, node) { + this._super(field_manager, node); + common.CompletionFieldMixin.init.call(this); + this.is_loaded = $.Deferred(); + this.initial_is_loaded = this.is_loaded; + + var self = this; + this.dataset = new Many2ManyDataSet(this, this.field.relation); + this.dataset.m2m = this; + this.dataset.on('unlink', self, function(ids) { + self.dataset_changed(); + }); + }, + start: function() { + this._super.apply(this, arguments); + + var self = this; + + self.load_view(); + self.on("change:effective_readonly", self, function() { + self.is_loaded = self.is_loaded.then(function() { + self.kanban_view.destroy(); + return $.when(self.load_view()).done(function() { + self.render_value(); + }); + }); + }); + }, + // WARNING: duplicated in 4 other M2M widgets + set_value: function(value_) { + value_ = value_ || []; + if (value_.length >= 1 && value_[0] instanceof Array) { + // value_ is a list of m2m commands. We only process + // LINK_TO and REPLACE_WITH in this context + var val = []; + _.each(value_, function (command) { + if (command[0] === commands.LINK_TO) { + val.push(command[1]); // (4, id[, _]) + } else if (command[0] === commands.REPLACE_WITH) { + val = command[2]; // (6, _, ids) + } + }); + value_ = val; + } + this._super(value_); + }, + get_value: function() { + return [commands.replace_with(this.get('value'))]; + }, + load_view: function() { + var self = this; + var Many2ManyKanbanView = core.view_registry.get('many2many_kanban'); + this.kanban_view = new Many2ManyKanbanView(this, this.dataset, false, { + 'create_text': _t("Add"), + 'creatable': self.get("effective_readonly") ? false : true, + 'quick_creatable': self.get("effective_readonly") ? false : true, + 'read_only_mode': self.get("effective_readonly") ? true : false, + 'confirm_on_delete': false, + }); + var embedded = (this.field.views || {}).kanban; + if (embedded) { + this.kanban_view.set_embedded_view(embedded); + } + this.kanban_view.m2m = this; + var loaded = $.Deferred(); + this.kanban_view.on("kanban_view_loaded",self,function() { + self.initial_is_loaded.resolve(); + loaded.resolve(); + }); + this.kanban_view.on('switch_mode', this, this.open_popup); + $.async_when().done(function () { + self.kanban_view.appendTo(self.$el); + }); + return loaded; + }, + render_value: function() { + var self = this; + this.dataset.set_ids(this.get("value")); + this.is_loaded = this.is_loaded.then(function() { + return self.kanban_view.do_search(self.build_domain(), self.dataset.get_context(), []); + }); + }, + dataset_changed: function() { + this.set({'value': this.dataset.ids}); + }, + open_popup: function(type, unused) { + if (type !== "form") + return; + var self = this; + var pop; + if (this.dataset.index === null) { + pop = new common.SelectCreatePopup(this); + pop.select_element( + this.field.relation, + { + title: _t("Add: ") + this.string + }, + new data.CompoundDomain(this.build_domain(), ["!", ["id", "in", this.dataset.ids]]), + this.build_context() + ); + pop.on("elements_selected", self, function(element_ids) { + _.each(element_ids, function(one_id) { + if(! _.detect(self.dataset.ids, function(x) {return x == one_id;})) { + self.dataset.set_ids([].concat(self.dataset.ids, [one_id])); + self.dataset_changed(); + self.render_value(); + } + }); + }); + } else { + var id = self.dataset.ids[self.dataset.index]; + pop = new common.FormOpenPopup(this); + pop.show_element(self.field.relation, id, self.build_context(), { + title: _t("Open: ") + self.string, + write_function: function(id, data, options) { + return self.dataset.write(id, data, {}).done(function() { + self.render_value(); + }); + }, + alternative_form_view: self.field.views ? self.field.views.form : undefined, + parent_view: self.view, + child_name: self.name, + readonly: self.get("effective_readonly") + }); + } + }, + add_id: function(id) { + this.quick_create.add_id(id); + }, +}); + +/** + * Widget for (many2many field) to upload one or more file in same time and display in list. + * The user can delete his files. + * Options on attribute ; "blockui" {Boolean} block the UI or not + * during the file is uploading + */ +var FieldMany2ManyBinaryMultiFiles = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: "FieldBinaryFileUploader", + init: function(field_manager, node) { + this._super(field_manager, node); + this.field_manager = field_manager; + this.node = node; + this.session = session; + if(this.field.type != "many2many" || this.field.relation != 'ir.attachment') { + throw _.str.sprintf(_t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model."), this.field.string); + } + this.data = {}; + this.set_value([]); + this.ds_file = new data.DataSetSearch(this, 'ir.attachment'); + this.fileupload_id = _.uniqueId('oe_fileupload_temp'); + $(window).on(this.fileupload_id, _.bind(this.on_file_loaded, this)); + }, + initialize_content: function() { + this.$el.on('change', 'input.oe_form_binary_file', this.on_file_change ); + }, + // WARNING: duplicated in 4 other M2M widgets + set_value: function(value_) { + value_ = value_ || []; + if (value_.length >= 1 && value_[0] instanceof Array) { + // value_ is a list of m2m commands. We only process + // LINK_TO and REPLACE_WITH in this context + var val = []; + _.each(value_, function (command) { + if (command[0] === commands.LINK_TO) { + val.push(command[1]); // (4, id[, _]) + } else if (command[0] === commands.REPLACE_WITH) { + val = command[2]; // (6, _, ids) + } + }); + value_ = val; + } + this._super(value_); + }, + get_value: function() { + return [commands.replace_with(this.get("value"))]; + }, + get_file_url: function (attachment) { + return this.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'datas_fname', id: attachment.id}); + }, + read_name_values : function () { + var self = this; + // don't reset know values + var ids = this.get('value'); + var _value = _.filter(ids, function (id) { return typeof self.data[id] == 'undefined'; } ); + // send request for get_name + if (_value.length) { + return this.ds_file.call('read', [_value, ['id', 'name', 'datas_fname']]).then(function (datas) { + _.each(datas, function (data) { + data.no_unlink = true; + data.url = self.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'datas_fname', id: data.id}); + self.data[data.id] = data; + }); + return ids; + }); + } else { + return $.when(ids); + } + }, + render_value: function () { + var self = this; + this.read_name_values().then(function (ids) { + var render = $(QWeb.render('FieldBinaryFileUploader.files', {'widget': self, 'values': ids})); + render.on('click', '.oe_delete', _.bind(self.on_file_delete, self)); + self.$('.oe_placeholder_files, .oe_attachments').replaceWith( render ); + + // reinit input type file + var $input = self.$('input.oe_form_binary_file'); + $input.after($input.clone(true)).remove(); + self.$(".oe_fileupload").show(); + + }); + }, + on_file_change: function (event) { + event.stopPropagation(); + var self = this; + var $target = $(event.target); + if ($target.val() !== '') { + var filename = $target.val().replace(/.*[\\\/]/,''); + // don't uplode more of one file in same time + if (self.data[0] && self.data[0].upload ) { + return false; + } + for (var id in this.get('value')) { + // if the files exits, delete the file before upload (if it's a new file) + if (self.data[id] && (self.data[id].filename || self.data[id].name) == filename && !self.data[id].no_unlink ) { + self.ds_file.unlink([id]); + } + } + + // block UI or not + if(this.node.attrs.blockui>0) { + framework.blockUI(); + } + + // TODO : unactivate send on wizard and form + + // submit file + this.$('form.oe_form_binary_form').submit(); + this.$(".oe_fileupload").hide(); + // add file on data result + this.data[0] = { + 'id': 0, + 'name': filename, + 'filename': filename, + 'url': '', + 'upload': true + }; + } + }, + on_file_loaded: function (event, result) { + var files = this.get('value'); + + // unblock UI + if(this.node.attrs.blockui>0) { + framework.unblockUI(); + } + + if (result.error || !result.id ) { + this.do_warn( _t('Uploading Error'), result.error); + delete this.data[0]; + } else { + if (this.data[0] && this.data[0].filename == result.filename && this.data[0].upload) { + delete this.data[0]; + this.data[result.id] = { + 'id': result.id, + 'name': result.name, + 'filename': result.filename, + 'url': this.get_file_url(result) + }; + } else { + this.data[result.id] = { + 'id': result.id, + 'name': result.name, + 'filename': result.filename, + 'url': this.get_file_url(result) + }; + } + var values = _.clone(this.get('value')); + values.push(result.id); + this.set({'value': values}); + } + this.render_value(); + }, + on_file_delete: function (event) { + event.stopPropagation(); + var file_id=$(event.target).data("id"); + if (file_id) { + var files = _.filter(this.get('value'), function (id) {return id != file_id;}); + if(!this.data[file_id].no_unlink) { + this.ds_file.unlink([file_id]); + } + this.set({'value': files}); + } + }, +}); + +/* + This type of field display a list of checkboxes. It works only with m2ms. This field will display one checkbox for each + record existing in the model targeted by the relation, according to the given domain if one is specified. Checked records + will be added to the relation. +*/ +var FieldMany2ManyCheckBoxes = common.AbstractField.extend(common.ReinitializeFieldMixin, { + className: "oe_form_many2many_checkboxes", + init: function() { + this._super.apply(this, arguments); + this.set("value", {}); + this.set("records", []); + this.field_manager.on("view_content_has_changed", this, function() { + var domain = new data.CompoundDomain(this.build_domain()).eval(); + if (! _.isEqual(domain, this.get("domain"))) { + this.set("domain", domain); + } + }); + this.records_orderer = new utils.DropMisordered(); + }, + initialize_field: function() { + common.ReinitializeFieldMixin.initialize_field.call(this); + this.on("change:domain", this, this.query_records); + this.set("domain", new data.CompoundDomain(this.build_domain()).eval()); + this.on("change:records", this, this.render_value); + }, + query_records: function() { + var self = this; + var model = new Model(this.field.relation); + this.records_orderer.add(model.call("search", [this.get("domain")], {"context": this.build_context()}).then(function(record_ids) { + return model.call("name_get", [record_ids] , {"context": self.build_context()}); + })).then(function(res) { + self.set("records", res); + }); + }, + render_value: function() { + this.$().html(QWeb.render("FieldMany2ManyCheckBoxes", {widget: this, selected: this.get("value")})); + var inputs = this.$("input"); + inputs.change(_.bind(this.from_dom, this)); + if (this.get("effective_readonly")) + inputs.attr("disabled", "true"); + }, + from_dom: function() { + var new_value = {}; + this.$("input").each(function() { + var elem = $(this); + new_value[elem.data("record-id")] = elem.is(":checked") ? true : undefined; + }); + if (! _.isEqual(new_value, this.get("value"))) + this.internal_set_value(new_value); + }, + // WARNING: (mostly) duplicated in 4 other M2M widgets + set_value: function(value_) { + value_ = value_ || []; + if (value_.length >= 1 && value_[0] instanceof Array) { + // value_ is a list of m2m commands. We only process + // LINK_TO and REPLACE_WITH in this context + var val = []; + _.each(value_, function (command) { + if (command[0] === commands.LINK_TO) { + val.push(command[1]); // (4, id[, _]) + } else if (command[0] === commands.REPLACE_WITH) { + val = command[2]; // (6, _, ids) + } + }); + value_ = val; + } + var formatted = {}; + _.each(value_, function(el) { + formatted[JSON.stringify(el)] = true; + }); + this._super(formatted); + }, + get_value: function() { + var value = _.filter(_.keys(this.get("value")), function(el) { + return this.get("value")[el]; + }, this); + value = _.map(value, function(el) { + return JSON.parse(el); + }); + return [commands.replace_with(value)]; + }, +}); + +/** + This field can be applied on many2many and one2many. It is a read-only field that will display a single link whose name is + "<number of linked records> <label of the field>". When the link is clicked, it will redirect to another act_window + action on the model of the relation and show only the linked records. + + Widget options: + + * views: The views to display in the act_window action. Must be a list of tuples whose first element is the id of the view + to display (or False to take the default one) and the second element is the type of the view. Defaults to + [[false, "tree"], [false, "form"]] . +*/ +var X2ManyCounter = common.AbstractField.extend(common.ReinitializeFieldMixin, { + className: "oe_form_x2many_counter", + init: function() { + this._super.apply(this, arguments); + this.set("value", []); + _.defaults(this.options, { + "views": [[false, "tree"], [false, "form"]], + }); + }, + render_value: function() { + var text = _.str.sprintf("%d %s", this.val().length, this.string); + this.$().html(QWeb.render("X2ManyCounter", {text: text})); + this.$("a").click(_.bind(this.go_to, this)); + }, + go_to: function() { + return this.view.recursive_save().then(_.bind(function() { + var val = this.val(); + var context = {}; + if (this.field.type === "one2many") { + context["default_" + this.field.relation_field] = this.view.datarecord.id; + } + var domain = [["id", "in", val]]; + return this.do_action({ + type: 'ir.actions.act_window', + name: this.string, + res_model: this.field.relation, + views: this.options.views, + target: 'current', + context: context, + domain: domain, + }); + }, this)); + }, + val: function() { + var value = this.get("value") || []; + if (value.length >= 1 && value[0] instanceof Array) { + value = value[0][2]; + } + return value; + } +}); + + + +core.form_widget_registry + .add('many2one', FieldMany2One) + .add('many2onebutton', Many2OneButton) + .add('many2many', FieldMany2Many) + .add('many2many_tags', FieldMany2ManyTags) + .add('many2many_kanban', FieldMany2ManyKanban) + .add('one2many', FieldOne2Many) + .add('one2many_list', FieldOne2Many) + .add('many2many_binary', FieldMany2ManyBinaryMultiFiles) + .add('many2many_checkboxes', FieldMany2ManyCheckBoxes) + .add('x2many_counter', X2ManyCounter); + +return { + FieldMany2ManyTags: FieldMany2ManyTags, +}; + +}); + +odoo.define('web_kanban.Many2ManyKanbanView', ['web.core', 'web_kanban.KanbanView', 'web.Widget'], function (require) { + "use strict"; + // This code has a dependency on the addon web_kanban. This is a weird dependency issue. To fix it, + // we should either move this code into web_kanban, or move web_kanban into the web client. + + var core = require('web.core'), + KanbanView = require('web_kanban.KanbanView'), + Widget = require('web.Widget'); + + var Many2ManyQuickCreate = Widget.extend({ + template: 'Many2ManyKanban.quick_create', + + /** + * close_btn: If true, the widget will display a "Close" button able to trigger + * a "close" event. + */ + init: function(parent, dataset, context, buttons) { + this._super(parent); + this.m2m = this.getParent().view.m2m; + this.m2m.quick_create = this; + this._dataset = dataset; + this._buttons = buttons || false; + this._context = context || {}; + }, + start: function () { + var self = this; + self.$text = this.$el.find('input').css("width", "200px"); + self.$text.textext({ + plugins : 'arrow autocomplete', + autocomplete: { + render: function(suggestion) { + return $('<span class="text-label"/>'). + data('index', suggestion.index).html(suggestion.label); + } + }, + ext: { + autocomplete: { + selectFromDropdown: function() { + $(this).trigger('hideDropdown'); + var index = Number(this.selectedSuggestionElement().children().children().data('index')); + var data = self.search_result[index]; + if (data.id) { + self.add_id(data.id); + } else { + data.action(); + } + }, + }, + itemManager: { + itemToString: function(item) { + return item.name; + }, + }, + }, + }).bind('getSuggestions', function(e, data) { + var _this = this; + var str = !!data ? data.query || '' : ''; + self.m2m.get_search_result(str).done(function(result) { + self.search_result = result; + $(_this).trigger('setSuggestions', {result : _.map(result, function(el, i) { + return _.extend(el, {index:i}); + })}); + }); + }); + self.$text.focusout(function() { + self.$text.val(""); + }); + }, + focus: function() { + this.$text[0].focus(); + }, + add_id: function(id) { + var self = this; + self.$text.val(""); + self.trigger('added', id); + this.m2m.dataset_changed(); + }, + }); + + var Many2ManyKanbanView = KanbanView.extend({ + get_quick_create_class: function () { + return Many2ManyQuickCreate; + }, + _is_quick_create_enabled: function() { + return this._super() && ! this.group_by; + }, + }); + + core.view_registry.add('many2many_kanban', Many2ManyKanbanView); + +}); diff --git a/addons/web/static/src/js/views/form_view.js b/addons/web/static/src/js/views/form_view.js new file mode 100644 index 00000000000..ac7ea1d7888 --- /dev/null +++ b/addons/web/static/src/js/views/form_view.js @@ -0,0 +1,1587 @@ +odoo.define('web.FormView', ['web.core', 'web.crash_manager', 'web.data', 'web.Dialog', 'web.form_common', 'web.Model', 'web.Sidebar', 'web.utils', 'web.View'], 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 common = require('web.form_common'); +var Model = require('web.Model'); +var Sidebar = require('web.Sidebar'); +var utils = require('web.utils'); +var View = require('web.View'); + +var _t = core._t; +var _lt = core._lt; +var QWeb = core.qweb; +var Class = core.Class; + +/** @namespace */ +// instance.web.form = instance.web.form || {}; + +/** + * Properties: + * - actual_mode: always "view", "edit" or "create". Read-only property. Determines + * the mode used by the view. + */ +var FormView = View.extend(common.FieldManagerMixin, { + /** + * Indicates that this view is not searchable, and thus that no search + * view should be displayed (if there is one active). + */ + searchable: false, + template: "FormView", + display_name: _lt('Form'), + view_type: "form", + /** + * @constructs instance.web.FormView + * @extends instance.web.View + * + * @param {instance.web.Session} session the current openerp session + * @param {instance.web.DataSet} dataset the dataset this view will work with + * @param {String} view_id the identifier of the OpenERP view object + * + * @property {instance.web.Registry} registry=instance.web.form.widgets widgets registry for this form view instance + */ + init: function(parent, dataset, view_id, options) { + var self = this; + this._super(parent); + this.ViewManager = parent; + this.set_default_options(options); + this.dataset = dataset; + this.model = dataset.model; + this.view_id = view_id || false; + this.fields_view = {}; + this.fields = {}; + this.fields_order = []; + this.datarecord = {}; + this._onchange_specs = {}; + this.onchanges_mutex = new utils.Mutex(); + this.default_focus_field = null; + this.default_focus_button = null; + this.fields_registry = core.form_widget_registry; + this.tags_registry = core.form_tag_registry; + this.widgets_registry = core.form_custom_registry; + this.has_been_loaded = $.Deferred(); + this.translatable_fields = []; + _.defaults(this.options, { + "not_interactible_on_create": false, + "initial_mode": "view", + "disable_autofocus": false, + "footer_to_buttons": false, + }); + this.is_initialized = $.Deferred(); + this.mutating_mutex = new utils.Mutex(); + this.save_list = []; + this.render_value_defs = []; + this.reload_mutex = new utils.Mutex(); + this.__clicked_inside = false; + this.__blur_timeout = null; + this.rendering_engine = new FormRenderingEngine(this); + self.set({actual_mode: self.options.initial_mode}); + this.has_been_loaded.done(function() { + self._build_onchange_specs(); + self.on("change:actual_mode", self, self.check_actual_mode); + self.check_actual_mode(); + self.on("change:actual_mode", self, self.init_pager); + self.init_pager(); + }); + self.on("load_record", self, self.load_record); + core.bus.on('clear_uncommitted_changes', this, function(e) { + if (!this.can_be_discarded()) { + e.preventDefault(); + } + }); + }, + view_loading: function(r) { + return this.load_form(r); + }, + destroy: function() { + _.each(this.get_widgets(), function(w) { + w.off('focused blurred'); + w.destroy(); + }); + if (this.$el) { + this.$el.off('.formBlur'); + } + this._super(); + }, + load_form: function(data) { + var self = this; + if (!data) { + throw new Error(_t("No data provided.")); + } + if (this.arch) { + throw "Form view does not support multiple calls to load_form"; + } + this.fields_order = []; + this.fields_view = data; + + this.rendering_engine.set_fields_registry(this.fields_registry); + this.rendering_engine.set_tags_registry(this.tags_registry); + this.rendering_engine.set_widgets_registry(this.widgets_registry); + this.rendering_engine.set_fields_view(data); + var $dest = this.$el.hasClass("oe_form_container") ? this.$el : this.$el.find('.oe_form_container'); + this.rendering_engine.render_to($dest); + + this.$el.on('mousedown.formBlur', function () { + self.__clicked_inside = true; + }); + + this.$buttons = $(QWeb.render("FormView.buttons", {'widget':self})); + if (this.options.$buttons) { + this.$buttons.appendTo(this.options.$buttons); + } else { + this.$el.find('.oe_form_buttons').replaceWith(this.$buttons); + } + this.$buttons.on('click', '.oe_form_button_create', + this.guard_active(this.on_button_create)); + this.$buttons.on('click', '.oe_form_button_edit', + this.guard_active(this.on_button_edit)); + this.$buttons.on('click', '.oe_form_button_save', + this.guard_active(this.on_button_save)); + this.$buttons.on('click', '.oe_form_button_cancel', + this.guard_active(this.on_button_cancel)); + if (this.options.footer_to_buttons) { + this.$el.find('footer').appendTo(this.$buttons); + } + + this.$sidebar = this.options.$sidebar || this.$el.find('.oe_form_sidebar'); + if (!this.sidebar && this.options.$sidebar) { + this.sidebar = new Sidebar(this); + this.sidebar.appendTo(this.$sidebar); + if (this.fields_view.toolbar) { + this.sidebar.add_toolbar(this.fields_view.toolbar); + } + this.sidebar.add_items('other', _.compact([ + self.is_action_enabled('delete') && { label: _t('Delete'), callback: self.on_button_delete }, + self.is_action_enabled('create') && { label: _t('Duplicate'), callback: self.on_button_duplicate } + ])); + } + + this.has_been_loaded.resolve(); + + // Add bounce effect on button 'Edit' when click on readonly page view. + this.$el.find(".oe_form_group_row,.oe_form_field,label,h1,.oe_title,.oe_notebook_page, .oe_list_content").on('click', function (e) { + if(self.get("actual_mode") == "view") { + var $button = self.options.$buttons.find(".oe_form_button_edit"); + $button.openerpBounce(); + e.stopPropagation(); + core.bus.trigger('click', e); + } + }); + //bounce effect on red button when click on statusbar. + this.$el.find(".oe_form_field_status:not(.oe_form_status_clickable)").on('click', function (e) { + if((self.get("actual_mode") == "view")) { + var $button = self.$el.find(".oe_highlight:not(.oe_form_invisible)").css({'float':'left','clear':'none'}); + $button.openerpBounce(); + e.stopPropagation(); + } + }); + this.trigger('form_view_loaded', data); + return $.when(); + }, + widgetFocused: function() { + // Clear click flag if used to focus a widget + this.__clicked_inside = false; + if (this.__blur_timeout) { + clearTimeout(this.__blur_timeout); + this.__blur_timeout = null; + } + }, + widgetBlurred: function() { + if (this.__clicked_inside) { + // clicked in an other section of the form (than the currently + // focused widget) => just ignore the blurring entirely? + this.__clicked_inside = false; + return; + } + var self = this; + // clear timeout, if any + this.widgetFocused(); + this.__blur_timeout = setTimeout(function () { + self.trigger('blurred'); + }, 0); + }, + + do_load_state: function(state, warm) { + if (state.id && this.datarecord.id != state.id) { + if (this.dataset.get_id_index(state.id) === null) { + this.dataset.ids.push(state.id); + } + this.dataset.select_id(state.id); + this.do_show(); + } + }, + /** + * + * @param {Object} [options] + * @param {Boolean} [mode=undefined] If specified, switch the form to specified mode. Can be "edit" or "view". + * @param {Boolean} [reload=true] whether the form should reload its content on show, or use the currently loaded record + * @return {$.Deferred} + */ + do_show: function (options) { + var self = this; + options = options || {}; + if (this.sidebar) { + this.sidebar.$el.show(); + } + if (this.$buttons) { + this.$buttons.show(); + } + this.$el.show().css({ + opacity: '0', + filter: 'alpha(opacity = 0)' + }); + this.$el.add(this.$buttons).removeClass('oe_form_dirty'); + + var shown = this.has_been_loaded; + if (options.reload !== false) { + shown = shown.then(function() { + if (self.dataset.index === null) { + // null index means we should start a new record + return self.on_button_new(); + } + var fields = _.keys(self.fields_view.fields); + fields.push('display_name'); + return self.dataset.read_index(fields, { + context: { 'bin_size': true, 'future_display_name' : true } + }).then(function(r) { + self.trigger('load_record', r); + }); + }); + } + return $.when(shown, this._super()).then(function() { + self._actualize_mode(options.mode || self.options.initial_mode); + self.$el.css({ + opacity: '1', + filter: 'alpha(opacity = 100)' + }); + core.bus.trigger('form_view_shown', self); + }); + }, + do_hide: function () { + if (this.sidebar) { + this.sidebar.$el.hide(); + } + if (this.$pager) { + this.$pager.hide(); + } + this._super(); + }, + load_record: function(record) { + var self = this, set_values = []; + if (!record) { + this.set({ 'title' : undefined }); + this.do_warn(_t("Form"), _t("The record could not be found in the database."), true); + return $.Deferred().reject(); + } + this.datarecord = record; + this._actualize_mode(); + this.set({ 'title' : record.id ? record.display_name : _t("New") }); + + _(this.fields).each(function (field, f) { + field._dirty_flag = false; + field._inhibit_on_change_flag = true; + var result = field.set_value(self.datarecord[f] || false); + field._inhibit_on_change_flag = false; + set_values.push(result); + }); + return $.when.apply(null, set_values).then(function() { + if (!record.id) { + // trigger onchanges + self.do_onchange(null); + } + self.on_form_changed(); + self.rendering_engine.init_fields(); + self.is_initialized.resolve(); + self.do_update_pager(record.id === null || record.id === undefined); + if (self.sidebar) { + self.sidebar.do_attachement_update(self.dataset, self.datarecord.id); + } + if (record.id) { + self.do_push_state({id:record.id}); + } else { + self.do_push_state({}); + } + self.$el.add(self.$buttons).removeClass('oe_form_dirty'); + self.autofocus(); + }); + }, + /** + * Loads and sets up the default values for the model as the current + * record + * + * @return {$.Deferred} + */ + load_defaults: function () { + var self = this; + var keys = _.keys(this.fields_view.fields); + if (keys.length) { + return this.dataset.default_get(keys).then(function(r) { + self.trigger('load_record', r); + }); + } + return self.trigger('load_record', {}); + }, + on_form_changed: function() { + this.trigger("view_content_has_changed"); + }, + do_notify_change: function() { + this.$el.add(this.$buttons).addClass('oe_form_dirty'); + }, + execute_pager_action: function(action) { + if (this.can_be_discarded()) { + switch (action) { + case 'first': + this.dataset.index = 0; + break; + case 'previous': + this.dataset.previous(); + break; + case 'next': + this.dataset.next(); + break; + case 'last': + this.dataset.index = this.dataset.ids.length - 1; + break; + } + var def = this.reload(); + this.trigger('pager_action_executed'); + return def; + } + return $.when(); + }, + init_pager: function() { + var self = this; + if (this.$pager) + this.$pager.remove(); + if (this.get("actual_mode") === "create") + return; + this.$pager = $(QWeb.render("FormView.pager", {'widget':self})); + if (this.options.$pager) { + this.$pager.appendTo(this.options.$pager); + } else { + this.$el.find('.oe_form_pager').replaceWith(this.$pager); + } + this.$pager.on('click','a[data-pager-action]',function() { + var $el = $(this); + if ($el.attr("disabled")) + return; + var action = $el.data('pager-action'); + var def = $.when(self.execute_pager_action(action)); + $el.attr("disabled"); + def.always(function() { + $el.removeAttr("disabled"); + }); + }); + this.do_update_pager(); + }, + do_update_pager: function(hide_index) { + this.$pager.toggle(this.dataset.ids.length > 1); + if (hide_index) { + $(".oe_form_pager_state", this.$pager).html(""); + } else { + $(".oe_form_pager_state", this.$pager).html(_.str.sprintf(_t("%d / %d"), this.dataset.index + 1, this.dataset.ids.length)); + } + }, + + _build_onchange_specs: function() { + var self = this; + var find = function(field_name, root) { + var fields = [root]; + while (fields.length) { + var node = fields.pop(); + if (!node) { + continue; + } + if (node.tag === 'field' && node.attrs.name === field_name) { + return node.attrs.on_change || ""; + } + fields = _.union(fields, node.children); + } + return ""; + }; + + self._onchange_fields = []; + self._onchange_specs = {}; + _.each(this.fields, function(field, name) { + self._onchange_fields.push(name); + self._onchange_specs[name] = find(name, field.node); + _.each(field.field.views, function(view) { + _.each(view.fields, function(_, subname) { + self._onchange_specs[name + '.' + subname] = find(subname, view.arch); + }); + }); + }); + }, + _get_onchange_values: function() { + var field_values = this.get_fields_values(); + if (field_values.id.toString().match(data.BufferedDataSet.virtual_id_regex)) { + delete field_values.id; + } + if (this.dataset.parent_view) { + // this belongs to a parent view: add parent field if possible + var parent_view = this.dataset.parent_view; + var child_name = this.dataset.child_name; + var parent_name = parent_view.get_field_desc(child_name).relation_field; + if (parent_name) { + // consider all fields except the inverse of the parent field + var parent_values = parent_view.get_fields_values(); + delete parent_values[child_name]; + field_values[parent_name] = parent_values; + } + } + return field_values; + }, + + do_onchange: function(widget) { + var self = this; + var onchange_specs = self._onchange_specs; + try { + var def = $.when({}); + var change_spec = widget ? onchange_specs[widget.name] : null; + if (!widget || (!_.isEmpty(change_spec) && change_spec !== "0")) { + var ids = [], + trigger_field_name = widget ? widget.name : self._onchange_fields, + values = self._get_onchange_values(), + context = new data.CompoundContext(self.dataset.get_context()); + + if (widget && widget.build_context()) { + context.add(widget.build_context()); + } + if (self.dataset.parent_view) { + var parent_name = self.dataset.parent_view.get_field_desc(self.dataset.child_name).relation_field; + context.add({field_parent: parent_name}); + } + + if (self.datarecord.id && !data.BufferedDataSet.virtual_id_regex.test(self.datarecord.id)) { + // In case of a o2m virtual id, we should pass an empty ids list + ids.push(self.datarecord.id); + } + def = self.alive(new Model(self.dataset.model).call( + "onchange", [ids, values, trigger_field_name, onchange_specs, context])); + } + this.onchanges_mutex.exec(function(){ + return def.then(function(response) { + var fields = {}; + if (widget){ + fields[widget.name] = widget.field; + } + else{ + fields = self.fields_view.fields; + } + var defs = []; + _.each(fields, function(field, fieldname){ + if (field && field.change_default) { + var value_; + if (response.value && (fieldname in response.value)) { + // Use value from onchange if onchange executed + value_ = response.value[fieldname]; + } else { + // otherwise get form value for field + value_ = self.fields[fieldname].get_value(); + } + var condition = fieldname + '=' + value_; + + if (value_) { + defs.push(self.alive(new Model('ir.values').call( + 'get_defaults', [self.model, condition] + )).then(function (results) { + if (!results.length) { + return response; + } + if (!response.value) { + response.value = {}; + } + for(var i=0; i<results.length; ++i) { + // [whatever, key, value] + var triplet = results[i]; + response.value[triplet[1]] = triplet[2]; + } + return response; + })); + } + } + }); + return _.isEmpty(defs) ? response : $.when.apply(null, defs); + }).then(function(response) { + return self.on_processed_onchange(response); + }); + }); + return this.onchanges_mutex.def; + } catch(e) { + console.error(e); + crash_manager.show_message(e); + return $.Deferred().reject(); + } + }, + on_processed_onchange: function(result) { + try { + var fields = this.fields; + _(result.domain).each(function (domain, fieldname) { + var field = fields[fieldname]; + if (!field) { return; } + field.node.attrs.domain = domain; + }); + + if (!_.isEmpty(result.value)) { + this._internal_set_values(result.value); + } + // FIXME XXX a list of warnings? + if (!_.isEmpty(result.warning)) { + new Dialog(this, { + size: 'medium', + title:result.warning.title, + buttons: [ + {text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }} + ] + }, QWeb.render("CrashManager.warning", result.warning)).open(); + } + + return $.Deferred().resolve(); + } catch(e) { + console.error(e); + crash_manager.show_message(e); + return $.Deferred().reject(); + } + }, + _process_operations: function() { + var self = this; + return this.mutating_mutex.exec(function() { + function iterate() { + + var mutex = new utils.Mutex(); + _.each(self.fields, function(field) { + self.onchanges_mutex.def.then(function(){ + mutex.exec(function(){ + return field.commit_value(); + }); + }); + }); + + var args = _.toArray(arguments); + return $.when.apply(null, [mutex.def, self.onchanges_mutex.def]).then(function() { + var save_obj = self.save_list.pop(); + if (save_obj) { + return self._process_save(save_obj).then(function() { + save_obj.ret = _.toArray(arguments); + return iterate(); + }, function() { + save_obj.error = true; + }); + } + return $.when(); + }).fail(function() { + self.save_list.pop(); + return $.when(); + }); + } + return iterate(); + }); + }, + _internal_set_values: function(values) { + for (var f in values) { + if (!values.hasOwnProperty(f)) { continue; } + var field = this.fields[f]; + // If field is not defined in the view, just ignore it + if (field) { + var value_ = values[f]; + if (field.get_value() != value_) { + field._inhibit_on_change_flag = true; + field.set_value(value_); + field._inhibit_on_change_flag = false; + field._dirty_flag = true; + } + } + } + this.on_form_changed(); + }, + set_values: function(values) { + var self = this; + return this.mutating_mutex.exec(function() { + self._internal_set_values(values); + }); + }, + /** + * Ask the view to switch to view mode if possible. The view may not do it + * if the current record is not yet saved. It will then stay in create mode. + */ + to_view_mode: function() { + this._actualize_mode("view"); + }, + /** + * Ask the view to switch to edit mode if possible. The view may not do it + * if the current record is not yet saved. It will then stay in create mode. + */ + to_edit_mode: function() { + this.onchanges_mutex = new utils.Mutex(); + this._actualize_mode("edit"); + }, + /** + * Ask the view to switch to a precise mode if possible. The view is free to + * not respect this command if the state of the dataset is not compatible with + * the new mode. For example, it is not possible to switch to edit mode if + * the current record is not yet saved in database. + * + * @param {string} [new_mode] Can be "edit", "view", "create" or undefined. If + * undefined the view will test the actual mode to check if it is still consistent + * with the dataset state. + */ + _actualize_mode: function(switch_to) { + var mode = switch_to || this.get("actual_mode"); + if (! this.datarecord.id) { + mode = "create"; + } else if (mode === "create") { + mode = "edit"; + } + this.render_value_defs = []; + this.set({actual_mode: mode}); + }, + check_actual_mode: function(source, options) { + var self = this; + if(this.get("actual_mode") === "view") { + self.$el.removeClass('oe_form_editable').addClass('oe_form_readonly'); + self.$buttons.find('.oe_form_buttons_edit').hide(); + self.$buttons.find('.oe_form_buttons_view').show(); + self.$sidebar.show(); + } else { + self.$el.removeClass('oe_form_readonly').addClass('oe_form_editable'); + self.$buttons.find('.oe_form_buttons_edit').show(); + self.$buttons.find('.oe_form_buttons_view').hide(); + self.$sidebar.hide(); + this.autofocus(); + } + }, + autofocus: function() { + if (this.get("actual_mode") !== "view" && !this.options.disable_autofocus) { + var fields_order = this.fields_order.slice(0); + if (this.default_focus_field) { + fields_order.unshift(this.default_focus_field.name); + } + for (var i = 0; i < fields_order.length; i += 1) { + var field = this.fields[fields_order[i]]; + if (!field.get('effective_invisible') && !field.get('effective_readonly') && field.$label) { + if (field.focus() !== false) { + break; + } + } + } + } + }, + on_button_save: function(e) { + var self = this; + $(e.target).attr("disabled", true); + return this.save().done(function(result) { + self.trigger("save", result); + self.reload().then(function() { + self.to_view_mode(); + core.bus.trigger('do_reload_needaction'); + core.bus.trigger('form_view_saved', self); + }); + }).always(function(){ + $(e.target).attr("disabled", false); + }); + }, + on_button_cancel: function(event) { + var self = this; + if (this.can_be_discarded()) { + if (this.get('actual_mode') === 'create') { + this.trigger('history_back'); + } else { + this.to_view_mode(); + $.when.apply(null, this.render_value_defs).then(function(){ + self.trigger('load_record', self.datarecord); + }); + } + } + this.trigger('on_button_cancel'); + return false; + }, + on_button_new: function() { + var self = this; + this.to_edit_mode(); + return $.when(this.has_been_loaded).then(function() { + if (self.can_be_discarded()) { + return self.load_defaults(); + } + }); + }, + on_button_edit: function() { + return this.to_edit_mode(); + }, + on_button_create: function() { + this.dataset.index = null; + this.do_show(); + }, + on_button_duplicate: function() { + var self = this; + return this.has_been_loaded.then(function() { + return self.dataset.call('copy', [self.datarecord.id, {}, self.dataset.context]).then(function(new_id) { + self.record_created(new_id); + self.to_edit_mode(); + }); + }); + }, + on_button_delete: function() { + var self = this; + var def = $.Deferred(); + this.has_been_loaded.done(function() { + if (self.datarecord.id && confirm(_t("Do you really want to delete this record?"))) { + self.dataset.unlink([self.datarecord.id]).done(function() { + if (self.dataset.size()) { + self.execute_pager_action('next'); + } else { + self.do_action('history_back'); + } + def.resolve(); + }); + } else { + $.async_when().done(function () { + def.reject(); + }); + } + }); + return def.promise(); + }, + can_be_discarded: function() { + if (this.$el.is('.oe_form_dirty')) { + if (!confirm(_t("Warning, the record has been modified, your changes will be discarded.\n\nAre you sure you want to leave this page ?"))) { + return false; + } + this.$el.removeClass('oe_form_dirty'); + } + return true; + }, + /** + * Triggers saving the form's record. Chooses between creating a new + * record or saving an existing one depending on whether the record + * already has an id property. + * + * @param {Boolean} [prepend_on_create=false] if ``save`` creates a new + * record, should that record be inserted at the start of the dataset (by + * default, records are added at the end) + */ + save: function(prepend_on_create) { + var self = this; + var save_obj = {prepend_on_create: prepend_on_create, ret: null}; + this.save_list.push(save_obj); + return self._process_operations().then(function() { + if (save_obj.error) + return $.Deferred().reject(); + return $.when.apply($, save_obj.ret); + }).done(function(result) { + self.$el.removeClass('oe_form_dirty'); + }); + }, + _process_save: function(save_obj) { + var self = this; + var prepend_on_create = save_obj.prepend_on_create; + try { + var form_invalid = false, + values = {}, + first_invalid_field = null, + readonly_values = {}; + for (var f in self.fields) { + if (!self.fields.hasOwnProperty(f)) { continue; } + f = self.fields[f]; + if (!f.is_valid()) { + form_invalid = true; + if (!first_invalid_field) { + first_invalid_field = f; + } + } else if (f.name !== 'id' && (!self.datarecord.id || f._dirty_flag)) { + // Special case 'id' field, do not save this field + // on 'create' : save all non readonly fields + // on 'edit' : save non readonly modified fields + if (!f.get("readonly")) { + values[f.name] = f.get_value(true); + } else { + readonly_values[f.name] = f.get_value(true); + } + } + } + if (form_invalid) { + self.set({'display_invalid_fields': true}); + first_invalid_field.focus(); + self.on_invalid(); + return $.Deferred().reject(); + } else { + self.set({'display_invalid_fields': false}); + var save_deferral; + if (!self.datarecord.id) { + // Creation save + save_deferral = self.dataset.create(values, {readonly_fields: readonly_values}).then(function(r) { + return self.record_created(r, prepend_on_create); + }, null); + } else if (_.isEmpty(values)) { + // Not dirty, noop save + save_deferral = $.Deferred().resolve({}).promise(); + } else { + // Write save + save_deferral = self.dataset.write(self.datarecord.id, values, {readonly_fields: readonly_values}).then(function(r) { + return self.record_saved(r); + }, null); + } + return save_deferral; + } + } catch (e) { + console.error(e); + return $.Deferred().reject(); + } + }, + on_invalid: function() { + var warnings = _(this.fields).chain() + .filter(function (f) { return !f.is_valid(); }) + .map(function (f) { + return _.str.sprintf('<li>%s</li>', + _.escape(f.string)); + }).value(); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("The following fields are invalid:"), warnings.join('')); + }, + /** + * Reload the form after saving + * + * @param {Object} r result of the write function. + */ + record_saved: function(r) { + this.trigger('record_saved', r); + if (!r) { + // should not happen in the server, but may happen for internal purpose + return $.Deferred().reject(); + } + return r; + }, + /** + * Updates the form' dataset to contain the new record: + * + * * Adds the newly created record to the current dataset (at the end by + * default) + * * Selects that record (sets the dataset's index to point to the new + * record's id). + * * Updates the pager and sidebar displays + * + * @param {Object} r + * @param {Boolean} [prepend_on_create=false] adds the newly created record + * at the beginning of the dataset instead of the end + */ + record_created: function(r, prepend_on_create) { + var self = this; + if (!r) { + // should not happen in the server, but may happen for internal purpose + this.trigger('record_created', r); + return $.Deferred().reject(); + } else { + this.datarecord.id = r; + if (!prepend_on_create) { + this.dataset.alter_ids(this.dataset.ids.concat([this.datarecord.id])); + this.dataset.index = this.dataset.ids.length - 1; + } else { + this.dataset.alter_ids([this.datarecord.id].concat(this.dataset.ids)); + this.dataset.index = 0; + } + this.do_update_pager(); + if (this.sidebar) { + this.sidebar.do_attachement_update(this.dataset, this.datarecord.id); + } + //openerp.log("The record has been created with id #" + this.datarecord.id); + return $.when(this.reload()).then(function () { + self.trigger('record_created', r); + return _.extend(r, {created: true}); + }); + } + }, + on_action: function (action) { + console.debug('Executing action', action); + }, + reload: function() { + var self = this; + return this.reload_mutex.exec(function() { + if (self.dataset.index === null || self.dataset.index === undefined) { + self.trigger("previous_view"); + return $.Deferred().reject().promise(); + } + if (self.dataset.index < 0) { + return $.when(self.on_button_new()); + } else { + var fields = _.keys(self.fields_view.fields); + fields.push('display_name'); + return self.dataset.read_index(fields, + { + context: { + 'bin_size': true, + 'future_display_name': true + }, + check_access_rule: true + }).then(function(r) { + self.trigger('load_record', r); + }).fail(function (){ + self.do_action('history_back'); + }); + } + }); + }, + get_widgets: function() { + return _.filter(this.getChildren(), function(obj) { + return obj instanceof common.FormWidget; + }); + }, + get_fields_values: function() { + var values = {}; + var ids = this.get_selected_ids(); + values["id"] = ids.length > 0 ? ids[0] : false; + _.each(this.fields, function(value_, key) { + values[key] = value_.get_value(); + }); + return values; + }, + get_selected_ids: function() { + var id = this.dataset.ids[this.dataset.index]; + return id ? [id] : []; + }, + recursive_save: function() { + var self = this; + return $.when(this.save()).then(function(res) { + if (self.dataset.parent_view) + return self.dataset.parent_view.recursive_save(); + }); + }, + recursive_reload: function() { + var self = this; + var pre = $.when(); + if (self.dataset.parent_view) + pre = self.dataset.parent_view.recursive_reload(); + return pre.then(function() { + return self.reload(); + }); + }, + is_dirty: function() { + return _.any(this.fields, function (value_) { + return value_._dirty_flag; + }); + }, + is_interactible_record: function() { + var id = this.datarecord.id; + if (!id) { + if (this.options.not_interactible_on_create) + return false; + } else if (typeof(id) === "string") { + if(data.BufferedDataSet.virtual_id_regex.test(id)) + return false; + } + return true; + }, + sidebar_eval_context: function () { + return $.when(this.build_eval_context()); + }, + open_defaults_dialog: function () { + var self = this; + var display = function (field, value) { + var FieldSelection = core.form_widget_registry.get('selection'), + FieldMany2One = core.form_widget_registry.get('many2one'); + + if (!value) { return value; } + if (field instanceof FieldSelection) { + return _(field.get('values')).find(function (option) { + return option[0] === value; + })[1]; + } else if (field instanceof FieldMany2One) { + return field.get_displayed(); + } + return value; + }; + var fields = _.chain(this.fields) + .map(function (field) { + var value = field.get_value(); + // ignore fields which are empty, invisible, readonly, o2m + // or m2m + if (!value + || field.get('invisible') + || field.get("readonly") + || field.field.type === 'one2many' + || field.field.type === 'many2many' + || field.field.type === 'binary' + || field.password) { + return false; + } + + return { + name: field.name, + string: field.string, + value: value, + displayed: display(field, value), + }; + }) + .compact() + .sortBy(function (field) { return field.string; }) + .value(); + var conditions = _.chain(self.fields) + .filter(function (field) { return field.field.change_default; }) + .map(function (field) { + var value = field.get_value(); + return { + name: field.name, + string: field.string, + value: value, + displayed: display(field, value), + }; + }) + .value(); + var d = new Dialog(this, { + title: _t("Set Default"), + args: { + fields: fields, + conditions: conditions + }, + buttons: [ + {text: _t("Close"), click: function () { d.close(); }}, + {text: _t("Save default"), click: function () { + var $defaults = d.$el.find('#formview_default_fields'); + var field_to_set = $defaults.val(); + if (!field_to_set) { + $defaults.parent().addClass('oe_form_invalid'); + return; + } + var condition = d.$el.find('#formview_default_conditions').val(), + all_users = d.$el.find('#formview_default_all').is(':checked'); + new data.DataSet(self, 'ir.values').call( + 'set_default', [ + self.dataset.model, + field_to_set, + self.fields[field_to_set].get_value(), + all_users, + true, + condition || false + ]).done(function () { d.close(); }); + }} + ] + }); + d.template = 'FormView.set_default'; + d.open(); + }, + register_field: function(field, name) { + this.fields[name] = field; + this.fields_order.push(name); + if (JSON.parse(field.node.attrs.default_focus || "0")) { + this.default_focus_field = field; + } + + field.on('focused', null, this.proxy('widgetFocused')) + .on('blurred', null, this.proxy('widgetBlurred')); + if (this.get_field_desc(name).translate) { + this.translatable_fields.push(field); + } + field.on('changed_value', this, function() { + if (field.is_syntax_valid()) { + this.trigger('field_changed:' + name); + } + if (field._inhibit_on_change_flag) { + return; + } + field._dirty_flag = true; + if (field.is_syntax_valid()) { + this.do_onchange(field); + this.on_form_changed(true); + this.do_notify_change(); + } + }); + }, + get_field_desc: function(field_name) { + return this.fields_view.fields[field_name]; + }, + get_field_value: function(field_name) { + return this.fields[field_name].get_value(); + }, + compute_domain: function(expression) { + return data.compute_domain(expression, this.fields); + }, + _build_view_fields_values: function() { + var a_dataset = this.dataset; + var fields_values = this.get_fields_values(); + var active_id = a_dataset.ids[a_dataset.index]; + _.extend(fields_values, { + active_id: active_id || false, + active_ids: active_id ? [active_id] : [], + active_model: a_dataset.model, + parent: {} + }); + if (a_dataset.parent_view) { + fields_values.parent = a_dataset.parent_view.get_fields_values(); + } + return fields_values; + }, + build_eval_context: function() { + var a_dataset = this.dataset; + return new data.CompoundContext(a_dataset.get_context(), this._build_view_fields_values()); + }, +}); + +/** + * Interface to be implemented by rendering engines for the form view. + */ +var FormRenderingEngineInterface = Class.extend({ + set_fields_view: function(fields_view) {}, + set_fields_registry: function(fields_registry) {}, + render_to: function($el) {}, +}); + +/** + * Default rendering engine for the form view. + * + * It is necessary to set the view using set_view() before usage. + */ +var FormRenderingEngine = FormRenderingEngineInterface.extend({ + init: function(view) { + this.view = view; + }, + set_fields_view: function(fvg) { + this.fvg = fvg; + this.version = parseFloat(this.fvg.arch.attrs.version); + if (isNaN(this.version)) { + this.version = 7.0; + } + }, + set_tags_registry: function(tags_registry) { + this.tags_registry = tags_registry; + }, + set_fields_registry: function(fields_registry) { + this.fields_registry = fields_registry; + }, + set_widgets_registry: function(widgets_registry) { + this.widgets_registry = widgets_registry; + }, + // Backward compatibility tools, current default version: v7 + process_version: function() { + if (this.version < 7.0) { + this.$form.find('form:first').wrapInner('<group col="4"/>'); + this.$form.find('page').each(function() { + if (!$(this).parents('field').length) { + $(this).wrapInner('<group col="4"/>'); + } + }); + } + }, + get_arch_fragment: function() { + var doc = $.parseXML(utils.json_node_to_xml(this.fvg.arch)).documentElement; + // IE won't allow custom button@type and will revert it to spec default : 'submit' + $('button', doc).each(function() { + $(this).attr('data-button-type', $(this).attr('type')).attr('type', 'button'); + }); + // IE's html parser is also a css parser. How convenient... + $('board', doc).each(function() { + $(this).attr('layout', $(this).attr('style')); + }); + return $('<div class="oe_form"/>').append(utils.xml_to_str(doc)); + }, + render_to: function($target) { + var self = this; + this.$target = $target; + + this.$form = this.get_arch_fragment(); + + this.process_version(); + + this.fields_to_init = []; + this.tags_to_init = []; + this.widgets_to_init = []; + this.labels = {}; + this.process(this.$form); + + this.$form.appendTo(this.$target); + + this.to_replace = []; + + _.each(this.fields_to_init, function($elem) { + var name = $elem.attr("name"); + if (!self.fvg.fields[name]) { + throw new Error(_.str.sprintf(_t("Field '%s' specified in view could not be found."), name)); + } + var obj = self.fields_registry.get_any([$elem.attr('widget'), self.fvg.fields[name].type]); + if (!obj) { + throw new Error(_.str.sprintf(_t("Widget type '%s' is not implemented"), $elem.attr('widget'))); + } + var w = new (obj)(self.view, utils.xml_to_json($elem[0])); + var $label = self.labels[$elem.attr("name")]; + if ($label) { + w.set_input_id($label.attr("for")); + } + self.alter_field(w); + self.view.register_field(w, $elem.attr("name")); + self.to_replace.push([w, $elem]); + }); + _.each(this.tags_to_init, function($elem) { + var tag_name = $elem[0].tagName.toLowerCase(); + var obj = self.tags_registry.get(tag_name); + var w = new (obj)(self.view, utils.xml_to_json($elem[0])); + self.to_replace.push([w, $elem]); + }); + _.each(this.widgets_to_init, function($elem) { + var widget_type = $elem.attr("type"); + var obj = self.widgets_registry.get(widget_type); + var w = new (obj)(self.view, utils.xml_to_json($elem[0])); + self.to_replace.push([w, $elem]); + }); + }, + init_fields: function() { + var defs = []; + _.each(this.to_replace, function(el) { + defs.push(el[0].replace(el[1])); + if (el[1].children().length) { + el[0].$el.append(el[1].children()); + } + }); + this.to_replace = []; + return $.when.apply($, defs); + }, + render_element: function(template /* dictionaries */) { + var dicts = [].slice.call(arguments).slice(1); + var dict = _.extend.apply(_, dicts); + dict['classnames'] = dict['class'] || ''; // class is a reserved word and might caused problem to Safari when used from QWeb + return $(QWeb.render(template, dict)); + }, + alter_field: function(field) { + }, + toggle_layout_debugging: function() { + if (!this.$target.has('.oe_layout_debug_cell:first').length) { + this.$target.find('[title]').removeAttr('title'); + this.$target.find('.oe_form_group_cell').each(function() { + var text = 'W:' + ($(this).attr('width') || '') + ' - C:' + $(this).attr('colspan'); + $(this).attr('title', text); + }); + } + this.$target.toggleClass('oe_layout_debugging'); + }, + process: function($tag) { + var self = this; + var tagname = $tag[0].nodeName.toLowerCase(); + if (this.tags_registry.contains(tagname)) { + this.tags_to_init.push($tag); + return (tagname === 'button') ? this.process_button($tag) : $tag; + } + var fn = self['process_' + tagname]; + if (fn) { + var args = [].slice.call(arguments); + args[0] = $tag; + return fn.apply(self, args); + } else { + // generic tag handling, just process children + $tag.children().each(function() { + self.process($(this)); + }); + self.handle_common_properties($tag, $tag); + $tag.removeAttr("modifiers"); + return $tag; + } + }, + process_button: function ($button) { + var self = this; + $button.children().each(function() { + self.process($(this)); + }); + return $button; + }, + process_widget: function($widget) { + this.widgets_to_init.push($widget); + return $widget; + }, + process_sheet: function($sheet) { + var $new_sheet = this.render_element('FormRenderingSheet', $sheet.getAttributes()); + this.handle_common_properties($new_sheet, $sheet); + var $dst = $new_sheet.find('.oe_form_sheet'); + $sheet.contents().appendTo($dst); + $sheet.before($new_sheet).remove(); + this.process($new_sheet); + }, + process_form: function($form) { + if ($form.find('> sheet').length === 0) { + $form.addClass('oe_form_nosheet'); + } + var $new_form = this.render_element('FormRenderingForm', $form.getAttributes()); + this.handle_common_properties($new_form, $form); + $form.contents().appendTo($new_form); + if ($form[0] === this.$form[0]) { + // If root element, replace it + this.$form = $new_form; + } else { + $form.before($new_form).remove(); + } + this.process($new_form); + }, + /* + * Used by direct <field> children of a <group> tag only + * This method will add the implicit <label...> for every field + * in the <group> + */ + preprocess_field: function($field) { + var self = this; + var name = $field.attr('name'), + field_colspan = parseInt($field.attr('colspan'), 10), + field_modifiers = JSON.parse($field.attr('modifiers') || '{}'); + + if ($field.attr('nolabel') === '1') + return; + $field.attr('nolabel', '1'); + var found = false; + this.$form.find('label[for="' + name + '"]').each(function(i ,el) { + $(el).parents().each(function(unused, tag) { + var name = tag.tagName.toLowerCase(); + if (name === "field" || name in self.tags_registry.map) + found = true; + }); + }); + if (found) + return; + + var $label = $('<label/>').attr({ + 'for' : name, + "modifiers": JSON.stringify({invisible: field_modifiers.invisible}), + "string": $field.attr('string'), + "help": $field.attr('help'), + "class": $field.attr('class'), + }); + $label.insertBefore($field); + if (field_colspan > 1) { + $field.attr('colspan', field_colspan - 1); + } + return $label; + }, + process_field: function($field) { + if ($field.parent().is('group')) { + // No implicit labels for normal fields, only for <group> direct children + var $label = this.preprocess_field($field); + if ($label) { + this.process($label); + } + } + this.fields_to_init.push($field); + return $field; + }, + process_group: function($group) { + var self = this; + $group.children('field').each(function() { + self.preprocess_field($(this)); + }); + var $new_group = this.render_element('FormRenderingGroup', $group.getAttributes()); + var $table; + if ($new_group.first().is('table.oe_form_group')) { + $table = $new_group; + } else if ($new_group.filter('table.oe_form_group').length) { + $table = $new_group.filter('table.oe_form_group').first(); + } else { + $table = $new_group.find('table.oe_form_group').first(); + } + + var $tr, $td, + cols = parseInt($group.attr('col') || 2, 10), + row_cols = cols; + + var children = []; + $group.children().each(function() { + var $child = $(this); + var colspan = parseInt($child.attr('colspan') || 1, 10); + var tagName = $child[0].tagName.toLowerCase(); + var $td = $('<td/>').addClass('oe_form_group_cell').attr('colspan', colspan); + var newline = tagName === 'newline'; + + // Note FME: those classes are used in layout debug mode + if ($tr && row_cols > 0 && (newline || row_cols < colspan)) { + $tr.addClass('oe_form_group_row_incomplete'); + if (newline) { + $tr.addClass('oe_form_group_row_newline'); + } + } + if (newline) { + $tr = null; + return; + } + if (!$tr || row_cols < colspan) { + $tr = $('<tr/>').addClass('oe_form_group_row').appendTo($table); + row_cols = cols; + } else if (tagName==='group') { + // When <group> <group/><group/> </group>, we need a spacing between the two groups + $td.addClass('oe_group_right'); + } + row_cols -= colspan; + + // invisibility transfer + var field_modifiers = JSON.parse($child.attr('modifiers') || '{}'); + var invisible = field_modifiers.invisible; + self.handle_common_properties($td, $("<dummy>").attr("modifiers", JSON.stringify({invisible: invisible}))); + + $tr.append($td.append($child)); + children.push($child[0]); + }); + if (row_cols && $td) { + $td.attr('colspan', parseInt($td.attr('colspan'), 10) + row_cols); + } + $group.before($new_group).remove(); + + $table.find('> tbody > tr').each(function() { + var to_compute = [], + row_cols = cols, + total = 100; + $(this).children().each(function() { + var $td = $(this), + $child = $td.children(':first'); + if ($child.attr('cell-class')) { + $td.addClass($child.attr('cell-class')); + } + switch ($child[0].tagName.toLowerCase()) { + case 'separator': + break; + case 'label': + if ($child.attr('for')) { + $td.attr('width', '1%').addClass('oe_form_group_cell_label'); + row_cols-= $td.attr('colspan') || 1; + total--; + } + break; + default: + var width = _.str.trim($child.attr('width') || ''), + iwidth = parseInt(width, 10); + if (iwidth) { + if (width.substr(-1) === '%') { + total -= iwidth; + width = iwidth + '%'; + } else { + // Absolute width + $td.css('min-width', width + 'px'); + } + $td.attr('width', width); + $child.removeAttr('width'); + row_cols-= $td.attr('colspan') || 1; + } else { + to_compute.push($td); + } + + } + }); + if (row_cols) { + var unit = Math.floor(total / row_cols); + if (!$(this).is('.oe_form_group_row_incomplete')) { + _.each(to_compute, function($td) { + var width = parseInt($td.attr('colspan'), 10) * unit; + $td.attr('width', width + '%'); + total -= width; + }); + } + } + }); + _.each(children, function(el) { + self.process($(el)); + }); + this.handle_common_properties($new_group, $group); + return $new_group; + }, + process_notebook: function($notebook) { + var self = this; + + // Extract useful info from the notebook xml declaration + var pages = []; + $notebook.find('> page').each(function() { + var $page = $(this); + var page_attrs = $page.getAttributes(); + page_attrs.id = _.uniqueId('notebook_page_'); + page_attrs.contents = $page.html(); + page_attrs.ref = $page; // reference to the current page node in the xml declaration + pages.push(page_attrs); + }); + + // Render the notebook and replace $notebook with it + var $new_notebook = self.render_element('FormRenderingNotebook', {'pages': pages}); + $notebook.before($new_notebook).remove(); + + // Bind the invisibility changers and find the page to display + var pageid_to_display; + _.each(pages, function(page) { + var $tab = $new_notebook.find('a[href=#' + page.id + ']').parent(); + var $content = $new_notebook.find('#' + page.id); + + // Case: <page autofocus="autofocus">; + // We attach the autofocus attribute to the node because it can be useful during the + // page execution + self.attach_node_attr($tab, page.ref, 'autofocus'); + self.attach_node_attr($content, page.ref, 'autofocus'); + if (!pageid_to_display && page.autofocus) { + // If multiple autofocus, keep the first one + pageid_to_display = page.id; + } + + // Case: <page attrs="{'invisible': domain}">; + self.handle_common_properties($tab, page.ref, common.NotebookInvisibilityChanger); + self.handle_common_properties($content, page.ref, common.NotebookInvisibilityChanger); + }); + if (!pageid_to_display) { + pageid_to_display = $new_notebook.find('div[role="tabpanel"]:not(.oe_form_invisible):first').attr('id'); + } + + // Display page. Note: we can't use bootstrap's show function because it is looking for + // document attached DOM, and the form view is only attached when everything is processed + $new_notebook.find('a[href=#' + pageid_to_display + ']').parent().addClass('active'); + $new_notebook.find('#' + pageid_to_display).addClass('active'); + + self.process($new_notebook.children()); + self.handle_common_properties($new_notebook, $notebook); + + return $new_notebook; + }, + process_separator: function($separator) { + var $new_separator = this.render_element('FormRenderingSeparator', $separator.getAttributes()); + $separator.before($new_separator).remove(); + this.handle_common_properties($new_separator, $separator); + return $new_separator; + }, + process_label: function($label) { + var name = $label.attr("for"), + field_orm = this.fvg.fields[name]; + var dict = { + string: $label.attr('string') || (field_orm || {}).string || '', + help: $label.attr('help') || (field_orm || {}).help || '', + _for: name ? _.uniqueId('oe-field-input-') : undefined, + }; + var align = parseFloat(dict.align); + if (isNaN(align) || align === 1) { + align = 'right'; + } else if (align === 0) { + align = 'left'; + } else { + align = 'center'; + } + dict.align = align; + var $new_label = this.render_element('FormRenderingLabel', dict); + $label.before($new_label).remove(); + this.handle_common_properties($new_label, $label); + if (name) { + this.labels[name] = $new_label; + } + return $new_label; + }, + handle_common_properties: function($new_element, $node, InvisibilityChanger) { + var str_modifiers = $node.attr("modifiers") || "{}"; + var modifiers = JSON.parse(str_modifiers); + var ic = null; + if (modifiers.invisible !== undefined) { + var InvisibilityChangerCls = InvisibilityChanger || common.InvisibilityChanger; + ic = new InvisibilityChangerCls(this.view, this.view, modifiers.invisible, $new_element); + } + $new_element.addClass($node.attr("class") || ""); + $new_element.attr('style', $node.attr('style')); + return {invisibility_changer: ic,}; + }, + /** + * Attach a node attribute to an html element to be able to use it after + * the processing of the page. + */ + attach_node_attr: function($new_element, $node, attr) { + $new_element.data(attr, $node.attr(attr)); + } +}); + +core.view_registry.add('form', FormView); + +return FormView; + +}); + diff --git a/addons/web/static/src/js/views/form_widgets.js b/addons/web/static/src/js/views/form_widgets.js new file mode 100644 index 00000000000..69150e4f199 --- /dev/null +++ b/addons/web/static/src/js/views/form_widgets.js @@ -0,0 +1,1590 @@ +odoo.define('web.form_widgets', ['web.core', 'web.crash_manager', 'web.data', 'web.datepicker', 'web.Dialog', 'web.form_common', 'web.formats', 'web.framework', 'web.Model', 'web.pyeval', 'web.session', 'web.utils'], function (require) { +"use strict"; + +var core = require('web.core'); +var crash_manager = require('web.crash_manager'); +var data = require('web.data'); +var datepicker = require('web.datepicker'); +var Dialog = require('web.Dialog'); +var common = require('web.form_common'); +var formats = require('web.formats'); +var framework = require('web.framework'); +var Model = require('web.Model'); +var pyeval = require('web.pyeval'); +var session = require('web.session'); +var utils = require('web.utils'); + +var _t = core._t; +var QWeb = core.qweb; + +var WidgetButton = common.FormWidget.extend({ + template: 'WidgetButton', + init: function(field_manager, node) { + node.attrs.type = node.attrs['data-button-type']; + this.is_stat_button = /\boe_stat_button\b/.test(node.attrs['class']); + this.icon_class = node.attrs.icon && "stat_button_icon fa " + node.attrs.icon + " fa-fw"; + this._super(field_manager, node); + this.force_disabled = false; + this.string = (this.node.attrs.string || '').replace(/_/g, ''); + if (JSON.parse(this.node.attrs.default_focus || "0")) { + // TODO fme: provide enter key binding to widgets + this.view.default_focus_button = this; + } + if (this.node.attrs.icon && (! /\//.test(this.node.attrs.icon))) { + this.node.attrs.icon = '/web/static/src/img/icons/' + this.node.attrs.icon + '.png'; + } + }, + start: function() { + this._super.apply(this, arguments); + this.view.on('view_content_has_changed', this, this.check_disable); + this.check_disable(); + this.$el.click(this.on_click); + if (this.node.attrs.help || session.debug) { + this.do_attach_tooltip(); + } + this.setupFocus(this.$el); + }, + on_click: function() { + var self = this; + this.force_disabled = true; + this.check_disable(); + this.execute_action().always(function() { + self.force_disabled = false; + self.check_disable(); + }); + }, + execute_action: function() { + var self = this; + var exec_action = function() { + if (self.node.attrs.confirm) { + var def = $.Deferred(); + var dialog = new Dialog(this, { + title: _t('Confirm'), + buttons: [ + {text: _t("Cancel"), click: function() { + this.parents('.modal').modal('hide'); + } + }, + {text: _t("Ok"), click: function() { + var self2 = this; + self.on_confirmed().always(function() { + self2.parents('.modal').modal('hide'); + }); + } + } + ], + }, $('<div/>').text(self.node.attrs.confirm)).open(); + dialog.on("closing", null, function() {def.resolve();}); + return def.promise(); + } else { + return self.on_confirmed(); + } + }; + if (!this.node.attrs.special) { + return this.view.recursive_save().then(exec_action); + } else { + return exec_action(); + } + }, + on_confirmed: function() { + var self = this; + + var context = this.build_context(); + return this.view.do_execute_action( + _.extend({}, this.node.attrs, {context: context}), + this.view.dataset, this.view.datarecord.id, function (reason) { + if (!_.isObject(reason)) { + self.view.recursive_reload(); + } + }); + }, + check_disable: function() { + var disabled = (this.force_disabled || !this.view.is_interactible_record()); + this.$el.prop('disabled', disabled); + this.$el.css('color', disabled ? 'grey' : ''); + } +}); + +var FieldChar = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: 'FieldChar', + widget_class: 'oe_form_field_char', + events: { + 'change input': 'store_dom_value', + }, + init: function (field_manager, node) { + this._super(field_manager, node); + this.password = this.node.attrs.password === 'True' || this.node.attrs.password === '1'; + }, + initialize_content: function() { + this.setupFocus(this.$('input')); + }, + store_dom_value: function () { + if (!this.get('effective_readonly') + && this.$('input').length + && this.is_syntax_valid()) { + this.internal_set_value( + this.parse_value( + this.$('input').val())); + } + }, + commit_value: function () { + this.store_dom_value(); + return this._super(); + }, + render_value: function() { + var show_value = this.format_value(this.get('value'), ''); + if (!this.get("effective_readonly")) { + this.$el.find('input').val(show_value); + } else { + if (this.password) { + show_value = new Array(show_value.length + 1).join('*'); + } + this.$(".oe_form_char_content").text(show_value); + } + }, + is_syntax_valid: function() { + if (!this.get("effective_readonly") && this.$("input").size() > 0) { + try { + this.parse_value(this.$('input').val(), ''); + return true; + } catch(e) { + return false; + } + } + return true; + }, + parse_value: function(val, def) { + return formats.parse_value(val, this, def); + }, + format_value: function(val, def) { + return formats.format_value(val, this, def); + }, + is_false: function() { + return this.get('value') === '' || this._super(); + }, + focus: function() { + var input = this.$('input:first')[0]; + return input ? input.focus() : false; + }, + set_dimensions: function (height, width) { + this._super(height, width); + this.$('input').css({ + height: height, + width: width + }); + } +}); + +var KanbanSelection = FieldChar.extend({ + init: function (field_manager, node) { + this._super(field_manager, node); + }, + start: function () { + var self = this; + this.states = []; + this._super.apply(this, arguments); + // hook on form view content changed: recompute the states, because it may be related to the current stage + this.getParent().on('view_content_has_changed', self, function () { + self.render_value(); + }); + }, + prepare_dropdown_selection: function() { + var self = this; + var _data = []; + var fetch_stage; + var selection = this.field.selection || []; + var stage_id = _.isArray(this.view.datarecord.stage_id) ? this.view.datarecord.stage_id[0] : this.view.datarecord.stage_id; + var legend_field = this.options && this.options.states_legend_field || false; + var fields_to_read = _.map( + this.options && this.options.states_legend || {}, + function (value, key, list) { return value; }); + if (legend_field && fields_to_read && stage_id) { + fetch_stage = new data.DataSet( + this, + self.view.fields[legend_field].field.relation).read_ids([stage_id], + fields_to_read); + } + else { fetch_stage = $.Deferred().resolve(false); } + return $.when(fetch_stage).then(function (stage_data) { + _.map(selection, function(res) { + var value = { + 'name': res[0], + 'tooltip': res[1], + 'state_name': res[1], + }; + if (stage_data && stage_data[0][self.options.states_legend[res[0]]]) { + value['state_name'] = stage_data[0][self.options.states_legend[res[0]]]; + value['tooltip'] = stage_data[0][self.options.states_legend[res[0]]]; + } + if (res[0] == 'normal') { value['state_class'] = 'oe_kanban_status'; } + else if (res[0] == 'done') { value['state_class'] = 'oe_kanban_status oe_kanban_status_green'; } + else { value['state_class'] = 'oe_kanban_status oe_kanban_status_red'; } + _data.push(value); + }); + return _data; + }); + }, + render_value: function() { + var self = this; + var dd_fetched = this.prepare_dropdown_selection(); + return $.when(dd_fetched).then(function (states) { + self.states = states; + self.$el.html(QWeb.render("KanbanSelection", {'widget': self})); + self.$el.find('li').on('click', self.set_kanban_selection.bind(self)); + }); + }, + /* setting the value: in view mode, perform an asynchronous call and reload + the form view; in edit mode, use set_value to save the new value that will + be written when saving the record. */ + set_kanban_selection: function (ev) { + var self = this; + var li = $(ev.target).closest('li'); + if (li.length) { + var value = String(li.data('value')); + if (this.view.get('actual_mode') == 'view') { + var write_values = {}; + write_values[self.name] = value; + return this.view.dataset._model.call( + 'write', [ + [this.view.datarecord.id], + write_values, + self.view.dataset.get_context() + ]).done(self.reload_record.bind(self)); + } + else { + return this.set_value(value); + } + } + }, + reload_record: function() { + this.view.reload(); + }, +}); + +var Priority = FieldChar.extend({ + init: function (field_manager, node) { + this._super(field_manager, node); + }, + prepare_priority: function() { + var self = this; + var selection = this.field.selection || []; + var init_value = selection && selection[0][0] || 0; + var data = _.map(selection.slice(1), function(element, index) { + var value = { + 'value': element[0], + 'name': element[1], + 'click_value': element[0], + }; + if (index === 0 && self.get('value') === element[0]) { + value['click_value'] = init_value; + } + return value; + }); + return data; + }, + render_value: function() { + this.priorities = this.prepare_priority(); + this.$el.html(QWeb.render("Priority", {'widget': this})); + if (!this.get('readonly')){ + this.$el.find('li').on('click', this.set_priority.bind(this)); + } + }, + /* setting the value: in view mode, perform an asynchronous call and reload + the form view; in edit mode, use set_value to save the new value that will + be written when saving the record. */ + set_priority: function (ev) { + var self = this; + var li = $(ev.target).closest('li'); + if (li.length) { + var value = String(li.data('value')); + if (this.view.get('actual_mode') == 'view') { + var write_values = {}; + write_values[self.name] = value; + return this.view.dataset._model.call( + 'write', [ + [this.view.datarecord.id], + write_values, + self.view.dataset.get_context() + ]).done(self.reload_record.bind(self)); + } + else { + return this.set_value(value); + } + } + + }, + reload_record: function() { + this.view.reload(); + }, +}); + +var FieldID = FieldChar.extend({ + process_modifiers: function () { + this._super(); + this.set({ readonly: true }); + }, +}); + +var FieldEmail = FieldChar.extend({ + template: 'FieldEmail', + initialize_content: function() { + this._super(); + var $button = this.$el.find('button'); + $button.click(this.on_button_clicked); + this.setupFocus($button); + }, + render_value: function() { + if (!this.get("effective_readonly")) { + this._super(); + } else { + this.$el.find('a') + .attr('href', 'mailto:' + this.get('value')) + .text(this.get('value') || ''); + } + }, + on_button_clicked: function() { + if (!this.get('value') || !this.is_syntax_valid()) { + this.do_warn(_t("E-mail Error"), _t("Can't send email to invalid e-mail address")); + } else { + location.href = 'mailto:' + this.get('value'); + } + } +}); + +var FieldUrl = FieldChar.extend({ + template: 'FieldUrl', + initialize_content: function() { + this._super(); + var $button = this.$el.find('button'); + $button.click(this.on_button_clicked); + this.setupFocus($button); + }, + render_value: function() { + if (!this.get("effective_readonly")) { + this._super(); + } else { + var tmp = this.get('value'); + var s = /(\w+):(.+)|^\.{0,2}\//.exec(tmp); + if (!s) { + tmp = "http://" + this.get('value'); + } + var text = this.get('value') ? this.node.attrs.text || tmp : ''; + this.$el.find('a').attr('href', tmp).text(text); + } + }, + on_button_clicked: function() { + if (!this.get('value')) { + this.do_warn(_t("Resource Error"), _t("This resource is empty")); + } else { + var url = $.trim(this.get('value')); + if(/^www\./i.test(url)) + url = 'http://'+url; + window.open(url); + } + } +}); + +var FieldFloat = FieldChar.extend({ + is_field_number: true, + widget_class: 'oe_form_field_float', + init: function (field_manager, node) { + this._super(field_manager, node); + this.internal_set_value(0); + if (this.node.attrs.digits) { + this.digits = this.node.attrs.digits; + } else { + this.digits = this.field.digits; + } + }, + set_value: function(value_) { + if (value_ === false || value_ === undefined) { + // As in GTK client, floats default to 0 + value_ = 0; + } + if (this.digits !== undefined && this.digits.length === 2) { + value_ = utils.round_decimals(value_, this.digits[1]); + } + this._super.apply(this, [value_]); + }, + focus: function () { + var $input = this.$('input:first'); + return $input.length ? $input.select() : false; + } +}); + +var FieldCharDomain = common.AbstractField.extend(common.ReinitializeFieldMixin, { + render_value: function() { + var self = this; + this.$el.html(QWeb.render("FieldCharDomain", {widget: this})); + if (this.get('value')) { + var model = this.options.model || this.field_manager.get_field_value(this.options.model_field); + var domain = pyeval.eval('domain', this.get('value')); + var ds = new data.DataSetStatic(self, model, self.build_context()); + ds.call('search_count', [domain]).then(function (results) { + $('.oe_domain_count', self.$el).text(results + ' records selected'); + if (self.get('effective_readonly')) { + $('button span', self.$el).text(' See selection'); + } + else { + $('button span', self.$el).text(' Change selection'); + } + }); + } else { + $('.oe_domain_count', this.$el).text('0 record selected'); + $('button span', this.$el).text(' Select records'); + } + this.$('.select_records').on('click', self.on_click); + }, + on_click: function(event) { + event.preventDefault(); + var self = this; + var model = this.options.model || this.field_manager.get_field_value(this.options.model_field); + + var options = { + title: this.get('effective_readonly') ? 'Selected records' : 'Select records...', + readonly: this.get('effective_readonly'), + disable_multiple_selection: this.get('effective_readonly'), + no_create: this.get('effective_readonly'), + }; + var domain = this.get('value'); + var popup = new common.DomainEditorPopup(this); + popup.select_element(model, options, domain, {}); + popup.on("elements_selected", self, function(selected_ids) { + if (!self.get('effective_readonly')) { + self.set_value(popup.get_domain(selected_ids)); + } + }); + }, +}); + +var FieldDatetime = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: "FieldDatetime", + build_widget: function() { + return new datepicker.DateTimeWidget(this); + }, + destroy_content: function() { + if (this.datewidget) { + this.datewidget.destroy(); + this.datewidget = undefined; + } + }, + initialize_content: function() { + if (!this.get("effective_readonly")) { + this.datewidget = this.build_widget(); + this.datewidget.on('datetime_changed', this, _.bind(function() { + this.internal_set_value(this.datewidget.get_value()); + }, this)); + this.datewidget.appendTo(this.$el); + this.setupFocus(this.datewidget.$input); + } + }, + render_value: function() { + if (!this.get("effective_readonly")) { + this.datewidget.set_value(this.get('value')); + } else { + this.$el.text(formats.format_value(this.get('value'), this, '')); + } + }, + is_syntax_valid: function() { + if (!this.get("effective_readonly") && this.datewidget) { + return this.datewidget.is_valid_(); + } + return true; + }, + is_false: function() { + return this.get('value') === '' || this._super(); + }, + focus: function() { + var input = this.datewidget && this.datewidget.$input[0]; + return input ? input.focus() : false; + }, + set_dimensions: function (height, width) { + this._super(height, width); + if (!this.get("effective_readonly")) { + this.datewidget.$input.css('height', height); + } + } +}); + +var FieldDate = FieldDatetime.extend({ + template: "FieldDate", + build_widget: function() { + return new datepicker.DateWidget(this); + } +}); + +var FieldText = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: 'FieldText', + events: { + 'keyup': function (e) { + if (e.which === $.ui.keyCode.ENTER) { + e.stopPropagation(); + } + }, + 'keypress': function (e) { + if (e.which === $.ui.keyCode.ENTER) { + e.stopPropagation(); + } + }, + 'change textarea': 'store_dom_value', + }, + initialize_content: function() { + if (! this.get("effective_readonly")) { + this.$textarea = this.$el.find('textarea'); + this.auto_sized = false; + this.default_height = this.$textarea.css('height'); + if (this.default_height === '0px') this.default_height = '90px'; + if (this.get("effective_readonly")) { + this.$textarea.attr('disabled', 'disabled'); + } + this.setupFocus(this.$textarea); + } else { + this.$textarea = undefined; + } + }, + commit_value: function () { + if (! this.get("effective_readonly") && this.$textarea) { + this.store_dom_value(); + } + return this._super(); + }, + store_dom_value: function () { + this.internal_set_value(formats.parse_value(this.$textarea.val(), this)); + }, + render_value: function() { + if (! this.get("effective_readonly")) { + var show_value = formats.format_value(this.get('value'), this, ''); + if (show_value === '') { + this.$textarea.css('height', parseInt(this.default_height, 10)+"px"); + } + this.$textarea.val(show_value); + if (! this.auto_sized) { + this.auto_sized = true; + this.$textarea.autosize(); + } else { + this.$textarea.trigger("autosize"); + } + } else { + var txt = this.get("value") || ''; + this.$(".oe_form_text_content").text(txt); + } + }, + is_syntax_valid: function() { + if (!this.get("effective_readonly") && this.$textarea) { + try { + formats.parse_value(this.$textarea.val(), this, ''); + return true; + } catch(e) { + return false; + } + } + return true; + }, + is_false: function() { + return this.get('value') === '' || this._super(); + }, + focus: function($el) { + var input = !this.get("effective_readonly") && this.$textarea && this.$textarea[0]; + return input ? input.focus() : false; + }, + set_dimensions: function (height, width) { + this._super(height, width); + if (!this.get("effective_readonly") && this.$textarea) { + this.$textarea.css({ + width: width, + minHeight: height + }); + } + }, +}); + +/** + * FieldTextHtml Widget + * Intended for FieldText widgets meant to display HTML content. This + * widget will instantiate the CLEditor (see cleditor in static/src/lib) + * To find more information about CLEditor configutation: go to + * http://premiumsoftware.net/cleditor/docs/GettingStarted.html + */ +var FieldTextHtml = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: 'FieldTextHtml', + init: function() { + this._super.apply(this, arguments); + }, + initialize_content: function() { + var self = this; + if (! this.get("effective_readonly")) { + self._updating_editor = false; + this.$textarea = this.$el.find('textarea'); + var width = ((this.node.attrs || {}).editor_width || 'calc(100% - 4px)'); + var height = ((this.node.attrs || {}).editor_height || 250); + this.$textarea.cleditor({ + width: width, // width not including margins, borders or padding + height: height, // height not including margins, borders or padding + controls: // controls to add to the toolbar + "bold italic underline strikethrough " + + "| removeformat | bullets numbering | outdent " + + "indent | link unlink | source", + bodyStyle: // style to assign to document body contained within the editor + "margin:4px; color:#4c4c4c; font-size:13px; font-family:'Lucida Grande',Helvetica,Verdana,Arial,sans-serif; cursor:text" + }); + this.$cleditor = this.$textarea.cleditor()[0]; + this.$cleditor.change(function() { + if (! self._updating_editor) { + self.$cleditor.updateTextArea(); + self.internal_set_value(self.$textarea.val()); + } + }); + if (this.field.translate) { + var $img = $('<img class="oe_field_translate oe_input_icon" src="/web/static/src/img/icons/terp-translate.png" width="16" height="16" border="0"/>') + .click(this.on_translate); + this.$cleditor.$toolbar.append($img); + } + } + }, + render_value: function() { + if (! this.get("effective_readonly")) { + this.$textarea.val(this.get('value') || ''); + this._updating_editor = true; + this.$cleditor.updateFrame(); + this._updating_editor = false; + } else { + this.$el.html(this.get('value')); + } + }, +}); + +var FieldBoolean = common.AbstractField.extend({ + template: 'FieldBoolean', + start: function() { + var self = this; + this.$checkbox = $("input", this.$el); + this.setupFocus(this.$checkbox); + this.$el.click(_.bind(function() { + this.internal_set_value(this.$checkbox.is(':checked')); + }, this)); + var check_readonly = function() { + self.$checkbox.prop('disabled', self.get("effective_readonly")); + self.click_disabled_boolean(); + }; + this.on("change:effective_readonly", this, check_readonly); + check_readonly.call(this); + this._super.apply(this, arguments); + }, + render_value: function() { + this.$checkbox[0].checked = this.get('value'); + }, + focus: function() { + var input = this.$checkbox && this.$checkbox[0]; + return input ? input.focus() : false; + }, + click_disabled_boolean: function(){ + var $disabled = this.$el.find('input[type=checkbox]:disabled'); + $disabled.each(function (){ + $(this).next('div').remove(); + $(this).closest("span").append($('<div class="boolean"></div>')); + }); + } +}); + +/** + The progressbar field expect a float from 0 to 100. +*/ +var FieldProgressBar = common.AbstractField.extend({ + template: 'FieldProgressBar', + render_value: function() { + this.$el.progressbar({ + value: this.get('value') || 0, + disabled: this.get("effective_readonly") + }); + var formatted_value = formats.format_value(this.get('value') || 0, { type : 'float' }); + this.$('span').html(formatted_value + '%'); + } +}); + +/** + The PercentPie field expect a float from 0 to 100. +*/ +var FieldPercentPie = common.AbstractField.extend({ + template: 'FieldPercentPie', + + render_value: function() { + var value = this.get('value'), + formatted_value = Math.round(value || 0) + '%', + svg = this.$('svg')[0]; + + svg.innerHTML = ""; + nv.addGraph(function() { + var width = 42, height = 42; + var chart = nv.models.pieChart() + .width(width) + .height(height) + .margin({top: 0, right: 0, bottom: 0, left: 0}) + .donut(true) + .showLegend(false) + .showLabels(false) + .tooltips(false) + .color(['#7C7BAD','#DDD']) + .donutRatio(0.62); + + d3.select(svg) + .datum([{'x': 'value', 'y': value}, {'x': 'complement', 'y': 100 - value}]) + .transition() + .call(chart) + .attr('style', 'width: ' + width + 'px; height:' + height + 'px;'); + + d3.select(svg) + .append("text") + .attr({x: width/2, y: height/2 + 3, 'text-anchor': 'middle'}) + .style({"font-size": "10px", "font-weight": "bold"}) + .text(formatted_value); + + return chart; + }); + + } +}); + +/** + The FieldBarChart expectsa list of values (indeed) +*/ +var FieldBarChart = common.AbstractField.extend({ + template: 'FieldBarChart', + + render_value: function() { + var value = JSON.parse(this.get('value')); + var svg = this.$('svg')[0]; + svg.innerHTML = ""; + nv.addGraph(function() { + var width = 34, height = 34; + var chart = nv.models.discreteBarChart() + .x(function (d) { return d.tooltip; }) + .y(function (d) { return d.value; }) + .width(width) + .height(height) + .margin({top: 0, right: 0, bottom: 0, left: 0}) + .tooltips(false) + .showValues(false) + .transitionDuration(350) + .showXAxis(false) + .showYAxis(false); + + d3.select(svg) + .datum([{key: 'values', values: value}]) + .transition() + .call(chart) + .attr('style', 'width: ' + (width + 4) + 'px; height: ' + (height + 8) + 'px;'); + + nv.utils.windowResize(chart.update); + + return chart; + }); + + } +}); + + +var FieldSelection = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: 'FieldSelection', + events: { + 'change select': 'store_dom_value', + }, + init: function(field_manager, node) { + this._super(field_manager, node); + this.set("value", false); + this.set("values", []); + this.records_orderer = new utils.DropMisordered(); + this.field_manager.on("view_content_has_changed", this, function() { + var domain = new data.CompoundDomain(this.build_domain()).eval(); + if (! _.isEqual(domain, this.get("domain"))) { + this.set("domain", domain); + } + }); + }, + initialize_field: function() { + common.ReinitializeFieldMixin.initialize_field.call(this); + this.on("change:domain", this, this.query_values); + this.set("domain", new data.CompoundDomain(this.build_domain()).eval()); + this.on("change:values", this, this.render_value); + }, + query_values: function() { + var self = this; + var def; + if (this.field.type === "many2one") { + var model = new Model(this.field.relation); + def = model.call("name_search", ['', this.get("domain")], {"context": this.build_context()}); + } else { + var values = _.reject(this.field.selection, function (v) { return v[0] === false && v[1] === ''; }); + def = $.when(values); + } + this.records_orderer.add(def).then(function(values) { + if (! _.isEqual(values, self.get("values"))) { + self.set("values", values); + } + }); + }, + initialize_content: function() { + // Flag indicating whether we're in an event chain containing a change + // event on the select, in order to know what to do on keyup[RETURN]: + // * If the user presses [RETURN] as part of changing the value of a + // selection, we should just let the value change and not let the + // event broadcast further (e.g. to validating the current state of + // the form in editable list view, which would lead to saving the + // current row or switching to the next one) + // * If the user presses [RETURN] with a select closed (side-effect: + // also if the user opened the select and pressed [RETURN] without + // changing the selected value), takes the action as validating the + // row + var ischanging = false; + var $select = this.$el.find('select') + .change(function () { ischanging = true; }) + .click(function () { ischanging = false; }) + .keyup(function (e) { + if (e.which !== 13 || !ischanging) { return; } + e.stopPropagation(); + ischanging = false; + }); + this.setupFocus($select); + }, + commit_value: function () { + this.store_dom_value(); + return this._super(); + }, + store_dom_value: function () { + if (!this.get('effective_readonly') && this.$('select').length) { + var val = JSON.parse(this.$('select').val()); + this.internal_set_value(val); + } + }, + set_value: function(value_) { + value_ = value_ === null ? false : value_; + value_ = value_ instanceof Array ? value_[0] : value_; + this._super(value_); + }, + render_value: function() { + var values = this.get("values"); + values = [[false, this.node.attrs.placeholder || '']].concat(values); + var found = _.find(values, function(el) { return el[0] === this.get("value"); }, this); + if (! found) { + found = [this.get("value"), _t('Unknown')]; + values = [found].concat(values); + } + if (! this.get("effective_readonly")) { + this.$().html(QWeb.render("FieldSelectionSelect", {widget: this, values: values})); + this.$("select").val(JSON.stringify(found[0])); + } else { + this.$el.text(found[1]); + } + }, + focus: function() { + var input = this.$('select:first')[0]; + return input ? input.focus() : false; + }, + set_dimensions: function (height, width) { + this._super(height, width); + this.$('select').css({ + height: height, + width: width + }); + } +}); + +var FieldRadio = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: 'FieldRadio', + events: { + 'click input': 'click_change_value' + }, + init: function(field_manager, node) { + /* Radio button widget: Attributes options: + * - "horizontal" to display in column + * - "no_radiolabel" don't display text values + */ + this._super(field_manager, node); + this.selection = _.clone(this.field.selection) || []; + this.domain = false; + this.uniqueId = _.uniqueId("radio"); + }, + initialize_content: function () { + this.on("change:effective_readonly", this, this.render_value); + this.field_manager.on("view_content_has_changed", this, this.get_selection); + this.get_selection(); + }, + click_change_value: function (event) { + var val = $(event.target).val(); + val = this.field.type == "selection" ? val : +val; + if (val !== this.get_value()) { + this.set_value(val); + } + }, + /** Get the selection and render it + * selection: [[identifier, value_to_display], ...] + * For selection fields: this is directly given by this.field.selection + * For many2one fields: perform a search on the relation of the many2one field + */ + get_selection: function() { + var self = this; + var selection = []; + var def = $.Deferred(); + if (self.field.type == "many2one") { + var domain = pyeval.eval('domain', this.build_domain()) || []; + if (! _.isEqual(self.domain, domain)) { + self.domain = domain; + var ds = new data.DataSetStatic(self, self.field.relation, self.build_context()); + ds.call('search', [self.domain]) + .then(function (records) { + ds.name_get(records).then(function (records) { + selection = records; + def.resolve(); + }); + }); + } else { + selection = self.selection; + def.resolve(); + } + } + else if (self.field.type == "selection") { + selection = self.field.selection || []; + def.resolve(); + } + return def.then(function () { + if (! _.isEqual(selection, self.selection)) { + self.selection = _.clone(selection); + self.renderElement(); + self.render_value(); + } + }); + }, + set_value: function (value_) { + if (this.field.type == "selection") { + value_ = _.find(this.field.selection, function (sel) { return sel[0] == value_;}); + } + else if (!this.selection.length) { + this.selection = [value_]; + } + + this._super(value_); + }, + get_value: function () { + var value = this.get('value'); + return value instanceof Array ? value[0] : value; + }, + render_value: function () { + var self = this; + this.$el.toggleClass("oe_readonly", this.get('effective_readonly')); + this.$("input").filter(function () {return this.value == self.get_value();}).prop("checked", true); + this.$(".oe_radio_readonly").text(this.get('value') ? this.get('value')[1] : ""); + } +}); + +var FieldReference = common.AbstractField.extend(common.ReinitializeFieldMixin, { + template: 'FieldReference', + init: function(field_manager, node) { + this._super(field_manager, node); + this.reference_ready = true; + }, + destroy_content: function() { + if (this.fm) { + this.fm.destroy(); + this.fm = undefined; + } + }, + initialize_content: function() { + var self = this; + var fm = new common.DefaultFieldManager(this); + this.fm = fm; + fm.extend_field_desc({ + "selection": { + selection: this.field_manager.get_field_desc(this.name).selection, + type: "selection", + }, + "m2o": { + relation: null, + type: "many2one", + }, + }); + this.selection = new FieldSelection(fm, { attrs: { + name: 'selection', + modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), + }}); + this.selection.on("change:value", this, this.on_selection_changed); + this.selection.appendTo(this.$(".oe_form_view_reference_selection")); + this.selection + .on('focused', null, function () {self.trigger('focused');}) + .on('blurred', null, function () {self.trigger('blurred');}); + + var FieldMany2One = core.form_widget_registry.get('many2one'); + this.m2o = new FieldMany2One(fm, { attrs: { + name: 'Referenced Document', + modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), + }}); + this.m2o.on("change:value", this, this.data_changed); + this.m2o.appendTo(this.$(".oe_form_view_reference_m2o")); + this.m2o + .on('focused', null, function () {self.trigger('focused');}) + .on('blurred', null, function () {self.trigger('blurred');}); + }, + on_selection_changed: function() { + if (this.reference_ready) { + this.internal_set_value([this.selection.get_value(), false]); + this.render_value(); + } + }, + data_changed: function() { + if (this.reference_ready) { + this.internal_set_value([this.selection.get_value(), this.m2o.get_value()]); + } + }, + set_value: function(val) { + if (val) { + val = val.split(','); + val[0] = val[0] || false; + val[1] = val[0] ? (val[1] ? parseInt(val[1], 10) : val[1]) : false; + } + this._super(val || [false, false]); + }, + get_value: function() { + return this.get('value')[0] && this.get('value')[1] ? (this.get('value')[0] + ',' + this.get('value')[1]) : false; + }, + render_value: function() { + this.reference_ready = false; + if (!this.get("effective_readonly")) { + this.selection.set_value(this.get('value')[0]); + } + this.m2o.field.relation = this.get('value')[0]; + this.m2o.set_value(this.get('value')[1]); + this.m2o.$el.toggle(!!this.get('value')[0]); + this.reference_ready = true; + }, +}); + +var FieldBinary = common.AbstractField.extend(common.ReinitializeFieldMixin, { + init: function(field_manager, node) { + var self = this; + this._super(field_manager, node); + this.binary_value = false; + this.useFileAPI = !!window.FileReader; + this.max_upload_size = 25 * 1024 * 1024; // 25Mo + if (!this.useFileAPI) { + this.fileupload_id = _.uniqueId('oe_fileupload'); + $(window).on(this.fileupload_id, function() { + var args = [].slice.call(arguments).slice(1); + self.on_file_uploaded.apply(self, args); + }); + } + }, + stop: function() { + if (!this.useFileAPI) { + $(window).off(this.fileupload_id); + } + this._super.apply(this, arguments); + }, + initialize_content: function() { + var self= this; + this.$el.find('input.oe_form_binary_file').change(this.on_file_change); + this.$el.find('button.oe_form_binary_file_save').click(this.on_save_as); + this.$el.find('.oe_form_binary_file_clear').click(this.on_clear); + this.$el.find('.oe_form_binary_file_edit').click(function(event){ + self.$el.find('input.oe_form_binary_file').click(); + }); + }, + on_file_change: function(e) { + var self = this; + var file_node = e.target; + if ((this.useFileAPI && file_node.files.length) || (!this.useFileAPI && $(file_node).val() !== '')) { + if (this.useFileAPI) { + var file = file_node.files[0]; + if (file.size > this.max_upload_size) { + var msg = _t("The selected file exceed the maximum file size of %s."); + core.bus.trigger('display_notification_warning', _t("File upload"), _.str.sprintf(msg, utils.human_size(this.max_upload_size))); + return false; + } + var filereader = new FileReader(); + filereader.readAsDataURL(file); + filereader.onloadend = function(upload) { + var data = upload.target.result; + data = data.split(',')[1]; + self.on_file_uploaded(file.size, file.name, file.type, data); + }; + } else { + this.$el.find('form.oe_form_binary_form input[name=session_id]').val(this.session.session_id); + this.$el.find('form.oe_form_binary_form').submit(); + } + this.$el.find('.oe_form_binary_progress').show(); + this.$el.find('.oe_form_binary').hide(); + } + }, + on_file_uploaded: function(size, name, content_type, file_base64) { + if (size === false) { + this.do_warn(_t("File Upload"), _t("There was a problem while uploading your file")); + // TODO: use openerp web crashmanager + console.warn("Error while uploading file : ", name); + } else { + this.filename = name; + this.on_file_uploaded_and_valid.apply(this, arguments); + } + this.$el.find('.oe_form_binary_progress').hide(); + this.$el.find('.oe_form_binary').show(); + }, + on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { + }, + on_save_as: function(ev) { + var value = this.get('value'); + if (!value) { + this.do_warn(_t("Save As..."), _t("The field is empty, there's nothing to save !")); + ev.stopPropagation(); + } else { + framework.blockUI(); + var c = crash_manager; + var filename_fieldname = this.node.attrs.filename; + var filename_field = this.view.fields && this.view.fields[filename_fieldname]; + this.session.get_file({ + url: '/web/binary/saveas_ajax', + data: {data: JSON.stringify({ + model: this.view.dataset.model, + id: (this.view.datarecord.id || ''), + field: this.name, + filename_field: (filename_fieldname || ''), + data: utils.is_bin_size(value) ? null : value, + filename: filename_field ? filename_field.get('value') : null, + context: this.view.dataset.get_context() + })}, + complete: framework.unblockUI, + error: c.rpc_error.bind(c) + }); + ev.stopPropagation(); + return false; + } + }, + set_filename: function(value) { + var filename = this.node.attrs.filename; + if (filename) { + var tmp = {}; + tmp[filename] = value; + this.field_manager.set_values(tmp); + } + }, + on_clear: function() { + if (this.get('value') !== false) { + this.binary_value = false; + this.internal_set_value(false); + } + return false; + } +}); + +var FieldBinaryFile = FieldBinary.extend({ + template: 'FieldBinaryFile', + initialize_content: function() { + this._super(); + if (this.get("effective_readonly")) { + var self = this; + this.$el.find('a').click(function(ev) { + if (self.get('value')) { + self.on_save_as(ev); + } + return false; + }); + } + }, + render_value: function() { + var show_value; + if (!this.get("effective_readonly")) { + if (this.node.attrs.filename) { + show_value = this.view.datarecord[this.node.attrs.filename] || ''; + } else { + show_value = (this.get('value') !== null && this.get('value') !== undefined && this.get('value') !== false) ? this.get('value') : ''; + } + this.$el.find('input').eq(0).val(show_value); + } else { + this.$el.find('a').toggle(!!this.get('value')); + if (this.get('value')) { + show_value = _t("Download"); + if (this.view) + show_value += " " + (this.view.datarecord[this.node.attrs.filename] || ''); + this.$el.find('a').text(show_value); + } + } + }, + on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { + this.binary_value = true; + this.internal_set_value(file_base64); + var show_value = name + " (" + utils.human_size(size) + ")"; + this.$el.find('input').eq(0).val(show_value); + this.set_filename(name); + }, + on_clear: function() { + this._super.apply(this, arguments); + this.$el.find('input').eq(0).val(''); + this.set_filename(''); + } +}); + +var FieldBinaryImage = FieldBinary.extend({ + template: 'FieldBinaryImage', + placeholder: "/web/static/src/img/placeholder.png", + render_value: function() { + var self = this; + var url; + this.session = session; + if (this.get('value') && !utils.is_bin_size(this.get('value'))) { + url = 'data:image/png;base64,' + this.get('value'); + } else if (this.get('value')) { + var id = JSON.stringify(this.view.datarecord.id || null); + var field = this.name; + if (this.options.preview_image) + field = this.options.preview_image; + url = session.url('/web/binary/image', { + model: this.view.dataset.model, + id: id, + field: field, + t: (new Date().getTime()), + }); + } else { + url = this.placeholder; + } + var $img = $(QWeb.render("FieldBinaryImage-img", { widget: this, url: url })); + $($img).click(function(e) { + if(self.view.get("actual_mode") == "view") { + var $button = $(".oe_form_button_edit"); + $button.openerpBounce(); + e.stopPropagation(); + } + }); + this.$el.find('> img').remove(); + this.$el.prepend($img); + $img.load(function() { + if (! self.options.size) + return; + $img.css("max-width", "" + self.options.size[0] + "px"); + $img.css("max-height", "" + self.options.size[1] + "px"); + }); + $img.on('error', function() { + $img.attr('src', self.placeholder); + core.bus.trigger('display_notification_warning', _t("Image"), _t("Could not display the selected image.")); + }); + }, + on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { + this.internal_set_value(file_base64); + this.binary_value = true; + this.render_value(); + this.set_filename(name); + }, + on_clear: function() { + this._super.apply(this, arguments); + this.render_value(); + this.set_filename(''); + }, + set_value: function(value_){ + var changed = value_ !== this.get_value(); + this._super.apply(this, arguments); + // By default, on binary images read, the server returns the binary size + // This is possible that two images have the exact same size + // Therefore we trigger the change in case the image value hasn't changed + // So the image is re-rendered correctly + if (!changed){ + this.trigger("change:value", this, { + oldValue: value_, + newValue: value_ + }); + } + } +}); + +var FieldStatus = common.AbstractField.extend({ + template: "FieldStatus", + init: function(field_manager, node) { + this._super(field_manager, node); + this.options.clickable = this.options.clickable || (this.node.attrs || {}).clickable || false; + this.options.visible = this.options.visible || (this.node.attrs || {}).statusbar_visible || false; + this.set({value: false}); + this.selection = {'unfolded': [], 'folded': []}; + this.set("selection", {'unfolded': [], 'folded': []}); + this.selection_dm = new utils.DropMisordered(); + this.dataset = new data.DataSetStatic(this, this.field.relation, this.build_context()); + }, + start: function() { + this.field_manager.on("view_content_has_changed", this, this.calc_domain); + this.calc_domain(); + this.on("change:value", this, this.get_selection); + this.on("change:evaluated_selection_domain", this, this.get_selection); + this.on("change:selection", this, function() { + this.selection = this.get("selection"); + this.render_value(); + }); + this.get_selection(); + if (this.options.clickable) { + this.$el.on('click','li[data-id]',this.on_click_stage); + } + if (this.$el.parent().is('header')) { + this.$el.after('<div class="oe_clear"/>'); + } + this._super(); + }, + set_value: function(value_) { + if (value_ instanceof Array) { + value_ = value_[0]; + } + this._super(value_); + }, + render_value: function() { + var self = this; + var content = QWeb.render("FieldStatus.content", { + 'widget': self, + 'value_folded': _.find(self.selection.folded, function(i){return i[0] === self.get('value');}) + }); + self.$el.html(content); + }, + calc_domain: function() { + var d = pyeval.eval('domain', this.build_domain()); + var domain = []; //if there is no domain defined, fetch all the records + + if (d.length) { + domain = ['|',['id', '=', this.get('value')]].concat(d); + } + + if (! _.isEqual(domain, this.get("evaluated_selection_domain"))) { + this.set("evaluated_selection_domain", domain); + } + }, + /** Get the selection and render it + * selection: [[identifier, value_to_display], ...] + * For selection fields: this is directly given by this.field.selection + * For many2one fields: perform a search on the relation of the many2one field + */ + get_selection: function() { + var self = this; + var selection_unfolded = []; + var selection_folded = []; + var fold_field = this.options.fold_field; + + var calculation = _.bind(function() { + if (this.field.type == "many2one") { + return self.get_distant_fields().then(function (fields) { + return new data.DataSetSearch(self, self.field.relation, self.build_context(), self.get("evaluated_selection_domain")) + .read_slice(_.union(_.keys(self.distant_fields), ['id']), {}).then(function (records) { + var ids = _.pluck(records, 'id'); + return self.dataset.name_get(ids).then(function (records_name) { + _.each(records, function (record) { + var name = _.find(records_name, function (val) {return val[0] == record.id;})[1]; + if (fold_field && record[fold_field] && record.id != self.get('value')) { + selection_folded.push([record.id, name]); + } else { + selection_unfolded.push([record.id, name]); + } + }); + }); + }); + }); + } else { + // For field type selection filter values according to + // statusbar_visible attribute of the field. For example: + // statusbar_visible="draft,open". + var select = this.field.selection; + for(var i=0; i < select.length; i++) { + var key = select[i][0]; + if(key == this.get('value') || !this.options.visible || this.options.visible.indexOf(key) != -1) { + selection_unfolded.push(select[i]); + } + } + return $.when(); + } + }, this); + this.selection_dm.add(calculation()).then(function () { + var selection = {'unfolded': selection_unfolded, 'folded': selection_folded}; + if (! _.isEqual(selection, self.get("selection"))) { + self.set("selection", selection); + } + }); + }, + /* + * :deprecated: this feature will probably be removed with OpenERP v8 + */ + get_distant_fields: function() { + var self = this; + if (! this.options.fold_field) { + this.distant_fields = {}; + } + if (this.distant_fields) { + return $.when(this.distant_fields); + } + return new Model(self.field.relation).call("fields_get", [[this.options.fold_field]]).then(function(fields) { + self.distant_fields = fields; + return fields; + }); + }, + on_click_stage: function (ev) { + var self = this; + var $li = $(ev.currentTarget); + var val; + if (this.field.type == "many2one") { + val = parseInt($li.data("id"), 10); + } + else { + val = $li.data("id"); + } + if (val != self.get('value')) { + this.view.recursive_save().done(function() { + var change = {}; + change[self.name] = val; + self.view.dataset.write(self.view.datarecord.id, change).done(function() { + self.view.reload(); + }); + }); + } + }, +}); + +var FieldMonetary = FieldFloat.extend({ + template: "FieldMonetary", + widget_class: 'oe_form_field_float oe_form_field_monetary', + init: function() { + this._super.apply(this, arguments); + this.set({"currency": false}); + if (this.options.currency_field) { + this.field_manager.on("field_changed:" + this.options.currency_field, this, function() { + this.set({"currency": this.field_manager.get_field_value(this.options.currency_field)}); + }); + } + this.on("change:currency", this, this.get_currency_info); + this.get_currency_info(); + this.ci_dm = new utils.DropMisordered(); + }, + start: function() { + var tmp = this._super(); + this.on("change:currency_info", this, this.reinitialize); + return tmp; + }, + get_currency_info: function() { + var self = this; + if (this.get("currency") === false) { + this.set({"currency_info": null}); + return; + } + return this.ci_dm.add(self.alive(new Model("res.currency").query(["symbol", "position"]) + .filter([["id", "=", self.get("currency")]]).first())).then(function(res) { + self.set({"currency_info": res}); + }); + }, + parse_value: function(val, def) { + return formats.parse_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def); + }, + format_value: function(val, def) { + return formats.format_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def); + }, +}); + +/** + This widget is intended to be used on stat button numeric fields. It will display + the value many2many and one2many. It is a read-only field that will + display a simple string "<value of field> <label of the field>" +*/ +var StatInfo = common.AbstractField.extend({ + is_field_number: true, + init: function() { + this._super.apply(this, arguments); + this.internal_set_value(0); + }, + set_value: function(value_) { + if (value_ === false || value_ === undefined) { + value_ = 0; + } + this._super.apply(this, [value_]); + }, + render_value: function() { + var options = { + value: this.get("value") || 0, + }; + if (! this.node.attrs.nolabel) { + if(this.options.label_field && this.view.datarecord[this.options.label_field]) { + options.text = this.view.datarecord[this.options.label_field]; + } + else { + options.text = this.string; + } + } + this.$el.html(QWeb.render("StatInfo", options)); + }, + +}); + +/** + This widget is intended to be used on boolean fields. It toggles a button + switching between a green bullet / gray bullet. +*/ +var FieldToggleBoolean = common.AbstractField.extend({ + render_value: function () { + this.icon = this.get_value() ? 'gtk-yes.png' : 'gtk-normal.png'; + this.$el.html(QWeb.render("FieldToggleBoolean", {'widget': this})); + this.$('.oe_toggle_button').on('click', this.set_toggle_button.bind(this)); + }, + set_toggle_button: function () { + var self = this; + var toggle_value = this.get_value() === false ? true: false; + if (this.view.get('actual_mode') == 'view') { + var rec_values = {}; + rec_values[self.node.attrs.name] = toggle_value; + return this.view.dataset._model.call( + 'write', [ + [this.view.datarecord.id], + rec_values, + self.view.dataset.get_context() + ]).done(function () { self.reload_record(); }); + } + else { + this.set_value(toggle_value); + } + }, + reload_record: function () { + this.view.reload(); + }, +}); + + +/** + * Registry of form fields, called by :js:`instance.web.FormView`. + * + * All referenced classes must implement FieldInterface. Those represent the classes whose instances + * will substitute to the <field> tags as defined in OpenERP's views. + */ +core.form_widget_registry + .add('char', FieldChar) + .add('id', FieldID) + .add('email', FieldEmail) + .add('url', FieldUrl) + .add('text',FieldText) + .add('html', FieldTextHtml) + .add('char_domain', FieldCharDomain) + .add('date', FieldDate) + .add('datetime', FieldDatetime) + .add('selection', FieldSelection) + .add('radio', FieldRadio) + .add('reference', FieldReference) + .add('boolean', FieldBoolean) + .add('toggle_button', FieldToggleBoolean) + .add('float', FieldFloat) + .add('percentpie', FieldPercentPie) + .add('barchart', FieldBarChart) + .add('integer', FieldFloat) + .add('float_time', FieldFloat) + .add('progressbar', FieldProgressBar) + .add('image', FieldBinaryImage) + .add('binary', FieldBinaryFile) + .add('statusbar', FieldStatus) + .add('monetary', FieldMonetary) + .add('priority', Priority) + .add('kanban_state_selection', KanbanSelection) + .add('statinfo', StatInfo); + + +/** + * Registry of widgets usable in the form view that can substitute to any possible + * tags defined in OpenERP's form views. + * + * Every referenced class should extend FormWidget. + */ +core.form_tag_registry.add('button', WidgetButton); + +return { + FieldChar: FieldChar, +}; + +}); diff --git a/addons/web/static/src/js/views/graph_view.js b/addons/web/static/src/js/views/graph_view.js new file mode 100644 index 00000000000..26826b19aac --- /dev/null +++ b/addons/web/static/src/js/views/graph_view.js @@ -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; + +}); diff --git a/addons/web/static/src/js/view_graph.js b/addons/web/static/src/js/views/graph_widget.js index 385f347e307..b06ab42f346 100644 --- a/addons/web/static/src/js/view_graph.js +++ b/addons/web/static/src/js/views/graph_widget.js @@ -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(); -}; - - -})(); +}); diff --git a/addons/web/static/src/js/views/list_common.js b/addons/web/static/src/js/views/list_common.js new file mode 100644 index 00000000000..e2e9ec1eb41 --- /dev/null +++ b/addons/web/static/src/js/views/list_common.js @@ -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 +}; + +}); diff --git a/addons/web/static/src/js/view_list.js b/addons/web/static/src/js/views/list_view.js index 97c4abda05f..ef875e51679 100644 --- a/addons/web/static/src/js/view_list.js +++ b/addons/web/static/src/js/views/list_view.js @@ -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; +}); + + diff --git a/addons/web/static/src/js/views/list_view_editable.js b/addons/web/static/src/js/views/list_view_editable.js new file mode 100644 index 00000000000..c4262fbce97 --- /dev/null +++ b/addons/web/static/src/js/views/list_view_editable.js @@ -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; + +}); diff --git a/addons/web/static/src/js/view_pivot.js b/addons/web/static/src/js/views/pivot_view.js index 1f12347fb42..8ca6e2976fb 100644 --- a/addons/web/static/src/js/view_pivot.js +++ b/addons/web/static/src/js/views/pivot_view.js @@ -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; + +}); + diff --git a/addons/web/static/src/js/views/search_filters.js b/addons/web/static/src/js/views/search_filters.js new file mode 100644 index 00000000000..dfc0d0bf4bc --- /dev/null +++ b/addons/web/static/src/js/views/search_filters.js @@ -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 +}; + +}); diff --git a/addons/web/static/src/js/views/search_inputs.js b/addons/web/static/src/js/views/search_inputs.js new file mode 100644 index 00000000000..97678cc60c8 --- /dev/null +++ b/addons/web/static/src/js/views/search_inputs.js @@ -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, +}; + +}); diff --git a/addons/web/static/src/js/views/search_menus.js b/addons/web/static/src/js/views/search_menus.js new file mode 100644 index 00000000000..56df4657c48 --- /dev/null +++ b/addons/web/static/src/js/views/search_menus.js @@ -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); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/search_view.js b/addons/web/static/src/js/views/search_view.js new file mode 100644 index 00000000000..a459dfe666b --- /dev/null +++ b/addons/web/static/src/js/views/search_view.js @@ -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; + }, +}); +}); diff --git a/addons/web/static/src/js/view_tree.js b/addons/web/static/src/js/views/tree_view.js index 45cc183511a..7c3950a5407 100644 --- a/addons/web/static/src/js/view_tree.js +++ b/addons/web/static/src/js/views/tree_view.js @@ -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; + +}); + diff --git a/addons/web/static/src/js/watch.js b/addons/web/static/src/js/watch.js deleted file mode 100644 index efa80a1d529..00000000000 --- a/addons/web/static/src/js/watch.js +++ /dev/null @@ -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; - } - }); -} - -})(); - diff --git a/addons/web/static/src/js/web_client.js b/addons/web/static/src/js/web_client.js new file mode 100644 index 00000000000..e4d943ce6bf --- /dev/null +++ b/addons/web/static/src/js/web_client.js @@ -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; + +}); diff --git a/addons/web/static/src/js/widgets/change_password.js b/addons/web/static/src/js/widgets/change_password.js new file mode 100644 index 00000000000..d1b7e98c74e --- /dev/null +++ b/addons/web/static/src/js/widgets/change_password.js @@ -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; +}); diff --git a/addons/web/static/src/js/data_export.js b/addons/web/static/src/js/widgets/data_export.js index b36a927307a..73ec37cdd17 100644 --- a/addons/web/static/src/js/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -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; +}); diff --git a/addons/web/static/src/js/widgets/database_manager.js b/addons/web/static/src/js/widgets/database_manager.js new file mode 100644 index 00000000000..9bd5b4d05c3 --- /dev/null +++ b/addons/web/static/src/js/widgets/database_manager.js @@ -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; + +}); diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js new file mode 100644 index 00000000000..7e242cf1d6e --- /dev/null +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -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, +}; +}); diff --git a/addons/web/static/src/js/widgets/loading.js b/addons/web/static/src/js/widgets/loading.js new file mode 100644 index 00000000000..75ac1a8b4b8 --- /dev/null +++ b/addons/web/static/src/js/widgets/loading.js @@ -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; +}); + diff --git a/addons/web/static/src/js/widgets/notification.js b/addons/web/static/src/js/widgets/notification.js new file mode 100644 index 00000000000..d99ef75b324 --- /dev/null +++ b/addons/web/static/src/js/widgets/notification.js @@ -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; + +}); diff --git a/addons/web/static/src/js/widgets/sidebar.js b/addons/web/static/src/js/widgets/sidebar.js new file mode 100644 index 00000000000..7005ec2012e --- /dev/null +++ b/addons/web/static/src/js/widgets/sidebar.js @@ -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; + +}); + diff --git a/addons/web/static/src/js/widgets/systray_menu.js b/addons/web/static/src/js/widgets/systray_menu.js new file mode 100644 index 00000000000..eb218a51bc7 --- /dev/null +++ b/addons/web/static/src/js/widgets/systray_menu.js @@ -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; + +}); + diff --git a/addons/web/static/src/js/widgets/user_menu.js b/addons/web/static/src/js/widgets/user_menu.js new file mode 100644 index 00000000000..450b0191486 --- /dev/null +++ b/addons/web/static/src/js/widgets/user_menu.js @@ -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; +}); |
