diff --git a/addons/web/static/src/img/k-waiting1.gif b/addons/web/static/src/img/k-waiting1.gif deleted file mode 100644 index b76cdfaac23..00000000000 Binary files a/addons/web/static/src/img/k-waiting1.gif and /dev/null differ diff --git a/addons/web/static/src/img/k-waiting2.gif b/addons/web/static/src/img/k-waiting2.gif deleted file mode 100644 index b0b79e80830..00000000000 Binary files a/addons/web/static/src/img/k-waiting2.gif and /dev/null differ 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} 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} 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(); + $('')[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); + "use strict"; - var inited = false; + var jobs = []; + var factories = Object.create(null); + var job_names = []; + var job_deps = []; - _.extend(openerp, { - // Per session namespace - // openerp. will map to - // openerp.instances.sessionname. using a closure - instances: {instance0: openerp}, - // links to the global openerp - _openerp: openerp, - // this unique id will be replaced by hostname_databasename by - // openerp.web.Session on the first connection - _session_id: "instance0", - _modules: openerp._modules || ['web'], - web_mobile: {}, - /** - * OpenERP instance constructor - * - * @param {Array|String} modules list of modules to initialize - */ - init: function(modules) { - if (modules === undefined) { - modules = openerp._modules; - } - modules = _.without(modules, "web"); - if (inited) - throw new Error("OpenERP was already inited"); - inited = true; - for(var i=0; i < modules.length; i++) { - var fct = openerp[modules[i]]; - if (typeof(fct) === "function") { - openerp[modules[i]] = {}; - for (var k in fct) { - openerp[modules[i]][k] = fct[k]; - } - fct(openerp, openerp[modules[i]]); + var services = Object.create({ + qweb: new QWeb2.Engine(), + $: $, + _: _, + }); + + var debug = ($.deparam($.param.querystring()).debug !== undefined); + + var odoo = window.odoo = { + testing: typeof QUnit === "object", + debug: debug, + + __DEBUG__: { + get_dependencies: function (name, transitive) { + var deps = name instanceof Array ? name: [name], + changed; + do { + changed = false; + _.each(job_deps, function (dep) { + if (_.contains(deps, dep.to) && (!_.contains(deps, dep.from))) { + deps.push(dep.from); + changed = true; + } + }); + } while (changed && transitive) + return deps; + }, + get_dependents: function (name) { + return _.pluck(_.where(job_deps, {from: name}), 'to'); + }, + factories: factories, + services: services, + }, + define: function () { + var args = Array.prototype.slice.call(arguments), + name = typeof args[0] === 'string' ? args.shift() : _.uniqueId('__job'), + deps = args[0] instanceof Array ? args.shift() : [], + factory = args[0]; + + if (odoo.debug) { + if (!(deps instanceof Array)) { + throw new Error ('Dependencies should be defined by an array', deps); + } + if (typeof factory !== 'function') { + throw new Error ('Factory should be defined by a function', factory); + } + if (typeof name !== 'string') { + throw new Error("Invalid name definition (should be a string", name); + } + if (name in factories) { + throw new Error("Service " + name + " already defined"); } } - openerp._modules = ['web'].concat(modules); - return openerp; - } - }); -})(); + factory.deps = deps; + factories[name] = factory; + + jobs.push({ + name: name, + factory: factory, + deps: deps, + }); + + job_names.push(name); + _.each(deps, function (dep) { + job_deps.push({from:dep, to:name}); + }); + + this.process_jobs(jobs, services); + }, + init: function () { + odoo.__DEBUG__.remaining_jobs = jobs; + odoo.__DEBUG__.web_client = services['web.web_client']; + + if (jobs.length) { + console.warn('Warning: not all jobs could be started.', jobs); + } + // _.each(factories, function (value, key) { + // delete factories[key]; + // }); + }, + process_jobs: function (jobs, services) { + var job, require; + while (jobs.length && (job = _.find(jobs, is_ready))) { + require = make_require(job); + + services[job.name] = job.factory.call(null, require); + if (require.__require_calls !== job.deps.length) { + console.warn('Job ' + job.name + ' did not require all its dependencies'); + } + jobs.splice(jobs.indexOf(job), 1); + } + return services; + + function is_ready (job) { + return _.every(job.factory.deps, function (name) { return name in services; }); + } + + function make_require (job) { + var deps = _.pick(services, job.deps); + + function require (name) { + if (!(name in deps)) { + console.error('Undefined dependency: ', name); + } else { + require.__require_calls++; + } + return deps[name]; + } + + require.__require_calls = 0; + return require; + } + } + }; + +})(); 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