1 /*---------------------------------------------------------
3 *---------------------------------------------------------*/
8 var instance = openerp;
9 openerp.web.views = {};
10 var QWeb = instance.web.qweb,
13 instance.web.ActionManager = instance.web.Widget.extend({
14 template: "ActionManager",
15 init: function(parent) {
17 this.inner_action = null;
18 this.inner_widget = null;
19 this.webclient = parent;
21 this.dialog_widget = null;
23 this.on('history_back', this, this.proxy('history_back'));
25 dialog_stop: function (reason) {
27 this.dialog.destroy(reason);
32 * Add a new widget to the action manager
34 * widget: typically, widgets added are instance.web.ViewManager. The action manager
35 * uses this list of widget to handle the breadcrumbs.
37 * options.on_reverse_breadcrumb: will be called when breadcrumb is selected
38 * options.clear_breadcrumbs: boolean, if true, current widgets are destroyed
39 * options.replace_breadcrumb: boolean, if true, replace current breadcrumb
41 push_widget: function(widget, action, options) {
44 options = options || {},
45 old_widget = this.inner_widget;
47 if (options.clear_breadcrumbs) {
48 to_destroy = this.widgets;
50 } else if (options.replace_breadcrumb) {
51 to_destroy = _.last(this.widgets);
52 this.widgets = _.initial(this.widgets);
54 if (widget instanceof instance.web.Widget) {
55 var title = widget.get('title') || action.display_name || action.name;
56 widget.set('title', title);
57 this.widgets.push(widget);
61 controller: {get: function () {return action.display_name || action.name; }},
63 destroy: function () {},
66 _.last(this.widgets).__on_reverse_breadcrumb = options.on_reverse_breadcrumb;
67 this.inner_action = action;
68 this.inner_widget = widget;
69 return $.when(this.inner_widget.appendTo(this.$el)).done(function () {
70 (action.target !== 'inline') && (!action.flags.headless) && widget.$header && widget.$header.show();
71 old_widget && old_widget.$el.hide();
72 if (options.clear_breadcrumbs) {
73 self.clear_widgets(to_destroy)
77 get_breadcrumbs: function () {
78 return _.flatten(_.map(this.widgets, function (widget) {
79 if (widget instanceof instance.web.ViewManager) {
80 return widget.view_stack.map(function (view, index) {
82 title: view.controller.get('title') || widget.title,
88 return {title: widget.get('title'), widget: widget };
92 get_title: function () {
93 if (this.widgets.length === 1) {
94 // horrible hack to display the action title instead of "New" for the actions
95 // that use a form view to edit something that do not correspond to a real model
96 // for example, point of sale "Your Session" or most settings form,
97 var widget = this.widgets[0];
98 if (widget instanceof instance.web.ViewManager && widget.view_stack.length === 1) {
102 return _.pluck(this.get_breadcrumbs(), 'title').join(' / ');
104 get_widgets: function () {
105 return this.widgets.slice(0);
107 history_back: function() {
108 var widget = _.last(this.widgets);
109 if (widget instanceof instance.web.ViewManager) {
110 var nbr_views = widget.view_stack.length;
112 return this.select_widget(widget, nbr_views - 2);
115 if (this.widgets.length > 1) {
116 widget = this.widgets[this.widgets.length - 2];
117 var index = widget.view_stack && widget.view_stack.length - 1;
118 this.select_widget(widget, index);
121 select_widget: function(widget, index) {
123 if (this.webclient.has_uncommitted_changes()) {
126 var widget_index = this.widgets.indexOf(widget),
127 def = $.when(widget.select_view && widget.select_view(index));
129 def.done(function () {
130 if (widget.__on_reverse_breadcrumb) {
131 widget.__on_reverse_breadcrumb();
133 _.each(self.widgets.splice(widget_index + 1), function (w) {
136 self.inner_widget = _.last(self.widgets);
137 self.inner_widget.display_breadcrumbs && self.inner_widget.display_breadcrumbs();
138 self.inner_widget.do_show && self.inner_widget.do_show();
141 clear_widgets: function(widgets) {
142 _.invoke(widgets || this.widgets, 'destroy');
145 this.inner_widget = null;
148 do_push_state: function(state) {
149 if (!this.webclient || !this.webclient.do_push_state || this.dialog) {
153 if (this.inner_action) {
154 if (this.inner_action._push_me === false) {
155 // this action has been explicitly marked as not pushable
158 state.title = this.get_title();
159 if(this.inner_action.type == 'ir.actions.act_window') {
160 state.model = this.inner_action.res_model;
162 if (this.inner_action.menu_id) {
163 state.menu_id = this.inner_action.menu_id;
165 if (this.inner_action.id) {
166 state.action = this.inner_action.id;
167 } else if (this.inner_action.type == 'ir.actions.client') {
168 state.action = this.inner_action.tag;
170 _.each(this.inner_action.params, function(v, k) {
171 if(_.isString(v) || _.isNumber(v)) {
175 state = _.extend(params || {}, state);
177 if (this.inner_action.context) {
178 var active_id = this.inner_action.context.active_id;
180 state.active_id = active_id;
182 var active_ids = this.inner_action.context.active_ids;
183 if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) {
184 // We don't push active_ids if it's a single element array containing the active_id
185 // This makes the url shorter in most cases.
186 state.active_ids = this.inner_action.context.active_ids.join(',');
190 this.webclient.do_push_state(state);
192 do_load_state: function(state, warm) {
196 if (_.isString(state.action) && instance.web.client_actions.contains(state.action)) {
197 var action_client = {
198 type: "ir.actions.client",
201 _push_me: state._push_me,
206 action_loaded = this.do_action(action_client);
208 var run_action = (!this.inner_widget || !this.inner_widget.action) || this.inner_widget.action.id !== state.action;
210 var add_context = {};
211 if (state.active_id) {
212 add_context.active_id = state.active_id;
214 if (state.active_ids) {
215 // The jQuery BBQ plugin does some parsing on values that are valid integers.
216 // It means that if there's only one item, it will do parseInt() on it,
217 // otherwise it will keep the comma seperated list as string.
218 add_context.active_ids = state.active_ids.toString().split(',').map(function(id) {
219 return parseInt(id, 10) || id;
221 } else if (state.active_id) {
222 add_context.active_ids = [state.active_id];
224 add_context.params = state;
228 action_loaded = this.do_action(state.action, { additional_context: add_context });
229 $.when(action_loaded || null).done(function() {
230 instance.webclient.menu.is_bound.done(function() {
231 if (self.inner_action && self.inner_action.id) {
232 instance.webclient.menu.open_action(self.inner_action.id);
238 } else if (state.model && state.id) {
239 // TODO handle context & domain ?
244 res_model: state.model,
246 type: 'ir.actions.act_window',
247 views: [[false, 'form']]
249 action_loaded = this.do_action(action);
250 } else if (state.sa) {
251 // load session action
255 action_loaded = this.rpc('/web/session/get_session_action', {key: state.sa}).then(function(action) {
257 return self.do_action(action);
262 $.when(action_loaded || null).done(function() {
263 if (self.inner_widget && self.inner_widget.do_load_state) {
264 self.inner_widget.do_load_state(state, warm);
269 * Execute an OpenERP action
271 * @param {Number|String|Object} Can be either an action id, a client action or an action descriptor.
272 * @param {Object} [options]
273 * @param {Boolean} [options.clear_breadcrumbs=false] Clear the breadcrumbs history list
274 * @param {Boolean} [options.replace_breadcrumb=false] Replace the current breadcrumb with the action
275 * @param {Function} [options.on_reverse_breadcrumb] Callback to be executed whenever an anterior breadcrumb item is clicked on.
276 * @param {Function} [options.hide_breadcrumb] Do not display this widget's title in the breadcrumb
277 * @param {Function} [options.on_close] Callback to be executed when the dialog is closed (only relevant for target=new actions)
278 * @param {Function} [options.action_menu_id] Manually set the menu id on the fly.
279 * @param {Object} [options.additional_context] Additional context to be merged with the action's context.
280 * @return {jQuery.Deferred} Action loaded
282 do_action: function(action, options) {
283 options = _.defaults(options || {}, {
284 clear_breadcrumbs: false,
285 on_reverse_breadcrumb: function() {},
286 hide_breadcrumb: false,
287 on_close: function() {},
288 action_menu_id: null,
289 additional_context: {},
292 if (action === false) {
293 action = { type: 'ir.actions.act_window_close' };
294 } else if (_.isString(action) && instance.web.client_actions.contains(action)) {
295 var action_client = { type: "ir.actions.client", tag: action, params: {} };
296 return this.do_action(action_client, options);
297 } else if (_.isNumber(action) || _.isString(action)) {
299 var additional_context = {
300 active_id : options.additional_context.active_id,
301 active_ids : options.additional_context.active_ids,
302 active_model : options.additional_context.active_model
304 return self.rpc("/web/action/load", { action_id: action, additional_context : additional_context }).then(function(result) {
305 return self.do_action(result, options);
309 instance.web.bus.trigger('action', action);
311 // Ensure context & domain are evaluated and can be manipulated/used
312 var ncontext = new instance.web.CompoundContext(options.additional_context, action.context || {});
313 action.context = instance.web.pyeval.eval('context', ncontext);
314 if (action.context.active_id || action.context.active_ids) {
315 // Here we assume that when an `active_id` or `active_ids` is used
316 // in the context, we are in a `related` action, so we disable the
317 // searchview's default custom filters.
318 action.context.search_disable_custom_filters = true;
321 action.domain = instance.web.pyeval.eval(
322 'domain', action.domain, action.context || {});
326 console.error("No type for action", action);
327 return $.Deferred().reject();
329 var type = action.type.replace(/\./g,'_');
330 var popup = action.target === 'new';
331 var inline = action.target === 'inline' || action.target === 'inlineview';
332 var form = _.str.startsWith(action.view_mode, 'form');
333 action.flags = _.defaults(action.flags || {}, {
334 views_switcher : !popup && !inline,
335 search_view : !popup && !inline,
336 action_buttons : !popup && !inline,
337 sidebar : !popup && !inline,
338 pager : (!popup || !form) && !inline,
339 display_title : !popup,
340 search_disable_custom_filters: action.context && action.context.search_disable_custom_filters
342 action.menu_id = options.action_menu_id;
343 if (!(type in this)) {
344 console.error("Action manager can't handle action of type " + action.type, action);
345 return $.Deferred().reject();
347 return this[type](action, options);
349 null_action: function() {
351 this.clear_widgets();
355 * @param {Object} executor
356 * @param {Object} executor.action original action
357 * @param {Function<instance.web.Widget>} executor.widget function used to fetch the widget instance
358 * @param {String} executor.klass CSS class to add on the dialog root, if action.target=new
359 * @param {Function<instance.web.Widget, undefined>} executor.post_process cleanup called after a widget has been added as inner_widget
360 * @param {Object} options
363 ir_actions_common: function(executor, options) {
365 if (executor.action.target === 'new') {
366 var pre_dialog = (this.dialog && !this.dialog.isDestroyed()) ? this.dialog : null;
368 // prevent previous dialog to consider itself closed,
369 // right now, as we're opening a new one (prevents
370 // reload of original form view)
371 pre_dialog.off('closing', null, pre_dialog.on_close);
373 if (this.dialog_widget && !this.dialog_widget.isDestroyed()) {
374 this.dialog_widget.destroy();
376 // explicitly passing a closing action to dialog_stop() prevents
377 // it from reloading the original form view
378 this.dialog_stop(executor.action);
379 this.dialog = new instance.web.Dialog(this, {
380 title: executor.action.name,
381 dialogClass: executor.klass,
384 // chain on_close triggers with previous dialog, if any
385 this.dialog.on_close = function(){
386 options.on_close.apply(null, arguments);
387 if (pre_dialog && pre_dialog.on_close){
388 // no parameter passed to on_close as this will
389 // only be called when the last dialog is truly
390 // closing, and *should* trigger a reload of the
391 // underlying form view (see comments above)
392 pre_dialog.on_close();
395 this.dialog.on("closing", null, this.dialog.on_close);
396 widget = executor.widget();
397 if (widget instanceof instance.web.ViewManager) {
398 _.extend(widget.flags, {
399 $buttons: this.dialog.$buttons,
400 footer_to_buttons: true,
402 if (widget.action.view_mode === 'form') {
403 widget.flags.headless = true;
406 this.dialog_widget = widget;
407 this.dialog_widget.setParent(this.dialog);
408 var initialized = this.dialog_widget.appendTo(this.dialog.$el);
412 if (this.inner_widget && this.webclient.has_uncommitted_changes()) {
413 return $.Deferred().reject();
415 widget = executor.widget();
416 this.dialog_stop(executor.action);
417 return this.push_widget(widget, executor.action, options);
419 ir_actions_act_window: function (action, options) {
422 return this.ir_actions_common({
423 widget: function () {
424 return new instance.web.ViewManager(self, null, null, null, action);
427 klass: 'oe_act_window',
430 ir_actions_client: function (action, options) {
432 var ClientWidget = instance.web.client_actions.get_object(action.tag);
434 return self.do_warn("Action Error", "Could not find client action '" + action.tag + "'.");
437 if (!(ClientWidget.prototype instanceof instance.web.Widget)) {
439 if ((next = ClientWidget(this, action))) {
440 return this.do_action(next, options);
445 return this.ir_actions_common({
446 widget: function () { return new ClientWidget(self, action); },
448 klass: 'oe_act_client',
449 }, options).then(function () {
450 if (action.tag !== 'reload') {self.do_push_state({});}
453 ir_actions_act_window_close: function (action, options) {
460 ir_actions_server: function (action, options) {
462 this.rpc('/web/action/run', {
463 action_id: action.id,
464 context: action.context || {}
465 }).done(function (action) {
466 self.do_action(action, options);
469 ir_actions_report_xml: function(action, options) {
471 instance.web.blockUI();
472 action = _.clone(action);
473 var eval_contexts = ([instance.session.user_context] || []).concat([action.context]);
474 action.context = instance.web.pyeval.eval('contexts',eval_contexts);
476 // iOS devices doesn't allow iframe use the way we do it,
477 // opening a new window seems the best way to workaround
478 if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
480 action: JSON.stringify(action),
481 token: new Date().getTime()
483 var url = self.session.url('/web/report', params);
484 instance.web.unblockUI();
485 $('<a href="'+url+'" target="_blank"></a>')[0].click();
488 var c = instance.webclient.crashmanager;
489 return $.Deferred(function (d) {
490 self.session.get_file({
492 data: {action: JSON.stringify(action)},
493 complete: instance.web.unblockUI,
502 c.rpc_error.apply(c, arguments);
508 ir_actions_act_url: function (action) {
509 if (action.target === 'self') {
510 instance.web.redirect(action.url);
512 window.open(action.url, '_blank');
518 instance.web.ViewManager = instance.web.Widget.extend({
519 template: "ViewManager",
521 * @param {Object} [dataset] null object (... historical reasons)
522 * @param {Array} [views] List of [view_id, view_type]
523 * @param {Object} [flags] various boolean describing UI state
525 init: function(parent, dataset, views, flags, action) {
527 var flags = action.flags || {};
528 if (!('auto_search' in flags)) {
529 flags.auto_search = action.auto_search !== false;
531 if (action.res_model === 'board.board' && action.view_mode === 'form') {
532 action.target = 'inline';
533 // Special case for Dashboards
535 views_switcher : false,
536 display_title : false,
540 action_buttons : false
543 this.action = action;
544 this.action_manager = parent;
545 var dataset = new instance.web.DataSetSearch(this, action.res_model, action.context, action.domain);
547 dataset.ids.push(action.res_id);
550 views = action.views;
555 this.flags = flags || {};
556 this.dataset = dataset;
557 this.view_order = [];
558 this.url_states = {};
560 this.view_stack = []; // used for breadcrumbs
561 this.active_view = null;
562 this.searchview = null;
563 this.active_search = null;
564 this.registry = instance.web.views;
565 this.title = this.action && this.action.name;
567 _.each(views, function (view) {
568 var view_type = view[1] || view.view_type,
569 View = instance.web.views.get_object(view_type, true),
570 view_label = View ? View.prototype.display_name: (void 'nope'),
573 options: view.options || {},
574 view_id: view[0] || view.view_id,
577 embedded_view: view.embedded_view,
578 title: self.action && self.action.name,
579 button_label: View ? _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}) : (void 'nope'),
581 self.view_order.push(view);
582 self.views[view_type] = view;
584 this.multiple_views = (self.view_order.length - ('form' in this.views ? 1 : 0)) > 1;
587 * @returns {jQuery.Deferred} initial view loading promise
591 var default_view = this.flags.default_view || this.view_order[0].type,
592 default_options = this.flags[default_view] && this.flags[default_view].options;
594 if (this.flags.headless) {
595 this.$('.oe-view-manager-header').hide();
598 var $sidebar = this.flags.sidebar ? this.$('.oe-view-manager-sidebar') : undefined,
599 $pager = this.$('.oe-view-manager-pager');
601 this.$breadcrumbs = this.$('.oe-view-title');
602 this.$switch_buttons = this.$('.oe-view-manager-switch button');
603 this.$header = this.$('.oe-view-manager-header');
604 this.$header_col = this.$header.find('.oe-header-title');
605 this.$search_col = this.$header.find('.oe-view-manager-search-view');
606 this.$switch_buttons.click(function (event) {
607 if (!$(event.target).hasClass('active')) {
608 self.switch_mode($(this).data('view-type'));
612 _.each(this.views, function (view) {
613 views_ids[view.type] = view.view_id;
614 view.options = _.extend({
615 $buttons: self.$('.oe-' + view.type + '-buttons'),
618 action : self.action,
619 action_views_ids : views_ids,
620 }, self.flags, self.flags[view.type], view.options);
621 if (view.type !== 'form') {
622 self.$('.oe-vm-switch-' + view.type).tooltip();
625 this.$('.oe_debug_view').click(this.on_debug_changed);
626 this.$el.addClass("oe_view_manager_" + ((this.action && this.action.target) || 'current'));
628 this.search_view_loaded = this.setup_search_view();
629 var main_view_loaded = this.switch_mode(default_view, null, default_options);
631 return $.when(main_view_loaded, this.search_view_loaded);
634 switch_mode: function(view_type, no_store, view_options) {
636 view = this.views[view_type];
639 return $.Deferred().reject();
641 if (view_type !== 'form') {
642 this.view_stack = [];
645 this.view_stack.push(view);
646 this.active_view = view;
648 view.created = this.create_view.bind(this)(view);
650 this.active_search = $.Deferred();
653 && this.flags.auto_search
654 && view.controller.searchable !== false) {
655 $.when(this.search_view_loaded, view.created).done(this.searchview.do_search);
657 this.active_search.resolve();
660 self.update_header();
661 return $.when(view.created, this.active_search).done(function () {
662 self.active_view = view;
663 self._display_view(view.type, view_options);
664 self.trigger('switch_mode', view_type, no_store, view_options);
665 if (self.session.debug) {
666 self.$('.oe_debug_view').html(QWeb.render('ViewManagerDebug', {
667 view: self.active_view.controller,
673 update_header: function () {
674 this.$switch_buttons.removeClass('active');
675 this.$('.oe-vm-switch-' + this.active_view.type).addClass('active');
677 _display_view: function (view_type, view_options) {
679 this.active_view.$container.show();
680 $.when(this.active_view.controller.do_show(view_options)).done(function () {
681 _.each(self.views, function (view) {
682 if (view.type !== view_type) {
683 view.controller && view.controller.do_hide();
684 view.$container && view.$container.hide();
685 view.options.$buttons && view.options.$buttons.hide();
688 self.active_view.options.$buttons && self.active_view.options.$buttons.show();
689 if (self.searchview) {
690 var is_hidden = self.active_view.controller.searchable === false;
691 self.searchview.toggle_visibility(!is_hidden);
692 self.$header_col.toggleClass('col-md-6', !is_hidden).toggleClass('col-md-12', is_hidden);
693 self.$search_col.toggle(!is_hidden);
695 self.display_breadcrumbs();
698 display_breadcrumbs: function () {
700 if (!this.action_manager) return;
701 var breadcrumbs = this.action_manager.get_breadcrumbs();
702 if (!breadcrumbs.length) return;
703 var $breadcrumbs = _.map(_.initial(breadcrumbs), function (bc) {
704 var $link = $('<a>').text(bc.title);
705 $link.click(function () {
706 self.action_manager.select_widget(bc.widget, bc.index);
708 return $('<li>').append($link);
710 $breadcrumbs.push($('<li>').addClass('active').text(_.last(breadcrumbs).title));
713 .append($breadcrumbs);
715 create_view: function(view) {
717 View = this.registry.get_object(view.type),
718 options = _.clone(view.options),
719 view_loaded = $.Deferred();
721 if (view.type === "form" && this.action && (this.action.target === 'new' || this.action.target === 'inline')) {
722 options.initial_mode = 'edit';
724 var controller = new View(this, this.dataset, view.view_id, options),
725 $container = this.$(".oe-view-manager-view-" + view.type + ":first");
728 view.controller = controller;
729 view.$container = $container;
731 if (view.embedded_view) {
732 controller.set_embedded_view(view.embedded_view);
734 controller.on('switch_mode', this, this.switch_mode.bind(this));
735 controller.on('history_back', this, function () {
736 self.action_manager && self.action_manager.trigger('history_back');
738 controller.on("change:title", this, function() {
739 self.display_breadcrumbs();
741 controller.on('view_loaded', this, function () {
742 view_loaded.resolve();
744 this.$('.oe-view-manager-pager > span').hide();
745 return $.when(controller.appendTo($container), view_loaded)
747 self.trigger("controller_inited", view.type, controller);
750 select_view: function (index) {
751 var view_type = this.view_stack[index].type;
752 this.view_stack.splice(index);
753 return this.switch_mode(view_type);
756 * @returns {Number|Boolean} the view id of the given type, false if not found
758 get_view_id: function(view_type) {
759 return this.views[view_type] && this.views[view_type].view_id || false;
762 * Sets up the current viewmanager's search view.
764 * @param {Number|false} view_id the view to use or false for a default one
765 * @returns {jQuery.Deferred} search view startup deferred
767 setup_search_view: function() {
768 if (this.searchview) {
769 this.searchview.destroy();
772 var view_id = (this.action && this.action.search_view_id && this.action.search_view_id[0]) || false;
774 var search_defaults = {};
776 var context = this.action ? this.action.context : [];
777 _.each(context, function (value, key) {
778 var match = /^search_default_(.*)$/.exec(key);
780 search_defaults[match[1]] = value;
786 hidden: this.flags.search_view === false,
787 disable_custom_filters: this.flags.search_disable_custom_filters,
788 $buttons: this.$('.oe-search-options'),
791 var SearchView = instance.web.SearchView;
792 this.searchview = new SearchView(this, this.dataset, view_id, search_defaults, options);
794 this.searchview.on('search_data', this, this.search.bind(this));
795 return this.searchview.appendTo(this.$(".oe-view-manager-search-view:first"));
797 search: function(domains, contexts, groupbys) {
799 controller = this.active_view.controller,
800 action_context = this.action.context || {};
801 instance.web.pyeval.eval_domains_and_contexts({
802 domains: [this.action.domain || []].concat(domains || []),
803 contexts: [action_context].concat(contexts || []),
804 group_by_seq: groupbys || []
805 }).done(function (results) {
808 _.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
809 JSON.stringify(results.error)));
811 self.dataset._model = new instance.web.Model(
812 self.dataset.model, results.context, results.domain);
813 var groupby = results.group_by.length
815 : action_context.group_by;
816 if (_.isString(groupby)) {
819 $.when(controller.do_search(results.domain, results.context, groupby || [])).then(function() {
820 self.active_search.resolve();
824 do_push_state: function(state) {
825 if (this.action_manager) {
826 state.view_type = this.active_view.type;
827 this.action_manager.do_push_state(state);
830 do_load_state: function(state, warm) {
831 if (state.view_type && state.view_type !== this.active_view.type) {
832 // warning: this code relies on the fact that switch_mode has an immediate side
833 // effect (setting the 'active_view' to its new value) AND an async effect (the
834 // view is created/loaded). So, the next statement (do_load_state) is executed
835 // on the new view, after it was initialized, but before it is fully loaded and
836 // in particular, before the do_show method is called.
837 this.switch_mode(state.view_type, true);
839 this.active_view.controller.do_load_state(state, warm);
841 on_debug_changed: function (evt) {
843 params = $(evt.target).data(),
845 current_view = this.active_view.controller;
848 var dialog = new instance.web.Dialog(this, { title: _t("Fields View Get") }).open();
849 $('<pre>').text(instance.web.json_node_to_xml(current_view.fields_view.arch, true)).appendTo(dialog.$el);
853 name: _t("JS Tests"),
855 type : 'ir.actions.act_url',
856 url: '/web/tests?mod=*'
860 var ids = current_view.get_selected_ids();
861 if (ids.length === 1) {
862 this.dataset.call('get_metadata', [ids]).done(function(result) {
863 var dialog = new instance.web.Dialog(this, {
864 title: _.str.sprintf(_t("Metadata (%s)"), self.dataset.model),
867 Ok: function() { this.parents('.modal').modal('hide');}
869 }, QWeb.render('ViewManagerDebugViewLog', {
871 format : instance.web.format_value
876 case 'toggle_layout_outline':
877 current_view.rendering_engine.toggle_layout_debugging();
880 current_view.open_defaults_dialog();
884 name: _t("Technical Translation"),
885 res_model : 'ir.translation',
886 domain : [['type', '!=', 'object'], '|', ['name', '=', this.dataset.model], ['name', 'ilike', this.dataset.model + ',']],
887 views: [[false, 'list'], [false, 'form']],
888 type : 'ir.actions.act_window',
894 this.dataset.call('fields_get', [false, {}]).done(function (fields) {
895 var $root = $('<dl>');
896 _(fields).each(function (attributes, name) {
897 $root.append($('<dt>').append($('<h4>').text(name)));
898 var $attrs = $('<dl>').appendTo($('<dd>').appendTo($root));
899 _(attributes).each(function (def, name) {
900 if (def instanceof Object) {
901 def = JSON.stringify(def);
904 .append($('<dt>').text(name))
905 .append($('<dd style="white-space: pre-wrap;">').text(def));
908 new instance.web.Dialog(self, {
909 title: _.str.sprintf(_t("Model %s fields"),
912 Ok: function() { this.parents('.modal').modal('hide');}
917 case 'edit_workflow':
918 return this.do_action({
919 res_model : 'workflow',
920 name: _t('Edit Workflow'),
921 domain : [['osv', '=', this.dataset.model]],
922 views: [[false, 'list'], [false, 'form'], [false, 'diagram']],
923 type : 'ir.actions.act_window',
928 this.do_edit_resource(params.model, params.id, evt.target.text);
930 case 'manage_filters':
932 res_model: 'ir.filters',
933 name: _t('Manage Filters'),
934 views: [[false, 'list'], [false, 'form']],
935 type: 'ir.actions.act_window',
937 search_default_my_filters: true,
938 search_default_model_id: this.dataset.model
942 case 'print_workflow':
943 if (current_view.get_selected_ids && current_view.get_selected_ids().length == 1) {
944 instance.web.blockUI();
946 context: { active_ids: current_view.get_selected_ids() },
947 report_name: "workflow.instance.graph",
949 model: this.dataset.model,
950 id: current_view.get_selected_ids()[0],
954 this.session.get_file({
956 data: {action: JSON.stringify(action)},
957 complete: instance.web.unblockUI
962 window.location.search="?";
966 console.warn("No debug handler for ", val);
970 do_edit_resource: function(model, id, name) {
975 type : 'ir.actions.act_window',
978 views : [[false, 'form']],
981 action_buttons : true,
988 instance.web.Sidebar = instance.web.Widget.extend({
989 init: function(parent) {
992 var view = this.getParent();
994 { 'name' : 'print', 'label' : _t('Print'), },
995 { 'name' : 'other', 'label' : _t('More'), }
1001 this.fileupload_id = _.uniqueId('oe_fileupload');
1002 $(window).on(this.fileupload_id, function() {
1003 var args = [].slice.call(arguments).slice(1);
1004 self.do_attachement_update(self.dataset, self.model_id,args);
1005 instance.web.unblockUI();
1012 this.$el.on('click','.dropdown-menu li a', function(event) {
1013 var section = $(this).data('section');
1014 var index = $(this).data('index');
1015 var item = self.items[section][index];
1016 if (item.callback) {
1017 item.callback.apply(self, [item]);
1018 } else if (item.action) {
1019 self.on_item_action_clicked(item);
1020 } else if (item.url) {
1023 event.preventDefault();
1026 redraw: function() {
1028 self.$el.html(QWeb.render('Sidebar', {widget: self}));
1030 // Hides Sidebar sections when item list is empty
1031 this.$('.oe_form_dropdown_section').each(function() {
1032 $(this).toggle(!!$(this).find('li').length);
1034 self.$("[title]").tooltip({
1035 delay: { show: 500, hide: 0}
1039 * For each item added to the section:
1042 * will be used as the item's name in the sidebar, can be html
1045 * descriptor for the action which will be executed, ``action`` and
1046 * ``callback`` should be exclusive
1049 * function to call when the item is clicked in the sidebar, called
1050 * with the item descriptor as its first argument (so information
1051 * can be stored as additional keys on the object passed to
1054 * ``classname`` (optional)
1055 * ``@class`` set on the sidebar serialization of the item
1057 * ``title`` (optional)
1058 * will be set as the item's ``@title`` (tooltip)
1060 * @param {String} section_code
1061 * @param {Array<{label, action | callback[, classname][, title]}>} items
1063 add_items: function(section_code, items) {
1066 this.items[section_code].unshift.apply(this.items[section_code],items);
1070 add_toolbar: function(toolbar) {
1072 _.each(['print','action','relate'], function(type) {
1073 var items = toolbar[type];
1075 for (var i = 0; i < items.length; i++) {
1077 label: items[i]['name'],
1079 classname: 'oe_sidebar_' + type
1082 self.add_items(type=='print' ? 'print' : 'other', items);
1086 on_item_action_clicked: function(item) {
1088 self.getParent().sidebar_eval_context().done(function (sidebar_eval_context) {
1089 var ids = self.getParent().get_selected_ids();
1091 if (self.getParent().get_active_domain) {
1092 domain = self.getParent().get_active_domain();
1095 domain = $.Deferred().resolve(undefined);
1097 if (ids.length === 0) {
1098 new instance.web.Dialog(this, { title: _t("Warning"), size: 'medium',}, $("<div />").text(_t("You must choose at least one record."))).open();
1101 var active_ids_context = {
1104 active_model: self.getParent().dataset.model,
1107 $.when(domain).done(function (domain) {
1108 if (domain !== undefined) {
1109 active_ids_context.active_domain = domain;
1111 var c = instance.web.pyeval.eval('context',
1112 new instance.web.CompoundContext(
1113 sidebar_eval_context, active_ids_context));
1115 self.rpc("/web/action/load", {
1116 action_id: item.action.id,
1118 }).done(function(result) {
1119 result.context = new instance.web.CompoundContext(
1120 result.context || {}, active_ids_context)
1121 .set_eval_context(c);
1122 result.flags = result.flags || {};
1123 result.flags.new_window = true;
1124 self.do_action(result, {
1125 on_close: function() {
1127 self.getParent().reload();
1134 do_attachement_update: function(dataset, model_id, args) {
1136 this.dataset = dataset;
1137 this.model_id = model_id;
1138 if (args && args[0].error) {
1139 this.do_warn(_t('Uploading Error'), args[0].error);
1142 this.on_attachments_loaded([]);
1144 var dom = [ ['res_model', '=', dataset.model], ['res_id', '=', model_id], ['type', 'in', ['binary', 'url']] ];
1145 var ds = new instance.web.DataSetSearch(this, 'ir.attachment', dataset.get_context(), dom);
1146 ds.read_slice(['name', 'url', 'type', 'create_uid', 'create_date', 'write_uid', 'write_date'], {}).done(this.on_attachments_loaded);
1149 on_attachments_loaded: function(attachments) {
1152 var prefix = this.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'name'});
1153 _.each(attachments,function(a) {
1155 if(a.type === "binary") {
1156 a.url = prefix + '&id=' + a.id + '&t=' + (new Date().getTime());
1159 self.items.files = attachments;
1161 this.$('.oe_sidebar_add_attachment .oe_form_binary_file').change(this.on_attachment_changed);
1162 this.$el.find('.oe_sidebar_delete_item').click(this.on_attachment_delete);
1164 on_attachment_changed: function(e) {
1165 var $e = $(e.target);
1166 if ($e.val() !== '') {
1167 this.$el.find('form.oe_form_binary_form').submit();
1168 $e.parent().find('input[type=file]').prop('disabled', true);
1169 $e.parent().find('button').prop('disabled', true).find('img, span').toggle();
1170 this.$('.oe_sidebar_add_attachment a').text(_t('Uploading...'));
1171 instance.web.blockUI();
1174 on_attachment_delete: function(e) {
1176 e.stopPropagation();
1178 var $e = $(e.currentTarget);
1179 if (confirm(_t("Do you really want to delete this attachment ?"))) {
1180 (new instance.web.DataSet(this, 'ir.attachment')).unlink([parseInt($e.attr('data-id'), 10)]).done(function() {
1181 self.do_attachement_update(self.dataset, self.model_id);
1187 instance.web.View = instance.web.Widget.extend({
1188 // name displayed in view switchers
1191 * Define a view type for each view to allow automatic call to fields_view_get.
1193 view_type: undefined,
1194 init: function(parent, dataset, view_id, options) {
1195 this._super(parent);
1196 this.ViewManager = parent;
1197 this.dataset = dataset;
1198 this.view_id = view_id;
1199 this.set_default_options(options);
1201 start: function () {
1202 return this.load_view();
1204 load_view: function(context) {
1206 var view_loaded_def;
1207 if (this.embedded_view) {
1208 view_loaded_def = $.Deferred();
1209 $.async_when().done(function() {
1210 view_loaded_def.resolve(self.embedded_view);
1213 if (! this.view_type)
1214 console.warn("view_type is not defined", this);
1215 view_loaded_def = instance.web.fields_view_get({
1216 "model": this.dataset._model,
1217 "view_id": this.view_id,
1218 "view_type": this.view_type,
1219 "toolbar": !!this.options.$sidebar,
1220 "context": this.dataset.get_context(),
1223 return this.alive(view_loaded_def).then(function(r) {
1224 self.fields_view = r;
1225 // add css classes that reflect the (absence of) access rights
1226 self.$el.addClass('oe_view')
1227 .toggleClass('oe_cannot_create', !self.is_action_enabled('create'))
1228 .toggleClass('oe_cannot_edit', !self.is_action_enabled('edit'))
1229 .toggleClass('oe_cannot_delete', !self.is_action_enabled('delete'));
1230 return $.when(self.view_loading(r)).then(function() {
1231 self.trigger('view_loaded', r);
1235 view_loading: function(r) {
1237 set_default_options: function(options) {
1238 this.options = options || {};
1239 _.defaults(this.options, {
1240 // All possible views options should be defaulted here
1244 action_views_ids: {}
1248 * Fetches and executes the action identified by ``action_data``.
1250 * @param {Object} action_data the action descriptor data
1251 * @param {String} action_data.name the action name, used to uniquely identify the action to find and execute it
1252 * @param {String} [action_data.special=null] special action handlers (currently: only ``'cancel'``)
1253 * @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
1254 * @param {Object} [action_data.context=null] additional action context, to add to the current context
1255 * @param {instance.web.DataSet} dataset a dataset object used to communicate with the server
1256 * @param {Object} [record_id] the identifier of the object on which the action is to be applied
1257 * @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
1259 do_execute_action: function (action_data, dataset, record_id, on_closed) {
1261 var result_handler = function () {
1262 if (on_closed) { on_closed.apply(null, arguments); }
1263 if (self.getParent() && self.getParent().on_action_executed) {
1264 return self.getParent().on_action_executed.apply(null, arguments);
1267 var context = new instance.web.CompoundContext(dataset.get_context(), action_data.context || {});
1270 var handler = function (action) {
1271 if (action && action.constructor == Object) {
1272 // filter out context keys that are specific to the current action.
1273 // Wrong default_* and search_default_* values will no give the expected result
1274 // Wrong group_by values will simply fail and forbid rendering of the destination view
1275 var ncontext = new instance.web.CompoundContext(
1276 _.object(_.reject(_.pairs(dataset.get_context().eval()), function(pair) {
1277 return pair[0].match('^(?:(?:default_|search_default_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null;
1280 ncontext.add(action_data.context || {});
1281 ncontext.add({active_model: dataset.model});
1284 active_id: record_id,
1285 active_ids: [record_id],
1288 ncontext.add(action.context || {});
1289 action.context = ncontext;
1290 return self.do_action(action, {
1291 on_close: result_handler,
1294 self.do_action({"type":"ir.actions.act_window_close"});
1295 return result_handler();
1299 if (action_data.special === 'cancel') {
1300 return handler({"type":"ir.actions.act_window_close"});
1301 } else if (action_data.type=="object") {
1302 var args = [[record_id]];
1303 if (action_data.args) {
1305 // Warning: quotes and double quotes problem due to json and xml clash
1306 // Maybe we should force escaping in xml or do a better parse of the args array
1307 var additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
1308 args = args.concat(additional_args);
1310 console.error("Could not JSON.parse arguments", action_data.args);
1314 return dataset.call_button(action_data.name, args).then(handler).then(function () {
1315 if (instance.webclient) {
1316 instance.webclient.menu.do_reload_needaction();
1319 } else if (action_data.type=="action") {
1320 return this.rpc('/web/action/load', {
1321 action_id: action_data.name,
1322 context: _.extend(instance.web.pyeval.eval('context', context), {'active_model': dataset.model, 'active_ids': dataset.ids, 'active_id': record_id}),
1326 return dataset.exec_workflow(record_id, action_data.name).then(handler);
1330 * Directly set a view to use instead of calling fields_view_get. This method must
1331 * be called before start(). When an embedded view is set, underlying implementations
1332 * of instance.web.View must use the provided view instead of any other one.
1334 * @param embedded_view A view.
1336 set_embedded_view: function(embedded_view) {
1337 this.embedded_view = embedded_view;
1339 do_show: function () {
1341 instance.web.bus.trigger('view_shown', this);
1343 do_hide: function () {
1346 is_active: function () {
1347 return this.ViewManager.active_view.controller === this;
1349 * Wraps fn to only call it if the current view is the active one. If the
1350 * current view is not active, doesn't call fn.
1352 * fn can not return anything, as a non-call to fn can't return anything
1355 * @param {Function} fn function to wrap in the active guard
1357 guard_active: function (fn) {
1359 return function () {
1360 if (self.is_active()) {
1361 fn.apply(self, arguments);
1365 do_push_state: function(state) {
1366 if (this.getParent() && this.getParent().do_push_state) {
1367 this.getParent().do_push_state(state);
1370 do_load_state: function (state, warm) {
1374 * Switches to a specific view type
1376 do_switch_view: function() {
1377 this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments)));
1379 do_search: function(domain, context, group_by) {
1381 on_sidebar_export: function() {
1382 new instance.web.DataExport(this, this.dataset).open();
1384 sidebar_eval_context: function () {
1388 * Asks the view to reload itself, if the reloading is asynchronous should
1389 * return a {$.Deferred} indicating when the reloading is done.
1391 reload: function () {
1395 * Return whether the user can perform the action ('create', 'edit', 'delete') in this view.
1396 * An action is disabled by setting the corresponding attribute in the view's main element,
1397 * like: <form string="" create="false" edit="false" delete="false">
1399 is_action_enabled: function(action) {
1400 var attrs = this.fields_view.arch.attrs;
1401 return (action in attrs) ? JSON.parse(attrs[action]) : true;
1406 * Performs a fields_view_get and apply postprocessing.
1407 * return a {$.Deferred} resolved with the fvg
1409 * @param {Object} args
1410 * @param {String|Object} args.model instance.web.Model instance or string repr of the model
1411 * @param {Object} [args.context] context if args.model is a string
1412 * @param {Number} [args.view_id] id of the view to be loaded, default view if null
1413 * @param {String} [args.view_type] type of view to be loaded if view_id is null
1414 * @param {Boolean} [args.toolbar=false] get the toolbar definition
1416 instance.web.fields_view_get = function(args) {
1417 function postprocess(fvg) {
1418 var doc = $.parseXML(fvg.arch).documentElement;
1419 fvg.arch = instance.web.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban'));
1420 if ('id' in fvg.fields) {
1421 // Special case for id's
1422 var id_field = fvg.fields['id'];
1423 id_field.original_type = id_field.type;
1424 id_field.type = 'id';
1426 _.each(fvg.fields, function(field) {
1427 _.each(field.views || {}, function(view) {
1433 args = _.defaults(args, {
1436 var model = args.model;
1437 if (typeof model === 'string') {
1438 model = new instance.web.Model(args.model, args.context);
1440 return args.model.call('fields_view_get', {
1441 view_id: args.view_id,
1442 view_type: args.view_type,
1443 context: args.context,
1444 toolbar: args.toolbar
1445 }).then(function(fvg) {
1446 return postprocess(fvg);
1450 instance.web.xml_to_json = function(node, strip_whitespace) {
1451 switch (node.nodeType) {
1453 return instance.web.xml_to_json(node.documentElement, strip_whitespace);
1456 return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
1458 var attrs = $(node).getAttributes();
1459 _.each(['domain', 'filter_domain', 'context', 'default_get'], function(key) {
1462 attrs[key] = JSON.parse(attrs[key]);
1467 tag: node.tagName.toLowerCase(),
1469 children: _.compact(_.map(node.childNodes, function(node) {
1470 return instance.web.xml_to_json(node, strip_whitespace);
1476 instance.web.json_node_to_xml = function(node, human_readable, indent) {
1477 // For debugging purpose, this function will convert a json node back to xml
1478 indent = indent || 0;
1479 var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''),
1480 r = sindent + '<' + node.tag,
1481 cr = human_readable ? '\n' : '';
1483 if (typeof(node) === 'string') {
1484 return sindent + node;
1485 } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) {
1487 _.str.sprintf(_t("Node [%s] is not a JSONified XML node"),
1488 JSON.stringify(node)));
1490 for (var attr in node.attrs) {
1491 var vattr = node.attrs[attr];
1492 if (typeof(vattr) !== 'string') {
1494 vattr = JSON.stringify(vattr);
1496 vattr = vattr.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
1497 if (human_readable) {
1498 vattr = vattr.replace(/"/g, "'");
1500 r += ' ' + attr + '="' + vattr + '"';
1502 if (node.children && node.children.length) {
1505 for (var i = 0, ii = node.children.length; i < ii; i++) {
1506 childs.push(instance.web.json_node_to_xml(node.children[i], human_readable, indent + 1));
1508 r += childs.join(cr);
1509 r += cr + sindent + '</' + node.tag + '>';
1515 instance.web.xml_to_str = function(node) {
1517 if (window.XMLSerializer) {
1518 str = (new XMLSerializer()).serializeToString(node);
1519 } else if (window.ActiveXObject) {
1522 throw new Error(_t("Could not serialize XML"));
1524 // Browsers won't deal with self closing tags except void elements:
1525 // http://www.w3.org/TR/html-markup/syntax.html
1526 var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' ');
1528 // The following regex is a bit naive but it's ok for the xmlserializer output
1529 str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function(match, tag, attrs) {
1530 if (void_elements.indexOf(tag) < 0) {
1531 return "<" + tag + attrs + "></" + tag + ">";
1540 * Registry for all the main views
1542 instance.web.views = new instance.web.Registry();
1546 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: