diff options
| author | Géry Debongnie <ged@odoo.com> | 2017-03-05 10:05:56 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-11 19:44:38 +0200 |
| commit | 905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch) | |
| tree | 265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js | |
| parent | 54d4c73af4ad05c7640d28c1892247c9538b8017 (diff) | |
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started
basically from scratch. The goal was to unify all the various views
under a common framework, to make them testable, to make then usable in
different conditions (in studio, or in the frontend), and to make our
lives easier.
Some important points are:
- we introduced new coding guidelines (camelCase, 80 chars width, ...)
- we have a brand new testing framework (still QUnit based)
- kanban view moved to the web addon
- calendar view (formerly web_calender) moved to web as well
- the tree view was removed
- all new code should be documented
We hope that this code is the start of a new era for the Odoo web
client, we want to have a high quality codebase, well documented, well
tested, well designed.
Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js')
123 files changed, 19086 insertions, 15003 deletions
diff --git a/addons/web/static/src/js/framework/data.js b/addons/web/static/src/js/_deprecated/data.js index 1d4258019ae..090325b0057 100644 --- a/addons/web/static/src/js/framework/data.js +++ b/addons/web/static/src/js/_deprecated/data.js @@ -2,12 +2,12 @@ odoo.define('web.data', function (require) { "use strict"; var Class = require('web.Class'); +var concurrency = require('web.concurrency'); var mixins = require('web.mixins'); var Model = require('web.Model'); var session = require('web.session'); var translation = require('web.translation'); var pyeval = require('web.pyeval'); -var utils = require('web.utils'); var _t = translation._t; @@ -320,7 +320,7 @@ var DataSet = Class.extend(mixins.PropertiesMixin, { this.index = null; this._sort = []; this._model = new Model(model, context); - this.orderer = new utils.DropMisordered(); + this.orderer = new concurrency.DropMisordered(); }, previous: function () { this.index -= 1; @@ -729,271 +729,6 @@ var DataSetSearch = DataSet.extend({ } }); -var BufferedDataSet = DataSetStatic.extend({ - virtual_id_prefix: "one2many_v_id_", - debug_mode: true, - init: function() { - this._super.apply(this, arguments); - this.reset_ids([]); - this.last_default_get = {}; - this.running_reads = []; - this.mutex = new utils.Mutex(); - }, - default_get: function(fields, options) { - var self = this; - return this._super(fields, options).done(function(res) { - self.last_default_get = _.clone(res); - }); - }, - get_cache: function (id) { - if (!this.cache[id]) { - this.cache[id] = { - 'id': id, - 'values': {}, - 'from_read': {}, - 'changes': {}, - 'readonly_fields': {}, - 'to_create': false, - 'to_delete': false}; - } - return this.cache[id]; - }, - _update_cache: function (id, options) { - // One should call this method after modifying this.from_read, - // this.to_create or this.change. It updates this.cache and - // this.readonly_fields. - var cached = this.get_cache(id); - if (options) { - _.extend(cached.from_read, options.from_read); - _.extend(cached.changes, options.changes); - _.extend(cached.readonly_fields, options.readonly_fields); - if (options.to_create !== undefined) cached.to_create = options.to_create; - if (options.to_delete !== undefined) cached.to_delete = options.to_delete; - } - cached.values = _.extend({'id': id}, cached.from_read, cached.changes, cached.readonly_fields); - return cached; - }, - create: function(data, options) { - var changes = _.extend({}, this.last_default_get, data); - var cached = this._update_cache(_.uniqueId(this.virtual_id_prefix), _.extend({'changes': changes, 'to_create': true}, options)); - this.trigger("dataset_changed", data, options); - return $.Deferred().resolve(cached.id); - }, - write: function (id, data, options) { - var self = this; - var cached = this.get_cache(id); - - // if update after a remove, it's like an add before updating - cached.to_delete = false; - - // apply change - var def = $.Deferred(); - this.mutex.exec(function () { - var dirty = false; - // _.each is broken if a field "length" is present - for (var k in data) { - if (!_.isEqual(data[k], cached.values[k])) { - dirty = true; - if (_.isEqual(data[k], cached.from_read[k])) { // clean changes - delete cached.changes[k]; - } else { - cached.changes[k] = data[k]; - } - } else { - delete data[k]; - } - } - self._update_cache(id, options); - - if (dirty) { - self.trigger("dataset_changed", id, data, options); - } - - return def.resolve(data).promise(); - }); - - return def; - }, - unlink: function(ids, callback, error_callback) { - var self = this; - _.each(ids, function (id) { - self.get_cache(id).to_delete = true; - }); - this.set_ids(_.difference(this.ids, _.pluck(_.filter(this.cache, function (c) {return c.to_delete;}), 'id'))); - this.trigger("dataset_changed", ids, callback, error_callback); - return utils.async_when({result: true}).done(callback); - }, - reset_ids: function(ids, options) { - var self = this; - this.set_ids(ids); - if (!options || !options.keep_read_data) { - this.cache = {}; - } else { - _.each(this.cache, function (cache) { - self._update_cache(cache.id, {'changes': {}, 'to_delete': false}); - }); - } - this.delete_all = false; - this.cancel_read(); - }, - cancel_read: function () { - _.invoke(_.clone(this.running_reads), 'reject'); - }, - read_ids: function (ids, fields, options) { - // read what is necessary from the server to have ids and the given - // fields in this.from_read - var self = this; - var to_get = _.filter(ids, function(id) { - var cache = self.get_cache(id); - return !cache.to_create && _.any(fields, function(x) {return cache.from_read[x] === undefined;}); - }); - options = options || {}; - - var return_records = function() { - var records = _.map(ids, function(id) { - return _.clone(self.get_cache(id).values); - }); - if (self.debug_mode) { - if (_.include(records, undefined)) { - throw "Record not correctly loaded"; - } - } - var sort_fields = self._sort, - compare = function (v1, v2) { - return (v1 < v2) ? -1 - : (v1 > v2) ? 1 - : 0; - }; - // Array.sort is not necessarily stable. We must be careful with this because - // sorting an array where all items are considered equal is a worst-case that - // will randomize the array with an unstable sort! Therefore we must avoid - // sorting if there are no sort_fields (i.e. all items are considered equal) - // See also: http://ecma262-5.com/ELS5_Section_15.htm#Section_15.4.4.11 - // http://code.google.com/p/v8/issues/detail?id=90 - if (sort_fields.length) { - records.sort(function (a, b) { - return _.reduce(sort_fields, function (acc, field) { - if (acc) { return acc; } - var sign = 1; - if (field[0] === '-') { - 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; } - //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]); - } - return sign * compare(a[field], b[field]); - }, 0); - }); - } - return $.when(records); - }; - if(to_get.length > 0) { - var def = $.Deferred(); - self.running_reads.push(def); - def.always(function() { - self.running_reads = _.without(self.running_reads, def); - }); - var _super = this._super; - this.mutex.exec(function () { - if (def.state() !== "pending") return; - _super.call(self, to_get, fields, options).then(_.bind(def.resolve, def), _.bind(def.reject, def)); - return def; - }); - return def.then(function(records) { - _.each(records, function(record, index) { - // add information into from_read - var id = to_get[index]; - self._update_cache(id, _.extend(options, {'from_read': record})); - }); - return return_records(); - }); - } else { - return return_records(); - } - }, - /** - * Invalidates caching of a record in the dataset to ensure the next read - * of that record will hit the server. - * - * Of use when an action is going to remote-alter a record which will then - * need to be reloaded, e.g. action button. - * - * @param {Object} id record to remove from the BDS's cache - */ - evict_record: function (id) { - // Don't evict records which haven't yet been saved: there is no more - // recent data on the server (and there potentially isn't any data), - // and this breaks the assumptions of other methods (that the data - // for new and altered records is both in the cache and in the change - // or to_create collection) - this.get_cache(id).from_read = {}; - this._update_cache(id); - }, - call_button: function (method, args) { - this.evict_record(args[0][0]); - return this._super(method, args); - }, - alter_ids: function(n_ids, options) { - var dirty = !_.isEqual(this.ids, n_ids); - this._super(n_ids, options); - if (dirty) { - this.trigger("dataset_changed", n_ids, options); - } - }, -}); - -BufferedDataSet.virtual_id_regex = /^one2many_v_id_.*$/; - -var ProxyDataSet = DataSetSearch.extend({ - init: function() { - this._super.apply(this, arguments); - this.create_function = null; - this.write_function = null; - this.read_function = null; - this.default_get_function = null; - this.unlink_function = null; - }, - read_ids: function (ids, fields, options) { - if (this.read_function) { - return this.read_function(ids, fields, options, this._super); - } else { - return this._super.apply(this, arguments); - } - }, - default_get: function(fields, options) { - if (this.default_get_function) { - return this.default_get_function(fields, options, this._super); - } else { - return this._super.apply(this, arguments); - } - }, - create: function(data, options) { - if (this.create_function) { - return this.create_function(data, options, this._super); - } else { - return this._super.apply(this, arguments); - } - }, - write: function (id, data, options) { - if (this.write_function) { - return this.write_function(id, data, options, this._super); - } else { - return this._super.apply(this, arguments); - } - }, - unlink: function(ids) { - if (this.unlink_function) { - return this.unlink_function(ids, this._super); - } else { - return this._super.apply(this, arguments); - } - }, -}); - var CompoundContext = Class.extend({ init: function () { this.__ref = "compound_context"; @@ -1016,7 +751,7 @@ var CompoundContext = Class.extend({ return this.__eval_context; }, eval: function() { - return pyeval.eval('context', this, undefined, {no_user_context: true}); + return pyeval.eval('context', this); }, }); @@ -1046,77 +781,37 @@ var CompoundDomain = Class.extend({ }, }); -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))); - } - } - - var field = fields[ex[0]]; - if (!field) { - throw new Error(_.str.sprintf( - _t("Unknown field %s in domain %s"), - ex[0], JSON.stringify(expr))); +function rawify (data) { + // replaces the value of relational fields by their raw value (the id, if + // any, for many2ones and an array of ids for x2manys), in turn + return _.mapObject(data, function (value) { + if (_.isObject(value)) { + return value.type === 'record' ? value.res_id : value.res_ids; } - var field_value = field.get_value ? field.get_value() : field.value; - var op = ex[1]; - var val = ex[2]; + return value; + }); +} - 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)); - } +function build_eval_context (record) { + var field_values = rawify(_.extend({}, record.data, { active_model: record.model })); + if ('id' in record.data) { + field_values.active_id = record.data.id; + field_values.active_ids = [record.data.id]; } - return _.all(stack, _.identity); + // FIXME: parent? + return new CompoundContext(field_values); +} + +function build_context(record, context) { + context = context || {}; + var eval_context = build_eval_context(record); + return new CompoundContext(context).set_eval_context(eval_context); +} + +function build_domain(record, domain) { + domain = domain || []; + var eval_context = build_eval_context(record); + return new CompoundDomain(domain).set_eval_context(eval_context); } return { @@ -1124,12 +819,11 @@ return { DataSet: DataSet, DataSetStatic: DataSetStatic, DataSetSearch: DataSetSearch, - BufferedDataSet: BufferedDataSet, - ProxyDataSet: ProxyDataSet, CompoundContext: CompoundContext, CompoundDomain: CompoundDomain, - compute_domain: compute_domain, noDisplayContent: "<em class=\"text-warning\">" + _t("Unnamed") + "</em>", + build_context: build_context, + build_domain: build_domain, }; }); diff --git a/addons/web/static/src/js/framework/data_model.js b/addons/web/static/src/js/_deprecated/data_model.js index f722339167f..f722339167f 100644 --- a/addons/web/static/src/js/framework/data_model.js +++ b/addons/web/static/src/js/_deprecated/data_model.js diff --git a/addons/web/static/src/js/framework/formats.js b/addons/web/static/src/js/_deprecated/formats.js index 479fa1082cd..479fa1082cd 100644 --- a/addons/web/static/src/js/framework/formats.js +++ b/addons/web/static/src/js/_deprecated/formats.js diff --git a/addons/web/static/src/js/framework/model.js b/addons/web/static/src/js/_deprecated/model.js index 377ff1a1757..377ff1a1757 100644 --- a/addons/web/static/src/js/framework/model.js +++ b/addons/web/static/src/js/_deprecated/model.js diff --git a/addons/web/static/src/js/apps.js b/addons/web/static/src/js/apps.js index 7250674272c..8c5135f3b7b 100644 --- a/addons/web/static/src/js/apps.js +++ b/addons/web/static/src/js/apps.js @@ -5,13 +5,11 @@ var core = require('web.core'); var framework = require('web.framework'); var Model = require('web.DataModel'); var session = require('web.session'); -var web_client = require('web.web_client'); var Widget = require('web.Widget'); var _t = core._t; var apps_client = null; -var qweb = core.qweb; var Apps = Widget.extend({ template: 'EmptyComponent', @@ -25,7 +23,8 @@ var Apps = Widget.extend({ }, get_client: function() { - // return the client via a deferred, resolved or rejected depending if the remote host is available or not. + // return the client via a deferred, resolved or rejected depending if + // the remote host is available or not. var check_client_available = function(client) { var d = $.Deferred(); var i = new Image(); diff --git a/addons/web/static/src/js/boot.js b/addons/web/static/src/js/boot.js index b84cd0f58f8..9d299b1da84 100644 --- a/addons/web/static/src/js/boot.js +++ b/addons/web/static/src/js/boot.js @@ -36,6 +36,7 @@ var job_deps = []; var job_deferred = []; + var services = Object.create({}); var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/mg; @@ -50,6 +51,7 @@ remaining_jobs: jobs, __DEBUG__: { + didLogInfo: $.Deferred(), get_dependencies: function (name, transitive) { var deps = name instanceof Array ? name: [name], changed; @@ -137,6 +139,9 @@ this.process_jobs(jobs, services); }, log: function () { + var missing = []; + var failed = []; + if (jobs.length) { var debug_jobs = {}; var rejected = []; @@ -176,8 +181,8 @@ } } } - var missing = odoo.__DEBUG__.get_missing_jobs(); - var failed = odoo.__DEBUG__.get_failed_jobs(); + missing = odoo.__DEBUG__.get_missing_jobs(); + failed = odoo.__DEBUG__.get_failed_jobs(); var unloaded = _.filter(debug_jobs, function (job) { return job.missing; }); var log = [(_.isEmpty(failed) ? (_.isEmpty(unloaded) ? 'info' : 'warning' ) : 'error') + ':', 'Some modules could not be started']; @@ -192,10 +197,14 @@ console[_.isEmpty(failed) || _.isEmpty(unloaded) ? 'info' : 'error'].apply(console, log); } } + odoo.__DEBUG__.js_modules = { + missing: missing, + failed: _.pluck(failed, 'name'), + }; + odoo.__DEBUG__.didLogInfo.resolve(); }, process_jobs: function (jobs, services) { var job; - var require; function process_job (job) { var require = make_require(job); @@ -252,6 +261,7 @@ } }); + // automatically log errors detected when loading modules var log_when_loaded = function () { _.delay(function () { diff --git a/addons/web/static/src/js/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 1d9c270b6c3..498d1b41ea4 100644 --- a/addons/web/static/src/js/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -1,19 +1,46 @@ odoo.define('web.AbstractWebClient', function (require) { "use strict"; +/** + * AbstractWebClient + * + * This class defines a simple, basic web client. It is mostly functional. + * The WebClient is in some way the most important class for the web framework: + * - this is the class that instantiate everything else, + * - it is the top of the component tree, + * - it coordinates many events bubbling up + */ + var ActionManager = require('web.ActionManager'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var crash_manager = require('web.crash_manager'); +var data_manager = require('web.data_manager'); var Loading = require('web.Loading'); +var mixins = require('web.mixins'); var NotificationManager = require('web.notification').NotificationManager; var session = require('web.session'); -var utils = require('web.utils'); var Widget = require('web.Widget'); var _t = core._t; -var WebClient = Widget.extend({ +var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { custom_events: { + clear_uncommitted_changes: function (e) { + this.clear_uncommitted_changes().then(e.data.callback); + }, + toggle_fullscreen: function (event) { + this.toggle_fullscreen(event.data.fullscreen); + }, + current_action_updated: function (e) { + this.current_action_updated(e.data.action); + }, + // GENERIC SERVICES + // the next events are dedicated to generic services required by + // downstream widgets. Mainly side effects, such as rpcs, notifications + // or cache. + + // notifications and warnings notification: function (e) { if(this.notification_manager) { this.notification_manager.notify(e.data.title, e.data.message, e.data.sticky); @@ -24,33 +51,43 @@ var WebClient = Widget.extend({ this.notification_manager.warn(e.data.title, e.data.message, e.data.sticky); } }, - clear_uncommitted_changes: function (e) { - this.clear_uncommitted_changes().then(e.data.callback); - }, - toggle_fullscreen: function (event) { - this.toggle_fullscreen(event.data.fullscreen); + load_views: function(event) { + var params = { + model: event.data.modelName, + context: event.data.context, + views_descr: event.data.views, + }; + return data_manager + .load_views(params, event.data.options || {}) + .then(event.data.on_success); }, - current_action_updated: function (e) { - this.current_action_updated(e.data.action); + load_filters: function(event) { + return data_manager + .load_filters(event.data.dataset, event.data.action_id) + .then(event.data.on_success); }, - perform_rpc: function(event) { - if (event.data.on_fail) { - event.data.on_fail(); + // session + get_session: function(event) { + if (event.data.callback) { + event.data.callback(session); } }, - perform_model_rpc: function(event) { - if (event.data.on_fail) { - event.data.on_fail(); - } + do_action: function (event) { + this.do_action(event.data.action, event.data.options || {}).then(function (result) { + if (event.data.on_success) { + event.data.on_success(result); + } + }); }, }, init: function(parent) { this.client_options = {}; + mixins.ServiceProvider.init.call(this); this._super(parent); this.origin = undefined; this._current_state = null; - this.menu_dm = new utils.DropMisordered(); - this.action_mutex = new utils.Mutex(); + this.menu_dm = new concurrency.DropMisordered(); + this.action_mutex = new concurrency.Mutex(); this.set('title_part', {"zopenerp": "Odoo"}); }, start: function() { @@ -239,7 +276,7 @@ var WebClient = Widget.extend({ // -------------------------------------------------------------- // Scrolltop handling // -------------------------------------------------------------- - get_scrollTop: function () { + getScrollTop: function () { }, //-------------------------------------------------------------- // Misc. @@ -249,6 +286,6 @@ var WebClient = Widget.extend({ }, }); -return WebClient; +return AbstractWebClient; }); diff --git a/addons/web/static/src/js/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index f7b81554bb4..9dfe28e01dd 100644 --- a/addons/web/static/src/js/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -1,6 +1,14 @@ odoo.define('web.ActionManager', function (require) { "use strict"; +/** + * ActionManager + * + * The action manager is quite important: it makes sure that actions (the Odoo + * objects, such as a client action, or a act_window) are properly started, + * and coordinated. + */ + var Bus = require('web.Bus'); var ControlPanel = require('web.ControlPanel'); var core = require('web.core'); @@ -8,6 +16,7 @@ var crash_manager = require('web.crash_manager'); var data = require('web.data'); var data_manager = require('web.data_manager'); var Dialog = require('web.Dialog'); +var dom = require('web.dom'); var framework = require('web.framework'); var pyeval = require('web.pyeval'); var session = require('web.session'); @@ -53,7 +62,7 @@ var Action = core.Class.extend({ /** * Not implemented for client actions */ - set_scrollTop: function() { + setScrollTop: function() { }, /** * Stores the DOM fragment of the action @@ -66,7 +75,7 @@ var Action = core.Class.extend({ * Not implemented for client actions * @return {int} the number of pixels the webclient is scrolled when leaving the action */ - get_scrollTop: function() { + getScrollTop: function() { return 0; }, /** @@ -136,7 +145,7 @@ var WidgetAction = Action.extend({ // Hack to remove badly inserted nvd3 tooltips ; should be removed when upgrading nvd3 lib $('body > .nvtooltip').remove(); - return framework.detach([{widget: this.widget}]); + return dom.detach([{widget: this.widget}]); }, /** * Destroys the widget @@ -168,20 +177,20 @@ var ViewManagerAction = WidgetAction.extend({ */ set_on_reverse_breadcrumb: function(callback, scrollTop) { this._super(callback); - this.set_scrollTop(scrollTop); + this.setScrollTop(scrollTop); }, /** * Sets the scroll position of the widgets's active_view * @param {int} [scrollTop] the number of pixels to scroll */ - set_scrollTop: function(scrollTop) { - this.widget.active_view.controller.set_scrollTop(scrollTop); + setScrollTop: function(scrollTop) { + this.widget.active_view.controller.setScrollTop(scrollTop); }, /** * @return {int} the number of pixels the webclient is scrolled when leaving the action */ - get_scrollTop: function() { - return this.widget.active_view.controller.get_scrollTop(); + getScrollTop: function() { + return this.widget.active_view.controller.getScrollTop(); }, /** * @return {Array} array of Objects that will be interpreted to display the breadcrumbs @@ -190,7 +199,7 @@ var ViewManagerAction = WidgetAction.extend({ var self = this; return this.widget.view_stack.map(function (view, index) { return { - title: view.controller.get('title') || self.title, + title: view.controller && view.controller.get('title') || self.title, index: index, action: self, }; @@ -254,7 +263,7 @@ var ActionManager = Widget.extend({ // Listen to event "DOM_updated" to restore the scroll position core.bus.on('DOM_updated', this, function() { if (this.inner_action) { - this.trigger_up('scrollTo', {offset: this.inner_action.get_scrollTop() || 0}); + this.trigger_up('scrollTo', {offset: this.inner_action.getScrollTop() || 0}); } }); @@ -304,7 +313,7 @@ var ActionManager = Widget.extend({ // Set on_reverse_breadcrumb callback on previous inner_action if (this.webclient && old_action) { - old_action.set_on_reverse_breadcrumb(options.on_reverse_breadcrumb, this.webclient.get_scrollTop()); + old_action.set_on_reverse_breadcrumb(options.on_reverse_breadcrumb, this.webclient.getScrollTop()); } // Update action_stack (must be done before appendTo to properly @@ -330,7 +339,7 @@ var ActionManager = Widget.extend({ // Hide the main ControlPanel for widgets that do not use it self.main_control_panel.do_hide(); } - framework.append(self.$el, new_widget_fragment, { + dom.append(self.$el, new_widget_fragment, { in_DOM: self.is_in_DOM, callbacks: [{widget: self.inner_widget}], }); @@ -347,9 +356,9 @@ var ActionManager = Widget.extend({ self.inner_widget = old_widget; }); }, - set_scrollTop: function(scrollTop) { + setScrollTop: function(scrollTop) { if (this.inner_action) { - this.inner_action.set_scrollTop(scrollTop); + this.inner_action.setScrollTop(scrollTop); } }, get_breadcrumbs: function () { @@ -416,7 +425,7 @@ var ActionManager = Widget.extend({ self.clear_action_stack(to_destroy); // Append the fragment of the action to restore to self.$el - framework.append(self.$el, action.get_fragment(), { + dom.append(self.$el, action.get_fragment(), { in_DOM: self.is_in_DOM, callbacks: [{widget: action.widget}], }); @@ -583,6 +592,7 @@ var ActionManager = Widget.extend({ on_reverse_breadcrumb: function() {}, hide_breadcrumb: false, on_close: function() {}, + on_load: function() {}, action_menu_id: null, additional_context: {}, }); @@ -609,7 +619,8 @@ var ActionManager = Widget.extend({ options.clear_breadcrumbs = (action.target === 'main') || options.clear_breadcrumbs; // Ensure context & domain are evaluated and can be manipulated/used - var ncontext = new data.CompoundContext(options.additional_context, action.context || {}); + var user_context = this.getSession().user_context; + var ncontext = new data.CompoundContext(user_context, 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 @@ -664,7 +675,8 @@ var ActionManager = Widget.extend({ }); } - return $.when(this[type](action, options)).then(function() { + return $.when(this[type](action, options)).then(function (executor_action) { + options.on_load(executor_action); return action; }); }, @@ -698,12 +710,12 @@ var ActionManager = Widget.extend({ // 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, { + this.dialog = new Dialog(this, _.defaults(options || {}, { title: executor.action.name, dialogClass: executor.klass, buttons: [], size: executor.action.context.dialog_size, - }); + })); // chain on_close triggers with previous dialog, if any this.dialog.on_close = function(){ @@ -722,7 +734,8 @@ var ActionManager = Widget.extend({ this.dialog.on("closed", null, this.dialog.on_close); this.dialog_widget = executor.widget(); if (this.dialog_widget instanceof ViewManager) { - _.extend(this.dialog_widget.flags, { + executor.action.viewManager = this.dialog_widget; + _.defaults(this.dialog_widget.flags, { $buttons: this.dialog.$footer, footer_to_buttons: true, }); @@ -740,19 +753,23 @@ var ActionManager = Widget.extend({ var fragment = document.createDocumentFragment(); return this.dialog_widget.appendTo(fragment).then(function() { self.dialog.open(); - framework.append(self.dialog.$el, fragment, { + dom.append(self.dialog.$el, fragment, { in_DOM: true, callbacks: [{widget: self.dialog_widget}], }); if(options.state && self.dialog_widget.do_load_state) { return self.dialog_widget.do_load_state(options.state); } - }).then(function () { return executor.action; }); + }).then(function () { + return executor.action; + }); } var def = this.inner_action && this.webclient && this.webclient.clear_uncommitted_changes() || $.when(); return def.then(function() { self.dialog_stop(executor.action); - return self.push_action(executor.widget(), executor.action, options); + return self.push_action(executor.action.viewManager = executor.widget(), executor.action, options).then(function () { + return executor.action; + }); }).fail(function() { return $.Deferred().reject(); }); @@ -814,7 +831,7 @@ var ActionManager = Widget.extend({ }, ir_actions_server: function (action, options) { var self = this; - return this.rpc('/web/action/run', { + return this.performRPC('/web/action/run', { action_id: action.id, context: action.context || {} }).then(function (action) { diff --git a/addons/web/static/src/js/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js index 173d91aea25..173d91aea25 100644 --- a/addons/web/static/src/js/control_panel.js +++ b/addons/web/static/src/js/chrome/control_panel.js diff --git a/addons/web/static/src/js/widgets/loading.js b/addons/web/static/src/js/chrome/loading.js index 645287ecf80..0c1b5e5025f 100644 --- a/addons/web/static/src/js/widgets/loading.js +++ b/addons/web/static/src/js/chrome/loading.js @@ -1,6 +1,17 @@ odoo.define('web.Loading', function (require) { "use strict"; +/** + * Loading Indicator + * + * When the user performs an action, it is good to give him some feedback that + * something is currently happening. The purpose of the Loading Indicator is to + * display a small rectangle on the bottom right of the screen with just the + * text 'Loading' and the number of currently running rpcs. + * + * After a delay of 3s, if a rpc is still not completed, we also block the UI. + */ + var core = require('web.core'); var framework = require('web.framework'); var session = require('web.session'); diff --git a/addons/web/static/src/js/menu.js b/addons/web/static/src/js/chrome/menu.js index 5fdfec5fb37..5fdfec5fb37 100644 --- a/addons/web/static/src/js/menu.js +++ b/addons/web/static/src/js/chrome/menu.js diff --git a/addons/web/static/src/js/widgets/pager.js b/addons/web/static/src/js/chrome/pager.js index d9b37dc889a..5ecf11b391c 100644 --- a/addons/web/static/src/js/widgets/pager.js +++ b/addons/web/static/src/js/chrome/pager.js @@ -11,24 +11,25 @@ var direction = { var Pager = Widget.extend({ template: "Pager", - events: { 'click .o_pager_previous': 'previous', 'click .o_pager_next': 'next', 'click .o_pager_value': '_edit', }, - /** * The pager goes from 1 to size (included). * The current value is current_min if limit === 1 - * or the interval [current_min, current_min + limit[ if limit > 1 + * or the interval [current_min, current_min + limit[ if limit > 1 + * * @param {Widget} [parent] the parent widget * @param {int} [size] the total number of elements * @param {int} [current_min] the first element of the current_page * @param {int} [limit] the number of elements per page * @param {boolean} [options.can_edit] editable feature of the pager - * @param {boolean} [options.single_page_hidden] (not) to display the pager if only one page - * @param {function} [options.validate] callback returning a Deferred to validate changes + * @param {boolean} [options.single_page_hidden] (not) to display the pager + * if only one page + * @param {function} [options.validate] callback returning a Deferred to + * validate changes */ init: function (parent, size, current_min, limit, options) { this.state = { @@ -47,12 +48,13 @@ var Pager = Widget.extend({ validate: function() { return $.Deferred().resolve(); }, + withAccessKey: true, // can be disabled, for example, for x2m widgets }); this._super(parent); }, - /** * Renders the pager + * * @returns {jQuery.Deferred} */ start: function () { @@ -62,28 +64,9 @@ var Pager = Widget.extend({ return this._super(); }, - /** - * Sets the state of the pager and renders it - * @param {Object} [state] the values to update (size, current_min and limit) - */ - update_state: function (state) { - _.extend(this.state, state); - this._render(); - }, - - /** - * Executes the previous action on the pager - */ - previous: function() { - this._change_selection(direction.previous); - }, - - /** - * Executes the next action on the pager - */ - next: function() { - this._change_selection(direction.next); - }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- /** * Disables the pager's arrows @@ -91,72 +74,40 @@ var Pager = Widget.extend({ disable: function() { this.$('button').prop("disabled", true); }, - /** * Enables the pager's arrows */ enable: function() { this.$('button').prop("disabled", false); }, - /** - * Private function that displays an input to edit the pager's state + * Executes the next action on the pager */ - _edit: function() { - if (this.options.can_edit) { - var self = this; - var $input = $('<input>', {type: 'text', value: this.$value.html()}); - - this.$value.html($input); - $input.focus(); - - // Event handlers - $input.click(function(ev) { - ev.stopPropagation(); // ignore clicks on the input - }); - $input.blur(function(ev) { - self._save($(ev.target)); // save the state when leaving the input - }); - $input.on('keydown', function (ev) { - ev.stopPropagation(); - if (ev.which === $.ui.keyCode.ENTER) { - self._save($(ev.target)); // save on enter - } else if (ev.which === $.ui.keyCode.ESCAPE) { - self._render(); // leave on escape - } - }); - } + next: function() { + this._change_selection(direction.next); }, - /** - * Private function that saves the state from the content of the input - * @param {jQuery} [$input] the jQuery element containing the new state + * Executes the previous action on the pager */ - _save: function($input) { - var self = this; - this.options.validate().then(function() { - var value = $input.val().split("-"); - var min = utils.confine(parseInt(value[0], 10), 1, self.state.size); - var max = utils.confine(parseInt(value[1], 10), 1, self.state.size); - - if (!isNaN(min)) { - self.state.current_min = min; - if (!isNaN(max)) { - self.state.limit = utils.confine(max-min+1, 1, self.state.size); - } else { - // The state has been given as a single value -> set the limit to 1 - self.state.limit = 1; - } - self.trigger('pager_changed', _.clone(self.state)); - } - }).always(function() { - // Render the pager's new state (removes the input) - self._render(); - }); + previous: function() { + this._change_selection(direction.previous); }, + /** + * Sets the state of the pager and renders it + * @param {Object} [state] the values to update (size, current_min and limit) + */ + updateState: function (state) { + _.extend(this.state, state); + this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- /** * Private function that updates the pager's state according to a pager action + * * @param {int} [direction] the action (previous or next) on the pager */ _change_selection: function (direction) { @@ -181,7 +132,34 @@ var Pager = Widget.extend({ self._render(); }); }, + /** + * Private function that displays an input to edit the pager's state + */ + _edit: function() { + if (this.options.can_edit) { + var self = this; + var $input = $('<input>', {type: 'text', value: this.$value.html()}); + this.$value.html($input); + $input.focus(); + + // Event handlers + $input.click(function(ev) { + ev.stopPropagation(); // ignore clicks on the input + }); + $input.blur(function(ev) { + self._save($(ev.target)); // save the state when leaving the input + }); + $input.on('keydown', function (ev) { + ev.stopPropagation(); + if (ev.which === $.ui.keyCode.ENTER) { + self._save($(ev.target)); // save on enter + } else if (ev.which === $.ui.keyCode.ESCAPE) { + self._render(); // leave on escape + } + }); + } + }, /** * Private function that renders the pager's state */ @@ -209,7 +187,34 @@ var Pager = Widget.extend({ this.$value.html(value); this.$limit.html(size); } + }, /** + * Private function that saves the state from the content of the input + * + * @param {jQuery} [$input] the jQuery element containing the new state + */ + _save: function($input) { + var self = this; + this.options.validate().then(function() { + var value = $input.val().split("-"); + var min = utils.confine(parseInt(value[0], 10), 1, self.state.size); + var max = utils.confine(parseInt(value[1], 10), 1, self.state.size); + + if (!isNaN(min)) { + self.state.current_min = min; + if (!isNaN(max)) { + self.state.limit = utils.confine(max-min+1, 1, self.state.size); + } else { + // The state has been given as a single value -> set the limit to 1 + self.state.limit = 1; + } + self.trigger('pager_changed', _.clone(self.state)); + } + }).always(function() { + // Render the pager's new state (removes the input) + self._render(); + }); }, + }); return Pager; diff --git a/addons/web/static/src/js/views/search_filters.js b/addons/web/static/src/js/chrome/search_filters.js index cedb63fa1d9..e4d58536df2 100644 --- a/addons/web/static/src/js/views/search_filters.js +++ b/addons/web/static/src/js/chrome/search_filters.js @@ -29,9 +29,9 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext init: function (parent, fields) { this._super(parent); this.fields = _(fields).chain() - .map(function(val, key) { return _.extend({}, val, {'name': key}); }) + .map(function (val, key) { return _.extend({}, val, {'name': key}); }) .filter(function (field) { return !field.deprecated && field.searchable; }) - .sortBy(function(field) {return field.string;}) + .sortBy(function (field) {return field.string;}) .value(); this.attrs = {_: _, fields: this.fields, selected: null}; this.value = null; @@ -39,10 +39,10 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext start: function () { return this._super().done(this.proxy('changed')); }, - changed: function() { + changed: function () { var nval = this.$(".o_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;})); + this.select_field(_.detect(this.fields, function (x) {return x.name == nval;})); } }, operator_changed: function (e) { @@ -53,7 +53,7 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext * * @param field a field descriptor object (as returned by fields_get, augmented by the field name) */ - select_field: function(field) { + select_field: function (field) { var self = this; if(this.attrs.selected !== null && this.attrs.selected !== undefined) { this.value.destroy(); @@ -66,10 +66,10 @@ var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.Ext } var type = field.type; - var Field = core.search_filters_registry.get_any([type, "char"]); + var Field = core.search_filters_registry.getAny([type, "char"]); this.value = new Field(this, field); - _.each(this.value.operators, function(operator) { + _.each(this.value.operators, function (operator) { $('<option>', {value: operator.value}) .text(String(operator.text)) .appendTo(self.$('.o_searchview_extended_prop_op')); @@ -166,7 +166,7 @@ ExtendedSearchProposition.Char = ExtendedSearchProposition.Field.extend({ {value: "∃", text: _lt("is set")}, {value: "∄", text: _lt("is not set")} ], - get_value: function() { + get_value: function () { return this.$el.val(); } }); @@ -223,7 +223,7 @@ ExtendedSearchProposition.DateTime = ExtendedSearchProposition.Field.extend({ } return str; }, - start: function() { + start: function () { return $.when( this._super.apply(this, arguments), this._create_new_widget("datewidget_0") @@ -268,7 +268,7 @@ ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({ toString: function () { return this.$el.val(); }, - get_value: function() { + get_value: function () { try { var val =this.$el.val(); return formats.parse_value(val === "" ? 0 : val, {'widget': 'integer'}); @@ -301,7 +301,7 @@ ExtendedSearchProposition.Float = ExtendedSearchProposition.Field.extend({ toString: function () { return this.$el.val(); }, - get_value: function() { + get_value: function () { try { var val =this.$el.val(); return formats.parse_value(val === "" ? 0.0 : val, {'widget': 'float'}); @@ -324,7 +324,7 @@ ExtendedSearchProposition.Selection = ExtendedSearchProposition.Field.extend({ var option = select.options[select.selectedIndex]; return option.label || option.text; }, - get_value: function() { + get_value: function () { return this.$el.val(); } }); @@ -339,7 +339,7 @@ ExtendedSearchProposition.Boolean = ExtendedSearchProposition.Field.extend({ return this.format_label( _t('%(field)s %(operator)s'), field, operator); }, - get_value: function() { + get_value: function () { return true; } }); diff --git a/addons/web/static/src/js/views/search_inputs.js b/addons/web/static/src/js/chrome/search_inputs.js index 99535f29797..99535f29797 100644 --- a/addons/web/static/src/js/views/search_inputs.js +++ b/addons/web/static/src/js/chrome/search_inputs.js diff --git a/addons/web/static/src/js/views/search_menus.js b/addons/web/static/src/js/chrome/search_menus.js index 0b28d13fea2..7eff17d7ed6 100644 --- a/addons/web/static/src/js/views/search_menus.js +++ b/addons/web/static/src/js/chrome/search_menus.js @@ -94,15 +94,16 @@ return Widget.extend({ this.$inputs.first().focus(); return; } - var search = this.searchview.build_search_data(), - view_manager = this.findAncestor(function (a) { + var user_context = this.getSession().user_context; + var search = this.searchview.build_search_data(); + var 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({ + }); + var view_context = view_manager ? view_manager.active_view.controller.get_context() : {}; + var results = pyeval.eval_domains_and_contexts({ domains: search.domains, - contexts: search.contexts.concat(view_context || []), + contexts: [user_context].concat(search.contexts.concat(view_context || [])), group_by_seq: search.groupbys || [], }); if (!_.isEmpty(results.group_by)) { @@ -274,7 +275,6 @@ return Widget.extend({ odoo.define('web.FilterMenu', function (require) { "use strict"; -var data_manager = require('web.data_manager'); var search_filters = require('web.search_filters'); var search_inputs = require('web.search_inputs'); var Widget = require('web.Widget'); @@ -301,12 +301,16 @@ return Widget.extend({ } }, }, - init: function (parent, filters) { + init: function (parent, filters, fields) { this._super(parent); this.filters = filters || []; this.searchview = parent; this.propositions = []; this.custom_filters_open = false; + this.fields = _.pick(fields, function (field, name) { + return field.selectable !== false && name !== 'id'; + }); + this.fields.id = { string: 'ID', type: 'id', searchable: true }; }, start: function () { var self = this; @@ -321,22 +325,6 @@ return Widget.extend({ } }); }, - get_fields: function () { - if (!this._fields_def) { - this._fields_def = data_manager.load_fields(this.searchview.dataset).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; - }); - } - return this._fields_def; - }, toggle_custom_filter_menu: function (is_open) { var self = this; this.custom_filters_open = !_.isUndefined(is_open) ? is_open : !this.custom_filters_open; @@ -353,14 +341,10 @@ return Widget.extend({ }); }, append_proposition: function () { - var self = this; - return this.get_fields().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); - return prop; - }); + var prop = new search_filters.ExtendedSearchProposition(this, this.fields); + this.propositions.push(prop); + this.$apply_filter.prop('disabled', false); + return prop.insertBefore(this.$add_filter_menu); }, remove_proposition: function (prop) { this.propositions = _.without(this.propositions, prop); @@ -396,7 +380,6 @@ odoo.define('web.GroupByMenu', function (require) { "use strict"; var core = require('web.core'); -var data_manager = require('web.data_manager'); var search_inputs = require('web.search_inputs'); var Widget = require('web.Widget'); @@ -416,13 +399,22 @@ return Widget.extend({ this.toggle_add_menu(); }, }, - init: function (parent, groups) { + init: function (parent, groups, fields) { + var self = this; this._super(parent); - this.groups = groups || []; - this.groupable_fields = []; this.searchview = parent; + this.groups = groups || []; + this.groupableFields = []; + var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; + _.each(fields, function (field, name) { + if (field.store && _.contains(groupable_types, field.type)) { + self.groupableFields.push(_.extend({}, field, {name: name})); + } + }); + self.groupableFields = _.sortBy(this.groupableFields, 'string'); }, start: function () { + var self = this; this.$menu = this.$('.o_group_by_menu'); var divider = this.$menu.find('.divider'); _.invoke(this.groups, 'insertBefore', divider); @@ -430,48 +422,28 @@ return Widget.extend({ divider.show(); } this.$add_group = this.$menu.find('.o_add_custom_group'); - }, - get_fields: function () { - var self = this; - if (!this._fields_def) { - this._fields_def = data_manager.load_fields(this.searchview.dataset).then(function (fields) { - var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; - var filter_group_field = _.filter(fields, function(field, name) { - if (field.store && _.contains(groupable_types, field.type)) { - field.name = name; - return field; - } - }); - self.groupable_fields = _.sortBy(filter_group_field, 'string'); - - self.$menu.append(QWeb.render('GroupByMenuSelector', self)); - self.$add_group_menu = self.$('.o_add_group'); - self.$group_selector = self.$('.o_group_selector'); - self.$('.o_select_group').click(function () { - self.toggle_add_menu(false); - var field = self.$group_selector.find(':selected').data('name'); - self.add_groupby_to_menu(field); - }); - }); - } - return this._fields_def; + this.$menu.append(QWeb.render('GroupByMenuSelector', this)); + this.$add_group_menu = this.$('.o_add_group'); + this.$group_selector = this.$('.o_group_selector'); + this.$('.o_select_group').click(function () { + self.toggle_add_menu(false); + var field = self.$group_selector.find(':selected').data('name'); + self.add_groupby_to_menu(field); + }); }, toggle_add_menu: function (is_open) { - var self = this; - this.get_fields().then(function () { - self.$add_group - .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined) - .toggleClass('o_open_menu', is_open); - self.$add_group_menu.toggle(is_open); - if (self.$add_group.hasClass('o_open_menu')) { - self.$group_selector.focus(); - } - }); + this.$add_group + .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined) + .toggleClass('o_open_menu', is_open); + this.$add_group_menu.toggle(is_open); + if (this.$add_group.hasClass('o_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: _.find(this.groupable_fields, {name: field_name}).string, + name: _.find(this.groupableFields, {name: field_name}).string, }}, this.searchview); var group = new search_inputs.FilterGroup([filter], this.searchview), divider = this.$('.divider').show(); diff --git a/addons/web/static/src/js/views/search_view.js b/addons/web/static/src/js/chrome/search_view.js index 65dbb4557e7..26305817e55 100644 --- a/addons/web/static/src/js/views/search_view.js +++ b/addons/web/static/src/js/chrome/search_view.js @@ -4,15 +4,12 @@ odoo.define('web.SearchView', function (require) { var AutoComplete = require('web.AutoComplete'); var config = require('web.config'); var core = require('web.core'); -var data_manager = require('web.data_manager'); var FavoriteMenu = require('web.FavoriteMenu'); var FilterMenu = require('web.FilterMenu'); var GroupByMenu = require('web.GroupByMenu'); var pyeval = require('web.pyeval'); var search_inputs = require('web.search_inputs'); -var View = require('web.View'); var Widget = require('web.Widget'); -var local_storage = require('web.local_storage'); var _t = core._t; var Backbone = window.Backbone; @@ -230,12 +227,12 @@ var FacetValueView = Widget.extend({ } }); -var SearchView = View.extend({ +var SearchView = Widget.extend({ events: { 'click .o_searchview_more': function (e) { $(e.target).toggleClass('fa-search-plus fa-search-minus'); - var visible_search_menu = (local_storage.getItem('visible_search_menu') !== 'true'); - local_storage.setItem('visible_search_menu', visible_search_menu); + var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu'); + this.call('local_storage', 'setItem', visibleSearchMenu !== 'true'); this.toggle_buttons(); }, 'keydown .o_searchview_input, .o_searchview_facet': function (e) { @@ -253,7 +250,7 @@ var SearchView = View.extend({ } }, }, - defaults: _.extend({}, View.prototype.defaults, { + defaults: _.extend({}, Widget.prototype.defaults, { hidden: false, disable_filters: false, disable_groupby: false, @@ -273,15 +270,20 @@ var SearchView = View.extend({ * @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() { + init: function (parent, dataset, fvg, options) { this._super.apply(this, arguments); + this.options = options; + this.dataset = dataset; + this.fields_view = fvg; + this.fields = this.fields_view.fields; this.query = undefined; this.title = this.options.action && this.options.action.name; this.action_id = this.options.action && this.options.action.id; this.search_fields = []; this.filters = []; this.groupbys = []; - this.visible_filters = (local_storage.getItem('visible_search_menu') === 'true'); + var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu'); + this.visible_filters = (visibleSearchMenu === 'true'); this.input_subviews = []; // for user input in searchbar this.search_defaults = this.options.search_defaults || {}; this.headless = this.options.hidden && _.isEmpty(this.search_defaults); @@ -295,13 +297,13 @@ var SearchView = View.extend({ var self = this; var def; if (!this.options.disable_favorites) { - def = data_manager.load_filters(this.dataset, this.action_id).then(function (filters) { + def = this.loadFilters(this.dataset, this.action_id).then(function (filters) { self.favorite_filters = filters; }); } return $.when(this._super(), def); }, - start: function() { + start: function () { if (this.headless) { this.do_hide(); } @@ -318,11 +320,11 @@ var SearchView = View.extend({ this.prepare_search_inputs(); if (this.$buttons) { if (!this.options.disable_filters) { - this.filter_menu = new FilterMenu(this, this.filters); + this.filter_menu = new FilterMenu(this, this.filters, this.fields); menu_defs.push(this.filter_menu.appendTo(this.$buttons)); } if (!this.options.disable_groupby) { - this.groupby_menu = new GroupByMenu(this, this.groupbys); + this.groupby_menu = new GroupByMenu(this, this.groupbys, this.fields); menu_defs.push(this.groupby_menu.appendTo(this.$buttons)); } if (!this.options.disable_favorites) { @@ -332,7 +334,7 @@ var SearchView = View.extend({ } return $.when.apply($, menu_defs).then(this.set_default_filters.bind(this)); }, - get_title: function() { + get_title: function () { return this.title; }, set_default_filters: function () { @@ -368,7 +370,7 @@ var SearchView = View.extend({ return; } var search = this.build_search_data(); - this.trigger('search_data', search.domains, search.contexts, search.groupbys); + this.trigger_up('search', search); }, /** * Extract search data from the view's facets. @@ -590,7 +592,7 @@ var SearchView = View.extend({ attrs.widget = undefined; } - var Obj = core.search_widgets_registry.get_any([attrs.widget, field.type]); + var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]); if (Obj) { self.search_fields.push(new (Obj) (filter.item, field, self)); } diff --git a/addons/web/static/src/js/widgets/sidebar.js b/addons/web/static/src/js/chrome/sidebar.js index 32513048087..79b06f9ee4d 100644 --- a/addons/web/static/src/js/widgets/sidebar.js +++ b/addons/web/static/src/js/chrome/sidebar.js @@ -120,10 +120,10 @@ var Sidebar = Widget.extend({ 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 ids = self.getParent().getSelectedIds(); var domain; - if (self.getParent().get_active_domain) { - domain = self.getParent().get_active_domain(); + if (self.getParent().getActiveDomain) { + domain = self.getParent().getActiveDomain(); } else { domain = $.Deferred().resolve(undefined); diff --git a/addons/web/static/src/js/widgets/systray_menu.js b/addons/web/static/src/js/chrome/systray_menu.js index 0d0e18476b0..24fd02a3736 100644 --- a/addons/web/static/src/js/widgets/systray_menu.js +++ b/addons/web/static/src/js/chrome/systray_menu.js @@ -3,33 +3,49 @@ odoo.define('web.SystrayMenu', function (require) { var Widget = require('web.Widget'); +/** + * The SystrayMenu is the class that manage the list of icons in the top right + * of the menu bar. + */ var SystrayMenu = Widget.extend({ /** * This widget renders the systray menu. It creates and renders widgets * pushed in instance.web.SystrayItems. */ - init: function(parent) { + init: function (parent) { this._super(parent); this.items = []; this.widgets = []; this.load = $.Deferred(); }, - start: function() { + /** + * @override + * @returns {Deferred} + */ + start: function () { var self = this; self._super.apply(this, arguments); - self.load_items(); + self._loadItems(); $.when.apply($, self.items).always(function () { self.load.resolve(); }); return self.load; }, - load_items: function() { + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiate items, using the classes located in SystrayMenu.items. + */ + _loadItems: function () { var self = this; SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) { return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50; }); - _.each(SystrayMenu.Items, function(widgetCls) { - var cur_systray_item = new widgetCls(self); + _.each(SystrayMenu.Items, function (WidgetClass) { + var cur_systray_item = new WidgetClass(self); self.widgets.push(cur_systray_item); self.items.push(cur_systray_item.prependTo(self.$el)); }); diff --git a/addons/web/static/src/js/chrome/user_menu.js b/addons/web/static/src/js/chrome/user_menu.js new file mode 100644 index 00000000000..8a33260432b --- /dev/null +++ b/addons/web/static/src/js/chrome/user_menu.js @@ -0,0 +1,112 @@ +odoo.define('web.UserMenu', function (require) { +"use strict"; + +/** + * This widget is appended by the webclient to the right of the navbar. + * It displays the avatar and the name of the logged user (and optionally the + * db name, in debug mode). + * If clicked, it opens a dropdown allowing the user to perform actions like + * editing its preferences, accessing the documentation, logging out... + */ + +var framework = require('web.framework'); +var Widget = require('web.Widget'); + + +var UserMenu = Widget.extend({ + template: 'UserMenu', + + /** + * @override + * @returns {Deferred} + */ + start: function () { + var self = this; + var session = this.getSession(); + this.$el.on('click', '.dropdown-menu li a[data-menu]', function (ev) { + ev.preventDefault(); + var menu = $(this).data('menu'); + self['_onMenu' + menu.charAt(0).toUpperCase() + menu.slice(1)](); + }); + return this._super.apply(this, arguments).then(function () { + var $avatar = self.$('.oe_topbar_avatar'); + if (!session.uid) { + $avatar.attr('src', $avatar.data('default-src')); + return $.when(); + } + var topbar_name = session.name; + if (session.debug) { + topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db); + } + self.$('.oe_topbar_name').text(topbar_name); + var avatar_src = session.url('/web/image', { + model:'res.users', + field: 'image_small', + id: session.uid, + }); + $avatar.attr('src', avatar_src); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onMenuAccount: function () { + var self = this; + this.trigger_up('clear_uncommitted_changes', { + callback: function () { + self.performRPC('/web/session/account').then(function (url) { + framework.redirect(url); + }).fail(function (result, ev){ + ev.preventDefault(); + framework.redirect('https://accounts.odoo.com/account'); + }); + }, + }); + }, + /** + * @private + */ + _onMenuDocumentation: function () { + window.open('https://www.odoo.com/documentation/user', '_blank'); + }, + /** + * @private + */ + _onMenuLogout: function () { + this.trigger_up('clear_uncommitted_changes', { + callback: this.do_action.bind(this, 'logout'), + }); + }, + /** + * @private + */ + _onMenuSettings: function () { + var self = this; + var session = this.getSession(); + this.trigger_up('clear_uncommitted_changes', { + callback: function () { + self.performRPC("/web/action/load", { + action_id: "base.action_res_users_my", + }).done(function (result) { + result.res_id = session.uid; + self.do_action(result); + }); + }, + }); + }, + /** + * @private + */ + _onMenuSupport: function () { + window.open('https://www.odoo.com/buy', '_blank'); + }, +}); + +return UserMenu; + +}); diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js new file mode 100644 index 00000000000..5b30d1a3c5c --- /dev/null +++ b/addons/web/static/src/js/chrome/view_manager.js @@ -0,0 +1,610 @@ +odoo.define('web.ViewManager', function (require) { +"use strict"; + +var ControlPanelMixin = require('web.ControlPanelMixin'); +var core = require('web.core'); +var data = require('web.data'); +var data_manager = require('web.data_manager'); +var dom = require('web.dom'); +var pyeval = require('web.pyeval'); +var SearchView = require('web.SearchView'); +var view_registry = require('web.view_registry'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var ViewManager = Widget.extend(ControlPanelMixin, { + className: "o_view_manager_content", + custom_events: { + execute_action: function(event) { + var data = event.data; + this.do_execute_action(data.action_data, data.model, data.record_id, data.on_closed) + .then(data.on_success, data.on_fail); + }, + search: function(event) { + var d = event.data; + _.extend(this.env, this._process_search_data(d.domains, d.contexts, d.groupbys)); + this.active_view.controller.reload(this.env); + }, + switch_view: function(event) { + if ('res_id' in event.data) { + this.env.currentId = event.data.res_id; + } + var options = {}; + if (event.data.mode) { + options.mode = event.data.mode; + } + this.switch_mode(event.data.view_type, options); + }, + env_updated: function(event) { + _.extend(this.env, event.data); + }, + push_state: function(event) { + this.do_push_state(event.data); + }, + }, + /** + * Called each time the view manager is attached into the DOM + */ + on_attach_callback: function() { + this.is_in_DOM = true; + if (this.active_view && this.active_view.controller.on_attach_callback) { + this.active_view.controller.on_attach_callback(); + } + }, + /** + * Called each time the view manager is detached from the DOM + */ + on_detach_callback: function() { + this.is_in_DOM = false; + if (this.active_view && this.active_view.controller.on_detach_callback) { + this.active_view.controller.on_detach_callback(); + } + }, + /** + * @param {Object} [dataset] + * @param {Array} [views] List of [view_id, view_type[, fields_view]] + * @param {Object} [flags] various boolean describing UI state + */ + init: function(parent, dataset, views, flags, options) { + var self = this; + this._super(parent); + + this.action = options && options.action || {}; + this.action_manager = options && options.action_manager; + this.flags = flags || {}; + this.dataset = dataset; + this.view_order = []; + this.views = {}; + this.view_stack = []; // used for breadcrumbs + this.active_view = null; + this.registry = view_registry; + this.title = this.action.name; + var actionGroupBy = self.action.context.group_by; + if (!actionGroupBy) { + actionGroupBy = [] + } else if (typeof actionGroupBy === 'string') { + actionGroupBy = [actionGroupBy] + } + _.each(views, function (view) { + var view_type = view[1] || view.view_type; + var View = self.registry.get(view_type); //.prototype.config.Controller; + if (!View) { + console.error("View type", "'"+view_type+"'", "is not present in the view registry."); + return; + } + var view_label = View.prototype.display_name; + var view_descr = { + accesskey: View.prototype.accesskey, + button_label: _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}), + controller: null, + fields_view: view[2] || view.fields_view, + icon: View.prototype.icon, + label: view_label, + mobile_friendly: View.prototype.mobile_friendly, + multi_record: View.prototype.multi_record, + options: _.extend({ + action: self.action, + limit: self.action.limit, + views: self.action.views, + groupBy: actionGroupBy, + }, self.flags, self.flags[view_type], view.options), + searchable: View.prototype.searchable, + title: self.title, + type: view_type, + view_id: view[0] || view.view_id, + }; + self.view_order.push(view_descr); + self.views[view_type] = view_descr; + }); + this.first_view = this.views[options && options.view_type]; // view to open first + this.default_view = this.get_default_view(); + + // env registers properties shared between views + this.env = { + modelName: this.dataset.model, + ids: this.dataset.ids.length ? this.dataset.ids : undefined, + currentId: this.dataset.ids.length ? this.dataset.ids[this.dataset.index] : undefined, + domain: undefined, + context: this.dataset.context, + groupBy: undefined, + }; + }, + willStart: function () { + var views_def; + var first_view_to_display = this.first_view || this.default_view; + if (!first_view_to_display.fields_view || (this.flags.search_view && !this.search_fields_view)) { + views_def = this.load_views(); + } + return $.when(this._super(), views_def); + }, + /** + * @return {Deferred} initial view and search view (if any) loading promise + */ + start: function() { + var self = this; + var _super = this._super.bind(this, arguments); + var def; + if (this.flags.search_view) { + def = this.setup_search_view().then(function() { + // udpate domain, context and groupby in the env + var d = self.searchview.build_search_data(); + _.extend(self.env, self._process_search_data(d.domains, d.contexts, d.groupbys)); + }); + } + return $.when(def).then(function() { + var defs = []; + defs.push(_super()); + + if (self.flags.views_switcher) { + self.render_switch_buttons(); + } + + // If a non multi-record first_view is given, switch to it but first push the default_view + // to the view_stack to complete the breadcrumbs + if (self.first_view && !self.first_view.multi_record && self.default_view.multi_record) { + self.view_stack.push(self.default_view); + } + var view_to_load = self.first_view || self.default_view; + var options = _.extend({}, view_to_load.options); + defs.push(self.switch_mode(view_to_load.type, options)); + + return $.when.apply($, defs).then(function() { + if (self.flags.on_load) { + self.flags.on_load(self); + } + }); + }); + }, + /** + * Loads all missing field_views of views in this.views and the search view. + * + * @return {Deferred} + */ + load_views: function () { + var self = this; + var views = []; + _.each(this.views, function (view) { + if (!view.fields_view) { + views.push([view.view_id, view.type]); + } + }); + var options = { + action_id: this.action.id, + toolbar: this.flags.sidebar, + }; + if (this.flags.search_view && !this.search_fields_view) { + options.load_filters = true; + var searchview_id = this.action.search_view_id && this.action.search_view_id[0]; + views.push([searchview_id || false, 'search']); + } + var params = { + model: this.dataset.model, + context: this.dataset.get_context(), + views_descr: views, + }; + return data_manager.load_views(params, options).then(function (fields_views) { + _.each(fields_views, function (fields_view, view_type) { + if (view_type === 'search') { + self.search_fields_view = fields_view; + } else { + self.views[view_type].fields_view = fields_view; + } + }); + }); + }, + /** + * Returns the default view with the following fallbacks: + * - use the default_view defined in the flags, if any + * - use the first view in the view_order + * + * @returns {Object} the default view + */ + get_default_view: function() { + return this.views[this.flags.default_view || this.view_order[0].type]; + }, + switch_mode: function(view_type, view_options) { + var self = this; + var view = this.views[view_type]; + + if (!view || this.currently_switching) { + return $.Deferred().reject(); + } else { + this.currently_switching = true; // prevent overlapping switches + } + + var old_view = this.active_view; + view_options = _.extend({}, view.options, view_options, this.env); + + if (view_options.groupBy && !view_options.groupBy.length) { + var actionContext = view_options ? view_options.action.context : {}; + var actionGroupBy = actionContext.group_by; + if (!actionGroupBy) { + actionGroupBy = [] + } else if (typeof actionGroupBy === 'string') { + actionGroupBy = [actionGroupBy] + } + view_options.groupBy = actionGroupBy; + } + + // Ensure that the fields_view has been loaded + var views_def; + if (!view.fields_view) { + views_def = this.load_views(); + } + + return $.when(views_def).then(function () { + if (view.multi_record) { + self.view_stack = []; + } else if (self.view_stack.length > 0 && !(_.last(self.view_stack).multi_record)) { + // Replace the last view by the new one if both are mono_record + self.view_stack.pop(); + } + self.view_stack.push(view); + + self.active_view = view; + + if (!view.loaded) { + view.loaded = $.Deferred(); + self.create_view(view, view_options).then(function(controller) { + view.controller = controller; + view.$fragment = $('<div>'); + controller.appendTo(view.$fragment).done(function() { + // Remove the unnecessary outer div + view.$fragment = view.$fragment.contents(); + view.loaded.resolve(); + }); + }); + } else { + view.loaded = view.loaded.then(function() { + return view.controller.reload(view_options); + }); + } + + return $.when(view.loaded) + .then(function() { + self._display_view(old_view); + self.trigger('switch_mode', view_type, view_options); + }).fail(function(e) { + if (!(e && e.code === 200 && e.data.exception_type)) { + self.do_warn(_t("Error"), view.controller.display_name + _t(" view couldn't be loaded")); + } + // Restore internal state + self.active_view = old_view; + self.view_stack.pop(); + }); + }).always(function () { + self.currently_switching = false; + }); + }, + _display_view: function (old_view) { + var view_controller = this.active_view.controller; + var view_fragment = this.active_view.$fragment; + + // Prepare the ControlPanel content and update it + var view_control_elements = this.render_view_control_elements(); + var cp_status = { + active_view_selector: '.o_cp_switch_' + this.active_view.type, + breadcrumbs: this.action_manager && this.action_manager.get_breadcrumbs(), + cp_content: _.extend({}, this.searchview_elements, view_control_elements), + hidden: this.flags.headless, + searchview: this.searchview, + search_view_hidden: !this.active_view.searchable, + }; + this.update_control_panel(cp_status); + + // Detach the old view and store it + if (old_view && old_view !== this.active_view) { + // Store the scroll position + if (this.action_manager && this.action_manager.webclient) { + old_view.controller.setScrollTop(this.action_manager.webclient.getScrollTop()); + } + // Do not detach ui-autocomplete elements to let jquery-ui garbage-collect them + var $to_detach = this.$el.contents().not('.ui-autocomplete'); + old_view.$fragment = dom.detach([{widget: old_view.controller}], {$to_detach: $to_detach}); + } + + // If the user switches from a multi-record to a mono-record view, + // the action manager should be scrolled to the top. + if (old_view && old_view.controller.multi_record === true && view_controller.multi_record === false) { + view_controller.setScrollTop(0); + } + + // Append the view fragment to this.$el + dom.append(this.$el, view_fragment, { + in_DOM: this.is_in_DOM, + callbacks: [{widget: view_controller}], + }); + }, + create_view: function(view, view_options) { + var self = this; + var arch = view.fields_view.arch; + var View = this.registry.get(arch.attrs.js_class || view.type); + var params = _.extend({}, view_options); + if (view.type === "form" && ((this.action.target === 'new' || this.action.target === 'inline') || + (view_options && view_options.mode === 'edit'))) { + params.mode = params.initial_mode || 'edit'; + } + + view = new View(arch, view.fields_view.fields, params); + return view.createController(this).then(function(controller) { + controller.on('history_back', this, function() { + if (self.action_manager) self.action_manager.trigger('history_back'); + }); + controller.on("change:title", this, function() { + if (self.action_manager && !self.flags.headless) { + var breadcrumbs = self.action_manager.get_breadcrumbs(); + self.update_control_panel({breadcrumbs: breadcrumbs}, {clear: false}); + } + }); + return controller; + }); + }, + select_view: function (index) { + var view_type = this.view_stack[index].type; + return this.switch_mode(view_type); + }, + /** + * Renders the switch buttons for multi- and mono-record views and adds + * listeners on them, but does not append them to the DOM + * Sets switch_buttons.$mono and switch_buttons.$multi to send to the ControlPanel + */ + render_switch_buttons: function() { + var self = this; + + // Partition the views according to their multi-/mono-record status + var views = _.partition(this.view_order, function(view) { + return view.multi_record === true; + }); + var multi_record_views = views[0]; + var mono_record_views = views[1]; + + // Inner function to render and prepare switch_buttons + var _render_switch_buttons = function(views) { + if (views.length > 1) { + var $switch_buttons = $(QWeb.render('ViewManager.switch-buttons', {views: views})); + // Create bootstrap tooltips + _.each(views, function(view) { + $switch_buttons.filter('.o_cp_switch_' + view.type).tooltip(); + }); + // Add onclick event listener + $switch_buttons.filter('button').click(_.debounce(function(event) { + var view_type = $(event.target).data('view-type'); + self.switch_mode(view_type); + }, 200, true)); + return $switch_buttons; + } + }; + + // Render switch buttons but do not append them to the DOM as this will + // be done later, simultaneously to all other ControlPanel elements + this.switch_buttons = {}; + this.switch_buttons.$multi = _render_switch_buttons(multi_record_views); + this.switch_buttons.$mono = _render_switch_buttons(mono_record_views); + }, + /** + * Renders the control elements (buttons, sidebar, pager) of the current view. + * Fills this.active_view.control_elements dictionnary with the rendered + * elements and the adequate view switcher, to send to the ControlPanel. + * Warning: it should be called before calling do_show on the view as the + * sidebar is extended to listen on the load_record event triggered as soon + * as do_show is done (the sidebar should thus be instantiated before). + */ + render_view_control_elements: function() { + if (!this.active_view.control_elements) { + var view_controller = this.active_view.controller; + var $buttons = this.flags.$buttons; + var elements = {}; + if (!this.flags.headless) { + elements = { + $buttons: $("<div>"), + $sidebar: $("<div>"), + $pager: $("<div>"), + }; + } + view_controller.renderButtons($buttons ? $buttons.empty() : elements.$buttons); + view_controller.renderSidebar(elements.$sidebar); + view_controller.renderPager(elements.$pager); + // Remove the unnecessary outer div + elements = _.mapObject(elements, function($node) { + return $node && $node.contents(); + }); + // Use the adequate view switcher (mono- or multi-record) + if (this.switch_buttons) { + if (this.active_view.multi_record) { + elements.$switch_buttons = this.switch_buttons.$multi; + } else { + elements.$switch_buttons = this.switch_buttons.$mono; + } + } + + // Store the rendered elements in the active_view to allow restoring them later + this.active_view.control_elements = elements; + } + return this.active_view.control_elements; + }, + /** + * Sets up the current viewmanager's search view. + * Sets $searchview and $searchview_buttons in searchview_elements to send to the ControlPanel + */ + setup_search_view: function() { + var self = this; + var search_defaults = {}; + var context = 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: $("<div>"), + action: this.action, + search_defaults: search_defaults, + }; + // Instantiate the SearchView, but do not append it nor its buttons to the DOM as this will + // be done later, simultaneously to all other ControlPanel elements + this.searchview = new SearchView(this, this.dataset, this.search_fields_view, options); + + return $.when(this.searchview.appendTo($("<div>"))).done(function() { + self.searchview_elements = {}; + self.searchview_elements.$searchview = self.searchview.$el; + self.searchview_elements.$searchview_buttons = self.searchview.$buttons.contents(); + }); + }, + _process_search_data: function(domains, contexts, groupbys) { + // var controller = this.active_view.controller; // the correct view must be loaded here + var action_context = this.action.context || {}; + var view_context = {}; //controller.get_context(); + var results = pyeval.eval_domains_and_contexts({ + domains: [this.action.domain || []].concat(domains || []), + contexts: [action_context, view_context].concat(contexts || []), + group_by_seq: groupbys || [], + eval_context: this.getSession().user_context, + }); + if (results.error) { + throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s", + JSON.stringify(results.error))); + } + // this.dataset._model = new Model(this.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)){ + // this.dataset.set_sort([]); + // } + return { + context: results.context, + domain: results.domain, + groupBy: results.group_by, + }; + }, + 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) { + var stateChanged = false; + if ('id' in state && state.id !== this.env.currentId) { + this.env.currentId = state.id; + stateChanged = true; + } + if (state.view_type && state.view_type !== this.active_view.type) { + stateChanged = true; + } + if (stateChanged) { + this.switch_mode(state.view_type); + } + }, + /** + * 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 {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, model, record_id, on_closed) { + var self = this; + var result_handler = on_closed || function () {}; + var context = new data.CompoundContext(this.env.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(self.env.context), function(pair) { + return pair[0].match('^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null; + })) + ); + ncontext.add(action_data.context || {}); + ncontext.add({active_model: self.env.modelName}); + 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 ? [[record_id]] : [this.env.ids]; + 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); + var dataset = new data.DataSet(this, model, this.env.context); + return dataset.call_button(action_data.name, args).then(handler); + } else if (action_data.type === "action") { + return data_manager.load_action(action_data.name, _.extend(pyeval.eval('context', context), { + active_model: this.env.modelName, + active_ids: this.env.ids, + active_id: record_id + })).then(handler); + } + }, + destroy: function () { + if (this.control_elements) { + if (this.control_elements.$switch_buttons) { + this.control_elements.$switch_buttons.off(); + } + } + return this._super.apply(this, arguments); + }, +}); + +return ViewManager; + +}); diff --git a/addons/web/static/src/js/web_client.js b/addons/web/static/src/js/chrome/web_client.js index f08947d092b..17993c73638 100644 --- a/addons/web/static/src/js/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -3,7 +3,6 @@ odoo.define('web.WebClient', function (require) { var AbstractWebClient = require('web.AbstractWebClient'); var core = require('web.core'); -var data = require('web.data'); var data_manager = require('web.data_manager'); var framework = require('web.framework'); var Menu = require('web.Menu'); diff --git a/addons/web/static/src/js/compatibility.js b/addons/web/static/src/js/compatibility.js deleted file mode 100644 index d6a97b76f0e..00000000000 --- a/addons/web/static/src/js/compatibility.js +++ /dev/null @@ -1,288 +0,0 @@ -// ------------------------------------------------------------------------------ -// Compatibility with Odoo v8. -// -// With the new module system, no global variable can (and should) be accessed -// in openerp. This file exports everything, to mimic the previous global -// namespace structure. This is only supposed to be used by 3rd parties to -// facilitate migration. Odoo addons should not use the 'openerp' variable at -// all. -// ------------------------------------------------------------------------------ -odoo.define('web.compatibility', function (require) { -"use strict"; - -var ActionManager = require('web.ActionManager'); -var core = require('web.core'); -var data = require('web.data'); -var Dialog = require('web.Dialog'); -var FavoriteMenu = require('web.FavoriteMenu'); -var form_common = require('web.form_common'); -var formats = require('web.formats'); -var FormView = require('web.FormView'); -var form_relational = require('web.form_relational'); // necessary -var form_widgets = require('web.form_widgets'); // necessary -var framework = require('web.framework'); -var ListView = require('web.ListView'); -var Model = require('web.DataModel'); -var pyeval = require('web.pyeval'); -var Registry = require('web.Registry'); -var SearchView = require('web.SearchView'); -var session = require('web.session'); -var Sidebar = require('web.Sidebar'); -var SystrayMenu = require('web.SystrayMenu'); -var time = require('web.time'); -var UserMenu = require('web.UserMenu'); -var utils = require('web.utils'); -var View = require('web.View'); -var ViewManager = require('web.ViewManager'); -var WebClient = require('web.WebClient'); -var Widget = require('web.Widget'); - -var client_started = $.Deferred(); - -var OldRegistry = Registry.extend({ - add: function (key, path) { - }, - get_object: function (key) { - return get_object(this.map[key]); - }, -}); - -window.openerp = window.openerp || {}; - -$.Mutex = utils.Mutex; -openerp._session_id = "instance0"; -openerp._t = core._t; -openerp.get_cookie = utils.get_cookie; - -openerp.qweb = core.qweb; -openerp.session = session; - -openerp.web = openerp.web || {}; -openerp.web._t = core._t; -openerp.web._lt = core._lt; - -openerp.web.ActionManager = ActionManager; -openerp.web.auto_str_to_date = time.auto_str_to_date; -openerp.web.blockUI = framework.blockUI; -openerp.web.BufferedDataSet = data.BufferedDataSet; -openerp.web.bus = core.bus; -openerp.web.Class = core.Class; -openerp.web.client_actions = make_old_registry(core.action_registry); -openerp.web.CompoundContext = data.CompoundContext; -openerp.web.CompoundDomain = data.CompoundDomain; -openerp.web.DataSetSearch = data.DataSetSearch; -openerp.web.DataSet = data.DataSet; -openerp.web.date_to_str = time.date_to_str; -openerp.web.Dialog = Dialog; -openerp.web.DropMisordered = utils.DropMisordered; - -openerp.web.form = openerp.web.form || {}; -openerp.web.form.AbstractField = form_common.AbstractField; -openerp.web.form.compute_domain = data.compute_domain; -openerp.web.form.DefaultFieldManager = form_common.DefaultFieldManager; -openerp.web.form.FieldChar = core.form_widget_registry.get('char'); -openerp.web.form.FieldFloat = core.form_widget_registry.get('float'); -openerp.web.form.FieldStatus = core.form_widget_registry.get('statusbar'); -openerp.web.form.FieldMany2ManyTags = core.form_widget_registry.get('many2many_tags'); -openerp.web.form.FieldMany2One = core.form_widget_registry.get('many2one'); -openerp.web.form.FormWidget = form_common.FormWidget; -openerp.web.form.tags = make_old_registry(core.form_tag_registry); -openerp.web.form.widgets = make_old_registry(core.form_widget_registry); -openerp.web.form.custom_widgets = make_old_registry(core.form_custom_registry); - -openerp.web.format_value = formats.format_value; -openerp.web.FormView = FormView; - -openerp.web.json_node_to_xml = utils.json_node_to_xml; - -openerp.web.ListView = ListView; -openerp.web.Model = Model; -openerp.web.normalize_format = time.strftime_to_moment_format; -openerp.web.py_eval = pyeval.py_eval; -openerp.web.pyeval = pyeval; -openerp.web.qweb = core.qweb; - -openerp.web.Registry = OldRegistry; - -openerp.web.search = {}; -openerp.web.search.FavoriteMenu = FavoriteMenu; -openerp.web.SearchView = SearchView; -openerp.web.Sidebar = Sidebar; -openerp.web.str_to_date = time.str_to_date; -openerp.web.str_to_datetime = time.str_to_datetime; -openerp.web.SystrayItems = SystrayMenu.Items; -openerp.web.unblockUI = framework.unblockUI; -openerp.web.UserMenu = UserMenu; -openerp.web.View = View; -openerp.web.ViewManager = ViewManager; -openerp.web.views = make_old_registry(core.view_registry); -openerp.web.WebClient = WebClient; -openerp.web.Widget = Widget; - -openerp.Widget = openerp.web.Widget; -openerp.Widget.prototype.session = session; - - -WebClient.include({ - init: function () { - openerp.client = this; - openerp.webclient = this; - start_modules(); - client_started.resolve(); - this._super.apply(this, arguments); - }, -}); - - -function make_old_registry(registry) { - return { - add: function (key, path) { - client_started.done(function () { - registry.add(key, get_object(path)); - }); - }, - }; -} -function get_object(path) { - var object_match = openerp; - path = path.split('.'); - // ignore first section - for(var i=1; i<path.length; ++i) { - object_match = object_match[path[i]]; - } - return object_match; -} - -/** - * OpenERP instance constructor - * - * @param {Array|String} modules list of modules to initialize - */ -var inited = false; -function start_modules (modules) { - if (modules === undefined) { - modules = odoo._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]]); - } - } - openerp._modules = ['web'].concat(modules); - return openerp; -}; - -// Monkey-patching of the ListView for backward compatibiliy of the colors and -// fonts row's attributes, as they are deprecated in 9.0. -ListView.include({ - willStart: function() { - if (this.fields_view.arch.attrs.colors) { - this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain() - .compact() - .map(function(color_pair) { - var pair = color_pair.split(':'), - color = pair[0], - expr = pair[1]; - return [color, py.parse(py.tokenize(expr)), expr]; - }).value(); - } - - if (this.fields_view.arch.attrs.fonts) { - this.fonts = _(this.fields_view.arch.attrs.fonts.split(';')).chain().compact() - .map(function(font_pair) { - var pair = font_pair.split(':'), - font = pair[0], - expr = pair[1]; - return [font, py.parse(py.tokenize(expr)), expr]; - }).value(); - } - - return this._super(); - }, - /** - * Returns the style for the provided record in the current view (from the - * ``@colors`` and ``@fonts`` attributes) - * - * @param {Record} record record for the current row - * @returns {String} CSS style declaration - */ - style_for: function (record) { - var len, style= ''; - - var context = _.extend({}, record.attributes, { - uid: session.uid, - current_date: moment().format('YYYY-MM-DD') - // TODO: time, datetime, relativedelta - }); - var i; - var pair; - var expression; - if (this.fonts) { - for(i=0, len=this.fonts.length; i<len; ++i) { - pair = this.fonts[i]; - var font = pair[0]; - expression = pair[1]; - if (py.PY_isTrue(py.evaluate(expression, context))) { - switch(font) { - case 'bold': - style += 'font-weight: bold;'; - break; - case 'italic': - style += 'font-style: italic;'; - break; - case 'underline': - style += 'text-decoration: underline;'; - break; - } - } - } - } - - if (!this.colors) { return style; } - for(i=0, len=this.colors.length; i<len; ++i) { - pair = this.colors[i]; - var color = pair[0]; - expression = pair[1]; - if (py.PY_isTrue(py.evaluate(expression, context))) { - return style += 'color: ' + color + ';'; - } - } - return style; - }, -}); - -// 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(); - } - }); -} - -}); diff --git a/addons/web/static/src/js/core/abstract_service.js b/addons/web/static/src/js/core/abstract_service.js new file mode 100644 index 00000000000..6ae0c3183da --- /dev/null +++ b/addons/web/static/src/js/core/abstract_service.js @@ -0,0 +1,20 @@ +odoo.define('web.AbstractService', function (require) { +"use strict"; + +var Class = require('web.Class'); + +var AbstractService = Class.extend({ + Services: [], + name: null, +}); + +var realExtend = AbstractService.extend; + +AbstractService.extend = function() { + var Service = realExtend.apply(this, arguments); + AbstractService.prototype.Services.push(Service); + return Service; +}; + +return AbstractService; +}); diff --git a/addons/web/static/src/js/framework/ajax.js b/addons/web/static/src/js/core/ajax.js index dee6f4599b4..dee6f4599b4 100644 --- a/addons/web/static/src/js/framework/ajax.js +++ b/addons/web/static/src/js/core/ajax.js diff --git a/addons/web/static/src/js/framework/bus.js b/addons/web/static/src/js/core/bus.js index bf33c1fe01e..bf33c1fe01e 100644 --- a/addons/web/static/src/js/framework/bus.js +++ b/addons/web/static/src/js/core/bus.js diff --git a/addons/web/static/src/js/framework/class.js b/addons/web/static/src/js/core/class.js index 8e53f0ba06d..8e53f0ba06d 100644 --- a/addons/web/static/src/js/framework/class.js +++ b/addons/web/static/src/js/core/class.js diff --git a/addons/web/static/src/js/core/collections.js b/addons/web/static/src/js/core/collections.js new file mode 100644 index 00000000000..bc27a9021cc --- /dev/null +++ b/addons/web/static/src/js/core/collections.js @@ -0,0 +1,44 @@ +odoo.define("web.collections", function (require) { + "use strict"; + + var Class = require("web.Class"); + + /** + * Allows to build a tree representation of a data. + */ + var Tree = Class.extend({ + /** + * @constructor + * @param {*} data - the data associated to the root node + */ + init: function (data) { + this._data = data; + this._children = []; + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns the root's associated data. + * + * @returns {*} + */ + getData: function () { + return this._data; + }, + /** + * Adds a child tree. + * + * @param {Tree} tree + */ + addChild: function (tree) { + this._children.push(tree); + }, + }); + + return { + Tree: Tree, + }; +}); diff --git a/addons/web/static/src/js/core/concurrency.js b/addons/web/static/src/js/core/concurrency.js new file mode 100644 index 00000000000..cab9df6529e --- /dev/null +++ b/addons/web/static/src/js/core/concurrency.js @@ -0,0 +1,237 @@ +odoo.define('web.concurrency', function (require) { +"use strict"; + +/** + * Concurrency Utils + * + * This file contains a short collection of useful helpers designed to help with + * everything concurrency related in Odoo. + * + * The basic concurrency primitives in Odoo JS are the callback, and the + * promises. Promises (deferred) are more composable, so we usually use them + * whenever possible. We use the jQuery implementation. + * + * Those functions are really nothing special, but are simply the result of how + * we solved some concurrency issues, when we noticed that a pattern emerged. + */ + +var Class = require('web.Class'); + +return { + /** + * The jquery implementation for $.when has a (most of the time) useful + * property: it is synchronous, if the deferred is resolved immediately. + * + * This means that when we execute $.when(def), then all registered + * callbacks will be executed before the next line is executed. This is + * useful quite often, but in some rare cases, we might want to force an + * async behavior. This is the purpose of this function, which simply adds a + * setTimeout before resolving the deferred. + * + * @returns {Deferred} + */ + asyncWhen: 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; + }, + /** + * Returns a deferred resolved after 'wait' milliseconds + * + * @param {int} [wait=0] the delay in ms + * @return {Deferred} + */ + delay: function (wait) { + var def = $.Deferred(); + setTimeout(def.resolve, wait); + return def; + }, + /** + * The DropMisordered abstraction is useful for situations where you have + * a sequence of operations that you want to do, but if one of them + * completes after a subsequent operation, then its result is obsolete and + * should be ignored. + * + * Note that is is kind of similar to the DropPrevious abstraction, but + * subtly different. The DropMisordered operations will all resolves if + * they complete in the correct order. + */ + DropMisordered: Class.extend({ + /** + * @constructor + * + * @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(); + }, + }), + /** + * The DropPrevious abstraction is useful when you have a sequence of + * operations that you want to execute, but you only care of the result of + * the last operation. + * + * For example, let us say that we have a _fetch method on a widget which + * fetches data. We want to rerender the widget after. We could do this: + * + * this._fetch().then(function (result) { + * self.state = result; + * self.render(); + * }); + * + * Now, we have at least two problems: + * - if this code is called twice and the second _fetch completes before the + * first, the end state will be the result of the first _fetch, which is + * not what we expect + * - in any cases, the user interface will rerender twice, which is bad. + * + * Now, if we have a DropPrevious: + * + * this.dropPrevious = new DropPrevious(); + * + * Then we can wrap the _fetch in a DropPrevious and have the expected + * result: + * + * this.dropPrevious + * .add(this._fetch()) + * .then(function (result) { + * self.state = result; + * self.render(); + * }); + */ + DropPrevious: Class.extend({ + /** + * Registers a new deferred and rejects the previous one + * + * @param {Deferred} the new deferred + * @returns {Promise} + */ + add: function (deferred) { + if (this.current_def) { this.current_def.reject(); } + var res = $.Deferred(); + deferred.then(res.resolve, res.reject); + this.current_def = res; + return res.promise(); + } + }), + /** + * A (Odoo) mutex is a primitive for serializing computations. This is + * useful to avoid a situation where two computations modify some shared + * state and cause some corrupted state. + * + * Imagine that we have a function to fetch some data _load(), which returns + * a deferred which resolves to something useful. Now, we have some code + * looking like this: + * + * return this._load().then(function (result) { + * this.state = result; + * }); + * + * If this code is run twice, but the second execution ends before the + * first, then the final state will be the result of the first call to + * _load. However, if we have a mutex: + * + * this.mutex = new Mutex(); + * + * and if we wrap the calls to _load in a mutex: + * + * return this.mutex.exec(function() { + * return this._load().then(function (result) { + * this.state = result; + * }); + * }); + * + * Then, it is guaranteed that the final state will be the result of the + * second execution. + * + * A Mutex has to be a class, and not a function, because we have to keep + * track of some internal state. + */ + Mutex: Class.extend({ + init: function () { + this.def = $.Deferred().resolve(); + }, + /** + * Add a computation to the queue, it will be executed as soon as the + * previous computations are completed. + * + * @param {function} action a function which may return a deferred + * @returns {Deferred} + */ + exec: function (action) { + var current = this.def; + var next = this.def = $.Deferred(); + return current.then(function() { + return $.when(action()).always(function() { + next.resolve(); + }); + }); + } + }), + /** + * Rejects a deferred as soon as a reference deferred is either resolved or + * rejected + * + * @param {Deferred} [target_def] the deferred to potentially reject + * @param {Deferred} [reference_def] the reference target + * @returns {Deferred} + */ + rejectAfter: function (target_def, reference_def) { + var res = $.Deferred(); + target_def.then(res.resolve, res.reject); + reference_def.always(res.reject); + return res.promise(); + } +}; + +}); diff --git a/addons/web/static/src/js/framework/dialog.js b/addons/web/static/src/js/core/dialog.js index 6dc65b63281..6214c99483e 100644 --- a/addons/web/static/src/js/framework/dialog.js +++ b/addons/web/static/src/js/core/dialog.js @@ -81,7 +81,15 @@ var Dialog = Widget.extend({ var text = b.text || ""; var classes = b.classes || ((buttons.length === 1)? "btn-primary" : "btn-default"); - var $b = $(QWeb.render('WidgetButton', { widget : { string: text, node: { attrs: {'class': classes, icon: b.icon} }, fa_icon: true }})); + var $b = $(QWeb.render('WidgetButton', { + widget: { + string: text, + node: { + attrs: {'class': classes, icon: b.icon} + }, + fa_icon: true + } + })); $b.prop('disabled', b.disabled); $b.on('click', function(e) { var click_def; diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js new file mode 100644 index 00000000000..4333061f924 --- /dev/null +++ b/addons/web/static/src/js/core/dom.js @@ -0,0 +1,164 @@ +odoo.define('web.dom', function (require) { +"use strict"; + +/** + * DOM Utility helpers + * + * We collect in this file some helpers to help integrate various DOM + * functionalities with the odoo framework. A common theme in these functions + * is the use of the main core.bus, which helps the framework react when + * something happens in the DOM. + */ + +var core = require('web.core'); + +/** + * Private function to notify that something has been attached in the DOM + * @param {htmlString or Element or Array or jQuery} [content] the content that + * has been attached in the DOM + * @params {Array} [callbacks] array of {widget: w, callback_args: args} such + * that on_attach_callback() will be called on each w with arguments args + */ +function _notify (content, callbacks) { + _.each(callbacks, function(c) { + if (c.widget && c.widget.on_attach_callback) { + c.widget.on_attach_callback(c.callback_args); + } + }); + core.bus.trigger('DOM_updated', content); +} + +return { + /** + * Appends content in a jQuery object and optionnally triggers an event + * + * @param {jQuery} [$target] the node where content will be appended + * @param {htmlString or Element or Array or jQuery} [content] DOM element, + * array of elements, HTML string or jQuery object to append to $target + * @param {Boolean} [options.in_DOM] true if $target is in the DOM + * @param {Array} [options.callbacks] array of objects describing the + * callbacks to perform (see _notify for a complete description) + */ + append: function ($target, content, options) { + $target.append(content); + if (options && options.in_DOM) { + _notify(content, options.callbacks); + } + }, + /** + * Autoresize a $textarea node, by recomputing its height when necessary + * @param {number} [options.min_height] by default, 50. + * @param {Widget} [options.parent] if set, autoresize will listen to some + * extra events to decide when to resize itself. This is useful for + * widgets that are not in the dom when the autoresize is declared. + */ + autoresize: function ($textarea, options) { + if ($textarea.data("auto_resize")) { + return; + } + function resize () { + var heightOffset; + var style = window.getComputedStyle($textarea[0], null); + if (style.boxSizing === 'content-box') { + heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)); + } else { + heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); + } + $fixed_text_area.width($textarea.width()); + $fixed_text_area.val($textarea.val()); + var height = $fixed_text_area[0].scrollHeight; + $textarea.css({height: Math.max(height + heightOffset, min_height)}); + } + + options = options || {}; + + var $fixed_text_area; + var min_height = (options && options.min_height) || 50; + if (!$fixed_text_area) { + $fixed_text_area = $('<textarea disabled>').css({ + position: 'absolute', + opacity: 0, + height: 10, + top: -10000, + left: -10000, + }); + $fixed_text_area.addClass($textarea[0].className); + $fixed_text_area.insertAfter($textarea); + $fixed_text_area.data("auto_resize", true); + } + + var style = window.getComputedStyle($textarea[0], null); + if (style.resize === 'vertical') { + $textarea[0].style.resize = 'none'; + } else if (style.resize === 'both') { + $textarea[0].style.resize = 'horizontal'; + } + resize(); + $textarea.data("auto_resize", true); + + $textarea.on('input focus', resize); + if (options.parent) { + core.bus.on('DOM_updated', options.parent, resize); + core.bus.on('view_shown', options.parent, resize); + } + + }, + /** + * Detaches widgets from the DOM and performs their on_detach_callback() + * + * @param {Array} [to_detach] array of {widget: w, callback_args: args} such + * that w.$el will be detached and w.on_detach_callback(args) will be + * called + * @param {jQuery} [options.$to_detach] if given, detached instead of + * widgets' $el + * @return {jQuery} the detached elements + */ + detach: function (to_detach, options) { + _.each(to_detach, function(d) { + if (d.widget.on_detach_callback) { + d.widget.on_detach_callback(d.callback_args); + } + }); + var $to_detach = options && options.$to_detach; + if (!$to_detach) { + $to_detach = $(_.map(to_detach, function(d) { + return d.widget.el; + })); + } + return $to_detach.detach(); + }, + /** + * Returns the distance between a DOM element and the top-left corner of the + * window + * + * @param {element} [e] the DOM element + * @return {Object} the left and top distances in pixels + */ + getPosition: function (e) { + var position = {left: 0, top: 0}; + while (e) { + position.left += e.offsetLeft; + position.top += e.offsetTop; + e = e.offsetParent; + } + return position; + }, + /** + * Prepends content in a jQuery object and optionnally triggers an event + * + * @param {jQuery} [$target] the node where content will be prepended + * @param {htmlString or Element or Array or jQuery} [content] DOM element, + * array of elements, HTML string or jQuery object to prepend to $target + * @param {Boolean} [options.in_DOM] true if $target is in the DOM + * @param {Array} [options.callbacks] array of objects describing the + * callbacks to perform (see _notify for a complete description) + */ + prepend: function ($target, content, options) { + $target.prepend(content); + if (options && options.in_DOM) { + _notify(content, options.callbacks); + } + }, +}; + +}); diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js new file mode 100644 index 00000000000..11d6a59ec33 --- /dev/null +++ b/addons/web/static/src/js/core/domain.js @@ -0,0 +1,225 @@ +odoo.define("web.Domain", function (require) { +"use strict"; + +var collections = require("web.collections"); +var pyeval = require("web.pyeval"); + +/** + * The Domain Class allows to work with a domain as a tree and provides tools + * to manipulate array and string representations of domains. + */ +var Domain = collections.Tree.extend({ + /** + * @constructor + * @param {string|Array|boolean|undefined} domain + * - The given domain can be: + * * a string representation of the Python prefix-array + * representation of the domain. + * * a JS prefix-array representation of the domain. + * * a boolean where the "true" domain match all records and the + * "false" domain does not match any records. + * * undefined, considered as the false boolean. + */ + init: function (domain) { + this._super.apply(this, arguments); + if (domain === true || domain === false || domain === undefined) { + this._data = domain || false; + } else { + this._parse(this.normalizeArray(_.clone(this.stringToArray(domain)))); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Evaluates the domain with a set of values. + * + * @param {Object} values - a mapping {fieldName -> fieldValue} (note: all + * the fields used in the domain should be given a + * value otherwise the computation will break) + * @returns {boolean} + */ + compute: function (values) { + if (this._data === true || this._data === false) { + // The domain is a always-true or a always-false domain + return this._data; + } else if (_.isArray(this._data)) { + // The domain is a [name, operator, value] entity + // First check if we have the field value in the field values set + if (!(this._data[0] in values)) { + throw new Error(_.str.sprintf( + "Unknown field %s in domain", + this._data[0] + )); + } + var fieldValue = values[this._data[0]]; + + switch (this._data[1]) { + case "=": + case "==": + return _.isEqual(fieldValue, this._data[2]); + case "!=": + case "<>": + return !_.isEqual(fieldValue, this._data[2]); + case "<": + return (fieldValue < this._data[2]); + case ">": + return (fieldValue > this._data[2]); + case "<=": + return (fieldValue <= this._data[2]); + case ">=": + return (fieldValue >= this._data[2]); + case "in": + return _.contains( + _.isArray(this._data[2]) ? this._data[2] : [this._data[2]], + fieldValue + ); + case "not in": + return !_.contains( + _.isArray(this._data[2]) ? this._data[2] : [this._data[2]], + fieldValue + ); + case "like": + return (fieldValue.toLowerCase().indexOf(this._data[2].toLowerCase()) >= 0); + case "ilike": + return (fieldValue.indexOf(this._data[2]) >= 0); + default: + throw new Error(_.str.sprintf( + "Domain %s uses an unsupported operator", + this._data + )); + } + } else { // The domain is a set of [name, operator, value] entitie(s) + switch (this._data) { + case "&": + return _.every(this._children, function (child) { + return child.compute(values); + }); + case "|": + return _.some(this._children, function (child) { + return child.compute(values); + }); + case "!": + return !this._children[0].compute(values); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Initializes the tree representation of the domain according to its given + * JS prefix-array representation. Note: the given array is considered + * already normalized. + * + * @private + * @param {Array} domain - normalized JS prefix-array representation of + * the domain + */ + _parse: function (domain) { + this._data = (domain.length === 0 ? true : domain[0]); + if (domain.length <= 1) return; + + var expected = 1; + for (var i = 1 ; i < domain.length ; i++) { + if (domain[i] === "&" || domain[i] === "|") { + expected++; + } else if (domain[i] !== "!") { + expected--; + } + + if (!expected) { + i++; + this._addSubdomain(domain.slice(1, i)); + this._addSubdomain(domain.slice(i)); + break; + } + } + }, + + /** + * Adds a domain as a child (e.g. if the current domain is ["|", A, B], + * using this method with a ["&", C, D] domain will result in a + * ["|", "|", A, B, "&", C, D]). + * Note: the internal tree representation is automatically simplified. + * + * @param {Array} domain - normalized JS prefix-array representation of a + * domain to add + */ + _addSubdomain: function (domain) { + if (!domain.length) return; + var subdomain = new Domain(domain); + + if (!subdomain._children.length || subdomain._data !== this._data) { + this._children.push(subdomain); + } else { + var self = this; + _.each(subdomain._children, function (childDomain) { + self._children.push(childDomain); + }); + } + }, + + //-------------------------------------------------------------------------- + // Static + //-------------------------------------------------------------------------- + + /** + * Converts JS prefix-array representation of a domain to a string + * representation of the Python prefix-array representation of this domain. + * + * @static + * @param {Array|string} domain + * @returns {string} + */ + arrayToString: function (domain) { + if (_.isString(domain)) return domain; + return JSON.stringify(domain || []) + .replace(/false/g, "False") + .replace(/true/g, "True"); + }, + /** + * Converts a string representation of the Python prefix-array + * representation of a domain to a JS prefix-array representation of this + * domain. + * + * @static + * @param {string|Array} domain + * @returns {Array} + */ + stringToArray: function (domain) { + if (!_.isString(domain)) return domain; + return pyeval.eval("domain", domain || "[]"); + }, + /** + * Makes implicit "&" operators explicit in the given JS prefix-array + * representation of domain (e.g [A, B] -> ["&", A, B]) + * + * @static + * @param {Array} domain - the JS prefix-array representation of the domain + * to normalize (! will be normalized in-place) + * @returns {Array} the normalized JS prefix-array representation of the + * given domain + */ + normalizeArray: function (domain) { + var expected = 1; + _.each(domain, function (item) { + if (item === "&" || item === "|") { + expected++; + } else if (item !== "!") { + expected--; + } + }); + if (expected < 0) { + domain.unshift.apply(domain, _.times(Math.abs(expected), _.constant("&"))); + } + return domain; + }, +}); + +return Domain; +}); diff --git a/addons/web/static/src/js/framework/local_storage.js b/addons/web/static/src/js/core/local_storage.js index 6cf82e87752..6cf82e87752 100644 --- a/addons/web/static/src/js/framework/local_storage.js +++ b/addons/web/static/src/js/core/local_storage.js diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js new file mode 100644 index 00000000000..f65276551cf --- /dev/null +++ b/addons/web/static/src/js/core/misc.js @@ -0,0 +1,182 @@ +odoo.define('web.framework', function (require) { +"use strict"; + +var core = require('web.core'); +var crash_manager = require('web.crash_manager'); +var ajax = require('web.ajax'); +var Widget = require('web.Widget'); + +var _t = core._t; + +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() { + 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); + }, +}); + +/** 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")); + $('body').addClass('o_ui_blocked'); + return tmp; +} + +function unblockUI () { + _.invoke(throbbers, 'destroy'); + throbbers = []; + $('body').removeClass('o_ui_blocked'); + 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() { + ajax.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'); + return $.Deferred(); +} +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 + ajax.rpc("/web/session/get_session_info", {}).then(function() { + Reload(parent, action); + }); +} +core.action_registry.add("reload_context", ReloadContext); + + +return { + blockUI: blockUI, + unblockUI: unblockUI, + redirect: redirect, +}; + +}); diff --git a/addons/web/static/src/js/framework/mixins.js b/addons/web/static/src/js/core/mixins.js index 05d4105e466..5caac9a2c8c 100644 --- a/addons/web/static/src/js/framework/mixins.js +++ b/addons/web/static/src/js/core/mixins.js @@ -3,6 +3,7 @@ odoo.define('web.mixins', function (require) { var Class = require('web.Class'); var utils = require('web.utils'); +var AbstractService = require('web.AbstractService'); /** * Mixin to structure objects' life-cycles folowing a parent-children @@ -122,7 +123,7 @@ function OdooEvent (target, name, data) { this.stopped = false; } -OdooEvent.prototype.stop_propagation = function () { +OdooEvent.prototype.stopPropagation = function () { this.stopped = true; }; @@ -270,7 +271,7 @@ var EventDispatcherMixin = _.extend({}, ParentedMixin, { }, once: function(events, dest, func) { // similar to this.on(), but func is executed only once - var self = this + var self = this; if (typeof func !== "function") { throw new Error("Event handler must be a function."); } @@ -358,10 +359,114 @@ var PropertiesMixin = _.extend({}, EventDispatcherMixin, { } }); +var ServicesMixin = { + call: function(service, method) { + var args = Array.prototype.slice.call(arguments, 2); + var result; + this.trigger_up('call_service', { + service: service, + method: method, + args: args, + callback: function(r) { + result = r; + }, + }); + return result; + }, + // AJAX calls + performRPC: function(route, args) { + return this.call('ajax', 'rpc', route, args); + }, + performModelRPC: function(model, method, args, kwargs) { + return this.performRPC('/web/dataset/call_kw/' + model + '/' + method, { + model: model, + method: method, + args: args || [], + kwargs: kwargs || {}, + }); + }, + loadFieldView: function(dataset, view_id, view_type, options) { + return this.loadViews(dataset.model, dataset.get_context(), [[view_id, view_type]], options).then(function (result) { + return result[view_type]; + }); + }, + loadViews: function(modelName, context, views, options) { + var def = $.Deferred(); + this.trigger_up('load_views', { + modelName: modelName, + context: context, + views: views, + options: options, + on_success: def.resolve.bind(def), + }); + return def; + }, + loadFilters: function(dataset, action_id) { + var def = $.Deferred(); + this.trigger_up('load_filters', { + dataset: dataset, + action_id: action_id, + on_success: def.resolve.bind(def), + }); + return def; + }, + // Session stuff + getSession: function() { + var session; + this.trigger_up('get_session', { + callback: function(result) { + session = result; + } + }); + return session; + }, + /** + * 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 (action, options) { + var def = $.Deferred(); + options = options || {}; + var oldOnSuccess = options.on_success; + options.on_success = function(result) { + oldOnSuccess(result); + def.resolve(result); + }; + this.trigger_up('do_action', {'action': action, options: options}); + return def; + }, + do_notify: function(title, message, sticky) { + this.trigger_up('notification', {title: title, message: message, sticky: sticky}); + }, + do_warn: function(title, message, sticky) { + this.trigger_up('warning', {title: title, message: message, sticky: sticky}); + }, +}; + +var ServiceProvider = { + services: {}, + init: function() { + var self = this; + _.each(AbstractService.prototype.Services, function(Service) { + var service = new Service(); + self.services[service.name] = service; + }); + this.custom_events.call_service = this._call_service.bind(this); + }, + _call_service: function(event) { + var service = this.services[event.data.service]; + var result = service[event.data.method].apply(service, event.data.args); + event.data.callback(result); + }, +}; + return { ParentedMixin: ParentedMixin, EventDispatcherMixin: EventDispatcherMixin, PropertiesMixin: PropertiesMixin, + ServicesMixin: ServicesMixin, + ServiceProvider: ServiceProvider, }; }); diff --git a/addons/web/static/src/js/framework/pyeval.js b/addons/web/static/src/js/core/pyeval.js index bcab0b0717f..df56f60074a 100644 --- a/addons/web/static/src/js/framework/pyeval.js +++ b/addons/web/static/src/js/core/pyeval.js @@ -2,7 +2,6 @@ odoo.define('web.pyeval', function (require) { "use strict"; var core = require('web.core'); -var session = require('web.session'); var utils = require('web.utils'); var _t = core._t; @@ -944,14 +943,14 @@ function eval_groupbys (contexts, evaluation_context) { function pycontext () { - return _.extend({ + return { 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); + }; } /** @@ -959,17 +958,17 @@ function pycontext () { * @param {Array} object domains or contexts to evaluate * @param {Object} [context] evaluation context */ -function pyeval (type, object, context, options) { - options = options || {}; +function pyeval (type, object, context) { context = _.extend(pycontext(), context || {}); //noinspection FallthroughInSwitchStatementJS switch(type) { case 'context': case 'contexts': - if (type === 'context') + if (type === 'context') { object = [object]; - return eval_contexts((options.no_user_context ? [] : [session.user_context]).concat(object), context); + } + return eval_contexts(object, context); case 'domain': case 'domains': if (type === 'domain') @@ -1013,34 +1012,11 @@ function ensure_evaluated (args, kwargs) { } 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 || []) + context: pyeval('contexts', source.contexts || [], source.eval_context), + domain: pyeval('domains', source.domains, source.eval_context), + group_by: pyeval('groupbys', source.group_by_seq || [], source.eval_context), }; } @@ -1054,7 +1030,6 @@ return { 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/qweb.js b/addons/web/static/src/js/core/qweb.js index 50742075e05..50742075e05 100644 --- a/addons/web/static/src/js/framework/qweb.js +++ b/addons/web/static/src/js/core/qweb.js diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js new file mode 100644 index 00000000000..d70c09e4ae0 --- /dev/null +++ b/addons/web/static/src/js/core/registry.js @@ -0,0 +1,97 @@ +odoo.define('web.Registry', function (require) { +"use strict"; + +var Class = require('web.Class'); + +/** + * The registry is really pretty much only a mapping from some keys to some + * values. The Registry class only add a few simple methods around that to make + * it nicer and slightly safer. + * + * Note that registries have a fundamental problem: the value that you try to + * get in a registry might not have been added yet, so of course, you need to + * make sure that your dependencies are solid. For this reason, it is a good + * practice to avoid using the registry if you can simply import what you need + * with the 'require' statement. + * + * However, on the flip side, sometimes you cannot just simply import something + * because we would have a dependency cycle. In that case, registries might + * help. + */ +var Registry = Class.extend({ + /** + * @constructor + * @param {Object} [mapping] the initial data in the registry + */ + init: function (mapping) { + this.map = Object.create(mapping || null); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a key (and a value) to the registry. + * + * @param {string} key + * @param {any} value + * @returns {Registry} can be used to chain add calls. + */ + add: function (key, value) { + this.map[key] = value; + return this; + }, + /** + * Check if the registry contains the value + * + * @param {string} key + * @returns {boolean} + */ + contains: function (key) { + return (key in this.map); + }, + /** + * 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; + }, + /** + * Returns the value associated to the given key. + * + * @param {string} key + * @returns {any} + */ + get: function (key) { + return this.map[key]; + }, + /** + * Tries a number of keys, and returns the first object matching one of + * the keys. + * + * @param {string[]} keys a sequence of keys to fetch the object for + * @returns {any} the first result found matching an object + */ + getAny: function (keys) { + for (var i=0; i<keys.length; i++) { + if (keys[i] in this.map) { + return this.map[keys[i]]; + } + } + return null; + }, +}); + +return Registry; + +}); + diff --git a/addons/web/static/src/js/framework/session.js b/addons/web/static/src/js/core/session.js index 516436af93c..ca56eb62439 100644 --- a/addons/web/static/src/js/framework/session.js +++ b/addons/web/static/src/js/core/session.js @@ -2,6 +2,7 @@ odoo.define('web.Session', function (require) { "use strict"; var ajax = require('web.ajax'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var utils = require('web.utils'); @@ -44,7 +45,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { // remove it totally (but need to make sure the cookies are properly set) this.name = "instance0"; // TODO: session store in cookie should be optional - this.qweb_mutex = new utils.Mutex(); + this.qweb_mutex = new concurrency.Mutex(); this.currencies = {}; this._groups_def = {}; }, diff --git a/addons/web/static/src/js/framework/time.js b/addons/web/static/src/js/core/time.js index af13aa5a3b1..af13aa5a3b1 100644 --- a/addons/web/static/src/js/framework/time.js +++ b/addons/web/static/src/js/core/time.js diff --git a/addons/web/static/src/js/framework/translation.js b/addons/web/static/src/js/core/translation.js index f2bad0db4ee..f2bad0db4ee 100644 --- a/addons/web/static/src/js/framework/translation.js +++ b/addons/web/static/src/js/core/translation.js diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js new file mode 100644 index 00000000000..578eb3478f1 --- /dev/null +++ b/addons/web/static/src/js/core/utils.js @@ -0,0 +1,436 @@ +odoo.define('web.utils', function (require) { +"use strict"; + +/** + * Utils + * + * Various generic utility functions + */ + +var translation = require('web.translation'); + +var _t = translation._t; +var id = -1; + +var utils = { + /** + * Throws an error if the given condition is not true + * + * @param {any} bool + */ + assert: function (bool) { + if (!bool) { + throw new Error("AssertionError"); + } + }, + /** + * Confines a value inside an interval + * + * @param {number} [val] the value to confine + * @param {number} [min] the minimum of the interval + * @param {number} [max] the maximum of the interval + * @return {number} val if val is in [min, max], min if val < min and max + * otherwise + */ + confine: function (val, min, max) { + return Math.max(min, Math.min(max, val)); + }, + /** + * computes (Math.floor(a/b), a%b and passes that to the callback. + * + * returns the callback's result + */ + 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); + }, + /** + * @param {number} value + * @param {integer} decimals + * @returns {boolean} + */ + float_is_zero: function (value, decimals) { + var epsilon = Math.pow(10, -decimals); + return Math.abs(utils.round_precision(value, epsilon)) < epsilon; + }, + /** + * Generate a unique numerical ID + * + * @returns {integer} + */ + generateID: function () { + return ++id; + }, + /** + * Read the cookie described by c_name + * + * @param {string} c_name + * @returns {string} + */ + 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 ""; + }, + /** + * Returns a human readable number + * + * @param {Number} number + */ + human_number: function (number, decimals) { + decimals = decimals | 0; + number = Math.round(number); + var d2 = Math.pow(10, decimals); + var val = _t("kMGTPE"); + var i = val.length-1, s; + while( i ) { + s = Math.pow(10,i--*3); + if( s <= number ) { + number = Math.round(number*d2/s)/d2 + val[i]; + } + } + return number; + }, + /** + * Returns a human readable size + * + * @param {Number} number of bytes + */ + 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]; + }, + /** + * Insert "thousands" separators in the provided number (which is actually + * a string) + * + * @param {String} num + * @returns {String} + */ + insert_thousand_seps: function (num) { + var negative = num[0] === '-'; + num = (negative ? num.slice(1) : num); + return (negative ? '-' : '') + utils.intersperse( + num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep); + }, + /** + * 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} + */ + 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); + }, + /** + * @param {any} object + * @param {any} path + * @returns + */ + into: function (object, path) { + if (!_(path).isArray()) { + path = path.split('.'); + } + for (var i = 0; i < path.length; i++) { + object = object[path[i]]; + } + return object; + }, + /** + * @param {string} v + * @returns {boolean} + */ + is_bin_size: function (v) { + return (/^\d+(\.\d*)? \w+$/).test(v); + }, + /** + * @param {any} node + * @param {any} human_readable + * @param {any} indent + * @returns {string} + */ + 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.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + } 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(utils.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 + '/>'; + } + }, + /** + * Left-pad provided arg 1 with zeroes until reaching size provided by second + * argument. + * + * @see rpad + * + * @param {number|string} str value to pad + * @param {number} size size to reach on the final padded value + * @returns {string} padded string + */ + lpad: function (str, size) { + str = "" + str; + return new Array(size - str.length + 1).join('0') + str; + }, + /** + * Passes the fractional and integer parts of x to the callback, returns + * the callback's result + */ + modf: function (x, fn) { + var mod = x%1; + if (mod < 0) { + mod += 1; + } + return fn(mod, Math.floor(x)); + }, + /** + * 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 + */ + round_decimals: function (value, decimals) { + return utils.round_precision(value, Math.pow(10,-decimals)); + }, + /** + * 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} value the value to be rounded + * @param {number} precision a precision parameter. eg: 0.01 rounds to two digits. + */ + 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; + + /** + * Javascript performs strictly the round half up method, which is asymmetric. However, in + * Python, the method is symmetric. For example: + * - In JS, Math.round(-0.5) is equal to -0. + * - In Python, round(-0.5) is equal to -1. + * We want to keep the Python behavior for consistency. + */ + var sign = normalized_value < 0 ? -1.0 : 1.0; + var rounded_value = sign * Math.round(Math.abs(normalized_value)); + return rounded_value * precision; + }, + /** + * @see lpad + * + * @param {string} str + * @param {number} size + * @returns {string} + */ + rpad: function (str, size) { + str = "" + str; + return str + new Array(size - str.length + 1).join('0'); + }, + /** + * Create a cookie + * @param {String} name : the name of the cookie + * @param {String} value : the value stored in the cookie + * @param {Integer} ttl : time to live of the cookie in millis. -1 to erase the cookie. + */ + set_cookie: function (name, value, ttl) { + ttl = ttl || 24*60*60*365; + document.cookie = [ + name + '=' + value, + 'path=/', + 'max-age=' + ttl, + 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString() + ].join(';'); + }, + /** + * @param {any} array + * @param {any} elem1 + * @param {any} elem2 + */ + swap: function (array, elem1, elem2) { + var i1 = array.indexOf(elem1); + var i2 = array.indexOf(elem2); + array[i2] = elem1; + array[i1] = elem2; + }, + /** + * @param {any} str + * @param {any} elseValues + * @param {any} trueValues + * @param {any} falseValues + * @returns + */ + toBoolElse: function (str, elseValues, trueValues, falseValues) { + var ret = _.str.toBool(str, trueValues, falseValues); + if (_.isUndefined(ret)) { + return elseValues; + } + return ret; + }, + /** + * @todo: is this really the correct place? + * + * @param {any} data + * @param {any} f + */ + traverse_records: function (data, f) { + if (data.type === 'record') { + f(data); + } else if (data.data) { + for (var i = 0; i < data.data.length; i++) { + utils.traverse_records(data.data[i], f); + } + } + }, + /** + * @param {any} node + * @param {any} strip_whitespace + * @returns + */ + xml_to_json: function (node, strip_whitespace) { + switch (node.nodeType) { + case 9: + return utils.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 utils.xml_to_json(node, strip_whitespace); + })), + }; + } + }, + /** + * @param {any} node + * @returns {string} + */ + 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; + }, +}; + +return utils; + +}); diff --git a/addons/web/static/src/js/framework/widget.js b/addons/web/static/src/js/core/widget.js index 25a30ae6511..221a127c9dd 100644 --- a/addons/web/static/src/js/framework/widget.js +++ b/addons/web/static/src/js/core/widget.js @@ -2,9 +2,7 @@ odoo.define('web.Widget', function (require) { "use strict"; var core = require('web.core'); -var session = require('web.session'); -var qweb = core.qweb; var mixins = core.mixins; /** @@ -57,7 +55,7 @@ var mixins = core.mixins; */ -var Widget = core.Class.extend(mixins.PropertiesMixin, { +var Widget = core.Class.extend(mixins.PropertiesMixin, mixins.ServicesMixin, { // Backbone-ish API tagName: 'div', id: null, @@ -81,7 +79,7 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * When that widget is destroyed by calling destroy(), the current instance will be * destroyed too. Can be null. */ - init: function(parent) { + init: function (parent) { mixins.PropertiesMixin.init.call(this); this.setParent(parent); // Bind on_/do_* methods to this @@ -104,14 +102,14 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @return {jQuery.Deferred} */ - willStart: function() { + willStart: function () { return $.when(); }, /** * Destroys the current widget, also destroys all its children before destroying itself. */ - destroy: function() { - _.each(this.getChildren(), function(el) { + destroy: function () { + _.each(this.getChildren(), function (el) { el.destroy(); }); if(this.$el) { @@ -124,9 +122,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @param target A jQuery object or a Widget instance. */ - appendTo: function(target) { + appendTo: function (target) { var self = this; - return this.__widgetRenderAndInsert(function(t) { + return this.__widgetRenderAndInsert(function (t) { self.$el.appendTo(t); }, target); }, @@ -135,9 +133,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @param target A jQuery object or a Widget instance. */ - prependTo: function(target) { + prependTo: function (target) { var self = this; - return this.__widgetRenderAndInsert(function(t) { + return this.__widgetRenderAndInsert(function (t) { self.$el.prependTo(t); }, target); }, @@ -146,9 +144,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @param target A jQuery object or a Widget instance. */ - insertAfter: function(target) { + insertAfter: function (target) { var self = this; - return this.__widgetRenderAndInsert(function(t) { + return this.__widgetRenderAndInsert(function (t) { self.$el.insertAfter(t); }, target); }, @@ -157,9 +155,9 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @param target A jQuery object or a Widget instance. */ - insertBefore: function(target) { + insertBefore: function (target) { var self = this; - return this.__widgetRenderAndInsert(function(t) { + return this.__widgetRenderAndInsert(function (t) { self.$el.insertBefore(t); }, target); }, @@ -168,10 +166,10 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @param target A jQuery object or a Widget instance. */ - attachTo: function(target) { + attachTo: function (target) { var self = this; this.setElement(target.$el || target); - return this.willStart().then(function() { + return this.willStart().then(function () { return self.start(); }); }, @@ -180,14 +178,14 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * * @param target A jQuery object or a Widget instance. */ - replace: function(target) { - return this.__widgetRenderAndInsert(_.bind(function(t) { + replace: function (target) { + return this.__widgetRenderAndInsert(_.bind(function (t) { this.$el.replaceAll(t); }, this), target); }, - __widgetRenderAndInsert: function(insertion, target) { + __widgetRenderAndInsert: function (insertion, target) { var self = this; - return this.willStart().then(function() { + return this.willStart().then(function () { self.renderElement(); insertion(target); return self.start(); @@ -197,12 +195,12 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * 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() + * 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() { + start: function () { return $.when(); }, /** @@ -210,10 +208,10 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * `this.template` must be defined. The context given to QWeb contains the "widget" * key that references `this`. */ - renderElement: function() { + renderElement: function () { var $el; if (this.template) { - $el = $(qweb.render(this.template, {widget: this}).trim()); + $el = $(core.qweb.render(this.template, {widget: this}).trim()); } else { $el = this._make_descriptive(); } @@ -323,13 +321,7 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { if (!this.custom_events.hasOwnProperty(key)) { continue; } var method = this.proxy(this.custom_events[key]); - this.on(key, this, wrap_handler(method)); - } - function wrap_handler(callback) { - return function (event) { - event.stop_propagation(); - callback(event); - }; + this.on(key, this, method); } }, undelegateEvents: function () { @@ -341,7 +333,7 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { * @param {String} selector CSS selector, rooted in $el * @returns {jQuery} selector match */ - $: function(selector) { + $: function (selector) { if (selector === undefined) return this.$el; return this.$el.find(selector); @@ -398,27 +390,6 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, { 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(title, message, sticky) { - this.trigger_up('notification', {title: title, message: message, sticky: sticky}); - }, - do_warn: function(title, message, sticky) { - this.trigger_up('warning', {title: title, message: message, sticky: sticky}); - }, - rpc: function(url, data, options) { - return this.alive(session.rpc(url, data, options)); - }, }); return Widget; diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js new file mode 100644 index 00000000000..fbf27f07eee --- /dev/null +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -0,0 +1,328 @@ +odoo.define('web.AbstractField', function (require) { +"use strict"; + +/** + * This is the basic field widget used by all the views to render a field in a view. + * These field widgets are mostly common to all views, in particular form and list + * views. + * + * The responsabilities of a field widget are mainly: + * - render a visual representation of the current value of a field + * - that representation is either in 'readonly' or in 'edit' mode + * - notify the rest of the system when the field has been changed by + * the user (in edit mode) + * + * Notes + * - the widget is not supposed to be able to switch between modes. If another + * mode is required, the view will take care of instantiating another widget. + * - notify the system when its value has changed and its mode is changed to 'readonly' + * - notify the system when some action has to be taken, such as opening a record + * - the Field widget should not, ever, under any circumstance, be aware of + * its parent. The way it communicates changes with the rest of the system is by + * triggering events (with trigger_up). These events bubble up and are interpreted + * by the most appropriate parent. + * + * Also, in some cases, it may not be practical to have the same widget for all + * views. In that situation, you can have a 'view specific widget'. Just register + * the widget in the registry prefixed by the view type and a dot. So, for example, + * a form specific many2one widget should be registered as 'form.many2one'. + * + * @module web.AbstractField + */ + +var field_utils = require('web.field_utils'); +var pyeval = require('web.pyeval'); +var Widget = require('web.Widget'); + +var AbstractField = Widget.extend({ + className: 'o_field_widget', + events: { + 'keydown': '_onKeydown', + }, + /** + * if this flag is set to true, the rest of the web client will assume that + * it is not editable. For example, the list view in editable mode will + * skip the widget when pressing tab + */ + readonly: false, + + /** + * If this flag is set to true, the field widget will be reset on every + * change which is made in the view (if the view supports it). This is + * currently a form view feature. + */ + resetOnAnyFieldChange: false, + + /** + * if true, the widget will replace a cell in an editable list view + */ + replace_element: false, + /** + * to override to indicate which field types are supported by the widget + */ + supportedFieldTypes: [], + + /** + * Abstract field class + * + * @constructor + * @param {Widget} parent + * @param {string} name The field name defined in the model + * @param {Object} record A record object (result of the get method of a basic model) + * @param {Object} [options] + * @param {string} [options.mode=readonly] should be 'readonly' or 'edit' + * @param {string} [options.required=false] + * @param {string} [options.idForLabel] + */ + init: function (parent, name, record, options) { + this._super(parent); + options = options || {}; + + // 'name' is the field name displayed by this widget + this.name = name; + + // the 'field' property is a description of all the various field properties, + // such as the type, the comodel (relation), ... + this.field = record.fields[name]; + + // the 'attrs' property contains the attributes of the xml 'field' tag + this.attrs = record.fieldAttrs[name] || {}; + + // this property tracks the current (parsed if needed) value of the field. + // Note that we don't use an event system anymore, using this.get('value') + // is no longer valid. + this.value = record.data[name]; + + // recordData tracks the values for the other fields for the same record. + // note that it is expected to be mostly a readonly property, you cannot + // use this to try to change other fields value, this is not how it is + // supposed to work. Also, do not use this.recordData[this.name] to get + // the current value, this could be out of sync after a _setValue. + this.recordData = record.data; + + // the 'string' property is a human readable (and translated) description + // of the field. Mostly useful to be displayed in various places in the + // UI, such as tooltips or create dialogs. + this.string = this.attrs.string || this.field.string || this.name; + + // Widget can often be configured in the 'options' attribute in the + // xml 'field' tag. These options are saved (and evaled) in nodeOptions + this.nodeOptions = pyeval.py_eval(this.attrs.options || '{}', this.recordData); + + // dataPointID is the id corresponding to the current record in the model. + // Its intended use is to be able to tag any messages going upstream, + // so the view knows which records was changed for example. + this.dataPointID = record.id; + + // this is the res_id for the record in database. Obviously, it is + // readonly. Also, when the user is creating a new record, there is + // no res_id. When the record will be created, the field widget will + // be destroyed (when the form view switches to readonly mode) and a new + // widget with a res_id in mode readonly will be created. + this.res_id = record.res_id; + + // useful mostly to trigger rpcs on the correct model + this.model = record.model; + + // a widget can be in two modes: 'edit' or 'readonly'. This mode should + // never be changed, if a view changes its mode, it will destroy and + // recreate a new field widget. + this.mode = options.mode || "readonly"; + + // this flag tracks if the widget is in a valid state, meaning that the + // current value represented in the DOM is a value that can be parsed + // and saved. For example, a float field can only use a number and not + // a string. + this._isValid = true; + + // the 'required' flag is basically only needed to determine if the widget + // is in a valid state (not valid if empty and required) + this.required = options.required || false; + + // the 'idForLabel' is the (html) id that should be set to the relevent + // dom entity. If done correctly, clicking on the corresponding label + // (in form view) will focus and select the value. + this.idForLabel = options.idForLabel; + }, + /** + * When a field widget is appended to the DOM, its start method is called, + * and will automatically call render. Most widgets should not override this. + * + * @returns {Deferred} + */ + start: function() { + var self = this; + return this._super.apply(this, arguments).then(function() { + self._render(); + if (self.required) { + self.$el.addClass('o_form_required'); + } + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Right now, this function is only used in editable list view when the user + * select a cell. The corresponding widget will be activated, which in general + * means that the input text will be focused and selected + */ + activate: function () { + }, + /** + * this method is used to determine if the field value is set to a meaningful + * value. This is useful to determine if a field should be displayed as empty + * + * @returns {boolean} + */ + isSet: function () { + return !!this.value; + }, + /** + * a field widget is valid if its value is valid and if there is a value when + * it is required. This is checked before saving a record, by the form view. + * + * @returns {boolean} + */ + isValid: function () { + return this._isValid && !(this.required && !this.isSet()); + }, + /** + * this method is supposed to be called from the outside of field widgets. + * The typical use case is when an onchange has changed the widget value. + * It will reset the widget to the values that could have changed, then will + * rerender the widget. + * + * @param {any} record + * @param {OdooEvent} [event] an event that triggered the reset action. It + * is optional, and may be used by a widget to share information from the + * moment a field change event is triggered to the moment a reset + * operation is applied. + * @returns {Deferred} A Deferred, which resolves when the widget rendering + * is complete + */ + reset: function (record, event) { + this._reset(record, event); + return this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * convert the value from the field to a string representation + * + * @private + * @param {any} value (from the field type) + * @returns {string} + */ + _formatValue: function (value) { + var options = _.extend({}, this.nodeOptions, { data: this.recordData }); + return field_utils.format_field(value, this.field, options); + }, + /** + * convert a string representation to a valid value, depending on the field + * type. + * + * @private + * @param {string} value + * @returns {any} + */ + _parseValue: function (value) { + return field_utils.parse_field(value, this.field); + }, + /** + * main rendering function. Override this if your widget has the same render + * for each mode. Note that this function is supposed to be idempotent: + * the result of calling 'render' twice is the same as calling it once. + * Also, the user experience will be better if your rendering function is + * synchronous. + * + * @private + * @returns {Deferred} + */ + _render: function () { + if (this.mode === 'edit') { + return this._renderEdit(); + } else if (this.mode === 'readonly') { + return this._renderReadonly(); + } + }, + /** + * Render the widget in edit mode. The actual implementation is left to the + * concrete widget. + * + * @private + * @returns {Deferred} + */ + _renderEdit: function () { + }, + /** + * Render the widget in readonly mode. The actual implementation is left to + * the concrete widget. + * + * @private + * @returns {Deferred} + */ + _renderReadonly: function () { + }, + /** + * pure version of reset, can be overridden, called before render() + * + * @private + * @param {any} record + * @param {OdooEvent} event the event that triggered the change + */ + _reset: function (record, event) { + this.value = record.data[this.name]; + this.recordData = record.data; + }, + /** + * this method is called by the widget, to change its value and to notify + * the outside world of its new state. This method also validates the new + * value. Note that this method does not rerender the widget, it should be + * handled by the widget itself, if necessary. + * + * @private + * @param {any} value + */ + _setValue: function (value) { + try { + value = this._parseValue(value); + this._isValid = true; + } catch(e) { + this._isValid = false; + } + var changes = {}; + changes[this.name] = value; + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: changes, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * might be controversial: intercept the tab key, to allow the editable list + * view to control where the focus is. + * + * @private + * @param {KeyEvent} event + */ + _onKeydown: function (event) { + if (event.which === $.ui.keyCode.TAB) { + this.trigger_up('move_next'); + event.preventDefault(); + } + }, +}); + +return AbstractField; + +}); diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js new file mode 100644 index 00000000000..d07bcb5dd95 --- /dev/null +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -0,0 +1,1300 @@ +odoo.define('web.basic_fields', function (require) { +"use strict"; + +/** + * This module contains most of the basic (meaning: non relational) field + * widgets. Field widgets are supposed to be used in views inheriting from + * BasicView, so, they can work with the records obtained from a BasicModel. + */ + +var AbstractField = require('web.AbstractField'); +var ajax = require('web.ajax'); +var config = require('web.config'); +var core = require('web.core'); +var crash_manager = require('web.crash_manager'); +var datepicker = require('web.datepicker'); +var dom = require('web.dom'); +var field_utils = require('web.field_utils'); +var framework = require('web.framework'); +var session = require('web.session'); +var utils = require('web.utils'); + +var qweb = core.qweb; +var _t = core._t; + + +var InputField = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'input': '_onInput', + }), + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit') { + this.tagName = 'input'; + } + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + activate: function () { + this.$el.focus(); + setTimeout(this.$el.select.bind(this.$el), 0); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _renderEdit: function () { + this.$el.addClass('o_form_input'); + this.$el.attr('type', 'text'); + if (this.attrs.placeholder) { + this.$el.attr('placeholder', this.attrs.placeholder); + } + this.$el.attr('id', this.idForLabel); + // save cursor position to restore it after updating value + var selectionStart = this.el.selectionStart; + var selectionEnd = this.el.selectionEnd; + this.$el.val(this._formatValue(this.value)); + this.el.selectionStart = selectionStart; + this.el.selectionEnd = selectionEnd; + }, + _renderReadonly: function () { + this.$el.empty().html(this._formatValue(this.value)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Implement keyboard movements. Mostly useful for its environment, such + * as a list view. + * + * @override + * @param {any} event + */ + _onKeydown: function (event) { + var input = this.$el[0]; + var is_not_selecting; + switch (event.which) { + case $.ui.keyCode.DOWN: + this.trigger_up('move_down'); + break; + case $.ui.keyCode.UP: + this.trigger_up('move_up'); + break; + case $.ui.keyCode.LEFT: + is_not_selecting = input.selectionEnd === input.selectionStart; + if (is_not_selecting && input.selectionStart === 0) { + this.trigger_up('move_left'); + } + break; + case $.ui.keyCode.RIGHT: + is_not_selecting = input.selectionEnd === input.selectionStart; + if (is_not_selecting && input.selectionEnd === input.value.length) { + this.trigger_up('move_right'); + } + break; + case $.ui.keyCode.ENTER: + this.trigger_up('move_next_line'); + break; + } + this._super.apply(this, arguments); + }, + /** + * We notify the outside world for each change in this input value. It does + * not necessarily mean that onchanges will be triggered instantly. + */ + _onInput: function () { + this._setValue(this.$el.val()); + }, +}); + +var FieldChar = InputField.extend({ + supportedFieldTypes: ['char', 'text'], + tagName: 'span', +}); + +var FieldDate = InputField.extend({ + className: "o_form_field_date", + tagName: "span", + replace_element: true, + supportedFieldTypes: ['date', 'datetime'], + + start: function () { + var self = this; + var def; + if (this.mode === 'edit') { + this.datewidget = this._makeDatePicker(); + this.datewidget.on('datetime_changed', this, function () { + if (this.datewidget.get_value() !== this._parseValue(this.value)) { + this._setValue(this.datewidget.get_value()); + } + }); + def = this.datewidget.appendTo('<div>').done(function () { + self.datewidget.$el.addClass(self.$el.attr('class')); + self.datewidget.$input.addClass('o_form_input'); + self.datewidget.$input.attr('id', self.idForLabel); + self.replaceElement(self.datewidget.$el); + }); + } + return $.when(def, this._super.apply(this, arguments)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + activate: function () { + var $input = this.datewidget.$input; + $input.focus(); + setTimeout($input.select.bind($input), 0); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _makeDatePicker: function () { + return new datepicker.DateWidget(this); + }, + _renderEdit: function () { + this.datewidget.set_value(this.value); + }, +}); + +var FieldDateTime = FieldDate.extend({ + supportedFieldTypes: ['datetime'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _makeDatePicker: function () { + return new datepicker.DateTimeWidget(this); + }, +}); + +var FieldMonetary = InputField.extend({ + className: 'o_list_number', + supportedFieldTypes: ['float', 'monetary'], + // FieldMonetary overrides _formatValue to ensure that the format_monetary + // method is used. This allows this widget to be used with other field + // types, such as float. + _formatValue: function (value) { + var options = _.extend({}, this.nodeOptions, { data: this.recordData }); + return field_utils.format_monetary(value, this.field, options); + }, + isSet: function () { + return this.value !== false; + } +}); + +var FieldBoolean = AbstractField.extend({ + className: 'o_field_boolean', + events: _.extend({}, AbstractField.prototype.events, { + change: '_onChange', + keydown: '_onKeyDown', + }), + replace_element: true, + supportedFieldTypes: ['boolean'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + activate: function () { + this.$input.focus(); + setTimeout(this.$input.select.bind(this.$input), 0); + }, + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _renderReadonly: function () { + var $checkbox = this._formatValue(this.value); + this.$input = $checkbox.find('input'); + this.$el.empty().append($checkbox); + }, + _renderEdit: function () { + this._renderReadonly(); + this.$input.prop('disabled', false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + _onChange: function () { + this._setValue(this.$input[0].checked); + }, + /** + * @param {KeyEvent} event + */ + _onKeydown: function (event) { + switch (event.which) { + case $.ui.keyCode.DOWN: + this.trigger_up('move_down'); + break; + case $.ui.keyCode.UP: + this.trigger_up('move_up'); + break; + case $.ui.keyCode.LEFT: + this.trigger_up('move_left'); + break; + case $.ui.keyCode.RIGHT: + this.trigger_up('move_right'); + break; + case $.ui.keyCode.TAB: + this.trigger_up('move_next'); + event.preventDefault(); + break; + case $.ui.keyCode.ENTER: + this.$input.prop('checked', !this.value); + this._setValue(!this.value); + break; + } + }, +}); + +var FieldInteger = InputField.extend({ + supportedFieldTypes: ['integer'], + isSet: function () { + return this.value !== false; + }, +}); + +var FieldFloat = InputField.extend({ + supportedFieldTypes: ['float'], + _renderReadonly: function () { + var precision; + var value = this.value; + if (this.attrs.digits) { + precision = this.node.attrs.digits; + } else { + precision = this.field.digits; + } + if (precision && precision.length === 2) { + value = utils.round_decimals(value, precision[1]); + } + var $span = $('<span>').addClass('o_form_field o_form_field_number').text(this._formatValue(value)); + this.$el.html($span); + }, + isSet: function () { + return this.value !== ''; + }, +}); + +var FieldFloatTime = FieldFloat.extend({ + supportedFieldTypes: ['float'], + _formatValue: function (value) { + 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); + }, + _parseValue: function (value) { + 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 * field_utils.parse_float(value, this.field); + } + var hours = field_utils.parse_integer(float_time_pair[0]); + var minutes = field_utils.parse_integer(float_time_pair[1]); + return factor * (hours + (minutes / 60)); + }, +}); + +var FieldText = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'input': function () { + this._setValue(this.$textarea.val()); + }, + }), + supportedFieldTypes: ['text'], + start: function () { + this.$el.addClass('o_list_text o_form_textarea'); + this.$el.attr('id', this.idForLabel); + + if (this.mode === 'edit') { + // in edit mode, the widget contains a textarea. We append it in + // start() instead of _renderEdit() to keep the same textarea even + // if several _renderEdit are done. This allows to keep the cursor + // position and to autoresize only once + this.$textarea = $('<textarea>').appendTo(this.$el); + if (this.attrs.placeholder) { + this.$textarea.attr('placeholder', this.attrs.placeholder); + } + dom.autoresize(this.$textarea, {parent: this}); + } + + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + activate: function () { + var $textarea = this.$('textarea'); + $textarea.focus(); + setTimeout($textarea.select.bind($textarea), 0); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _renderEdit: function () { + this.$textarea.val(this._formatValue(this.value)); + }, + _renderReadonly: function () { + this.$el.empty().text(this._formatValue(this.value)); + }, +}); + +var FieldHtml = InputField.extend({ + supportedFieldTypes: ['html'], + text_to_html: function (text) { + var value = text || ""; + if (value.match(/^\s*$/)) { + value = '<p><br/></p>'; + } else { + value = "<p>"+value.split(/<br\/?>/).join("<br/></p><p>")+"</p>"; + value = value.replace(/<p><\/p>/g, '').replace('<p><p>', '<p>').replace('<p><p ', '<p ').replace('</p></p>', '</p>'); + } + return value; + }, + _render: function () { + this.$el.html(this.text_to_html(this.value)); + }, +}); + +/** + * displays a handle and allows the resequencing + * This widget works for integer fields + */ +var HandleWidget = AbstractField.extend({ + tagName: 'span', + className: 'o_row_handle fa fa-arrows ui-sortable-handle', + description: "", + readonly: true, + replace_element: true, + supportedFieldTypes: ['integer'], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * content is only "set" with pure css... + * @override + */ + _render: function () { + this.$el.empty(); + }, +}); + +var EmailWidget = InputField.extend({ + prefix: 'mailto', + supportedFieldTypes: ['char'], + init: function () { + this._super.apply(this, arguments); + this.tagName = this.mode === 'readonly' ? 'a' : 'input'; + }, + _renderReadonly: function () { + this.$el.text(this.value) + .addClass('o_form_uri o_text_overflow') + .attr('href', this.prefix + ':' + this.value); + } +}); + +var FieldPhone = EmailWidget.extend({ + prefix: 'tel', + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'readonly' && !this._can_call()) { + this.tagName = 'span'; + } + }, + _renderReadonly: function () { + this._super(); + if(this._can_call()) { + var text = this.$el.text(); + this.$el.html(text.substr(0, text.length/2) + "­" + text.substr(text.length/2)); // To prevent Skype app to find the phone number + } else { + this.$el.removeClass('o_form_uri'); + } + }, + _can_call: function () { + // Phone fields are clickable in readonly on small screens ~= on phones + // This can be overriden by call-capable modules to display a clickable + // link in different situations, like always regardless of screen size, + // or only allow national calls for example. + return config.device.size_class <= config.device.SIZES.XS; + } +}); + +var UrlWidget = InputField.extend({ + supportedFieldTypes: ['char'], + init: function () { + this._super.apply(this, arguments); + this.tagName = this.mode === 'readonly' ? 'a' : 'input'; + }, + _renderReadonly: function () { + this.$el.text(this.value) + .addClass('o_form_uri o_text_overflow') + .attr('href', this.value); + } +}); + +var AbstractFieldBinary = AbstractField.extend({ + events: _.extend({}, AbstractField.prototype.events, { + 'change .o_form_input_file': 'on_file_change', + 'click .o_select_file_button': function () { + this.$('.o_form_input_file').click(); + }, + 'click .o_clear_file_button': 'on_clear', + }), + init: function (parent, name, record) { + this._super.apply(this, arguments); + this.fields = record.fields; + this.useFileAPI = !!window.FileReader; + this.max_upload_size = 25 * 1024 * 1024; // 25Mo + if (!this.useFileAPI) { + var self = this; + this.fileupload_id = _.uniqueId('o_fileupload'); + $(window).on(this.fileupload_id, function () { + var args = [].slice.call(arguments).slice(1); + self.on_file_uploaded.apply(self, args); + }); + } + }, + destroy: function () { + if (this.fileupload_id) { + $(window).off(this.fileupload_id); + } + this._super.apply(this, arguments); + }, + 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."); + this.do_warn(_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.$('form.o_form_binary_form input[name=session_id]').val(this.session.session_id); + this.$('form.o_form_binary_form').submit(); + } + this.$('.o_form_binary_progress').show(); + this.$('button').hide(); + } + }, + on_file_uploaded: function (size, name) { + if (size === false) { + this.do_warn(_t("File Upload"), _t("There was a problem while uploading your file")); + // TODO: use crashmanager + console.warn("Error while uploading file : ", name); + } else { + this.on_file_uploaded_and_valid.apply(this, arguments); + } + this.$('.o_form_binary_progress').hide(); + this.$('button').show(); + }, + on_file_uploaded_and_valid: function (size, name, content_type, file_base64) { + this.set_filename(name); + this._setValue(file_base64); + this._render(); + }, + /** + * We need to update another field. This method is so deprecated it is not + * even funny. We need to replace this with the mechanism of field widgets + * declaring statically that they need to listen to every changes in other + * fields + * + * @deprecated + * + * @param {any} value + */ + set_filename: function (value) { + var filename = this.attrs.filename; + if (filename && filename in this.fields) { + this.trigger_up('update_field', { name: filename, value: value }); + } + }, + on_clear: function () { + this.set_filename(''); + this._setValue(false); + this._render(); + }, +}); + +var FieldBinaryImage = AbstractFieldBinary.extend({ + template: 'FieldBinaryImage', + placeholder: "/web/static/src/img/placeholder.png", + events: _.extend({}, AbstractFieldBinary.prototype.events, { + 'click img': function () { + if (this.mode === "readonly") { + this.trigger_up('bounce_edit'); + } + }, + }), + supportedFieldTypes: ['binary'], + _render: function () { + var self = this; + var attrs = this.attrs; + var url = this.placeholder; + if (this.value) { + if (!utils.is_bin_size(this.value)) { + url = 'data:image/png;base64,' + this.value; + } else { + url = session.url('/web/image', { + model: this.model, + id: JSON.stringify(this.res_id), + field: this.nodeOptions.preview_image || this.name, + // unique forces a reload of the image when the record has been updated + unique: (this.recordData.__last_update || '').replace(/[^0-9]/g, ''), + }); + } + } + var $img = $('<img>').attr('src', url); + $img.css({ + width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width, + height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height, + }); + this.$('> img').remove(); + this.$el.prepend($img); + $img.on('error', function () { + self.on_clear(); + $img.attr('src', self.placeholder); + self.do_warn(_t("Image"), _t("Could not display the selected image.")); + }); + }, + isSet: function () { + return true; + }, +}); + +var FieldBinaryFile = AbstractFieldBinary.extend({ + template: 'FieldBinaryFile', + events: _.extend({}, AbstractFieldBinary.prototype.events, { + 'click': function (event) { + if (this.mode === 'readonly' && this.value) { + this.on_save_as(event); + } + }, + 'click .o_form_input': function () { // eq[0] + this.$('.o_form_input_file').click(); + }, + }), + supportedFieldTypes: ['binary'], + init: function () { + this._super.apply(this, arguments); + this.filename_value = this.recordData[this.attrs.filename]; + }, + _renderReadonly: function () { + this.do_toggle(!!this.value); + if (this.value) { + this.$el.empty().append($("<span/>").addClass('fa fa-download')); + if (this.filename_value) { + this.$el.append(" " + this.filename_value); + } + } + }, + _renderEdit: function () { + if(this.value) { + this.$el.children().removeClass('o_hidden'); + this.$('.o_select_file_button').first().addClass('o_hidden'); + this.$('.o_form_input').eq(0).val(this.filename_value || this.value); + } else { + this.$el.children().addClass('o_hidden'); + this.$('.o_select_file_button').first().removeClass('o_hidden'); + } + }, + set_filename: function (value) { + this._super.apply(this, arguments); + this.filename_value = value; // will be used in the re-render + // the filename being edited but not yet saved, if the user clicks on + // download, he'll get the file corresponding to the current value + // stored in db, which isn't the one whose filename is displayed in the + // input, so we disable the download button + this.$('.o_save_file_button').prop('disabled', true); + }, + on_save_as: function (ev) { + if (!this.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.attrs.filename; + this.session.get_file({ + 'url': '/web/content', + 'data': { + 'model': this.model, + 'id': this.res_id, + 'field': this.name, + 'filename_field': filename_fieldname, + 'filename': this.recordData[filename_fieldname] || null, + 'download': true, + 'data': utils.is_bin_size(this.value) ? null : this.value, + }, + 'complete': framework.unblockUI, + 'error': c.rpc_error.bind(c) + }); + ev.stopPropagation(); + } + }, +}); + +var PriorityWidget = AbstractField.extend({ + // the current implementation of this widget makes it + // only usable for fields of type selection + className: "o_priority", + events: { + 'mouseover > a': function (e) { + clearTimeout(this.hover_timer); + this.$('.o_priority_star').removeClass('fa-star-o').addClass('fa-star'); + $(e.currentTarget).nextAll().removeClass('fa-star').addClass('fa-star-o'); + }, + 'mouseout > a': function () { + clearTimeout(this.hover_timer); + + var self = this; + this.hover_timer = setTimeout(function () { + self._render(); + }, 200); + }, + 'click > a': function (e) { + e.preventDefault(); + e.stopPropagation(); + + var new_value = String($(e.currentTarget).data('value')); + if(new_value === this.value) { + new_value = this.empty_value; + } + this._setValue(new_value); + }, + }, + supportedFieldTypes: ['integer'], + render_star: function (tag, is_full, value, tip) { + return $(tag) + .attr('title', tip) + .attr('data-value', value) + .addClass('o_priority_star fa') + .toggleClass('fa-star', is_full) + .toggleClass('fa-star-o', !is_full); + }, + _render: function () { + var self = this; + var value = parseInt(this.value, 10); + this.$el.empty(); + this.empty_value = this.field.selection[0][0]; + _.each(this.field.selection.slice(1), function (choice) { + self.$el.append(self.render_star('<a href="#">', value >= parseInt(choice[0]), choice[0], choice[1])); + }); + }, +}); + +var AttachmentImage = AbstractField.extend({ + template: 'AttachmentImage', +}); + +var StateSelectionWidget = AbstractField.extend({ + template: 'FormSelection', + events: { + 'click a': function (e) { + e.preventDefault(); + }, + 'click li': 'set_selection' + }, + prepare_dropdown_values: function () { + var self = this; + var _data = []; + var current_stage_id = self.recordData.stage_id && self.recordData.stage_id[0]; + var stage_data = { + id: current_stage_id, + legend_normal: this.recordData.legend_normal || undefined, + legend_blocked : this.recordData.legend_blocked || undefined, + legend_done: this.recordData.legend_done || undefined, + }; + _.map(this.field.selection || [], function (selection_item) { + var value = { + 'name': selection_item[0], + 'tooltip': selection_item[1], + }; + if (selection_item[0] === 'normal') { + value.state_name = stage_data.legend_normal ? stage_data.legend_normal : selection_item[1]; + } else if (selection_item[0] === 'done') { + value.state_class = 'o_status_green'; + value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1]; + } else { + value.state_class = 'o_status_red'; + value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1]; + } + _data.push(value); + }); + return _data; + }, + _render: function () { + var self = this; + var states = this.prepare_dropdown_values(); + // Adapt "FormSelection" + var current_state = _.find(states, function (state) { + return state.name === self.value; + }); + this.$('.o_status') + .removeClass('o_status_red o_status_green') + .addClass(current_state.state_class); + + // Render "FormSelection.Items" and move it into "FormSelection" + var $items = $(qweb.render('FormSelection.items', { + states: _.without(states, current_state) + })); + var $dropdown = this.$('.dropdown-menu'); + $dropdown.children().remove(); // remove old items + $items.appendTo($dropdown); + }, + set_selection: function (ev) { + var li = $(ev.target).closest('li'); + if (li.length) { + var value = String(li.data('value')); + this._setValue(value); + if (this.mode === 'edit') { + this._render(); + } + } + }, +}); + +var LabelSelection = AbstractField.extend({ + _render: function () { + this.classes = this.nodeOptions && this.nodeOptions.classes || {}; + var lbl_class = this.classes[this.value] || 'primary'; + this.$el.addClass('label label-' + lbl_class).text(this._formatValue(this.value)); + }, +}); + +var FieldBooleanButton = AbstractField.extend({ + className: 'o_stat_info', + supportedFieldTypes: ['boolean'], + _render: function () { + this.$el.empty(); + var text, hover; + switch (this.nodeOptions.terminology) { + case "active": + text = this.value ? _t("Active") : _t("Inactive"); + hover = this.value ? _t("Deactivate") : _t("Activate"); + break; + case "archive": + text = this.value ? _t("Active") : _t("Archived"); + hover = this.value ? _t("Archive") : _t("Restore"); + break; + case "close": + text = this.value ? _t("Active") : _t("Closed"); + hover = this.value ? _t("Close") : _t("Open"); + break; + default: + var opt_terms = this.nodeOptions.terminology || {}; + if (typeof opt_terms === 'string') { + opt_terms = {}; //unsupported terminology + } + text = this.value ? _t(opt_terms.string_true) || _t("On") + : _t(opt_terms.string_false) || _t("Off"); + hover = this.value ? _t(opt_terms.hover_true) || _t("Switch Off") + : _t(opt_terms.hover_false) || _t("Switch On"); + } + var val_color = this.value ? 'text-success' : 'text-danger'; + var hover_color = this.value ? 'text-danger' : 'text-success'; + var $val = $('<span>').addClass('o_stat_text o_not_hover ' + val_color).text(text); + var $hover = $('<span>').addClass('o_stat_text o_hover ' + hover_color).text(hover); + this.$el.append($val).append($hover); + }, + isSet: function () { + return true; + }, +}); + +var FieldID = InputField.extend({ + supportedFieldTypes: ['integer'], + init: function () { + this._super.apply(this, arguments); + this.mode = 'readonly'; + }, +}); + +var StatInfo = AbstractField.extend({ + supportedFieldTypes: ['integer', 'float'], + _render: function () { + var options = { + value: this.value || 0, + }; + if (! this.nodeOptions.nolabel) { + if(this.nodeOptions.label_field && this.recordData[this.nodeOptions.label_field]) { + options.text = this.recordData[this.nodeOptions.label_field]; + } else { + options.text = this.string; + } + } + this.$el.html(qweb.render("StatInfo", options)); + this.$el.addClass('o_stat_info'); + }, + isSet: function () { + return true; + }, +}); + +var FieldPercentPie = AbstractField.extend({ + template: 'FieldPercentPie', + supportedFieldTypes: ['integer'], + start: function () { + this.$left_mask = this.$('.o_mask').first(); + this.$right_mask = this.$('.o_mask').last(); + this.$pie_value = this.$('.o_pie_value'); + return this._super(); + }, + _render: function () { + var value = this.value || 0; + var degValue = 360*value/100; + + this.$right_mask.toggleClass('o_full', degValue >= 180); + + var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)'; + var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)'; + this.$left_mask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg}); + this.$right_mask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg}); + + this.$pie_value.html(Math.round(value) + '%'); + }, + isSet: function () { + return true; + }, +}); + +/** + * FieldProgressBar + * parameters + * - title: title of the bar, displayed on top of the bar + * options + * - editable: boolean if value is editable + * - current_value: get the current_value from the field that must be present in the view + * - max_value: get the max_value from the field that must be present in the view + * - edit_max_value: boolean if the max_value is editable + * - title: title of the bar, displayed on top of the bar --> not translated, use parameter "title" instead + */ +var FieldProgressBar = AbstractField.extend({ + template: "ProgressBar", + events: { + 'change input': 'on_change_input', + 'input input': 'on_change_input', + 'keyup input': function (e) { + if(e.which === $.ui.keyCode.ENTER) { + this.on_change_input(e); + } + }, + }, + supportedFieldTypes: ['integer', 'float'], + init: function () { + this._super.apply(this, arguments); + + // the progressbar needs the values and not the field name, passed in options + if (this.recordData[this.nodeOptions.current_value]) { + this.value = this.recordData[this.nodeOptions.current_value]; + } + this.max_value = this.recordData[this.nodeOptions.max_value] || 100; + this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable; + this.edit_max_value = this.nodeOptions.edit_max_value || false; + this.title = _t(this.attrs.title || this.nodeOptions.title) || ''; + this.edit_on_click = !this.nodeOptions.edit_max_value || false; + + this.write_mode = false; + }, + _render: function () { + var self = this; + this._render_value(); + + if(!this.readonly) { + if(this.edit_on_click) { + this.$el.on('click', '.o_progress', function (e) { + var $target = $(e.currentTarget); + self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); + self._render_value(); + self.on_update(self.value); + }); + } else { + this.$el.on('click', function () { + if (!self.write_mode) { + var $input = $('<input>', {type: 'text', class: 'o_progressbar_value'}); + $input.on('blur', _.bind(self.on_change_input, self)); + self.$('.o_progressbar_value').replaceWith($input); + self.write_mode = true; + self._render_value(); + } + }); + } + } + return this._super(); + }, + on_update: function (value) { + if(!isNaN(value)) { + if (this.edit_max_value) { + try { + this.max_value = this._parseValue(value); + this._isValid = true; + } catch(e) { + this._isValid = false; + } + var changes = {}; + changes[this.nodeOptions.max_value] = this.max_value; + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: changes, + }); + } else { + this._setValue(value); + } + } + }, + on_change_input: function (e) { + var $input = $(e.target); + if(e.type === 'change' && !$input.is(':focus')) { + return; + } + if(isNaN($input.val())) { + this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid.")); + } else { + if(e.type === 'input') { + this._render_value($input.val()); + if(parseFloat($input.val()) === 0) { + $input.select(); + } + } else { + if(this.edit_max_value) { + this.max_value = $(e.target).val(); + } else { + this.value = $(e.target).val() || 0; + } + var $div = $('<div>', {class: 'o_progressbar_value'}); + this.$('.o_progressbar_value').replaceWith($div); + this.write_mode = false; + + this._render_value(); + this.on_update(this.edit_max_value ? this.max_value : this.value); + } + } + }, + _render_value: function (v) { + var value = this.value; + var max_value = this.max_value; + if(!isNaN(v)) { + if(this.edit_max_value) { + max_value = v; + } else { + value = v; + } + } + value = value || 0; + max_value = max_value || 0; + + var widthComplete; + if(value <= max_value) { + widthComplete = value/max_value * 100; + } else { + widthComplete = 100; + } + + this.$('.o_progress').toggleClass('o_progress_overflow', value > max_value); + this.$('.o_progressbar_complete').css('width', widthComplete + '%'); + + if(!this.write_mode) { + if(max_value !== 100) { + this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value)); + } else { + this.$('.o_progressbar_value').html(utils.human_number(value) + "%"); + } + } else if(isNaN(v)) { + this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value); + this.$('.o_progressbar_value').focus().select(); + } + }, + isSet: function () { + return true; + }, +}); + +/** + This widget is intended to be used on boolean fields. It toggles a button + switching between a green bullet / gray bullet. +*/ +var FieldToggleBoolean = AbstractField.extend({ + template: "toggle_button", + events: { + 'click': 'set_toggle_button' + }, + supportedFieldTypes: ['boolean'], + _render: function () { + var class_name = this.value ? 'o_toggle_button_success' : 'text-muted'; + this.$('i').attr('class', ('fa fa-circle ' + class_name)); + }, + set_toggle_button: function () { + var toggle_value = !this.value; + this._setValue(toggle_value); + if (this.mode === 'edit') { + this._render(); + } + }, + isSet: function () { + return true; + }, +}); + +var JournalDashboardGraph = AbstractField.extend({ + className: "o_dashboard_graph", + init: function () { + this._super.apply(this, arguments); + this.graph_type = this.attrs.graph_type; + this.data = JSON.parse(this.value); + }, + start: function () { + nv.utils.windowResize(this._onResize.bind(this)); + return this._super.apply(this, arguments); + }, + destroy: function () { + nv.utils.offWindowResize(this._onResize); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _customizeChart: function () { + if (this.graph_type === 'bar') { + // Add classes related to time on each bar of the bar chart + var bar_classes = _.map(this.data[0].values, function (v) {return v.type; }); + + _.each(this.$('.nv-bar'), function (v, k){ + // classList doesn't work with phantomJS & addClass doesn't work with a SVG element + $(v).attr('class', $(v).attr('class') + ' ' + bar_classes[k]); + }); + } + }, + /** + * @private + */ + _render: function () { + // note: the rendering of this widget is aynchronous as nvd3 does a + // setTimeout(0) before executing the callback given to addGraph + var self = this; + this.chart = null; + nv.addGraph(function () { + self.$svg = self.$el.append('<svg>'); + switch(self.graph_type) { + case "line": + self.$svg.addClass('o_graph_linechart'); + + self.chart = nv.models.lineChart(); + self.chart.forceY([0]); + self.chart.options({ + x: function (d, u) { return u; }, + margin: {'left': 0, 'right': 0, 'top': 0, 'bottom': 0}, + showYAxis: false, + showLegend: false, + }); + self.chart.xAxis.tickFormat(function (d) { + var label = ''; + _.each(self.data, function (v){ + if (v.values[d] && v.values[d].x){ + label = v.values[d].x; + } + }); + return label; + }); + self.chart.yAxis.tickFormat(d3.format(',.2f')); + + break; + + case "bar": + self.$svg.addClass('o_graph_barchart'); + + self.chart = nv.models.discreteBarChart() + .x(function (d) { return d.label; }) + .y(function (d) { return d.value; }) + .showValues(false) + .showYAxis(false) + .margin({'left': 0, 'right': 0, 'top': 0, 'bottom': 40}); + + self.chart.xAxis.axisLabel(self.data[0].title); + self.chart.yAxis.tickFormat(d3.format(',.2f')); + + break; + } + d3.select(self.$('svg')[0]) + .datum(self.data) + .transition().duration(1200) + .call(self.chart); + + self._customizeChart(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onResize: function () { + if (this.chart) { + this.chart.update(); + this._customizeChart(); + } + }, + +}); + +/** + * This widget is intended to be used on Text fields. It will provide Ace Editor + * for editing XML and Python. + */ +var AceEditor = AbstractField.extend({ + template: "AceEditor", + willStart: function () { + if (!window.ace && !this.loadJS_def) { + this.loadJS_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () { + return $.when(ajax.loadJS('/web/static/lib/ace/mode-python.js'), + ajax.loadJS('/web/static/lib/ace/mode-xml.js') + ); + }); + } + return $.when(this._super(), this.loadJS_def); + }, + start: function () { + this._startAce(this.$('.ace-view-editor')[0]); + return this._super(); + }, + destroy: function () { + if (this.aceEditor) { + this.aceEditor.destroy(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _renderEdit: function () { + var value = this._formatValue(this.value); + if (value !== this.lastValue) { + this.aceSession.setValue(this._formatValue(this.value)); + } + }, + _renderReadonly: function () { + this.aceSession.setValue(this._formatValue(this.value)); + }, + _setValue: function (value) { + this.lastValue = value; + this._super(value); + }, + _startAce: function (node) { + var self = this; + this.aceEditor = ace.edit(node); + this.aceEditor.setOptions({ + "maxLines": Infinity, + showPrintMargin: false, + }); + if (this.mode === 'readonly') { + this.aceEditor.renderer.setOptions({ + displayIndentGuides: false, + showGutter: false, + }); + this.aceEditor.setOptions({ + highlightActiveLine: false, + highlightGutterLine: false, + readOnly: true, + }); + this.aceEditor.renderer.$cursorLayer.element.style.display = "none"; + } + this.aceEditor.$blockScrolling = true; + this.aceSession = this.aceEditor.getSession(); + this.aceSession.setOptions({ + useWorker: false, + mode: "ace/mode/"+(this.nodeOptions.mode || 'xml'), + tabSize: 2, + useSoftTabs: true, + }); + this.aceEditor.on("change", function () { + if (self.aceSession.getUndoManager().hasUndo()) { + self._setValue(self.aceSession.getValue()); + } + }); + }, +}); + +return { + EmailWidget: EmailWidget, + FieldBinaryFile: FieldBinaryFile, + FieldBinaryImage: FieldBinaryImage, + FieldBoolean: FieldBoolean, + FieldBooleanButton: FieldBooleanButton, + FieldChar: FieldChar, + FieldDate: FieldDate, + FieldDateTime: FieldDateTime, + FieldFloat: FieldFloat, + FieldFloatTime: FieldFloatTime, + FieldHtml: FieldHtml, + FieldID: FieldID, + FieldInteger: FieldInteger, + FieldMonetary: FieldMonetary, + FieldPercentPie: FieldPercentPie, + FieldPhone: FieldPhone, + FieldProgressBar: FieldProgressBar, + FieldText: FieldText, + FieldToggleBoolean: FieldToggleBoolean, + HandleWidget: HandleWidget, + InputField: InputField, + AttachmentImage: AttachmentImage, + LabelSelection: LabelSelection, + StateSelectionWidget: StateSelectionWidget, + PriorityWidget: PriorityWidget, + StatInfo: StatInfo, + UrlWidget: UrlWidget, + JournalDashboardGraph: JournalDashboardGraph, + AceEditor: AceEditor, +}; + +}); diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js new file mode 100644 index 00000000000..0bfddd03098 --- /dev/null +++ b/addons/web/static/src/js/fields/field_registry.js @@ -0,0 +1,66 @@ +odoo.define('web.field_registry', function (require) { +"use strict"; + +var Registry = require('web.Registry'); + +return new Registry(); +}); + +odoo.define('web._field_registry', function(require) { +"use strict"; + +var AbstractField = require('web.AbstractField'); +var basic_fields = require('web.basic_fields'); +var relational_fields = require('web.relational_fields'); +var registry = require('web.field_registry'); + + +// Basic fields +registry + .add('abstract', AbstractField) + .add('input', basic_fields.InputField) + .add('integer', basic_fields.FieldInteger) + .add('html', basic_fields.FieldHtml) + .add('boolean', basic_fields.FieldBoolean) + .add('date', basic_fields.FieldDate) + .add('datetime', basic_fields.FieldDateTime) + .add('text', basic_fields.FieldText) + .add('float', basic_fields.FieldFloat) + .add('char', basic_fields.FieldChar) + .add('handle', basic_fields.HandleWidget) + .add('email', basic_fields.EmailWidget) + .add('phone', basic_fields.FieldPhone) + .add('url', basic_fields.UrlWidget) + .add('image', basic_fields.FieldBinaryImage) + .add('binary', basic_fields.FieldBinaryFile) + .add('monetary', basic_fields.FieldMonetary) + .add('priority', basic_fields.PriorityWidget) + .add('attachment_image', basic_fields.AttachmentImage) + .add('label_selection', basic_fields.LabelSelection) + .add('state_selection', basic_fields.StateSelectionWidget) + .add('boolean_button', basic_fields.FieldBooleanButton) + .add('id', basic_fields.FieldID) + .add('statinfo', basic_fields.StatInfo) + .add('percentpie', basic_fields.FieldPercentPie) + .add('float_time', basic_fields.FieldFloatTime) + .add('progressbar', basic_fields.FieldProgressBar) + .add('toggle_button', basic_fields.FieldToggleBoolean) + .add('dashboard_graph', basic_fields.JournalDashboardGraph) + .add('ace', basic_fields.AceEditor); + +// Relational fields +registry + .add('selection', relational_fields.FieldSelection) + .add('radio', relational_fields.FieldRadio) + .add('many2one', relational_fields.FieldMany2One) + .add('list.many2one', relational_fields.ListFieldMany2One) + .add('kanban.many2one', relational_fields.KanbanFieldMany2One) + .add('many2many', relational_fields.FieldMany2Many) + .add('many2many_tags', relational_fields.FieldMany2ManyTags) + .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes) + .add('form.many2many_tags', relational_fields.FormFieldMany2ManyTags) + .add('one2many', relational_fields.FieldOne2Many) + .add('statusbar', relational_fields.FieldStatus) + .add('one2many_list', relational_fields.FieldOne2Many); + +});
\ No newline at end of file diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js new file mode 100644 index 00000000000..3b06af9efc4 --- /dev/null +++ b/addons/web/static/src/js/fields/field_utils.js @@ -0,0 +1,240 @@ +odoo.define('web.field_utils', function (require) { +"use strict"; + +/** + * Field Utils + * + * This file contains two types of functions: formatting functions and parsing + * functions. + * + * Each field type has to display in string form at some point, but it should be + * stored in memory with the actual value. For example, a float value of 0.5 is + * represented as the string "0.5". + */ + +var core = require('web.core'); +var session = require('web.session'); +var time = require('web.time'); +var utils = require('web.utils'); + +function format_boolean(value) { + var $input = $('<input type="checkbox">') + .prop('checked', value) + .prop('disabled', true); + return $('<div>') + .addClass('o_checkbox') + .append($input) + .append($('<span>')); +} + +function format_char(value) { + return typeof value === 'string' ? value : ''; +} + +function format_date(value) { + var l10n = core._t.database.parameters; + var date_format = time.strftime_to_moment_format(l10n.date_format); + return value && moment(time.str_to_date(value)).format(date_format); +} + +function format_datetime(value) { + var l10n = core._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); + var datetime_format = date_format + ' ' + time_format; + return value && moment(time.str_to_datetime(value)).format(datetime_format); +} + +// Format a float, according to the local settings +// Params: +// * value: a number describing the raw value of the float +// * field [optional]: a description of a field, that may contains extra +// information, such as a given precision +function format_float(value, field) { + var l10n = core._t.database.parameters; + var precision = (field && field.digits) ? field.digits[1] : 2; + var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + return formatted.join(l10n.decimal_point); +} + +function format_float_time(value) { + 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); +} + + +function format_id(value) { + return value ? value.toString() : false; +} + +function format_integer(value) { + if (!value && value !== 0) { + // previously, it returned 'false'. I don't know why. But for the Pivot + // view, I want to display the concept of 'no value' with an empty + // string. + return ""; + } + return utils.insert_thousand_seps(_.str.sprintf('%d', value)); +} + +function format_many2one(value) { + return value && (_.isArray(value) ? value[1] : value.data.display_name) || ''; +} + +function format_many2many(value) { + var names = _.map(value.data, function(p) { + return p.data.display_name; + }); + return names.join(', '); +} + +function format_monetary(value, field, options) { + options = options || {}; + var currency_id = options.currency_id; + if (!currency_id && options.data) { + var currency_field = options.currency_field || field.currency_field || 'currency_id'; + currency_id = options.data[currency_field] && options.data[currency_field].res_id; + } + var currency = session.get_currency(currency_id); + + var digits_precision = (currency && currency.digits) || [69,2]; + var precision = digits_precision[1]; + var formatted = _.str.sprintf('%.' + precision + 'f', value || 0).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + var l10n = core._t.database.parameters; + var formatted_value = formatted.join(l10n.decimal_point); + + if (!currency) { + return formatted_value; + } + if (currency.position === "after") { + return formatted_value += ' ' + currency.symbol; + } else { + return currency.symbol + ' ' + formatted_value; + } +} + +function format_field(value, field, options) { + var formatter = result['format_' + field.type]; + return formatter(value, field, options); +} + +function format_selection(value, field) { + if (!value) { + return ''; + } + var val = _.find(field.selection, function(option) { + return option[0] === value; + }); + return val[1]; +} + +function parse_field(value, field) { + var parser = result['parse_' + field.type]; + return parser(value, field); +} + + +function parse_float(value) { + value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), ''); + value = value.replace(core._t.database.parameters.decimal_point, '.'); + var parsed = Number(value); + if (isNaN(parsed)) { + throw new Error(_.str.sprintf(core._t("'%s' is not a correct float"), value)); + } + return parsed; +} + +function parse_float_time(value) { + 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_float(value); + var hours = parse_integer(float_time_pair[0]); + var minutes = parse_integer(float_time_pair[1]); + return factor * (hours + (minutes / 60)); +} + +function parse_integer(value) { + value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), ''); + var parsed = Number(value); + // do not accept not numbers or float values + if (isNaN(parsed) || parsed % 1) { + throw new Error(_.str.sprintf(core._t("'%s' is not a correct integer"), value)); + } + return parsed; +} + +function parse_monetary(formatted_value) { + var l10n = core._t.database.parameters; + var value = formatted_value.replace(l10n.thousands_sep, '') + .replace(l10n.decimal_point, '.') + .match(/([0-9]+(\.[0-9]*)?)/)[1]; + return parseFloat(value); +} + +function identity(value) { + return value; +} + +var result = { + format_binary: identity, // todo + format_boolean: format_boolean, + format_char: format_char, + format_date: format_date, + format_datetime: format_datetime, + format_float: format_float, + format_float_time: format_float_time, + format_html: identity, // todo + format_id: format_id, + format_integer: format_integer, + format_many2many: format_many2many, + format_many2one: format_many2one, + format_monetary: format_monetary, + format_one2many: identity, // todo + format_reference: identity, // todo + format_selection: format_selection, + format_text: format_char, + + format_field: format_field, + + parse_binary: identity, // todo + parse_boolean: identity, // todo + parse_char: identity, // todo + parse_date: identity, // todo + parse_datetime: identity, // todo + parse_float: parse_float, + parse_float_time: parse_float_time, + parse_html: identity, // todo + parse_id: identity, + parse_integer: parse_integer, + parse_many2many: identity, // todo + parse_many2one: identity, + parse_monetary: parse_monetary, + parse_one2many: identity, + parse_reference: identity, // todo + parse_selection: identity, // todo + parse_text: identity, // todo + + parse_field: parse_field, + +}; + +return result; + +}); diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js new file mode 100644 index 00000000000..4282d78faf7 --- /dev/null +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -0,0 +1,1493 @@ +odoo.define('web.relational_fields', function (require) { +"use strict"; + +/** + * Relational Fields + * + * In this file, we have a collection of various relational field widgets. + * Relational field widgets are more difficult to use/manipulate, because the + * relations add a level of complexity: a value is not a basic type, it can be + * a collection of other records. + * + * Also, the way relational fields are edited is more complex. We can change + * the corresponding record(s), or alter some of their fields. + */ + +var AbstractField = require('web.AbstractField'); +var concurrency = require('web.concurrency'); +var config = require('web.config'); +var ControlPanel = require('web.ControlPanel'); +var dialogs = require('web.view_dialogs'); +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var field_utils = require('web.field_utils'); +var KanbanRenderer = require('web.KanbanRenderer'); +var ListRenderer = require('web.ListRenderer'); +var mixins = require('web.mixins'); +var Pager = require('web.Pager'); +var pyeval = require('web.pyeval'); + +var _t = core._t; +var qweb = core.qweb; + +/** + * Abstract widget to handle relational fields + */ +var AbstractRelationalField = AbstractField.extend({ + init: function (parent, name, record) { + this._super.apply(this, arguments); + // 'domain' and 'context' that should be used by the field widget. It + // can come from the attrs, and is mostly useful for rpcs + this.domain = this.attrs.domain || this.field.domain || []; + this.context = this.attrs.context || this.field.context || {}; + + // todo: remove this + this.record = record; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _reset: function (record) { + this.record = record; + this._super.apply(this, arguments); + }, +}); + +//------------------------------------------------------------------------------ +// Many2one widgets +//------------------------------------------------------------------------------ + +var M2ODialog = Dialog.extend({ + template: "M2ODialog", + init: function (parent, name, value) { + this.name = name; + this.value = value; + this._super(parent, { + title: _.str.sprintf(_t("Create a %s"), this.name), + size: 'medium', + buttons: [{ + text: _t('Create'), + classes: 'btn-primary', + click: function () { + if (this.$("input").val() !== ''){ + this.trigger_up('quick_create', { value: this.$('input').val() }); + this.close(); + } else { + this.$("input").focus(); + } + }, + }, { + text: _t('Create and edit'), + classes: 'btn-primary', + close: true, + click: function () { + this.trigger_up('search_create_popup', { + view_type: 'form', + value: this.$('input').val(), + }); + }, + }, { + text: _t('Cancel'), + close: true, + }], + }); + }, + start: function () { + this.$("p").text(_.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name)); + this.$("input").val(this.value); + }, +}); + +var FieldMany2One = AbstractRelationalField.extend(mixins.ServicesMixin, { + template: 'FieldMany2One', + custom_events: { + 'quick_create': '_onQuickCreate', + 'search_create_popup': '_onSearchCreatePopup', + }, + events: _.extend({}, AbstractField.prototype.events, { + 'click .o_form_input': '_onInputClick', + 'focusout .o_form_input': '_onInputFocusout', + 'keyup .o_form_input': '_onInputKeyup', + 'click .o_external_button': '_onExternalButtonClick', + 'click': '_onClick', + }), + + init: function () { + this._super.apply(this, arguments); + this.limit = 7; + this.orderer = new concurrency.DropMisordered(); + this.can_create = 'can_create' in this.attrs ? this.attrs.can_create : true; + this.can_write = 'can_write' in this.attrs ? this.attrs.can_write : true; + this.nodeOptions = _.defaults(this.nodeOptions, { + quick_create: true, + }); + this.m2o_value = field_utils.format_many2one(this.value); + }, + start: function () { + // what is 'this.floating' for? add a comment! + this.floating = false; + + this.$input = this.$('.o_form_input'); + this.$external_button = this.$('.o_external_button'); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + activate: function () { + this.$input.focus(); + setTimeout(this.$input.select.bind(this.$input), 0); + }, + reinitialize: function (value) { + this.floating = false; + this._setValue(value); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _bindAutoComplete: function () { + var self = this; + this.$input.autocomplete({ + source: function (req, resp) { + self._search(req.term).then(function (result) { + resp(result); + }); + }, + select: function (event, ui) { + var item = ui.item; + self.floating = false; + if (item.id) { + self.reinitialize({id: item.id, display_name: item.name}); + } else if (item.action) { + item.action(); + } + return false; + }, + focus: function (event) { + event.preventDefault(); // don't automatically select values on focus + }, + autoFocus: true, + html: true, + minLength: 0, + delay: 200, + }); + this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" }); + this.autocomplete_bound = true; + }, + /** + * @private + * @param {string} [name] + * @returns {Object} + */ + _createContext: function (name) { + var tmp = {}; + var field = this.nodeOptions.create_name_field; + if (field === undefined) { + field = "name"; + } + if (field !== false && name && this.nodeOptions.quick_create !== false) { + tmp["default_" + field] = name; + } + return tmp; + }, + /** + * @private + */ + _focus: function () { + if (this.mode === "edit") { + this.$input.focus(); + } + }, + /** + * @private + * @returns {Array} + */ + _getSearchBlacklist: function () { + return []; + }, + /** + * @private + * @param {string} name + */ + _quickCreate: function (name) { + var self = this; + var slow_create = this._searchCreatePopup.bind(this, "form", false, this._createContext(name)); + if (this.nodeOptions.quick_create) { + this.trigger_up('name_create', { + model: this.field.relation, + name: name, + context: data.build_context(this.record, this.context), + on_success: function (result) { + if (self.mode === "edit") { + self.reinitialize({id: result[0], display_name: result[1]}); + } + }, + on_fail: slow_create, + }); + } else { + slow_create(); + } + }, + /** + * @private + */ + _renderEdit: function () { + var value = this.m2o_value; + + // this is a stupid hack necessary to support the always_reload flag. + // the field value has been reread by the basic model. We use it to + // display the full address of a patner, separated by \n. This is + // really a bad way to do it. Now, we need to remove the extra lines + // and hope for the best that noone tries to uses this mechanism to do + // something else. + if (this.field.__always_reload) { + value = value.split('\n')[0]; + } + this.$input.val(value); + if (!this.autocomplete_bound) { + this._bindAutoComplete(); + } + this._updateExternalButton(); + }, + /** + * @private + */ + _renderReadonly: function () { + var value = _.escape((this.m2o_value || "").trim()).split("\n").join("<br/>"); + this.$el.html(value); + if (!this.nodeOptions.no_open) { + this.$el.attr('href', '#'); + this.$el.addClass('o_form_uri'); + } + }, + /** + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this.m2o_value = field_utils.format_many2one(this.value); + }, + /** + * @private + * @param {string} search_val + * @returns {Deferred} + */ + _search: function (search_val) { + var self = this; + var def = $.Deferred(); + this.orderer.add(def); + + var exclusion_domain = []; + var blacklisted_ids = this._getSearchBlacklist(); + if (blacklisted_ids.length > 0) { + exclusion_domain.push(['id', 'not in', blacklisted_ids]); + } + var domain = new data.CompoundDomain(data.build_domain(this.record, this.domain), exclusion_domain); + this.trigger_up('name_search', { + model: this.field.relation, + search_val: search_val, + domain: pyeval.eval('domain', domain), + operator: 'ilike', + limit: this.limit + 1, + on_success: function (result) { + // possible selections for the m2o + var values = _.map(result, function (x) { + x[1] = x[1].split("\n")[0]; + return { + label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent, + value: x[1], + name: x[1], + id: x[0], + }; + }); + + // search more... if more results than limit + if (values.length > self.limit) { + values = values.slice(0, self.limit); + values.push({ + label: _t("Search More..."), + action: function () { + self.trigger_up('name_search', { + model: self.field.relation, + search_val: search_val, + domain: pyeval.eval('domain', domain), + operator: 'ilike', + limit: 160, + on_success: self._searchCreatePopup.bind(self, "search"), + }); + }, + classname: 'o_m2o_dropdown_option', + }); + } + var create_enabled = self.can_create && !self.nodeOptions.no_create; + // quick create + var raw_result = _.map(result, function (x) { return x[1]; }); + if (create_enabled && !self.nodeOptions.no_quick_create && + search_val.length > 0 && !_.contains(raw_result, search_val)) { + values.push({ + label: _.str.sprintf(_t('Create "<strong>%s</strong>"'), + $('<span />').text(search_val).html()), + action: self._quickCreate.bind(self, search_val), + classname: 'o_m2o_dropdown_option' + }); + } + // create and edit ... + if (create_enabled && !self.nodeOptions.no_create_edit) { + values.push({ + label: _t("Create and Edit..."), + action: self._searchCreatePopup.bind(self, "form", false, self._createContext(search_val)), + classname: 'o_m2o_dropdown_option', + }); + } else if (values.length === 0) { + values.push({ + label: _t("No results to show..."), + }); + } + + def.resolve(values); + }, + }); + + return def; + }, + /** + * all search/create popup handling + * + * @private + * @param {any} view + * @param {any} ids + * @param {any} context + */ + _searchCreatePopup: function (view, ids, context) { + var self = this; + new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, { + res_model: this.field.relation, + domain: data.build_domain(this.record, this.domain), + context: new data.CompoundContext(data.build_context(this.record, this.context), context || {}), + 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, + on_selected: function (element_ids) { + self.reinitialize({id: element_ids[0]}); + self._focus(); + } + })).open(); + }, + /** + * @private + */ + _updateExternalButton: function () { + var has_external_button = !this.nodeOptions.no_open && !this.floating && this.isSet(); + this.$external_button.toggle(has_external_button); + this.$el.toggleClass('o_with_button', has_external_button); + }, + + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onClick: function (event) { + var self = this; + if (this.mode === 'readonly' && !this.nodeOptions.no_open) { + event.preventDefault(); + event.stopPropagation(); + var context = data.build_context(this.record, this.context); + var args = [[this.value.res_id], context.eval()]; + this.performModelRPC(this.field.relation, 'get_formview_action', args) + .then(function (action) { + self.trigger_up('do_action', {action: action}); + }); + } + }, + /** + * @private + */ + _onExternalButtonClick: function () { + if (!this.value) { + this.focus(); + return; + } + var self = this; + var context = data.build_context(this.record, this.context); + var model = this.field.relation; + var method = 'get_formview_id'; + var args = [[this.value.res_id], context.eval()]; + this.performModelRPC(model, method, args).then(function (view_id) { + new dialogs.FormViewDialog(self, { + res_model: self.field.relation, + res_id: self.value.res_id, + context: context, + title: _t("Open: ") + self.string, + view_id: view_id, + readonly: !self.can_write, + on_saved: function () { + self.trigger_up('reload', {db_id: self.value.id}); + }, + }).open(); + }); + }, + /** + * @private + */ + _onInputClick: function () { + if (this.$input.autocomplete("widget").is(":visible")) { + this.$input.autocomplete("close"); + } else { + this.$input.autocomplete("search", ""); + } + }, + /** + * @private + */ + _onInputFocusout: function () { + if (this.can_create && this.floating) { + new M2ODialog(this, this.string, this.$input.val()).open(); + } + }, + /** + * @private + */ + _onInputKeyup: function () { + if (this.$input.val() === "") { + this.reinitialize(false); + } else if (this.m2o_value !== this.$input.val()) { + this.floating = true; + this._updateExternalButton(); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onSearchCreatePopup: function (event) { + var data = event.data; + this._searchCreatePopup(data.view_type, false, this._createContext(data.value)); + }, +}); + +var ListFieldMany2One = FieldMany2One.extend({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _renderReadonly: function () { + this.$el.text(this.m2o_value); + }, +}); + +var KanbanFieldMany2One = AbstractRelationalField.extend({ + tagName: 'span', + init: function () { + this._super.apply(this, arguments); + this.m2o_value = field_utils.format_many2one(this.value); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _render: function () { + this.$el.text(this.m2o_value); + }, +}); + +//------------------------------------------------------------------------------ +// X2Many widgets +//------------------------------------------------------------------------------ + +var FieldX2Many = AbstractRelationalField.extend({ + tagName: 'div', + custom_events: _.extend({}, AbstractRelationalField.prototype.custom_events, { + field_changed: '_onFieldChanged', + kanban_record_delete: '_onDeleteRecord', + list_record_delete: '_onDeleteRecord', + add_record: '_onAddRecord', + open_record: '_onOpenRecord', + toggle_column_order: '_onToggleColumnOrder', + }), + + /** + * @override + */ + init: function (parent, name, record, options) { + this._super.apply(this, arguments); + this.operations = []; + this.isReadonly = this.mode === 'readonly'; + this.view = this.field.views.tree || this.field.views.kanban; + this.activeActions = {}; + var arch = this.view && this.view.arch; + if (arch) { + this.activeActions.create = arch.attrs.create ? + JSON.parse(arch.attrs.create) : + true; + this.editable = arch.attrs.editable; + } + this.widgets_registry = options && options.widgets_registry; + }, + /** + * @override + */ + start: function () { + return this._renderControlPanel().then(this._super.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This function is meant to be overriden by the One2ManyField. + * + * @private + * @returns {Boolean} true if the 'Add an item' row should be available in + * the list renderer, false otherwise + */ + _hasCreateLine: function () { + return !this.isReadonly; + }, + /** + * Instanciates or updates the adequate renderer. + * + * @override + * @private + * @returns {Deferred} + */ + _render: function () { + if (!this.view) { + return this._super(); + } + if (this.renderer) { + this.renderer.updateState(this.value); + this.pager.updateState({ size: this.value.count }); + return $.when(); + } + var arch = this.view.arch; + if (arch.tag === 'tree') { + this.renderer = new ListRenderer(this, this.value, { + arch: arch, + mode: this.mode, + addCreateLine: this._hasCreateLine(), + addTrashIcon: !this.isReadonly, + }); + } + if (arch.tag === 'kanban') { + var record_options = { + editable: false, + deletable: false, + model: this.value.model, // required by includes of KanbanRecord + read_only_mode: this.isReadonly, + }; + this.renderer = new KanbanRenderer(this, this.value, { + arch: arch, + record_options: record_options + }); + } + return this.renderer ? this.renderer.appendTo(this.$el) : this._super(); + }, + /** + * Instanciates a control panel with the appropriate buttons and a pager. + * Prepends the control panel's $el to this widget's $el. + * + * @private + * @returns {Deferred} + */ + _renderControlPanel: function () { + if (!this.view) { + return $.when(); + } + var self = this; + var defs = []; + this.control_panel = new ControlPanel(this, "X2ManyControlPanel"); + this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, { + single_page_hidden: true, + withAccessKey: false, + }); + this.pager.on('pager_changed', this, function (new_state) { + this.trigger_up('load', { + id: this.value.id, + limit: new_state.limit, + offset: new_state.current_min - 1, + on_success: function (value) { + self.value = value; + self._render(); + }, + }); + }); + this._renderButtons(); + defs.push(this.pager.appendTo($('<div>'))); // start the pager + defs.push(this.control_panel.prependTo(this.$el)); + return $.when.apply($, defs).then(function () { + self.control_panel.update({ + cp_content: { + $buttons: self.$buttons, + $pager: self.pager.$el, + } + }); + }); + }, + /** + * Renders the buttons and sets this.$buttons. + * + * @private + */ + _renderButtons: function () { + if (!this.isReadonly && this.view.arch.tag === 'kanban') { + var options = { create_text: this.nodeOptions.create_text }; + this.$buttons = $(qweb.render('KanbanView.buttons', options)); + this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this)); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user clicks on the 'Add an item' link (list case) or the + * 'Add' button (kanban case). + * + * @abstract + * @private + */ + _onAddRecord: function () { + // to implement + }, + /** + * Removes the given record from the relation. + * Stops the propagation of the event to prevent it from being handled again + * by the parent controller. + * + * @private + * @param {OdooEvent} + */ + _onDeleteRecord: function (event) { + event.stopPropagation(); + this._setValue({ + operation: 'REMOVE', + id: event.data.id + }); + }, + /** + * Updates the given record with the changes. + * + * @private + * @param {OdooEvent} + */ + _onFieldChanged: function (event) { + if (event.target === this) { + return; + } + event.stopPropagation(); + // changes occured in an editable list + var changes = event.data.changes; + if (Object.keys(changes).length) { + this._setValue({ + operation: 'UPDATE', + id: event.data.dataPointID, + data: changes + }); + } + }, + /** + * Intercepts the 'open_record' event to edit its data and lets it bubble up + * to the form view. + * + * @private + */ + _onOpenRecord: function (event) { + _.extend(event.data, { + context: data.build_context(this.record, this.context), + domain: data.build_context(this.record, this.domain), + form_view: this.field.views && this.field.views.form, + readonly: this.mode === 'readonly', + string: this.string, + }); + }, + /** + * Adds field name information to the event, so that the view upstream is + * aware of which widgets it has to redraw. + * + * @private + * @param {OdooEvent} + */ + _onToggleColumnOrder: function (event) { + event.data.field = this.name; + }, +}); + +var FieldOne2Many = FieldX2Many.extend({ + className: 'o_form_field_one2many', + supportedFieldTypes: ['one2many'], + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * @override + * @param {Object} record + * @param {OdooEvent} [event] an event that triggered the reset action + * @returns {Deferred} + */ + reset: function (record, event) { + var self = this; + if (event && event.target === this && self.view.arch.tag === 'tree' && this.editable) { + var command = event.data.changes[this.name]; + if (command.operation === 'UPDATE') { + var fieldsChanged = _.keys(command.data); + this.renderer.confirmChange(record.data[this.name], command.id, fieldsChanged); + return $.when(); + } + } + return this._super.apply(this, arguments).then(function () { + if (event && event.target === self && self.view.arch.tag === 'tree') { + if (event.data.changes[self.name].operation === 'CREATE') { + // todo: get last record if editable = bottom + var newID = self.value.data[0].id; + self.renderer.editRecord(newID); + } + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overrides enable the 'Add an item' feature if the 'create' action is + * available. + * + * @override + * @private + */ + _hasCreateLine: function () { + return this._super.apply(this, arguments) && this.activeActions.create; + }, + /** + * Overrides to only render the buttons if the 'create' action is available. + * + * @override + * @private + */ + _renderButtons: function () { + if (this.activeActions.create) { + this._super.apply(this, arguments); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens a FormViewDialog to allow creating a new record for a one2many. + * + * @override + * @private + * @param {OdooEvent|MouseEvent} event this event comes either from the 'Add + * record' link in the list editable renderer, or from the 'Create' button + * in the kanban view + */ + _onAddRecord: function (event) { + // we don't want interference with the components upstream. + event.stopPropagation(); + + if (this.editable) { + this._setValue({ + operation: 'CREATE', + position: this.editable, + }); + } else { + this.trigger_up('add_one2many_record', { + domain: data.build_domain(this.record, this.domain), + context: data.build_context(this.record, this.context), + field: this.field, + on_save: function (record) { + this._setValue({ + operation: 'ADD', + id: record.id + }); + }.bind(this), + }); + } + }, + /** + * Overrides the handler to set a specific 'on_save' callback as the o2m + * sub-records aren't saved directly when the user clicks on 'Save' in the + * dialog. Instead, the relational record is changed in the local data, and + * this change is saved in DB when the user clicks on 'Save' in the main + * form view. + * + * @override + * @private + * @param {OdooEvent} + */ + _onOpenRecord: function (event) { + var self = this; + this._super.apply(this, arguments); + _.extend(event.data, { + on_save: function (record) { + // update in a form view opened from the o2m (kanban or non editable list) + var relRecord = _.find(self.value.data, function (d) { + return d.res_id === record.res_id; + }); + self._setValue({ + operation: 'UPDATE', + id: relRecord.id, + data: record.data, + }); + }, + }); + }, +}); + +var FieldMany2Many = FieldX2Many.extend({ + className: 'o_form_field_many2many', + supportedFieldTypes: ['many2many'], + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.nodeOptions = _.defaults(this.nodeOptions, { + create_text: _t('Add'), + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Opens a SelectCreateDialog. + * + * @override + * @private + * @param {OdooEvent} + */ + _onAddRecord: function () { + var self = this; + var domain = data.build_domain(this.record, this.domain); + new dialogs.SelectCreateDialog(this, { + res_model: this.field.relation, + domain: new data.CompoundDomain(domain, ["!", ["id", "in", this.value.res_ids]]), + context: data.build_context(this.record, this.context), + title: _t("Add: ") + this.string, + no_create: this.nodeOptions.no_create || !this.activeActions.create, + on_selected: function (res_ids) { + var new_ids = _.difference(res_ids, self.value.res_ids); + if (new_ids.length) { + var values = _.map(new_ids, function (id) { + return {id: id}; + }); + self._setValue({ + operation: 'ADD_M2M', + ids: values + }); + } + } + }).open(); + }, + /** + * Overrides the handler to reload the field when the subrecord is saved. + * + * @override + * @private + * @param {OdooEvent} + */ + _onOpenRecord: function (event) { + this._super.apply(this, arguments); + _.extend(event.data, { + on_saved: this.trigger_up.bind(this, 'reload', {db_id: event.data.id}), + }); + }, +}); + +var FieldMany2ManyTags = AbstractRelationalField.extend({ + tag_template: "FieldMany2ManyTag", + className: "o_form_field o_form_field_many2manytags", + replace_element: true, + supportedFieldTypes: ['many2many'], + custom_events: { + field_changed: '_onFieldChanged', + move_next: '_onMoveNext', + }, + events: _.extend({}, AbstractField.prototype.events, { + 'click .o_delete': '_onDeleteTag', + 'keydown .o_form_field_many2one input': '_onKeyDown', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + activate: function () { + this.many2one.focus(); + }, + isSet: function () { + return !!this.value && this.value.count; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {any} data + */ + _addTag: function (data) { + if (!_.contains(this.value.res_ids, data.id)) { + this._setValue({ + operation: 'ADD_M2M', + ids: data + }); + } + }, + /** + * @private + * @param {any} id + */ + _removeTag: function (id) { + var record = _.findWhere(this.value.data, {res_id: id}); + this._setValue({ + operation: 'REMOVE', + id: record.id + }); + }, + /** + * @private + */ + _renderEdit: function () { + var self = this; + this._renderTags(); + this.many2one = new FieldMany2One(this, this.name, this.record, { + idForLabel: this.idForLabel, + mode: 'edit', + }); + // to prevent the M2O to take the value of the M2M + this.many2one.value = false; + // to prevent the M2O to take the relational values of the M2M + this.many2one.m2o_value = ''; + + this.many2one.nodeOptions.no_open = true; + this.many2one._getSearchBlacklist = function () { + return self.value.res_ids; + }; + this.many2one.appendTo(this.$el); + }, + /** + * @private + */ + _renderReadonly: function () { + this._renderTags(); + }, + /** + * @private + */ + _renderTags: function () { + var elements = this.value ? _.pluck(this.value.data, 'data') : []; + this.$el.html(qweb.render(this.tag_template, { + elements: elements, + readonly: this.mode === "readonly", + })); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onDeleteTag: function (event) { + this._removeTag($(event.target).parent().data('id')); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + if (event.target === this) { + return; + } + event.stopPropagation(); + var new_value = event.data.changes[this.name]; + if (new_value) { + this._addTag(new_value); + this.many2one.reinitialize(false); + } + }, + /** + * @private + * @param {KeyboardEvent} event + */ + _onKeyDown: function (event) { + if($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) { + var $badges = this.$('.badge'); + if($badges.length) { + this._removeTag($badges.last().data('id')); + } + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onMoveNext: function (event) { + if (event.target === this) { + return; + } + // Intercept event triggered up by the many2one, to prevent triggering it twice + event.stopPropagation(); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onQuickCreate: function (event) { + this._quickCreate(event.data.value); + }, +}); + +var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({ + events: _.extend({}, FieldMany2ManyTags.prototype.events, { + 'click .badge': '_onOpenColorPicker', + 'mousedown .o_colorpicker span': '_onUpdateColor', + 'focusout .o_colorpicker': '_onCloseColorPicker', + }), + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onCloseColorPicker: function (){ + this.$color_picker.remove(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onOpenColorPicker: function (event) { + var tag_id = $(event.currentTarget).data('id'); + var tag = _.findWhere(this.value.data, { res_id: tag_id }); + if (tag && 'color' in tag.data) { // if there is a color field on the related model + this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', { + 'widget': this, + 'tag_id': tag_id, + })); + + $(event.currentTarget).append(this.$color_picker); + this.$color_picker.dropdown('toggle'); + this.$color_picker.attr("tabindex", 1).focus(); + } + }, + /** + * @private + * @param {MouseEvent} event + */ + _onUpdateColor: function (event) { + event.preventDefault(); + var self = this; + var color = $(event.currentTarget).data('color'); + var id = $(event.currentTarget).data('id'); + var tag = self.$("span.badge[data-id='" + id + "']"); + var current_color = tag.data('color'); + + if (color === current_color) { return; } + + this.trigger_up('field_changed', { + dataPointID: _.findWhere(this.value.data, {res_id: id}).id, + changes: { + color: color, + }, + force_save: true, + }); + }, +}); + +var FieldMany2ManyCheckBoxes = AbstractRelationalField.extend({ + template: 'FieldMany2ManyCheckBoxes', + events: _.extend({}, AbstractRelationalField.prototype.events, { + change: '_onChange', + }), + supportedFieldTypes: ['many2many'], + init: function () { + this._super.apply(this, arguments); + this.m2mValues = this.field.__many2many_information; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _render: function () { + var self = this; + this._super.apply(this, arguments); + _.each(this.value, function (id) { + self.$('input[data-record-id="' + id + '"]').prop('checked', true); + }); + }, + /** + * @private + */ + _renderReadonly: function () { + this.$("input").prop("disabled", true); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChange: function () { + var ids = _.map(this.$('input:checked'), function (input) { + return $(input).data("record-id"); + }); + this._setValue({ + operation: 'REPLACE_WITH', + ids: ids, + }); + }, +}); + +//------------------------------------------------------------------------------ +// Widgets handling both basic and relational fields (selection and Many2one) +//------------------------------------------------------------------------------ + +var FieldStatus = AbstractField.extend({ + template: "FieldStatus", + className: 'o_statusbar_status', + events: { + 'click .o_arrow_button': '_onClickStage', + }, + supportedFieldTypes: ['selection', 'many2one'], + init: function () { + this._super.apply(this, arguments); + this._setState(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this._setState(); + }, + /** + * @private + */ + _setState: function () { + if (this.field.type === 'many2one') { + this.status_information = this.field.__status_information; + var status = _.findWhere(this.status_information, {id: this.value.res_id}); + if (status) { + status.selected = true; + } + } else { + var self = this; + this.status_information = _.map(this.field.selection, function (val) { + return { id: val[0], display_name: val[1], selected: val[0] === self.value }; + }); + } + }, + /** + * @private + */ + _render: function () { + var visible = this.attrs.statusbar_visible; + var selection_folded = []; + var selection_unfolded = []; + _.each(this.status_information, function (info) { + if (info.selected && (info.fold || visible && visible.indexOf(info.display_name) === -1)) { + selection_folded.push(info); + } else { + selection_unfolded.push(info); + } + }); + var mobile = config.device.size_class <= config.device.SIZES.XS; + // grep: FieldStatus.content.mobile, FieldStatus.content.desktop + var content = qweb.render("FieldStatus.content." + (mobile ? 'mobile' : 'desktop'), { + selection_folded: selection_folded.reverse(), + selection_unfolded: selection_unfolded.reverse(), + clickable: !!this.attrs.clickable, + }); + this.$el.html(content); + this.$('.disabled').prop('disabled', true); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Note that this function is debounced... + * + * @private + * @param {MouseEvent} event + */ + _onClickStage: _.debounce(function (event) { + var $stage = $(event.currentTarget); + var val; + if (this.field.type === "many2one") { + val = parseInt($stage.data("value"), 10); + } else { + val = $stage.data("value"); + } + if (val) { + this._setValue(val); + } + }, 300, true), +}); + +/** + * The FieldSelection widget is a simple select tag with a dropdown menu to + * allow the selection of a range of values. It is designed to work with fields + * of type 'selection' and 'many2one'. + */ +var FieldSelection = AbstractField.extend({ + template: 'FieldSelection', + supportedFieldTypes: ['selection', 'many2one'], + events: _.extend({}, AbstractField.prototype.events, { + 'input': '_onInput', + }), + replace_element: true, + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.values = []; + if (this.field.type === 'many2one') { + this.values = this.field.__selection_information; + } else { + this.values = _.reject(this.field.selection, function (v) { + return v[0] === false && v[1] === ''; + }); + } + this.values = [[false, this.attrs.placeholder || '']].concat(this.values); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Format the value to a valid string + * + * @override + * @private + * @param {any} value + * @returns {string} + */ + _formatValue: function (value) { + if (this.field.type === 'many2one') { + var options = _.extend({}, this.nodeOptions); + return field_utils.format_many2one(value, this.field, this.recordData, options); + } else { + return this._super(value); + } + }, + /** + * @override + * @private + */ + _renderEdit: function () { + this.$el.empty(); + for (var i = 0 ; i < this.values.length ; i++) { + this.$el.append($('<option/>', { + value: JSON.stringify(this.values[i][0]), + html: this.values[i][1] + })); + } + this.$el.val(JSON.stringify(this._parseValue(this.value))); + }, + /** + * @override + * @private + */ + _renderReadonly: function () { + this.$el.empty().html(this._formatValue(this.value)); + }, + /** + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + if (this.field.type === 'many2one') { + this.value = this.value.data.id; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * The small slight difficulty is that we have to set the value differently + * depending on the field type. + * @private + */ + _onInput: function () { + var res_id = JSON.parse(this.$el.val()); + if (this.field.type === 'many2one') { + var value = _.find(this.values, function (val) { + return val[0] === res_id; + }); + this._setValue({id: res_id, display_name: value[1]}); + } else { + this._setValue(res_id); + } + }, +}); + +var FieldRadio = FieldSelection.extend({ + template: 'FieldRadio', + supportedFieldTypes: ['selection', 'many2one'], + events: _.extend({}, AbstractField.prototype.events, { + 'click input': '_onInputClick', + }), + /** + * @constructs FieldRadio + */ + init: function () { + this._super.apply(this, arguments); + if (this.field.type === 'selection') { + this.values = this.field.selection || []; + } else if (this.field.type === 'many2one') { + this.values = _.map(this.field.__status_information, function (val) { + return [val.id, val.display_name]; + }); + } + this.unique_id = _.uniqueId("radio"); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {boolean} always true + */ + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @override + */ + _renderEdit: function () { + this.$("input").prop("checked", false); + var key; + if (this.field.type === 'many2one') { + key = this._parseValue(this.value); + } else { + key = this.value; + } + this.$('input[data-value="' + key + '"]').prop('checked', true); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onInputClick: function (event) { + var res_id = $(event.target).data('value'); + if (this.field.type === 'many2one') { + var value = _.find(this.values, function (val) { + return val[0] === res_id; + }); + this._setValue({id: res_id, display_name: value[1]}); + } else { + this._setValue(res_id); + } + }, +}); + +return { + FieldMany2One: FieldMany2One, + KanbanFieldMany2One: KanbanFieldMany2One, + ListFieldMany2One: ListFieldMany2One, + + FieldOne2Many: FieldOne2Many, + + FieldMany2Many: FieldMany2Many, + FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes, + FieldMany2ManyTags: FieldMany2ManyTags, + FormFieldMany2ManyTags: FormFieldMany2ManyTags, + + FieldRadio: FieldRadio, + FieldSelection: FieldSelection, + FieldStatus: FieldStatus, +}; + +}); diff --git a/addons/web/static/src/js/fields/upgrade_fields.js b/addons/web/static/src/js/fields/upgrade_fields.js new file mode 100644 index 00000000000..8d72cecf391 --- /dev/null +++ b/addons/web/static/src/js/fields/upgrade_fields.js @@ -0,0 +1,177 @@ +odoo.define('web.upgrade_widgets', function (require) { +"use strict"; + +/** + * The upgrade widgets are intended to be used in config settings. + * When checked, an upgrade popup is showed to the user. + */ + +var basic_fields = require('web.basic_fields'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var field_registry = require('web.field_registry'); +var framework = require('web.framework'); +var relational_fields = require('web.relational_fields'); + +var _t = core._t; +var QWeb = core.qweb; + +var FieldBoolean = basic_fields.FieldBoolean; +var FieldRadio = relational_fields.FieldRadio; + + +/** + * Mixin that defines the common functions shared between Boolean and Radio + * upgrade widgets + */ +var AbstractFieldUpgrade = { + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Redirects the user to the odoo-enterprise/uprade page + * + * @private + * @returns {Deferred} + */ + _confirmUpgrade: function () { + var args = [[["share", "=", false]]]; + return this.performModelRPC('res.users', 'search_count', args).then(function (data) { + framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data); + }); + }, + /** + * This function is meant to be overriden to insert the 'Enterprise' label + * JQuery node at the right place. + * + * @abstract + * @private + * @param {JQuery} the 'Enterprise' label to insert + */ + _insertEnterpriseLabel: function ($enterprise_label) {}, + /** + * Opens the Upgrade dialog. + * + * @private + */ + _openDialog: function () { + var message = $(QWeb.render('EnterpriseUpgrade')); + + var buttons = [ + { + text: _t("Upgrade now"), + classes: 'btn-primary', + close: true, + click: this._confirmUpgrade.bind(this), + }, + { + text: _t("Cancel"), + close: true, + }, + ]; + + new Dialog(this, { + size: 'medium', + buttons: buttons, + $content: $('<div>', { + html: message, + }), + title: _t("Odoo Enterprise"), + }).open(); + }, + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + this._insertEnterpriseLabel($("<span>", { + text: "Enterprise", + 'class': "label label-primary oe_inline" + })); + }, + /** + * This function is meant to be overriden to reset the $el to its initial + * state. + * + * @abstract + * @private + */ + _resetValue: function () {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {MouseEvent} + */ + _onInputClicked: function (event) { + if ($(event.currentTarget).prop("checked")) { + this._openDialog().on('closed', this, this._resetValue.bind(this)); + } + }, + +}; + +var UpgradeBoolean = FieldBoolean.extend(AbstractFieldUpgrade, { + events: _.extend({}, FieldBoolean.prototype.events, { + 'click input': '_onInputClicked', + }), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _insertEnterpriseLabel: function ($enterprise_label) { + this.$el.append(' ').append($enterprise_label); + }, + /** + * @override + */ + _resetValue: function () { + this.$input.prop("checked", false).change(); + }, +}); + +var UpgradeRadio = FieldRadio.extend(AbstractFieldUpgrade, { + events: _.extend({}, FieldRadio.prototype.events, { + 'click input:last': '_onInputClicked', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + isSet: function () { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _insertEnterpriseLabel: function ($enterprise_label) { + this.$('label').last().append(' ').append($enterprise_label); + }, + /** + * @override + */ + _resetValue: function () { + this.$('input').first().prop("checked", true).click(); + }, +}); + + +field_registry + .add('upgrade_boolean', UpgradeBoolean) + .add('upgrade_radio', UpgradeRadio); + +}); diff --git a/addons/web/static/src/js/framework/crash_manager.js b/addons/web/static/src/js/framework/crash_manager.js deleted file mode 100644 index af982ecd15d..00000000000 --- a/addons/web/static/src/js/framework/crash_manager.js +++ /dev/null @@ -1,211 +0,0 @@ -odoo.define('web.CrashManager', function (require) { -"use strict"; - -var ajax = require('web.ajax'); -var core = require('web.core'); -var Dialog = require('web.Dialog'); - -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) { - var self = this; - if (!this.active) { - return; - } - if (this.connection_lost) { - return; - } - if (error.code === -32098) { - core.bus.trigger('connection_lost'); - this.connection_lost = true; - var timeinterval = setInterval(function() { - ajax.jsonRpc('/web/webclient/version_info').then(function() { - clearInterval(timeinterval); - core.bus.trigger('connection_restored'); - self.connection_lost = false; - }); - }, 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: _t("Odoo 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: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"), - subtitle: error.data.title, - $content: $(QWeb.render('CrashManager.warning', {error: error})) - }).open(); - }, - show_error: function(error) { - if (!this.active) { - return; - } - var dialog = new Dialog(this, { - title: _.str.capitalize(error.type || error.message) || _t("Odoo Error"), - $content: $(QWeb.render('CrashManager.error', {error: error})) - }).open(); - - // When the dialog opens, initialize the copy feature and destroy it when the dialog is closed - var $clipboardBtn; - var clipboard; - dialog.opened(function () { - $clipboardBtn = dialog.$(".o_clipboard_button"); - $clipboardBtn.tooltip({title: _t("Copied !"), trigger: "manual", placement: "left"}); - clipboard = new window.Clipboard($clipboardBtn[0], { - text: function () { - return (_t("Error") + ":\n" + error.message + "\n\n" + error.data.debug).trim(); - } - }); - clipboard.on("success", function (e) { - _.defer(function () { - $clipboardBtn.tooltip("show"); - _.delay(function () { - $clipboardBtn.tooltip("hide"); - }, 800); - }); - }); - }); - dialog.on("closed", this, function () { - $clipboardBtn.tooltip("destroy"); - clipboard.destroy(); - }); - - // When the full traceback is shown, scroll it to the end (useful for better python error reporting) - dialog.$(".o_error_detail").on("shown.bs.collapse", function (e) { - e.target.scrollTop = e.target.scrollHeight; - }); - }, - show_message: function(exception) { - this.show_error({ - type: _t("Odoo 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: _.str.capitalize(error.type) || _t("Odoo Warning"), - buttons: [ - {text: error.data.arguments[2], classes : "btn-primary", click: function() { - window.location.href = '#action='+error.data.arguments[1]; - self.destroy(); - }}, - {text: _t("Cancel"), click: function() { self.destroy(); }, close: true} - ], - $content: 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/framework/data_manager.js b/addons/web/static/src/js/framework/data_manager.js deleted file mode 100644 index 55a4b4da15b..00000000000 --- a/addons/web/static/src/js/framework/data_manager.js +++ /dev/null @@ -1,280 +0,0 @@ -odoo.define('web.DataManager', function (require) { -"use strict"; - -var core = require('web.core'); -var Model = require('web.DataModel'); -var session = require('web.session'); -var utils = require('web.utils'); - -function traverse(tree, f) { - if (f(tree)) { - _.each(tree.children, function(c) { traverse(c, f); }); - } -} - -return core.Class.extend({ - init: function () { - this.Filters = new Model('ir.filters'); - this._init_cache(); - }, - - _init_cache: function () { - this._cache = { - actions: {}, - fields_views: {}, - fields: {}, - filters: {}, - views: {}, - }; - }, - - /** - * Invalidates the whole cache - * Suggestion: could be refined to invalidate some part of the cache - */ - invalidate: function () { - this._init_cache(); - }, - - /** - * Loads an action from its id or xmlid. - * - * @param {int|string} [action_id] the action id or xmlid - * @param {Object} [additional_context] used to load the action - * @return {Deferred} resolved with the action whose id or xmlid is action_id - */ - load_action: function (action_id, additional_context) { - var self = this; - var key = this._gen_key(action_id, additional_context || {}); - - if (!this._cache.actions[key]) { - this._cache.actions[key] = session.rpc("/web/action/load", { - action_id: action_id, - additional_context : additional_context, - }).then(function (action) { - self._cache.actions[key] = action.no_cache ? null : self._cache.actions[key]; - return action; - }, this._invalidate.bind(this, this._cache.actions, key)); - } - - return this._cache.actions[key].then(function (action) { - return $.extend(true, {}, action); - }); - }, - - /** - * Loads various information concerning views: fields_view for each view, - * and optionally filters and fields. - * - * @param {Object} [dataset] the dataset for which the views are loaded - * @param {Array} [views_descr] array of [view_id, view_type] - * @param {Object} [options] dictionnary of various options: - * - options.load_filters: whether or not to load the filters, - * - options.action_id: the action_id (required to load filters), - * - options.load_fields: whether or not to load the fields, - * - options.toolbar: whether or not a toolbar will be displayed, - * @return {Deferred} resolved with the requested views information - */ - load_views: function (dataset, views_descr, options) { - var self = this; - var model = dataset.model; - var context = dataset.get_context(); - var key = this._gen_key(model, views_descr, options || {}, context); - - if (!this._cache.views[key]) { - // Don't load fields or filters if already in cache - options.load_fields = options.load_fields && !this._cache.fields[model]; - var filters_key; - if (options.load_filters) { - filters_key = this._gen_key(model, options.action_id); - options.load_filters = !this._cache.filters[filters_key]; - } - - this._cache.views[key] = dataset.call('load_views', { - views: views_descr, - options: options, - context: context, - }).then(function (result) { - // Postprocess fields_views and insert them into the fields_views cache - result.fields_views = _.mapObject(result.fields_views, self._postprocess_fvg.bind(self)); // FIXME: lazy postprocess? - _.each(views_descr, function (view_descr) { - var toolbar = options.toolbar && view_descr[1] !== 'search'; - var fv_key = self._gen_key(model, view_descr[0], view_descr[1], toolbar, context); - self._cache.fields_views[fv_key] = $.when(result.fields_views[view_descr[1]]); - }); - - // Insert filters, if any, into the filters cache - if (result.filters) { - self._cache.filters[filters_key] = $.when(result.filters); - } - - // Insert fields, if any, into the fields cache - if (result.fields) { - self._cache.fields[model] = $.when(result.fields); - } - - return result.fields_views; - }, this._invalidate.bind(this, this._cache.views, key)); - } - - return this._cache.views[key]; - }, - - /** - * Loads the fields_view of a given model, view_type and optional view_id. - * - * @param {Object} [dataset] the dataset for which the fields_view is loaded - * @param {int} [view_id] the id of the view (optional) - * @param {string} [view_type] the type of the view - * @param {Boolean} [toolbar] whether or not a toolbar will be displayed - * @return {Deferred} resolved with the requested fields_view - */ - load_fields_view: function (dataset, view_id, view_type, toolbar) { - var key = this._gen_key(dataset.model, view_id, view_type, toolbar, dataset.get_context()); - if (!this._cache.fields_views[key]) { - this._cache.fields_views[key] = dataset.call('fields_view_get', { - view_id: view_id, - view_type: view_type, - toolbar: toolbar, - context: dataset.get_context(), - }).then(this._postprocess_fvg.bind(this), this._invalidate.bind(this, this._cache.fields_views, key)); - } - return this._cache.fields_views[key]; - }, - - /** - * Loads the fields of a given model. - * - * @param {Object} [dataset] the dataset for which the fields are loaded - * @return {Deferred} resolved with the requested fields - */ - load_fields: function (dataset) { - if (!this._cache.fields[dataset.model]) { - this._cache.fields[dataset.model] = dataset.call('fields_get', { - context: dataset.get_context(), - }).fail(this._invalidate.bind(this, this._cache.fields, dataset.model)); - } - return this._cache.fields[dataset.model]; - }, - - /** - * Loads the filters of a given model and optional action id. - * - * @param {Object} [dataset] the dataset for which the filters are loaded - * @param {int} [action_id] the id of the action (optional) - * @return {Deferred} resolved with the requested filters - */ - load_filters: function (dataset, action_id) { - var key = this._gen_key(dataset.model, action_id); - if (!this._cache.filters[key]) { - this._cache.filters[key] = this.Filters.call('get_filters', [dataset.model, action_id], { - context: dataset.get_context(), - }).fail(this._invalidate.bind(this, this._cache.filters, key)); - } - return this._cache.filters[key]; - }, - - /** - * Calls 'create_or_replace' on 'ir_filters'. - * - * @param {Object} [filter] the filter description - * @return {Deferred} resolved with the id of the created or replaced filter - */ - create_filter: function (filter) { - var self = this; - return this.Filters - .call('create_or_replace', [filter]) - .then(function (filter_id) { - var key = [ - filter.model_id, - filter.action_id || false, - ].join(','); - self._invalidate(self._cache.filters, key); - return filter_id; - }); - }, - - /** - * Calls 'unlink' on 'ir_filters'. - * - * @param {Object} [filter] the description of the filter to remove - * @return {Deferred} - */ - delete_filter: function (filter) { - var self = this; - return this.Filters - .call('unlink', [filter.id]) - .then(function () { - self._cache.filters = {}; // invalidate cache - }); - }, - - /** - * Private function that postprocesses fields_view (mainly parses the arch attribute) - */ - _postprocess_fvg: function (fields_view) { - var self = this; - - // Parse and process arch - var doc = $.parseXML(fields_view.arch).documentElement; - var fields = fields_view.fields; - fields_view.arch = utils.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban')); - traverse(fields_view.arch, function(node) { - if (typeof node === 'string') { - return false; - } - if (node.tag === 'field') { - fields[node.attrs.name].__attrs = node.attrs; - if (fields[node.attrs.name].type === 'many2one') { // FIXME: this shouldn't be done here - if (node.attrs.widget === 'statusbar' || node.attrs.widget === 'radio') { - fields[node.attrs.name].__fetch_status = true; - } else if (node.attrs.widget === 'selection') { - fields[node.attrs.name].__fetch_selection = true; - } - } - if (node.attrs.widget === 'many2many_checkboxes') { - fields[node.attrs.name].__fetch_many2manys = true; - } - return false; - } - return node.tag !== 'arch'; - }); - - // Special case for id's - if ('id' in fields_view.fields) { - var id_field = fields_view.fields.id; - id_field.original_type = id_field.type; - id_field.type = 'id'; - } - - // Process inner views (one2manys) - _.each(fields_view.fields, function(field) { - _.each(field.views || {}, function(view) { - self._postprocess_fvg(view); - }); - }); - - return fields_view; - }, - - /** - * Private function that generates a cache key from its arguments - */ - _gen_key: function () { - return _.map(Array.prototype.slice.call(arguments), function (arg) { - if (!arg) { - return false; - } - return _.isObject(arg) ? JSON.stringify(arg) : arg; - }).join(','); - }, - - /** - * Private function that invalidates a cache entry - */ - _invalidate: function (cache, key) { - delete cache[key]; - }, -}); - -}); diff --git a/addons/web/static/src/js/framework/dom_utils.js b/addons/web/static/src/js/framework/dom_utils.js deleted file mode 100644 index d0beff2988b..00000000000 --- a/addons/web/static/src/js/framework/dom_utils.js +++ /dev/null @@ -1,68 +0,0 @@ -odoo.define('web.dom_utils', function (require) { -"use strict"; - -var core = require('web.core'); - -/** - * Autoresize a $textarea node, by recomputing its height when necessary - * @param {number} [options.min_height] by default, 50. - * @param {Widget} [options.parent] if set, autoresize will listen to some extra - * events to decide when to resize itself. This is useful for widgets that are - * not in the dom when the autoresize is declared. - */ -function autoresize ($textarea, options) { - if ($textarea.data("auto_resize")) { - return; - } - options = options || {}; - - var $fixed_text_area; - var min_height = (options && options.min_height) || 50; - if (!$fixed_text_area) { - $fixed_text_area = $('<textarea disabled>').css({ - position: 'absolute', - opacity: 0, - height: 10, - top: -10000, - left: -10000, - }); - $fixed_text_area.addClass($textarea[0].className); - $fixed_text_area.insertAfter($textarea); - $fixed_text_area.data("auto_resize", true); - } - - var style = window.getComputedStyle($textarea[0], null); - if (style.resize === 'vertical') { - $textarea[0].style.resize = 'none'; - } else if (style.resize === 'both') { - $textarea[0].style.resize = 'horizontal'; - } - resize(); - $textarea.data("auto_resize", true); - - $textarea.on('input focus', resize); - if (options.parent) { - core.bus.on('DOM_updated', options.parent, resize); - core.bus.on('view_shown', options.parent, resize); - } - - function resize () { - var heightOffset; - var style = window.getComputedStyle($textarea[0], null); - if (style.boxSizing === 'content-box') { - heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)); - } else { - heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); - } - $fixed_text_area.width($textarea.width()); - $fixed_text_area.val($textarea.val()); - var height = $fixed_text_area[0].scrollHeight; - $textarea.css({height: Math.max(height + heightOffset, min_height)}); - } -} - -return { - autoresize: autoresize, -}; - -}); diff --git a/addons/web/static/src/js/framework/domain_utils.js b/addons/web/static/src/js/framework/domain_utils.js deleted file mode 100644 index 8cefb7cabbe..00000000000 --- a/addons/web/static/src/js/framework/domain_utils.js +++ /dev/null @@ -1,19 +0,0 @@ -odoo.define("web.domainUtils", function (require) { -"use strict"; - -var pyeval = require("web.pyeval"); - -function domainToString(domain) { - if (_.isString(domain)) return domain; - return JSON.stringify(domain || []).replace(/false/g, "False").replace(/true/g, "True"); -} -function stringToDomain(domain) { - if (!_.isString(domain)) return domain; - return pyeval.eval("domain", domain || "[]"); -} - -return { - domainToString: domainToString, - stringToDomain: stringToDomain, -}; -}); diff --git a/addons/web/static/src/js/framework/misc.js b/addons/web/static/src/js/framework/misc.js deleted file mode 100644 index e083ee25b22..00000000000 --- a/addons/web/static/src/js/framework/misc.js +++ /dev/null @@ -1,438 +0,0 @@ -odoo.define('web.framework', function (require) { -"use strict"; - -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var ajax = require('web.ajax'); -var Widget = require('web.Widget'); - -var _t = core._t; - -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() { - 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); - }, -}); - -/** 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")); - $('body').addClass('o_ui_blocked'); - return tmp; -} - -function unblockUI () { - _.invoke(throbbers, 'destroy'); - throbbers = []; - $('body').removeClass('o_ui_blocked'); - 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() { - ajax.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'); - return $.Deferred(); -} -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 - ajax.rpc("/web/session/get_session_info", {}).then(function() { - Reload(parent, action); - }); -} -core.action_registry.add("reload_context", ReloadContext); - - -// nvd3 customization -//------------------------------------------------------------------------- -if ('nv' in window) { - 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(); - }; - - // monkey patch nvd3 to prevent it to display a tooltip (position: absolute) with - // a negative `top`; with this patch the highest tooltip's position is still in the - // graph - var originalCalcTooltipPosition = nv.tooltip.calcTooltipPosition; - nv.tooltip.calcTooltipPosition = function () { - var container = originalCalcTooltipPosition.apply(this, arguments); - container.style.top = container.style.top.split('px')[0] < 0 ? 0 + 'px' : container.style.top; - return container; - }; -} - -// 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'; -$.fn.tooltip.Constructor.DEFAULTS.delay = { show: 1000, hide: 0 }; -//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); - } -}); - -/** - * Private function to notify that something has been attached in the DOM - * @param {htmlString or Element or Array or jQuery} [content] the content that - * has been attached in the DOM - * @params {Array} [callbacks] array of {widget: w, callback_args: args} such - * that on_attach_callback() will be called on each w with arguments args - */ -function _notify (content, callbacks) { - _.each(callbacks, function(c) { - if (c.widget && c.widget.on_attach_callback) { - c.widget.on_attach_callback(c.callback_args); - } - }); - core.bus.trigger('DOM_updated', content); -} -/** - * Appends content in a jQuery object and optionnally triggers an event - * @param {jQuery} [$target] the node where content will be appended - * @param {htmlString or Element or Array or jQuery} [content] DOM element, - * array of elements, HTML string or jQuery object to append to $target - * @param {Boolean} [options.in_DOM] true if $target is in the DOM - * @param {Array} [options.callbacks] array of objects describing the callbacks - * to perform (see _notify for a complete description) - */ -function append ($target, content, options) { - $target.append(content); - if (options && options.in_DOM) { - _notify(content, options.callbacks); - } -} -/** - * Prepends content in a jQuery object and optionnally triggers an event - * @param {jQuery} [$target] the node where content will be prepended - * @param {htmlString or Element or Array or jQuery} [content] DOM element, - * array of elements, HTML string or jQuery object to prepend to $target - * @param {Boolean} [options.in_DOM] true if $target is in the DOM - * @param {Array} [options.callbacks] array of objects describing the callbacks - * to perform (see _notify for a complete description) - */ -function prepend ($target, content, options) { - $target.prepend(content); - if (options && options.in_DOM) { - _notify(content, options.callbacks); - } -} - -/** - * Detaches widgets from the DOM and performs their on_detach_callback() - * @param {Array} [to_detach] array of {widget: w, callback_args: args} such - * that w.$el will be detached and w.on_detach_callback(args) will be called - * @param {jQuery} [options.$to_detach] if given, detached instead of widgets' $el - * @return {jQuery} the detached elements - */ -function detach (to_detach, options) { - _.each(to_detach, function(d) { - if (d.widget.on_detach_callback) { - d.widget.on_detach_callback(d.callback_args); - } - }); - var $to_detach = options && options.$to_detach; - if (!$to_detach) { - $to_detach = $(_.map(to_detach, function(d) { - return d.widget.el; - })); - } - return $to_detach.detach(); -} - -/** - * Returns the distance between a DOM element and the top-left corner of the window - * @param {element} [e] the DOM element - * @return {Object} the left and top distances in pixels - */ -function getPosition(e) { - var position = {left: 0, top: 0}; - while (e) { - position.left += e.offsetLeft; - position.top += e.offsetTop; - e = e.offsetParent; - } - return position; -} - -return { - blockUI: blockUI, - unblockUI: unblockUI, - redirect: redirect, - append: append, - prepend: prepend, - detach: detach, - getPosition: getPosition, -}; - -}); - -odoo.define('web.IFrameWidget', function (require) { -"use strict"; - -var Widget = require('web.Widget'); - -/** - * Generic widget to create an iframe that listens for clicks - * - * It should be extended by overwritting the methods: - * init: function(parent) { - * this._super(parent, <url_of_iframe> - * }, - * iframe_clicked: function(e){ - * filter the clicks you want to use and apply - * an action on it - * } - */ -var IFrameWidget = Widget.extend({ - tagName: 'iframe', - init: function(parent, url) { - this._super(parent); - this.url = url; - }, - start: function() { - this.$el.css({height: '100%', width: '100%', border: 0}); - this.$el.attr({src: this.url}); - this.$el.on("load", this.bind_events.bind(this)); - return this._super(); - }, - bind_events: function(){ - this.$el.contents().click(this.iframe_clicked.bind(this)); - }, - iframe_clicked: function(e){ - } -}); - -return IFrameWidget; - -}); diff --git a/addons/web/static/src/js/framework/registry.js b/addons/web/static/src/js/framework/registry.js deleted file mode 100644 index b8bc1dbadfe..00000000000 --- a/addons/web/static/src/js/framework/registry.js +++ /dev/null @@ -1,54 +0,0 @@ -odoo.define('web.Registry', 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/utils.js b/addons/web/static/src/js/framework/utils.js deleted file mode 100644 index 6b0a2ddbcc0..00000000000 --- a/addons/web/static/src/js/framework/utils.js +++ /dev/null @@ -1,566 +0,0 @@ -odoo.define('web.utils', 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.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); - } 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 ""; -} - -/** - * Create a cookie - * @param {String} name : the name of the cookie - * @param {String} value : the value stored in the cookie - * @param {Integer} ttl : time to live of the cookie in millis. -1 to erase the cookie. - */ -function set_cookie(name, value, ttl) { - ttl = ttl || 24*60*60*365; - document.cookie = [ - name + '=' + value, - 'path=/', - 'max-age=' + ttl, - 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString() - ].join(';'); -}; - -/** - * 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} number 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]; -} - -/** - * Naive and unlocalized human size parser. - * Returns the numeric amount specified in the given human formatted string. - * - * Eg: - * - * parse_human_size('4 Kb') === 4096 - * parse_human_size('10m') === 10485760 - * parse_human_size('340.99') === 340.99 - * - * @param {String} input human formatted string - */ -function parse_human_size(size) { - var units = "bkmgtpezy".split(''); - var parsed = size.toString().match(/^([0-9\.,]*)(\s*)?([a-z]{1,2})?$/i); - if (parsed === null) { - throw("Could not parse: " + size); - } - var amount = parseFloat(parsed[1].replace(',', '.')); - if (isNaN(amount) || !isFinite(amount)) { - throw("Invalid amount: " + size); - } - var unit = parsed[3] ? parsed[3][0].toLowerCase() : ''; - var index = units.indexOf(unit); - if (unit && index === -1) { - throw("Invalid unit: " + size); - } - if (index > 0) { - amount = amount * Math.pow(1024, index); - } - return amount; -} - -/** - * Returns a human readable number - * - * @param {Number} number - */ -function human_number (number, decimals) { - var decimals = decimals | 0; - var number = Math.round(number); - var d2 = Math.pow(10, decimals); - var val = _t("kMGTPE"); - var i = val.length-1, s; - while( i ) { - s = Math.pow(10,i--*3); - if( s <= number ) { - number = Math.round(number*d2/s)/d2 + val[i]; - } - } - return number; -} - -/** - * 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; - - /** - * Javascript performs strictly the round half up method, which is asymmetric. However, in - * Python, the method is symmetric. For example: - * - In JS, Math.round(-0.5) is equal to -0. - * - In Python, round(-0.5) is equal to -1. - * We want to keep the Python behavior for consistency. - */ - var sign = normalized_value < 0 ? -1.0 : 1.0; - var rounded_value = sign * Math.round(Math.abs(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; -}; - -/** - * Confines a value inside an interval - * @param {Number} [val] the value to confine - * @param {Number} [min] the minimum of the interval - * @param {Number} [max] the maximum of the interval - * @return val if val is in [min, max], min if val < min and max otherwise - */ -function confine (val, min, max) { - return Math.max(min, Math.min(max, val)); -} - -function assert (bool) { - if (!bool) { - throw new Error("AssertionError"); - } -} - -/* Logical XOR */ -function xor (a, b) { - return (a && !b) || (!a && b); -} - -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(); - }, -}); - -var DropPrevious = Class.extend({ - /** - * Registers a new deferred and rejects the previous one - * @param {$.Deferred} the new deferred - * @returns {$.Promise} - */ - add: function (deferred) { - if (this.current_def) { this.current_def.reject(); } - var res = $.Deferred(); - deferred.then(res.resolve, res.reject); - this.current_def = res; - return res.promise(); - } -}); - -/** - * Rejects a deferred as soon as a reference deferred is either resolved or rejected - * @param {$.Deferred} [target_def] the deferred to potentially reject - * @param {$.Deferred} [reference_def] the reference target - * @returns {$.Deferred} - */ -function reject_after(target_def, reference_def) { - var res = $.Deferred(); - target_def.then(res.resolve, res.reject); - reference_def.always(res.reject); - return res.promise(); -} - -/** - * Returns a deferred resolved after 'wait' milliseconds - * @param {int} [wait] the delay in ms - * @return {$.Deferred} - */ -function delay (wait) { - var def = $.Deferred(); - setTimeout(def.resolve, wait); - return def; -} - -function swap(array, elem1, elem2) { - var i1 = array.indexOf(elem1); - var i2 = array.indexOf(elem2); - array[i2] = elem1; - array[i1] = elem2; -} - -function toBoolElse (str, elseValues, trueValues, falseValues) { - var ret = _.str.toBool(str, trueValues, falseValues); - if (_.isUndefined(ret)) { - return elseValues; - } - return ret; -} - -function async_when() { - 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 { - 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, - set_cookie: set_cookie, - insert_thousand_seps: insert_thousand_seps, - is_bin_size: is_bin_size, - binary_to_binsize: binary_to_binsize, - human_size: human_size, - parse_human_size: parse_human_size, - human_number: human_number, - round_precision: round_precision, - round_decimals: round_decimals, - float_is_zero: float_is_zero, - confine: confine, - assert: assert, - xor: xor, - DropMisordered: DropMisordered, - DropPrevious: DropPrevious, - reject_after: reject_after, - delay: delay, - swap: swap, - toBoolElse: toBoolElse, - async_when: async_when, -}; - -}); diff --git a/addons/web/static/src/js/framework/view.js b/addons/web/static/src/js/framework/view.js deleted file mode 100644 index c5a017710e9..00000000000 --- a/addons/web/static/src/js/framework/view.js +++ /dev/null @@ -1,211 +0,0 @@ -odoo.define('web.View', function (require) { -"use strict"; - -var core = require('web.core'); -var data = require('web.data'); -var data_manager = require('web.data_manager'); -var pyeval = require('web.pyeval'); -var Widget = require('web.Widget'); - -var View = Widget.extend({ - events: { - 'click a[type=action]': function (ev) { - ev.preventDefault(); - var action_data = $(ev.target).attr('name'); - this.do_action(action_data); - } - }, - defaults: { - action: {}, - }, - // name displayed in view switchers - display_name: '', - // used by views that need a searchview. - searchable: true, - // used by views that need a searchview but don't want it to be displayed. - searchview_hidden: false, - // multi_record is used to distinguish views displaying a single record - // (e.g. FormView) from those that display several records (e.g. ListView) - multi_record: true, - // indicates whether or not the view is mobile-friendly - mobile_friendly: false, - // icon is the font-awesome icon to display in the view switcher - icon: 'fa-question', - // whether or not the view requires the model's fields to render itself - require_fields: false, - init: function(parent, dataset, fields_view, options) { - this._super(parent); - this.ViewManager = parent; - this.dataset = dataset; - this.model = dataset.model; - this.fields_view = fields_view; - this.options = _.defaults({}, options, this.defaults); - }, - /** - * Triggers event 'view_loaded'. - * Views extending start() must call this.super() once they are ready. - * @return {Deferred} - */ - start: function() { - // add classname that reflect the (absence of) access rights - this.$el.toggleClass('o_cannot_create', !this.is_action_enabled('create')); - return this._super().then(this.trigger.bind(this, 'view_loaded')); - }, - /** - * 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 {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_|show_).+|.+_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 ? [[record_id]] : [dataset.ids]; - 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); - } else if (action_data.type === "action") { - return data_manager.load_action(action_data.name, _.extend(pyeval.eval('context', context), { - active_model: dataset.model, - active_ids: dataset.ids, - active_id: record_id - })).then(handler); - } - }, - do_show: function () { - this._super(); - core.bus.trigger('view_shown', this); - }, - 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) { - }, - /** - * This function should render the action buttons of the view. - * It should be called after start(). - * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted - * $node may be undefined, in which case the View can insert the buttons somewhere else - */ - render_buttons: function($node) { - }, - /** - * This function should render the sidebar of the view. - * It should be called after start(). - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - * $node may be undefined, in which case the View can insert the sidebar somewhere else - */ - render_sidebar: function($node) { - }, - /** - * This function should render the pager of the view. - * It should be called after start(). - * @param {jQuery} [$node] a jQuery node where the pager should be inserted - * $node may be undefined, in which case the View can insert the pager somewhere else - */ - render_pager: function($node) { - }, - /** - * 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 {}; - }, - destroy: function () { - if (this.$buttons) { - this.$buttons.off(); - } - return this._super.apply(this, arguments); - }, - set_scrollTop: function(scrollTop) { - this.scrollTop = scrollTop; - }, - get_scrollTop: function() { - return this.scrollTop; - } -}); - -return View; - -}); diff --git a/addons/web/static/src/js/libs.js b/addons/web/static/src/js/libs.js new file mode 100644 index 00000000000..1a058aee761 --- /dev/null +++ b/addons/web/static/src/js/libs.js @@ -0,0 +1,138 @@ +odoo.define('web.libs', function () { +"use strict"; + +/** + * External Libraries Customization + * + * This is the place where we do some slightly crappy libraries customization. + * We are not proud of this, but at least, we try to avoid patching them in + * place. + * + * In an ideal world, this file would not be needed, because external libraries + * would not have bugs, their APIS would be done in a way that we could use them + * as is, and their distribution process would make sure that they are properly + * configured. However, we do not live in such a world, so this file is + * necessary. + */ + +// nvd3 customization +//------------------------------------------------------------------------- +if ('nv' in window) { + 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(); + }; + + // monkey patch nvd3 to prevent it to display a tooltip (position: absolute) + // with a negative `top`; with this patch the highest tooltip's position is + // still in the graph + var originalCalcTooltipPosition = nv.tooltip.calcTooltipPosition; + nv.tooltip.calcTooltipPosition = function () { + var container = originalCalcTooltipPosition.apply(this, arguments); + container.style.top = container.style.top.split('px')[0] < 0 ? 0 + 'px' : container.style.top; + return container; + }; +} + +// 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'; +$.fn.tooltip.Constructor.DEFAULTS.delay = { show: 1000, hide: 0 }; +//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.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; +var 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); + } +}); + +}); diff --git a/addons/web/static/src/js/services/ajax_service.js b/addons/web/static/src/js/services/ajax_service.js new file mode 100644 index 00000000000..b86585eb5d8 --- /dev/null +++ b/addons/web/static/src/js/services/ajax_service.js @@ -0,0 +1,16 @@ +odoo.define('web.AjaxService', function (require) { +"use strict"; + +var AbstractService = require('web.AbstractService'); +var session = require('web.session'); + +var AjaxService = AbstractService.extend({ + name: 'ajax', + rpc: function(route, args) { + return session.rpc(route, args); + }, +}); + +return AjaxService; + +});
\ No newline at end of file diff --git a/addons/web/static/src/js/config.js b/addons/web/static/src/js/services/config.js index 3354c799655..3354c799655 100644 --- a/addons/web/static/src/js/config.js +++ b/addons/web/static/src/js/services/config.js diff --git a/addons/web/static/src/js/services/core.js b/addons/web/static/src/js/services/core.js index 41e30c1bd52..acd22c09fa1 100644 --- a/addons/web/static/src/js/services/core.js +++ b/addons/web/static/src/js/services/core.js @@ -45,7 +45,6 @@ return { one2many_view_registry: new Registry(), search_filters_registry: new Registry(), search_widgets_registry: new Registry(), - view_registry: new Registry(), csrf_token: odoo.csrf_token, }; diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js index e01a3e75680..f09eec23daf 100644 --- a/addons/web/static/src/js/services/crash_manager.js +++ b/addons/web/static/src/js/services/crash_manager.js @@ -1,3 +1,215 @@ +odoo.define('web.CrashManager', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +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) { + var self = this; + if (!this.active) { + return; + } + if (this.connection_lost) { + return; + } + if (error.code === -32098) { + core.bus.trigger('connection_lost'); + this.connection_lost = true; + var timeinterval = setInterval(function() { + ajax.jsonRpc('/web/webclient/version_info').then(function() { + clearInterval(timeinterval); + core.bus.trigger('connection_restored'); + self.connection_lost = false; + }); + }, 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: _t("Odoo 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: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"), + subtitle: error.data.title, + $content: $(QWeb.render('CrashManager.warning', {error: error})) + }).open(); + }, + show_error: function(error) { + if (!this.active) { + return; + } + var dialog = new Dialog(this, { + title: _.str.capitalize(error.type || error.message) || _t("Odoo Error"), + $content: $(QWeb.render('CrashManager.error', {error: error})) + }).open(); + + // When the dialog opens, initialize the copy feature and destroy it when the dialog is closed + var $clipboardBtn; + var clipboard; + dialog.opened(function () { + $clipboardBtn = dialog.$(".o_clipboard_button"); + $clipboardBtn.tooltip({title: _t("Copied !"), trigger: "manual", placement: "left"}); + clipboard = new window.Clipboard($clipboardBtn[0], { + text: function () { + return (_t("Error") + ":\n" + error.message + "\n\n" + error.data.debug).trim(); + } + }); + clipboard.on("success", function (e) { + _.defer(function () { + $clipboardBtn.tooltip("show"); + _.delay(function () { + $clipboardBtn.tooltip("hide"); + }, 800); + }); + }); + }); + dialog.on("closed", this, function () { + $clipboardBtn.tooltip("destroy"); + clipboard.destroy(); + }); + + // When the full traceback is shown, scroll it to the end (useful for better python error reporting) + dialog.$(".o_error_detail").on("shown.bs.collapse", function (e) { + e.target.scrollTop = e.target.scrollHeight; + }); + }, + show_message: function(exception) { + this.show_error({ + type: _t("Odoo 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: _.str.capitalize(error.type) || _t("Odoo Warning"), + buttons: [ + {text: error.data.arguments[2], classes : "btn-primary", click: function() { + window.location.href = '#action='+error.data.arguments[1]; + self.destroy(); + }}, + {text: _t("Cancel"), click: function() { self.destroy(); }, close: true} + ], + $content: QWeb.render('CrashManager.warning', {error: error}), + }).open(); + } +}); + +core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler); + +return CrashManager; +}); + odoo.define('web.crash_manager', function (require) { "use strict"; diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index 69fc68908f6..7188f7d9126 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -1,3 +1,224 @@ +odoo.define('web.DataManager', function (require) { +"use strict"; + +var core = require('web.core'); +var Model = require('web.DataModel'); +var session = require('web.session'); +var utils = require('web.utils'); + +return core.Class.extend({ + init: function () { + this.Filters = new Model('ir.filters'); + this._init_cache(); + }, + + _init_cache: function () { + this._cache = { + actions: {}, + fields_views: {}, + filters: {}, + views: {}, + }; + }, + + /** + * Invalidates the whole cache + * Suggestion: could be refined to invalidate some part of the cache + */ + invalidate: function () { + this._init_cache(); + }, + + /** + * Loads an action from its id or xmlid. + * + * @param {int|string} [action_id] the action id or xmlid + * @param {Object} [additional_context] used to load the action + * @return {Deferred} resolved with the action whose id or xmlid is action_id + */ + load_action: function (action_id, additional_context) { + var self = this; + var key = this._gen_key(action_id, additional_context || {}); + + if (!this._cache.actions[key]) { + this._cache.actions[key] = session.rpc("/web/action/load", { + action_id: action_id, + additional_context : additional_context, + }).then(function (action) { + self._cache.actions[key] = action.no_cache ? null : self._cache.actions[key]; + return action; + }, this._invalidate.bind(this, this._cache.actions, key)); + } + + return this._cache.actions[key].then(function (action) { + return $.extend(true, {}, action); + }); + }, + + /** + * Loads various information concerning views: fields_view for each view, + * the fields of the corresponding model, and optionally the filters. + * + * @param {Object} [dataset] the dataset for which the views are loaded + * @param {Array} [views_descr] array of [view_id, view_type] + * @param {Object} [options] dictionnary of various options: + * - options.load_filters: whether or not to load the filters, + * - options.action_id: the action_id (required to load filters), + * - options.toolbar: whether or not a toolbar will be displayed, + * @return {Deferred} resolved with the requested views information + */ + load_views: function (params, options) { + var self = this; + + var model = params.model; + var context = params.context; + var views_descr = params.views_descr; + var key = this._gen_key(model, views_descr, options || {}, context); + + if (!this._cache.views[key]) { + // Don't load filters if already in cache + var filters_key; + if (options.load_filters) { + filters_key = this._gen_key(model, options.action_id); + options.load_filters = !this._cache.filters[filters_key]; + } + + this._cache.views[key] = session.rpc('/web/dataset/call_kw/' + model + '/load_views', { + args: [], + kwargs: { + views: views_descr, + options: options, + context: context.eval(), + }, + model: model, + method: 'load_views', + }).then(function (result) { + // Postprocess fields_views and insert them into the fields_views cache + result.fields_views = _.mapObject(result.fields_views, self._postprocess_fvg.bind(self)); + _.each(result.fields_views, function (fields_view) { + _.defaults(fields_view.fields, result.fields); + }); + _.each(views_descr, function (view_descr) { + var toolbar = options.toolbar && view_descr[1] !== 'search'; + var fv_key = self._gen_key(model, view_descr[0], view_descr[1], toolbar, context); + self._cache.fields_views[fv_key] = $.when(result.fields_views[view_descr[1]]); + }); + + // Insert filters, if any, into the filters cache + if (result.filters) { + self._cache.filters[filters_key] = $.when(result.filters); + } + + return result.fields_views; + }, this._invalidate.bind(this, this._cache.views, key)); + } + + return this._cache.views[key].then(function (views) { + return $.extend(true, {}, views); + }); + }, + + /** + * Loads the filters of a given model and optional action id. + * + * @param {Object} [dataset] the dataset for which the filters are loaded + * @param {int} [action_id] the id of the action (optional) + * @return {Deferred} resolved with the requested filters + */ + load_filters: function (dataset, action_id) { + var key = this._gen_key(dataset.model, action_id); + if (!this._cache.filters[key]) { + this._cache.filters[key] = this.Filters.call('get_filters', [dataset.model, action_id], { + context: dataset.get_context(), + }).fail(this._invalidate.bind(this, this._cache.filters, key)); + } + return this._cache.filters[key]; + }, + + /** + * Calls 'create_or_replace' on 'ir_filters'. + * + * @param {Object} [filter] the filter description + * @return {Deferred} resolved with the id of the created or replaced filter + */ + create_filter: function (filter) { + var self = this; + return this.Filters + .call('create_or_replace', [filter]) + .then(function (filter_id) { + var key = [ + filter.model_id, + filter.action_id || false, + ].join(','); + self._invalidate(self._cache.filters, key); + return filter_id; + }); + }, + + /** + * Calls 'unlink' on 'ir_filters'. + * + * @param {Object} [filter] the description of the filter to remove + * @return {Deferred} + */ + delete_filter: function (filter) { + var self = this; + return this.Filters + .call('unlink', [filter.id]) + .then(function () { + self._cache.filters = {}; // invalidate cache + }); + }, + + /** + * Private function that postprocesses fields_view (mainly parses the arch attribute) + */ + _postprocess_fvg: function (fields_view) { + var self = this; + + // Parse arch + var doc = $.parseXML(fields_view.arch).documentElement; + fields_view.arch = utils.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban')); + + // Special case for id's + if ('id' in fields_view.fields) { + var id_field = fields_view.fields.id; + id_field.original_type = id_field.type; + id_field.type = 'id'; + } + + // Process inner views (one2manys) + _.each(fields_view.fields, function(field) { + _.each(field.views || {}, function(view) { + self._postprocess_fvg(view); + }); + }); + + return fields_view; + }, + + /** + * Private function that generates a cache key from its arguments + */ + _gen_key: function () { + return _.map(Array.prototype.slice.call(arguments), function (arg) { + if (!arg) { + return false; + } + return _.isObject(arg) ? JSON.stringify(arg) : arg; + }).join(','); + }, + + /** + * Private function that invalidates a cache entry + */ + _invalidate: function (cache, key) { + delete cache[key]; + }, +}); + +}); + odoo.define('web.data_manager', function (require) { "use strict"; diff --git a/addons/web/static/src/js/services/local_storage_service.js b/addons/web/static/src/js/services/local_storage_service.js new file mode 100644 index 00000000000..a4527026238 --- /dev/null +++ b/addons/web/static/src/js/services/local_storage_service.js @@ -0,0 +1,25 @@ +odoo.define('web.LocalStorageService', function (require) { +'use strict'; + +var AbstractService = require('web.AbstractService'); +var localStorage = require('web.local_storage'); + +var LocalStorageService = AbstractService.extend({ + name: 'local_storage', + setItem: function(key, value) { + localStorage.setItem(key,value); + }, + getItem: function(key) { + return localStorage.getItem(key); + }, + removeItem: function(key) { + localStorage.removeItem(key); + }, + clear: function() { + localStorage.clear(); + } +}); + +return LocalStorageService; + +}); diff --git a/addons/web/static/src/js/view_manager.js b/addons/web/static/src/js/view_manager.js deleted file mode 100644 index 2c792e393d7..00000000000 --- a/addons/web/static/src/js/view_manager.js +++ /dev/null @@ -1,479 +0,0 @@ -odoo.define('web.ViewManager', function (require) { -"use strict"; - -var ControlPanelMixin = require('web.ControlPanelMixin'); -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); -var pyeval = require('web.pyeval'); -var SearchView = require('web.SearchView'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var ViewManager = Widget.extend(ControlPanelMixin, { - className: "o_view_manager_content", - /** - * Called each time the view manager is attached into the DOM - */ - on_attach_callback: function() { - this.is_in_DOM = true; - if (this.active_view && this.active_view.controller.on_attach_callback) { - this.active_view.controller.on_attach_callback(); - } - }, - /** - * Called each time the view manager is detached from the DOM - */ - on_detach_callback: function() { - this.is_in_DOM = false; - if (this.active_view && this.active_view.controller.on_detach_callback) { - this.active_view.controller.on_detach_callback(); - } - }, - /** - * @param {Object} [dataset] - * @param {Array} [views] List of [view_id, view_type[, fields_view]] - * @param {Object} [flags] various boolean describing UI state - */ - init: function(parent, dataset, views, flags, options) { - var self = this; - this._super(parent); - - this.action = options && options.action || {}; - this.action_manager = options && options.action_manager; - this.flags = flags || {}; - this.dataset = dataset; - this.view_order = []; - this.views = {}; - this.view_stack = []; // used for breadcrumbs - this.active_view = null; - this.registry = core.view_registry; - this.title = this.action.name; - _.each(views, function (view) { - var view_type = view[1] || view.view_type; - var View = self.registry.get(view_type); - if (!View) { - console.error("View type", "'"+view_type+"'", "is not present in the view registry."); - return; - } - var view_label = View.prototype.display_name; - var view_descr = { - accesskey: View.prototype.accesskey, - button_label: _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}), - controller: null, - fields_view: view[2] || view.fields_view, - icon: View.prototype.icon, - label: view_label, - mobile_friendly: View.prototype.mobile_friendly, - multi_record: View.prototype.multi_record, - options: view.options || {}, - require_fields: View.prototype.require_fields, - title: self.title, - type: view_type, - view_id: view[0] || view.view_id, - }; - self.view_order.push(view_descr); - self.views[view_type] = view_descr; - }); - this.first_view = this.views[options && options.view_type]; // view to open first - this.default_view = this.get_default_view(); - }, - willStart: function () { - var views_def; - var first_view_to_display = this.first_view || this.default_view; - if (!first_view_to_display.fields_view || (this.flags.search_view && !this.search_fields_view)) { - views_def = this.load_views(first_view_to_display.require_fields); - } - return $.when(this._super(), views_def); - }, - /** - * @return {Deferred} initial view and search view (if any) loading promise - */ - start: function() { - var self = this; - _.each(this.views, function (view) { - view.options = _.extend({ - action: self.action, - }, self.flags, self.flags[view.type], view.options); - }); - if (this.flags.search_view) { - this.search_view_loaded = this.setup_search_view(); - } - if (this.flags.views_switcher) { - this.render_switch_buttons(); - } - - // If a non multi-record first_view is given, switch to it but first push the default_view - // to the view_stack to complete the breadcrumbs - if (this.first_view && !this.first_view.multi_record && this.default_view.multi_record) { - this.default_view.controller = this.create_view(this.default_view); - this.view_stack.push(this.default_view); - } - var view_to_load = this.first_view || this.default_view; - var options = this.flags[view_to_load] && this.flags[view_to_load].options; - var main_view_loaded = this.switch_mode(view_to_load.type, options); - - return $.when(this._super(), main_view_loaded, this.search_view_loaded); - }, - /** - * Loads all missing field_views of views in this.views and the search view. - * - * @param {Boolean} [load_fields] whether or not to load the fields as well - * @return {Deferred} - */ - load_views: function (load_fields) { - var self = this; - var views = []; - _.each(this.views, function (view) { - if (!view.fields_view) { - views.push([view.view_id, view.type]); - } - }); - var options = { - action_id: this.action.id, - load_fields: load_fields, - toolbar: this.flags.sidebar, - }; - if (this.flags.search_view && !this.search_fields_view) { - options.load_filters = true; - var searchview_id = this.action.search_view_id && this.action.search_view_id[0]; - views.push([searchview_id || false, 'search']); - } - return data_manager.load_views(this.dataset, views, options).then(function (fields_views) { - _.each(fields_views, function (fields_view, view_type) { - if (view_type === 'search') { - self.search_fields_view = fields_view; - } else { - self.views[view_type].fields_view = fields_view; - } - }); - }); - }, - /** - * Returns the default view with the following fallbacks: - * - use the default_view defined in the flags, if any - * - use the first view in the view_order - * - * @returns {Object} the default view - */ - get_default_view: function() { - return this.views[this.flags.default_view || this.view_order[0].type]; - }, - switch_mode: function(view_type, view_options) { - var self = this; - var view = this.views[view_type]; - var old_view = this.active_view; - - if (!view || this.currently_switching) { - return $.Deferred().reject(); - } else { - this.currently_switching = true; // prevent overlapping switches - } - - // Ensure that the fields_view has been loaded - var views_def; - if (!view.fields_view) { - views_def = this.load_views(view.require_fields); - } - - return $.when(views_def).then(function () { - if (view.multi_record) { - self.view_stack = []; - } else if (self.view_stack.length > 0 && !(_.last(self.view_stack).multi_record)) { - // Replace the last view by the new one if both are mono_record - self.view_stack.pop(); - } - self.view_stack.push(view); - - self.active_view = view; - - if (!view.loaded) { - if (!view.controller) { - view.controller = self.create_view(view, view_options); - } - view.$fragment = $('<div>'); - view.loaded = view.controller.appendTo(view.$fragment).done(function () { - // Remove the unnecessary outer div - view.$fragment = view.$fragment.contents(); - self.trigger("controller_inited", view.type, view.controller); - }); - } - - // Call do_search on the searchview to compute domains, contexts and groupbys - if (self.search_view_loaded && - self.flags.auto_search && - view.controller.searchable !== false) { - self.active_search = $.Deferred(); - $.when(self.search_view_loaded, view.loaded).done(function() { - self.searchview.do_search(); - }); - } - - return $.when(view.loaded, self.active_search) - .then(function() { - return self._display_view(view_options, old_view).then(function() { - self.trigger('switch_mode', view_type, view_options); - }); - }).fail(function(e) { - if (!(e && e.code === 200 && e.data.exception_type)) { - self.do_warn(_t("Error"), view.controller.display_name + _t(" view couldn't be loaded")); - } - // Restore internal state - self.active_view = old_view; - self.view_stack.pop(); - }); - }).always(function () { - self.currently_switching = false; - }); - }, - _display_view: function (view_options, old_view) { - var self = this; - var view_controller = this.active_view.controller; - var view_fragment = this.active_view.$fragment; - - // Show the view - return $.when(view_controller.do_show(view_options)).done(function () { - var view_control_elements = self.render_view_control_elements(); - // Prepare the ControlPanel content and update it - var cp_status = { - active_view_selector: '.o_cp_switch_' + self.active_view.type, - breadcrumbs: self.action_manager && self.action_manager.get_breadcrumbs(), - cp_content: _.extend({}, self.searchview_elements, view_control_elements), - hidden: self.flags.headless, - searchview: self.searchview, - search_view_hidden: view_controller.searchable === false || view_controller.searchview_hidden, - }; - self.update_control_panel(cp_status); - - // Detach the old view and store it - if (old_view && old_view !== self.active_view) { - // Store the scroll position - if (self.action_manager && self.action_manager.webclient) { - old_view.controller.set_scrollTop(self.action_manager.webclient.get_scrollTop()); - } - // Do not detach ui-autocomplete elements to let jquery-ui garbage-collect them - var $to_detach = self.$el.contents().not('.ui-autocomplete'); - old_view.$fragment = framework.detach([{widget: old_view.controller}], {$to_detach: $to_detach}); - } - - // If the user switches from a multi-record to a mono-record view, - // the action manager should be scrolled to the top. - if (old_view && old_view.controller.multi_record === true && view_controller.multi_record === false) { - view_controller.set_scrollTop(0); - } - - // Append the view fragment to self.$el - framework.append(self.$el, view_fragment, { - in_DOM: self.is_in_DOM, - callbacks: [{widget: view_controller}], - }); - }); - }, - create_view: function(view, view_options) { - var self = this; - var js_class = view.fields_view.arch.attrs && view.fields_view.arch.attrs.js_class; - var View = this.registry.get(js_class || view.type); - var options = _.clone(view.options); - if (view.type === "form" && ((this.action.target === 'new' || this.action.target === 'inline') || - (view_options && view_options.mode === 'edit'))) { - options.initial_mode = options.initial_mode || 'edit'; - } - - var controller = new View(this, this.dataset, view.fields_view, options); - - 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() { - if (self.action_manager && !self.flags.headless) { - var breadcrumbs = self.action_manager.get_breadcrumbs(); - self.update_control_panel({breadcrumbs: breadcrumbs}, {clear: false}); - } - }); - - return controller; - }, - select_view: function (index) { - var view_type = this.view_stack[index].type; - return this.switch_mode(view_type); - }, - /** - * Renders the switch buttons for multi- and mono-record views and adds - * listeners on them, but does not append them to the DOM - * Sets switch_buttons.$mono and switch_buttons.$multi to send to the ControlPanel - */ - render_switch_buttons: function() { - var self = this; - - // Partition the views according to their multi-/mono-record status - var views = _.partition(this.view_order, function(view) { - return view.multi_record === true; - }); - var multi_record_views = views[0]; - var mono_record_views = views[1]; - - // Inner function to render and prepare switch_buttons - var _render_switch_buttons = function(views) { - if (views.length > 1) { - var $switch_buttons = $(QWeb.render('ViewManager.switch-buttons', {views: views})); - // Create bootstrap tooltips - _.each(views, function(view) { - $switch_buttons.filter('.o_cp_switch_' + view.type).tooltip(); - }); - // Add onclick event listener - $switch_buttons.filter('button').click(_.debounce(function(event) { - var view_type = $(event.target).data('view-type'); - self.switch_mode(view_type); - }, 200, true)); - return $switch_buttons; - } - }; - - // Render switch buttons but do not append them to the DOM as this will - // be done later, simultaneously to all other ControlPanel elements - this.switch_buttons = {}; - this.switch_buttons.$multi = _render_switch_buttons(multi_record_views); - this.switch_buttons.$mono = _render_switch_buttons(mono_record_views); - }, - /** - * Renders the control elements (buttons, sidebar, pager) of the current view. - * Fills this.active_view.control_elements dictionnary with the rendered - * elements and the adequate view switcher, to send to the ControlPanel. - * Warning: it should be called before calling do_show on the view as the - * sidebar is extended to listen on the load_record event triggered as soon - * as do_show is done (the sidebar should thus be instantiated before). - */ - render_view_control_elements: function() { - if (!this.active_view.control_elements) { - var view_controller = this.active_view.controller; - var $buttons = this.flags.$buttons; - var elements = {}; - if (!this.flags.headless) { - elements = { - $buttons: $("<div>"), - $sidebar: $("<div>"), - $pager: $("<div>"), - }; - } - view_controller.render_buttons($buttons ? $buttons.empty() : elements.$buttons); - view_controller.render_sidebar(elements.$sidebar); - view_controller.render_pager(elements.$pager); - // Remove the unnecessary outer div - elements = _.mapObject(elements, function($node) { - return $node && $node.contents(); - }); - // Use the adequate view switcher (mono- or multi-record) - if (this.switch_buttons) { - if (this.active_view.multi_record) { - elements.$switch_buttons = this.switch_buttons.$multi; - } else { - elements.$switch_buttons = this.switch_buttons.$mono; - } - } - - // Store the rendered elements in the active_view to allow restoring them later - this.active_view.control_elements = elements; - } - return this.active_view.control_elements; - }, - /** - * Sets up the current viewmanager's search view. - * Sets $searchview and $searchview_buttons in searchview_elements to send to the ControlPanel - */ - setup_search_view: function() { - var self = this; - if (this.searchview) { - this.searchview.destroy(); - } - - var search_defaults = {}; - var context = 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: $("<div>"), - action: this.action, - search_defaults: search_defaults, - }; - // Instantiate the SearchView, but do not append it nor its buttons to the DOM as this will - // be done later, simultaneously to all other ControlPanel elements - this.searchview = new SearchView(this, this.dataset, this.search_fields_view, options); - - this.searchview.on('search_data', this, this.search.bind(this)); - return $.when(this.searchview.appendTo($("<div>"))).done(function() { - self.searchview_elements = {}; - self.searchview_elements.$searchview = self.searchview.$el; - self.searchview_elements.$searchview_buttons = self.searchview.$buttons.contents(); - }); - }, - /** - * Executed on event "search_data" thrown by the SearchView - */ - search: function(domains, contexts, groupbys) { - var self = this; - var controller = this.active_view.controller; // the correct view must be loaded here - var action_context = this.action.context || {}; - var 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) { - this.switch_mode(state.view_type, true); - } - this.active_view.controller.do_load_state(state, warm); - }, - destroy: function () { - if (this.control_elements) { - if (this.control_elements.$switch_buttons) { - this.control_elements.$switch_buttons.off(); - } - } - return this._super.apply(this, arguments); - }, -}); - -return ViewManager; - -}); diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js new file mode 100644 index 00000000000..b90b1448ce2 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -0,0 +1,498 @@ +odoo.define('web.AbstractController', function (require) { +"use strict"; + +/** + * The Controller class is the class coordinating the model and the renderer. + * It is the C in MVC, and is what was formerly known in Odoo as a View. + * + * Its role is to listen to events bubbling up from the model/renderer, and call + * the appropriate methods if necessary. It also render control panel buttons, + * and react to changes in the search view. Basically, all interactions from + * the renderer/model with the outside world (meaning server/reading in session/ + * reading localstorage, ...) has to go through the controller. + */ + +var Widget = require('web.Widget'); + + +var AbstractController = Widget.extend({ + custom_events: { + open_record: '_onOpenRecord', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {AbstractModel} model + * @param {AbstractRenderer} renderer + * @param {object} params + * @param {string} params.modelName + * @param {any} [params.handle] a handle that will be given to the model (some id) + * @param {any} params.initialState the initialState + * @param {string} [params.noContentHelp] + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.model = model; + this.renderer = renderer; + this.modelName = params.modelName; + this.handle = params.handle; + this.activeActions = params.activeActions; + this.initialState = params.initialState; + this.noContentHelp = params.noContentHelp; + }, + /** + * Simply renders and updates the url. + * + * @returns {Deferred} + */ + start: function () { + return $.when( + this._super.apply(this, arguments), + this.renderer.appendTo(this.$el) + ).then(this._update.bind(this, this.initialState)); + }, + destroy: function () { + if (this.$buttons) { + this.$buttons.off(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns any special keys that may be useful when reloading the view to + * get the same effect. This is necessary for saving the current view in + * the favorites. For example, a graph view might want to add a key to + * save the current graph type. + * + * @returns {Object} + */ + getContext: function () { + return {}; + }, + /** + * @see setScrollTop + * @returns {number} + */ + getScrollTop: function () { + return this.scrollTop; + }, + /** + * Returns a title that may be displayed in the breadcrumb area. For + * example, the name of the record. + * + * Note: this seems wrong right now, it should not be implemented, we have + * no guarantee that there is a display_name variable in a controller. + * + * @returns {string} + */ + getTitle: function () { + return this.display_name; + }, + /** + * The use of this method is discouraged. It is still snakecased, because + * it currently is used in many templates, but we will move to a simpler + * mechanism as soon as we can. + * + * @deprecated + * @param {string} action type of action, such as 'create', 'read', ... + * @returns {boolean} + */ + is_action_enabled: function (action) { + return this.activeActions[action]; + }, + /** + * Short helper method to reload the view + * + * @param {Object} [params] This object will simply be given to the update + * @returns {Deferred} + */ + reload: function (params) { + return this.update(params || {}); + }, + /** + * Most likely called by the view manager, this method is responsible for + * adding buttons in the control panel (buttons such as save/discard/...) + * + * Note that there is no guarantee that this method will be called. The + * controller is supposed to work even without a view manager, for example + * in the frontend (odoo frontend = public website) + * + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { + }, + /** + * For views that require a pager, this method will be called to allow the + * controller to instantiate and render a pager. Note that in theory, the + * controller can actually render whatever he wants in the pager zone. If + * your view does not want a pager, just let this method empty. + * + * @param {Query Node} $node + */ + renderPager: function ($node) { + }, + /** + * Same as renderPager, but for the 'sidebar' zone (the zone with the menu + * dropdown in the control panel next to the buttons) + * + * @param {Query Node} $node + */ + renderSidebar: function ($node) { + }, + /** + * Not sure about this one, it probably needs to be reworked, maybe merged + * in get/set local state methods. + * + * @see getScrollTop + * @param {number} scrollTop + */ + setScrollTop: function (scrollTop) { + this.scrollTop = scrollTop; + }, + /** + * This is the main entry point for the controller. Changes from the search + * view arrive in this method, and internal changes can sometimes also call + * this method. It is basically the way everything notifies the controller + * that something has changed. + * + * The update method is responsible for fetching necessary data, then + * updating the renderer and wait for the rendering to complete. + * + * @param {Object} params will be given to the model and to the renderer + * @param {Object} [options] + * @param {boolean} [options.reload=true] if true, the model will reload data + * + * @returns {Deferred} + */ + update: function (params, options) { + var self = this; + var shouldReload = (options && 'reload' in options) ? options.reload : true; + var def = shouldReload ? this.model.reload(this.handle, params) : $.when(); + return def.then(function (handle) { + self.handle = handle || self.handle; // update handle if we reloaded + var state = self.model.get(self.handle); + var localState = self.renderer.getLocalState(); + return self.renderer.updateState(state, params).then(function () { + self.renderer.setLocalState(localState); + self._update(state); + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method is the way a view can notifies the outside world that + * something has changed. The main use for this is to update the url, for + * example with a new id. + * + * @param {Object} [state] information that will be pushed to the outside + * world + */ + _pushState: function (state) { + this.trigger_up('push_state', state || {}); + }, + /** + * Hide or show the nocontent helper. For this, it also remove the renderer + * from the dom, and reattach it when necessary. + * + * This method is a helper for controllers that want to display a help + * message when no content is available. It is suggested to override + * _update to call this method. + * + * @param {boolean} hasNoContent + */ + _toggleNoContentHelper: function (hasNoContent) { + if (hasNoContent) { + this.renderer.$el.detach(); + var $msg = $('<div>') + .addClass('oe_view_nocontent') + .html(this.noContentHelp); + this.$el.html($msg); + } else { + if (!document.contains(this.renderer.el)) { + this.$('div.oe_view_nocontent').remove(); + this.$el.append(this.renderer.$el); + } + } + }, + /** + * This method is called after each update or when the start method is + * completed. + * + * Its primary use is to be used as a hook to update all parts of the UI, + * besides the renderer. For example, it may be used to enable/disable + * some buttons in the control panel, such as the current graph type for a + * graph view. + * + * @param {Object} state the state given by the model + * @returns {Deferred} + */ + _update: function (state) { + this._pushState(); + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When an Odoo event arrives requesting a record to be opened, this method + * gets the res_id, and request a switch view in the appropriate mode + * + * Note: this method seems wrong, it relies on the model being a basic model, + * to get the res_id. It should receive the res_id in the event data + * @todo move this to basic controller? or view manager + * + * @param {OdooEvent} event + * @param {number} event.data.id The local model ID for the record to be + * opened + * @param {string} [event.data.mode='readonly'] + */ + _onOpenRecord: function (event) { + var record = this.model.get(event.data.id, {raw: true}); + this.trigger_up('switch_view', { + view_type: 'form', + res_id: record.res_id, + mode: event.data.mode || 'readonly', + }); + }, + +}); + +return AbstractController; + +}); + + // do_show: function () { + // this._super.apply(this, arguments); + // core.bus.trigger('view_shown', this); + // }, + // 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))); + // }, + // hasNoContent: function (state) { + // return !state.count && this.no_content_help; + // }, + // displayNoContentHelp: function () { + // var $msg = $('<div>') + // .addClass('oe_view_nocontent') + // .html(this.no_content_help); + // this.$el.append($msg); + // return; + // }, + // open_record: function (id, options) { + // // TODO: move this to view manager at some point + // var state = this.model.get(this.db_id); + // var record = this.model.get(id); + // var res_ids; + // if (state.groupedBy.length) { + // res_ids = _.pluck(_.flatten(_.pluck(state.data, 'data')), 'res_id'); + // } else { + // res_ids = _.pluck(state.data, 'res_id'); + // } + // options = _.extend({}, options, { + // dataset: { + // res_ids: res_ids, + // current_id: record.res_id, + // }, + // }); + // this.trigger_up('switch_view', { + // view_type: 'form', + // options: options, + // }); + // }, + // open_dialog: function (event) { + // var data = event.data; + // data.flags = _.defaults(data.flags || {}, { + // mode: 'edit', + // footer_to_buttons: true, + // action_buttons: false, + // headless: true + // }); + // this.do_action({ + // type: 'ir.actions.act_window', + // res_model: data.model || this.model, + // res_id: data.id, + // view_id: data.view_id, + // view_mode: data.type || 'form', + // view_type: data.type || 'form', + // views: [[false, data.type || 'form']], + // target: 'new', + // context: data.context, + // flags: data.flags, + // }, { + // on_load: function (action) { + // action.viewManager.$('.o_form_statusbar').remove(); + // data.on_load && data.on_load(action.viewManager); + // }, + // }); + // }, + // _onDeletedRecords: function () { + // this.update_state(this.db_id); + // }, + // update_state: function (db_id) { + // this.db_id = db_id; + // var state = this.model.get(db_id); + // if (state.type === 'record') { + // this.set({ title : state.data.id ? state.data.display_name : _t("New") }); + // } + // if (this.pager && this.config.hasPager) { + // this.update_pager(); + // } + // if (this.$buttons) { + // this.update_buttons(); + // } + // this.$('.oe_view_nocontent').remove(); + // if (this.hasNoContent(state)) { + // if (this.renderer) { + // this.renderer.do_hide(); + // } + // this.displayNoContentHelp(); + // return; + // } + // if (this.renderer) { + // this.update_renderer(); + // this.renderer.do_show(); + // } + // var params = state.res_id ? {id: state.res_id} : {}; + // this.do_push_state(params); + // core.bus.trigger('view_shown'); + // }, + // reload: function (event) { + // var db_id = event && event.data && event.data.db_id; + // if (db_id) { + // // reload the relational field given its db_id + // this.model.reload(db_id).then(this._confirmSave.bind(this, db_id)); + // } else { + // // no db_id given, so reload the main record + // return this.model.reload(this.db_id).then(this.update_state.bind(this)); + // } + // }, + // update_renderer: function () { + // var state = this.model.get(this.db_id); + // return this.renderer.update(state); + // }, + // renderPager: function ($node, options) { + // var data = this.model.get(this.db_id, {raw: true}); + // this.pager = new Pager(this, data.count, data.offset + 1, this.page_size, options); + + // this.pager.on('pager_changed', this, function (new_state) { + // var self = this; + // var data = this.model.get(this.db_id); + // this.pager.disable(); + // var limit_changed = (this.page_size !== new_state.limit); + // this.page_size = new_state.limit; + // this.model + // .setLimit(data.id, new_state.limit) + // .setOffset(data.id, new_state.current_min - 1) + // .reload(data.id) + // .then(function (state) { + // self.update_state(state); + // // Reset the scroll position to the top on page changed only + // if (!limit_changed) { + // self.trigger_up('scrollTo', {offset: 0}); + // } + // }) + // .then(this.pager.enable.bind(this.pager)); + // }); + // this.pager.appendTo($node = $node || this.options.$pager); + // this.update_pager(); // to force proper visibility + // }, + // update_buttons: function () { + // }, + // update_pager: function () { + // var data = this.model.get(this.db_id); + // this.pager.updateState({ + // current_min: data.offset + 1, + // size: data.count, + // }); + // var is_pager_visible = (data.type === 'record') || (!!data.count && (data.groupedBy && !data.groupedBy.length)); + // this.pager.do_toggle(is_pager_visible); + // }, + // do_search: function (domain, context, group_by) { + // var load = this.db_id ? this._reload_data : this._load_data; + // return load.call(this, domain, context, group_by).then(this.update_state.bind(this)); + // }, + // _load_data: function (domain, context, group_by) { + // return this.model + // .load({ + // type: 'record', + // model: this.modelName, + // domain: domain, + // groupedBy: group_by, + // context: context, + // }); + // }, + // _reload_data: function (domain, context, group_by) { + // return this.model + // .setDomain(this.db_id, domain) + // .setContext(this.db_id, context) + // .setGroupBy(this.db_id, group_by) + // .reload(this.db_id); + // }, + // sidebar_eval_context: function () { + // return $.when({}); + // }, + // /** + // * Return whether the user can perform a given action (e.g. 'create', 'edit') 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; + // }, + // config: { + // openGroupByDefault: false, + // page_size: 40, + // hasPager: true, // TODO: change this into false + // js_libs: [], // the list of lazy-loaded js dependencies + // css_libs: [], // the list of lazy-loaded css dependencies + // }, + // defaults: { + // action: {}, + // }, + // used by views that need a searchview. + // searchable: true, + // used by views that need a searchview but don't want it to be displayed. + // searchview_hidden: false, + + // events: { + // 'click a[type=action]': function (ev) { + // ev.preventDefault(); + // var action_data = $(ev.target).attr('name'); + // this.do_action(action_data); + // } + // }, + + // custom_events: _.extend({}, FieldManagerMixin.custom_events, { + // open_record: function (event) { + // this.open_record(event.data.id); + // }, + // reload: 'reload', + // open_dialog: 'open_dialog', + // close_dialog: function () { + // this.do_action({type: 'ir.actions.act_window_close'}); + // }, + // // TODO: add open_action, ... + // }), + diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js new file mode 100644 index 00000000000..78aa595a398 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_model.js @@ -0,0 +1,69 @@ +odoo.define('web.AbstractModel', function (require) { +"use strict"; + +/** + * An AbstractModel is the M in MVC. We tend to think of MVC more on the server + * side, but we are talking here on the web client side. + * + * The duties of the Model are to fetch all relevant data, and to make them + * available for the rest of the view. Also, every modification to that data + * should pass through the model. + * + * Note that the model is not a widget, it does not need to be rendered or + * appended to the dom. However, it inherits from the EventDispatcherMixin, + * in order to be able to notify its parent by bubbling events up. + */ + +var Class = require('web.Class'); +var mixins = require('web.mixins'); + +var AbstractModel = Class.extend(mixins.EventDispatcherMixin, mixins.ServicesMixin, { + /** + * @param {Widget} parent + */ + init: function (parent) { + mixins.EventDispatcherMixin.init.call(this); + this.setParent(parent); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method should return the complete state necessary for the renderer + * to display the currently viewed data. + * + * @returns {*} + */ + get: function () { + }, + /** + * The load method is called once in a model, when we load the data for the + * first time. The method returns (a deferred that resolves to) some kind + * of token/handle. The handle can then be used with the get method to + * access a representation of the data. + * + * @param {Object} params + * @param {string} params.modelName the name of the model + * @returns {Deferred} The deferred resolves to some kind of handle + */ + load: function (params) { + return $.when(); + }, + /** + * When something changes, the data may need to be refetched. This is the + * job for this method: reloading (only if necessary) all the data and + * making sure that they are ready to be redisplayed. + * + * @param {Object} params + * @returns {Deferred} + */ + reload: function (params) { + return $.when(); + }, +}); + +return AbstractModel; + +}); diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js new file mode 100644 index 00000000000..c47f6f7e3f3 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -0,0 +1,119 @@ +odoo.define('web.AbstractRenderer', function (require) { +"use strict"; + +/** + * The renderer should not handle pagination, data loading, or coordination + * with the control panel. It is only concerned with rendering. + * + */ + +var Widget = require('web.Widget'); + +return Widget.extend({ + /** + * @constructor + * @param {Widget} parent + * @param {any} state + * @param {Object} params + */ + init: function (parent, state, params) { + this._super(parent); + this.state = state; + this.arch = params.arch; + }, + /** + * The rendering can be asynchronous (but it is not encouraged). The start + * method simply makes sure that we render the view. + * + * @returns {Deferred} + */ + start: function () { + this.$el.addClass(this.arch.attrs.class); + return $.when(this._render(), this._super()); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns any relevant state that the renderer might want to keep. + * + * The idea is that a renderer can be destroyed, then be replaced by another + * one instantiated with the state from the model and the localState from + * the renderer, and the end result should be the same. + * + * The kind of state that we expect the renderer to have is mostly DOM state + * such as the scroll position, the currently active tab page, ... + * + * This method is called before each updateState, by the controller. + * + * @see setLocalState + * @returns {any} + */ + getLocalState: function () { + }, + /** + * This is the reverse operation from getLocalState. With this method, we + * expect the renderer to restore all DOM state, if it is relevant. + * + * This method is called after each updateState, by the controller. + * + * @see getLocalState + * @param {any} localState the result of a call to getLocalState + */ + setLocalState: function (localState) { + }, + /** + * update the state of the view. It always retrigger a full rerender. + * + * @param {any} state + * @param {Object} params + * @returns {Deferred} + */ + updateState: function (state, params) { + this.state = state; + return this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render the view + * + * @abstract + * @private + * @returns {Deferred} + */ + _render: function () { + return $.when(); + }, +}); + +}); + + // // this method is used to get local state unknown by renderer parent, but + // // necessary to restore the renderer to a full state after an update. For + // // example, the currently opened tabs in a form view, or the current scrolling + // // position + // getLocalState: function () { + // // to be implemented by actual renderer + // }, + // // this method is used after an update (and may also be used after a new renderer + // // is instantiated). For example, opening a new record in a form view is + // // done by instantiating a new renderer. But the curently opened page should + // // be maintained if possible. + // setLocalState: function (local_state) { + // // to be implemented by actual renderer + // }, + + // var local_state = this.getLocalState(); + // .then(this.setLocalState.bind(this, local_state)); + // // todo: add and use getWidget method + // getWidget: function (node) { + // // read field type from node + // // or get widget name from attrs + // }, + // }, diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js new file mode 100644 index 00000000000..c95e4f084fe --- /dev/null +++ b/addons/web/static/src/js/views/abstract_view.js @@ -0,0 +1,181 @@ +odoo.define('web.AbstractView', function (require) { +"use strict"; + +/** + * This is the base class inherited by all (JS) views. Odoo JS views are the + * widgets used to display information in the main area of the web client + * (note: the search view is not a "JS view" in that sense). + * + * The abstract view role is to take a set of fields, an arch (the xml + * describing the view in db), and some params, and then, to create a + * controller, a renderer and a model. This is the classical MVC pattern, but + * the word 'view' has historical significance in Odoo code, so we replaced the + * V in MVC by the 'renderer' word. + * + * JS views are supposed to be used like this: + * 1. instantiate a view with some arch, fields and params + * 2. call the createController method on the view instance. This returns a + * controller (with a model and a renderer as sub widgets) + * 3. append the controller somewhere + * + * Note that once a controller has been instantiated, the view class is no + * longer useful (unless you want to create another controller), and will be + * in most case discarded. + */ + +var Class = require('web.Class'); +var ajax = require('web.ajax'); +var AbstractModel = require('web.AbstractModel'); +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractController = require('web.AbstractController'); + +var AbstractView = Class.extend({ + // name displayed in view switchers + display_name: '', + // indicates whether or not the view is mobile-friendly + mobile_friendly: false, + // icon is the font-awesome icon to display in the view switcher + icon: 'fa-question', + // multi_record is used to distinguish views displaying a single record + // (e.g. FormView) from those that display several records (e.g. ListView) + multi_record: true, + + // determine if a search view should be displayed in the control panel and + // allowed to interact with the view. Currently, the only not searchable + // views are the form view and the diagram view. + searchable: true, + + config: { + Model: AbstractModel, + Renderer: AbstractRenderer, + Controller: AbstractController, + js_libs: [], + css_libs: [], + }, + + /** + * The constructor function is supposed to set 3 variables: rendererParams, + * controllerParams and loadParams. These values will be used to initialize + * the model, renderer and controllers. + * + * @constructs AbstractView + * + * @param {any} arch + * @param {any} fields + * @param {Object} params + * @param {string} params.modelName The actual model name + * @param {Object} params.context + * @param {number} [params.count] + * @param {string[]} params.domain + * @param {string[]} params.groupBy + * @param {number} [params.currentId] + * @param {number[]} [params.ids] + * @param {string} [params.action.help] + */ + init: function (arch, fields, params) { + this.rendererParams = { + arch: arch + }; + + this.controllerParams = { + modelName: params.modelName, + activeActions: { + edit: arch.attrs.edit ? JSON.parse(arch.attrs.edit) : true, + create: arch.attrs.create ? JSON.parse(arch.attrs.create) : true, + delete: arch.attrs.delete ? JSON.parse(arch.attrs.delete) : true, + }, + noContentHelp: params.action && params.action.help, + }; + + this.loadParams = { + context: params.context, + count: params.count || ((this.controllerParams.ids !== undefined) && + this.controllerParams.ids.length) || 0, + domain: params.domain, + groupedBy: params.groupBy, + modelName: params.modelName, + res_id: params.currentId, + res_ids: params.ids, + }; + if (params.modelName) { + this.loadParams.modelName = params.modelName; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Main method of the view class. + * + * @param {Widget} parent The parent of the resulting Controller (most + * likely a view manager) + * @returns {Deferred} The deferred resolves to a controller + */ + createController: function (parent) { + var self = this; + var model = this.model || this.createModel(parent); + return $.when(model.load(this.loadParams), this._loadLibs()).then(function () { + var state = model.get(arguments[0]); + var renderer = self.createRenderer(parent, state); + var Controller = self.config.Controller; + var controllerParams = _.extend({ + initialState: state, + }, self.controllerParams); + var controller = new Controller(parent, model, renderer, controllerParams); + renderer.setParent(controller); + + if (!self.model) { + // if we have a model, it already has a parent. Otherwise, we + // set the controller, so the rpcs from the model actually work + model.setParent(controller); + } + return controller; + }); + }, + createModel: function (parent) { + var Model = this.config.Model; + return new Model(parent); + }, + createRenderer: function (parent, state) { + var Renderer = this.config.Renderer; + return new Renderer(parent, state, this.rendererParams); + }, + /** + * this is usedful to customize the actual class to use before calling + * createView. + * + * @param {Controller} Controller + */ + setController: function (Controller) { + this.config.Controller = Controller; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Makes sure that the js_libs and css_libs are properly loaded. Note that + * the ajax loadJS and loadCSS methods don't do anything if the given file + * is already loaded. + * + * @private + * @returns {Deferred} + */ + _loadLibs: function () { + var defs = []; + _.each(this.config.js_libs, function (url) { + defs.push(ajax.loadJS(url)); + }); + _.each(this.config.css_libs, function (url) { + defs.push(ajax.loadCSS(url)); + }); + return $.when.apply($, defs); + }, +}); + +return AbstractView; + +}); diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js new file mode 100644 index 00000000000..24b5c7fa3d0 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -0,0 +1,178 @@ +odoo.define('web.BasicController', function (require) { +"use strict"; + +/** + * The BasicController is mostly here to share code between views that will use + * a BasicModel (or a subclass). Currently, the BasicViews are the form, list + * and kanban views. + */ + +var AbstractController = require('web.AbstractController'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Pager = require('web.Pager'); + +var _t = core._t; + +return AbstractController.extend(FieldManagerMixin, { + custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { + reload: '_onReload', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.archiveEnabled + * @param {boolean} params.confirmOnDelete + * @param {boolean} params.hasButtons + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.archiveEnabled = params.archiveEnabled; + this.confirmOnDelete = params.confirmOnDelete; + this.hasButtons = params.hasButtons; + FieldManagerMixin.init.call(this, this.model); + this.handle = params.initialState.id; + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + return this._super.apply(this, arguments) + .then(this._updateEnv.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + renderPager: function ($node, options) { + var data = this.model.get(this.handle, {raw: true}); + this.pager = new Pager(this, data.count, data.offset + 1, data.limit, options); + + this.pager.on('pager_changed', this, function (newState) { + var self = this; + this.pager.disable(); + var limitChanged = (data.limit !== newState.limit); + this.reload({limit: newState.limit, offset: newState.current_min - 1}) + .then(function () { + // Reset the scroll position to the top on page changed only + if (!limitChanged) { + self.trigger_up('scrollTo', {offset: 0}); + } + }) + .then(this.pager.enable.bind(this.pager)); + }); + this.pager.appendTo($node); + this._updatePager(); // to force proper visibility + }, + /** + * @override + * @returns {Deferred} + */ + update: function (params, options) { + var self = this; + return this._super(params, options).then(function () { + self._updateEnv(); + self._updatePager(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Delete records (and ask for confirmation if necessary) + * + * @param {string[]} ids list of local record ids + */ + _deleteRecords: function (ids) { + var self = this; + function doIt() { + return self.model + .deleteRecords(ids, self.modelName, self.handle) + .then(self._onDeletedRecords.bind(self, ids)); + } + if (this.confirmOnDelete) { + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: doIt, + }); + } else { + doIt(); + } + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @see _deleteRecord + * @param {string[]} ids list of deleted ids (basic model local handles) + */ + _onDeletedRecords: function (ids) { + this.update({}); + }, + /** + * Helper method, to get the current environment variables from the model + * and notifies the component chain (by bubbling an event up) + */ + _updateEnv: function () { + var env = this.model.get(this.handle, {env: true}); + this.trigger_up('env_updated', env); + }, + /** + * Helper method, to make sure the information displayed by the pager is up + * to date. + */ + _updatePager: function () { + if (this.pager) { + var data = this.model.get(this.handle, {raw: true}); + this.pager.updateState({ + current_min: data.offset + 1, + size: data.count, + }); + var isRecord = data.type === 'record'; + var hasData = !!data.count; + var isGrouped = data.groupedBy ? !!data.groupedBy.length : false; + var isNew = this.model.isNew(this.handle); + var isPagerVisible = isRecord ? !isNew : (hasData && !isGrouped); + + this.pager.do_toggle(isPagerVisible); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When a reload event triggers up, we need to reload the full view. + * For example, after a form view dialog saved some data. + * + * @todo: rename db_id into handle + * + * @param {OdooEvent} event + * @param {Object} event.data + * @param {string} [event.data.db_id] handle of the data to reload and + * re-render (reload the whole form by default) + * @param {string[]} [event.data.fieldNames] list of the record's fields to + * reload + */ + _onReload: function (event) { + var data = event && event.data || {}; + var handle = data.db_id; + if (handle) { + // reload the relational field given its db_id + this.model.reload(handle).then(this._confirmSave.bind(this, handle)); + } else { + // no db_id given, so reload the main record + this.reload({fieldNames: data.fieldNames}); + } + }, + +}); + +}); diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js new file mode 100644 index 00000000000..5baf65f1c30 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -0,0 +1,1996 @@ +odoo.define('web.BasicModel', function (require) { +"use strict"; + +/** + * Basic Model + * + * This class contains all the logic necessary to communicate between the + * python models and the web client. More specifically, its job is to give a + * simple unified API to the rest of the web client (in particular, the views and + * the field widgets) to query and modify actual records in db. + * + * From a high level perspective, BasicModel is essentially a hashmap with + * integer keys and some data and metadata object as value. Each object in this + * hashmap represents a piece of data, and can be reloaded and modified by using + * its id as key in many methods. + * + * Here is a description of what those data point look like: + * { + * _changes: { + * data: { fieldName: field_values | [commands] }, + * relational_data: { field_names: datapoint } + * }, + * aggregate_values: { fieldName: number} | undefined + * count: number, default=0, + * context: {string:value}, + * data: {string:value} | [{string:value}], + * field_names: [string], + * fields: {string:field description}, + * grouped_by: [strings] | undefined, + * id: number, + * is_group: boolean, + * is_list: boolean, + * is_open: boolean, + * is_record: boolean, + * limit: number | undefined (default=80 for lists) + * model: string, + * offset: number, default=0, + * openGroupByDefault: boolean, + * ordered_by: [order] | undefined, + * res_id: [number]|undefined, + * res_ids: [number] | undefined, + * relational_data: { + * field_names: datapoints + * }, + * static: boolean, * like previous DatasetStatic + * } + * + * Notes: + * - id: is totally unrelated to res_id. id is a web client local concept + * - res_id: if set to a number, it is an actual id for a record in the server + * database. If set to 'virtual_' + number, it is a record not yet saved (so, + * in create mode). + * - res_ids: if set, it represent the context in which the data point is actually + * used. For example, a given record in a form view (opened from a list view) + * might have a res_id = 2 and res_ids = [1,2,3] + * - offset: this is mainly used for pagination. Useful when we need to load + * another page, then we can simply change the offset and reload. + * - count is basically the number of records being manipulated. We can't use + * res_ids, because we might have a very large number of records, or a + * domain, and the res_ids would be the current page, not the full set. + * - model is the actual name of a (odoo) model, such as 'res.partner' + * - fields contains the description of all the fields from the model. Note that + * these properties might have been modified by a view (for example, with + * required=true. So, the fields kind of depends of the context of the + * data point. + * - field_names: list of some relevant field names (string). Usually, it + * denotes the fields present in the view. Only those fields should be + * loaded. + * - _cache and _changes are private, they should not leak out of the basicModel + * and be used by anyone else. + * + * Commands: + * commands are the base commands for x2many (0 -> 6), but with a + * slight twist: each [0, _, values] command is augmented with a virtual id: + * it means that when the command is added in basicmodel, it generates an id + * looking like this: 'virtual_' + number, and uses this id to identify the + * element, so it can be edited later. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var data = require('web.data'); // TODO: remove dependency to data.js +var pyeval = require('web.pyeval'); +var session = require('web.session'); + +var x2ManyCommands = { + // (0, _, {values}) + CREATE: 0, + create: function (values) { + return [x2ManyCommands.CREATE, false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + update: function (id, values) { + return [x2ManyCommands.UPDATE, id, values]; + }, + // (2, id[, _]) + DELETE: 2, + delete: function (id) { + return [x2ManyCommands.DELETE, id, false]; + }, + // (3, id[, _]) removes relation, but not linked record itself + FORGET: 3, + forget: function (id) { + return [x2ManyCommands.FORGET, id, false]; + }, + // (4, id[, _]) + LINK_TO: 4, + link_to: function (id) { + return [x2ManyCommands.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]; + } +}; + +var Model = AbstractModel.extend({ + /** + * @override + */ + init: function () { + // this mutex is necessary to make sure some operations are done + // sequentially, for example, an onchange needs to be completed before a + // save is performed. + this.mutex = new concurrency.Mutex(); + + // this dictionary is used to hold all many2one informations for widget + // selection. + this.many2ones = {}; + + // this dictionary is used to hold all many2many informations for widget + // many2many_checkboxes. + this.many2manys = {}; + + this.localData = Object.create(null); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + + /** + * Add a default record to a list object. This method actually make a new + * record with the makeDefaultRecord method, then add it to the list object. + * + * @param {string} listID a valid handle for a list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @returns {Deferred<string>} resolves to the id of the new created record + */ + addDefaultRecord: function (listID, options) { + var list = this.localData[listID]; + var context = this._getContext(list); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fieldAttrs: list.fieldAttrs, + fieldNames: list.fieldNames, + fields: list.fields, + }; + return this.makeDefaultRecord(list.model, params).then(function(id) { + list.count++; + if (position === 'top') { + list.data.unshift(id); + } else { + list.data.push(id); + } + return id; + }); + }, + /** + * Delete a list of records, then, if a parent_id is given, reload the + * parent. + * + * @todo we should remove the deleted records from the localData + * @todo is it really necessary to reload the data? it seems artificial, and + * the caller should be able to do that himself + * @todo why can't we infer modelName? + * + * @param {string[]} recordIds list of local resources ids. They should all + * be of type 'record', and of the same model + * @param {string} modelName + * @param {string} parent_id + * @returns {Deferred} + */ + deleteRecords: function (recordIds, modelName, parent_id) { + var self = this; + var records = _.map(recordIds, function (id) { return self.localData[id]; }); + return this.performModelRPC(modelName, 'unlink', [_.pluck(records, 'res_id')], { + context: session.user_context, // todo: combine with view context + }).then(function () { + _.each(records, function (record) { + record.res_ids.splice(record.offset, 1)[0]; + record.res_id = record.res_ids[record.offset]; + record.count--; + }); + if (parent_id) { + return self.reload(parent_id); + } + }); + }, + /** + * Discard all changes in a local resource. Basically, it removes + * everything that was stored in a _changes key. + * + * @param {string} id local resource id + */ + discardChanges: function (id) { + var element = this.localData[id]; + this._visitChildren(element, function (elem) { + elem._changes = null; + }); + }, + /** + * Duplicate a record (by calling the 'copy' route) + * + * @param {string} recordID id for a local resource + * @returns {Deferred -> string} resolves to the id of duplicate record + */ + duplicateRecord: function (recordID) { + var self = this; + var record = this.localData[recordID]; + return this.performModelRPC(record.model, 'copy', [record.data.id], { + context: this._getContext(record), + }).then(function (res_id) { + var index = record.res_ids.indexOf(record.res_id); + record.res_ids.splice(index + 1, 0, res_id); + return self.load({ + fieldAttrs: record.fieldAttrs, + fieldNames: record.fieldNames, + fields: record.fields, + modelName: record.model, + res_id: res_id, + res_ids: record.res_ids.slice(0), + }); + }); + }, + /** + * The get method first argument is the handle returned by the load method. + * It is optional (the handle can be undefined). In some case, it makes + * sense to use the handle as a key, for example the BasicModel holds the + * data for various records, each with its local ID. + * + * synchronous method, it assumes that the resource has already been loaded. + * + * @param {string} id local id for the resource + * @param {any} options + * @param {boolean} [options.env=false] if true, will only return res_id + * (if record) or res_ids (if list) + * @param {boolean} [options.raw=false] if true, will not follow relations + * @returns {Object} + */ + get: function (id, options) { + var self = this; + options = options || {}; + + if (!(id in this.localData)) { + return null; + } + + var record = this.localData[id]; + + if (options.env) { + var env = { + ids: record.res_ids ? record.res_ids.slice(0) : [], + }; + if (record.type === 'record') { + env.currentId = this.isNew(record.id) ? undefined : record.res_id; + } + return env; + } + + // do not copy fields: this has a really big performance cost, for views + // with many records and lots of fields (for ex, kanban view contacts) + var fields = record.fields; + delete record.fields; + var element = $.extend(true, {}, record); + record.fields = fields; + element.fields = fields; + + var field, relDataPoint; + if (element.type === 'record') { + // apply changes + _.extend(element.data, element._changes); + + for (var fieldName in element.data) { + field = element.fields[fieldName]; + + // get relational datapoint + if (field.type === 'many2one') { + if (options.raw) { + relDataPoint = this.localData[element.data[fieldName]]; + element.data[fieldName] = relDataPoint ? relDataPoint.res_id : false; + } else { + element.data[fieldName] = this.get(element.data[fieldName]) || false; + } + } + if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + relDataPoint = this.localData[element.data[fieldName]]; + var ids = _.map(relDataPoint.data, function (id) { + return self.localData[id].res_id; + }); + element.data[fieldName] = ids; + } else { + element.data[fieldName] = this.get(element.data[fieldName]) || []; + } + } + + // weird way to get out many2ones for widget=selection + if (field.__fetch_selection) { + field.__selection_information = self.many2ones[field.relation]; + } + + // weird way to get out many2manys for widget=many2many_checkboxes + if (field.__fetch_many2manys) { + field.__many2many_information = self.many2manys[field.relation]; + } + } + + // this is not strictly necessary, but it hides some implementation + // details, and can easily be removed if needed. + delete element.orderedBy; + delete element.aggregateValues; + delete element.groupedBy; + } + if (element.type === 'list') { + // apply changes if any + if (element._changes) { + element.data = element._changes; + element.count = element._changes.length; + element.res_ids = _.map(element._changes, function (elemID) { + return self.localData[elemID].res_id; + }); + } + // get relational datapoint + element.data = _.map(element.data, function (elemID) { + return self.get(elemID); + }); + } + + delete element._cache; + delete element._changes; + delete element.static; + delete element.parentID; + delete element.rawContext; + return element; + }, + /** + * return true if a record is dirty. A record is considered dirty if it has + * some unsaved changes. A list is considered dirty if its _changes key is + * set to an array of its new datapoints (possibly empty) + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isDirty: function (id) { + var isDirty = false; + var record = this.localData[id]; + this._visitChildren(record, function (r) { + if (r.type === "record" ? !_.isEmpty(r._changes) : r._changes) { + isDirty = true; + } + }); + return isDirty; + }, + /** + * Check if a record is new, meaning if it is in the process of being + * created and no actual record exists in db. + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isNew: function (id) { + return typeof this.localData[id].res_id !== 'number'; + }, + /** + * Main entry point, the goal of this method is to fetch and process all + * data (following relations if necessary) for a given record/list. + * + * @todo document all params + * + * @param {any} params + * @param {Object} [params.fieldAttrs={}] contains the attrs of each field + * @param {Array} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @param {string} [params.type] 'record' or 'list' + * @returns {Deferred -> string} resolves to a local id, or handle + */ + load: function (params) { + params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); + + if (params.type === 'record' && params.res_id === undefined) { + return this.makeDefaultRecord(params.modelName, params); + } + var dataPoint = this._makeDataPoint(params); + return this._load(dataPoint).then(function () { + return dataPoint.id; + }); + }, + /** + * When one needs to create a record from scratch, a not so simple process + * needs to be done: + * - call the /default_get route to get default values + * - fetch all relational data + * - apply all onchanges if necessary + * - fetch all relational data + * + * This method tries to optimize the process as much as possible. Also, + * it is quite horrible and should be refactored at some point. + * + * @param {any} params + * @param {string} modelName model name + * @param {Object} params.context the context for the new record + * @param {Object} [params.fieldAttrs={}] contains the attrs of each field + * @param {Array} params.fieldNames the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @param {Object} params.context the context for the new record + * @returns {Deferred -> string} resolves to the id for the created resource + */ + makeDefaultRecord: function (modelName, params) { + var self = this; + var fields_key = _.without(params.fieldNames, '__last_update'); + + return this.performModelRPC(modelName, 'default_get', [fields_key], { + context: params.context, + }).then(function (result) { + // fill default values for missing fields + for (var i = 0; i < params.fieldNames.length; i++) { + var fieldName = params.fieldNames[i]; + if (!(fieldName in result)) { + var field = params.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + result[fieldName] = []; + } else { + result[fieldName] = null; + } + } + } + + var data = {}; + var record = self._makeDataPoint({ + modelName: modelName, + data: data, + fields: params.fields, + fieldNames: params.fieldNames, + fieldAttrs: params.fieldAttrs, + context: params.context, + res_ids: params.res_ids, + }); + + var defs = []; // FIXME: remove defs? + _.each(params.fieldNames, function (name) { + var field = params.fields[name]; + data[name] = null; + record._changes = record._changes || {}; + if (field.type === 'many2one' && result[name]) { + var rec = self._makeDataPoint({ + context: record.context, + data: {id: result[name]}, + modelName: field.relation, + }); + record._changes[name] = rec.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + var x2manyList = self._makeDataPoint({ + context: record.context, + fieldAttrs: field.fieldAttrs, + fields: field.relatedFields, + limit: field.limit, + modelName: field.relation, + res_ids: [], + static: true, + type: 'list', + }); + record._changes[name] = x2manyList.id; + var many2ones = {}; + _.each(result[name], function (command) { + if (command[0] === 0) { + // CREATE + var r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fields: field.relatedFields, + fieldAttrs: field.fieldAttrs, + }); + x2manyList._changes.push(r.id); + r._changes = command[2]; + + // this is necessary so the fields are initialized + for (var fieldName in command[2]) { + r.data[fieldName] = null; + } + + for (var name in r._changes) { + if (r.fields[name].type === 'many2one') { + var rec = self._makeDataPoint({ + context: r.context, + modelName: r.fields[name].relation, + data: {id: r._changes[name]} + }); + r._changes[name] = rec.id; + many2ones[name] = true; + } + } + } + if (command[0] === 6) { + // REPLACE_WITH + x2manyList._changes = []; + } + }); + + // fetch many2ones display_name + _.each(_.keys(many2ones), function (name) { + defs.push(self._fetchNameGets(x2manyList, name)); + }); + } else { + record._changes[name] = result[name]; + } + }); + return $.when.apply($, defs) + .then(function () { + var shouldApplyOnchange = false; + var field; + for (var field_name in record.data) { + field = record.fields[field_name]; + if (field.onChange) { + shouldApplyOnchange = true; + } + } + if (shouldApplyOnchange) { + return self._applyOnChange(record, fields_key); + } else { + return $.when(); + } + }) + .then(function () { + return self._fetchRelationalData(record); + }) + .then(function () { + return self._postprocess(record); + }) + .then(function () { + return record.id; + }); + }); + }, + /** + * This helper method is designed to help developpers that want to use a + * field widget outside of a view. In that case, we want a way to create + * data without actually performing a fetch. + * + * @param {string} model name of the model + * @param {Object[]} fields a description of field properties + * @param {Object} [attrs] various field attrs that we want to set + * @returns {string} the local id for the created resource + */ + makeRecord: function (model, fields, attrs) { + var self = this; + var record_fields = {}; + _.each(fields, function (field) { + record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain'); + }); + var record = this._makeDataPoint({ + modelName: model, + fields: record_fields, + fieldAttrs: attrs, + }); + _.each(fields, function (field) { + if ('value' in field) { + if (field.type === 'many2one') { + var id = field.value[0]; + var display_name = field.value[1]; + var relatedRecord = self._makeDataPoint({ + modelName: field.relation, + data: { + id: id, + display_name: display_name, + } + }); + record.data[field.name] = relatedRecord.id; + } else { + record.data[field.name] = field.value; + } + } + }); + return record.id; + }, + /** + * This method just does a name_create call to the server. + * + * @todo Maybe this method should also create a local record... + * + * @param {string} model the name of the model + * @param {string} name the desired name of the created record + * @param {Object} context the context to be passed to the rpc + * @returns {Deferred} + */ + nameCreate: function (model, name, context) { + return this.performModelRPC(model, 'name_create', [name], { + context: _.extend({}, session.user_context, context), + }); + }, + /** + * Do a name_search. I think that it should be done directly by the widget + * @todo remove this + * @deprecated + * + * @param {any} model + * @param {any} name + * @param {any} domain + * @param {any} operator + * @param {any} limit + * @returns + */ + nameSearch: function (model, name, domain, operator, limit) { + // fixme: correctly handle context + return this.performModelRPC(model, 'name_search', [], { + name: name || '', + args: domain || [], + operator: operator || 'ilike', + limit: limit || 0, + }); + }, + /** + * This is an extremely important method. All changes in any field go + * through this method. It will then apply them in the local state, check + * if onchanges needs to be applied, actually do them if necessary, then + * resolves with the list of changed fields. + * + * @param {string} record_id + * @param {Object} changes a map field => new value + * @returns {string[]} list of changed fields + */ + notifyChanges: function (record_id, changes) { + var self = this; + var record = this.localData[record_id]; + var onChangeFields = []; // the fields that have changed and that have an on_change + var field; + var defs = []; + record._changes = record._changes || {}; + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName])); + } else if (field.type === 'many2one') { + defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName])); + } else { + record._changes[fieldName] = changes[fieldName]; + } + if (field.onChange) { + onChangeFields.push(fieldName); + } + } + + return $.when.apply($, defs).then(function () { + var onchangeDef; + if (onChangeFields.length) { + onchangeDef = self._applyOnChange(record, onChangeFields).then(function (result) { + return _.keys(changes).concat(Object.keys(result && result.value || {})); + }); + } else { + onchangeDef = $.Deferred().resolve(_.keys(changes)); + } + return onchangeDef.then(function (fieldNames) { + _.each(fieldNames, function (name) { + if (record._changes && record._changes[name] === record.data[name]) { + delete record._changes[name]; + } + }); + return fieldNames; + }); + }); + }, + /** + * Reload all data for a given resource + * + * @param {string} id local id for a resource + * @param {Object} [options] + * @returns {Deferred -> string} resolves to the id of the resource + */ + reload: function (id, options) { + options = options || {}; + var element = this.localData[id]; + + if (element.type === 'record') { + if ('currentId' in options && !options.currentId) { + var params = { + context: element.context, + fieldAttrs: element.fieldAttrs, + fieldNames: element.fieldNames, + fields: element.fields, + }; + return this.makeDefaultRecord(element.model, params); + } + this.discardChanges(id); + } + + if (options.context !== undefined) { + element.context = options.context; + } + if (options.domain !== undefined) { + element.domain = options.domain; + } + if (options.groupBy !== undefined) { + element.groupedBy = options.groupBy; + } + if (options.limit !== undefined) { + element.limit = options.limit; + } + if (options.offset !== undefined) { + this._setOffset(element.id, options.offset); + } + if (options.currentId !== undefined) { + element.res_id = options.currentId; + } + if (options.ids !== undefined) { + element.res_ids = options.ids; + element.count = element.res_ids.length; + } + if (element.type === 'record') { + element.offset = _.indexOf(element.res_ids, element.res_id); + } + var loadOptions = _.pick(options, 'fieldNames'); + return this._load(element, loadOptions).then(function (result) { + return result.id; + }); + }, + /** + * Save a local resource, if needed. This is a complicated operation, + * - it needs to check all changes, + * - generate commands for x2many fields, + * - call the /create or /write method according to the record status + * - After that, it has to reload all data, in case something changed, + * server side. + * + * @param {string} record_id local resource + * @param {Object} [options] + * @param {boolean} [options.reload=true] if true, data will be reloaded + * @returns {Deferred} + */ + save: function (record_id, options) { + var self = this; + options = options || {}; + var shouldReload = 'reload' in options ? options.reload : true; + return this.mutex.exec(function () { + var record = self.localData[record_id]; + var method = self.isNew(record_id) ? 'create' : 'write'; + var changes = _.extend({}, record._changes); + var commands = self._generateX2ManyCommands(record); + for (var fieldName in commands) { + if (commands[fieldName] === null) { + delete changes[fieldName]; + } else { + changes[fieldName] = commands[fieldName]; + } + } + + // replace local ids by actual ids in relational changes + for (fieldName in changes) { + var field = record.fields[fieldName]; + if (field.type === 'many2one') { + if (changes[fieldName]) { + var res_id = self.localData[changes[fieldName]].res_id; + changes[fieldName] = res_id; + } else { + changes[fieldName] = false; + } + } + } + + if (method === 'create') { + _.each(record.fieldNames, function (name) { + changes[name] = changes[name] || record.data[name]; + if (changes[name] === null) { + delete changes[name]; + } + }); + } + // make sure we don't write an undefined id + delete changes.id; + + // in the case of a write, only perform the RPC if there are changes to save + if (method === 'create' || Object.keys(changes).length) { + var args = method === 'write' ? [[record.data.id], changes] : [changes]; + return self.performModelRPC(record.model, method, args, { + context: session.user_context, // todo: combine with view context + }).then(function (id) { + if (method === 'create') { + record.res_id = id; // create returns an id, write returns a boolean + record.data.id = id; + record.offset = record.res_ids.length; + record.res_ids.push(id); + record.count++; + } + // erase changes as they have been applied + record._changes = {}; + + return shouldReload ? self._fetchRecord(record) : false; + }); + } else { + return $.when(record_id); + } + }); + }, + /** + * For list resources, this changes the orderedBy key, then performs the + * sort directly, in javascript. This is used for sorting static datasets, + * such as a one2many in a form view. For dynamic datasets, such as a list + * view, this method will be called, but then the sort will be ignored since + * we will reload data. + * + * @todo don't sort in js when we reload data anyway + * + * @param {string} list_id id for the list resource + * @param {string} fieldName valid field name + * @returns {this} so we can chain call this method + */ + setSort: function (list_id, fieldName) { + var list = this.localData[list_id]; + if (list.type === 'record') { + return; + } + list.offset = 0; + if (list.orderedBy.length === 0) { + list.orderedBy.push({name: fieldName, asc: true}); + } else if (list.orderedBy[0].name === fieldName){ + list.orderedBy[0].asc = !list.orderedBy[0].asc; + } else { + var orderedBy = _.reject(list.orderedBy, function (o) { + return o.name === fieldName; + }); + list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy); + } + this._sortList(list); + return this; + }, + /** + * Toggle the active value of given records (to archive/unarchive them) + * + * @param {Array} recordIDs local ids of the records to (un)archive + * @param {boolean} value false to archive, true to unarchive (value of the active field) + * @param {string} parentID id of the parent resource to reload + * @returns {Deferred -> string} resolves to the parent id + */ + toggleActive: function(recordIDs, value, parentID) { + var self = this; + var parent = this.localData[parentID]; + var resIDs = _.map(recordIDs, function(recordID) { + return self.localData[recordID].res_id; + }); + return this + .performModelRPC(parent.model, 'write', [resIDs, { active: value }]) + .then(this.reload.bind(this, parentID)); + + }, + /** + * Toggle (open/close) a group in a grouped list, then fetches relevant + * data + * + * @param {string} groupId + * @returns {Deferred -> string} resolves to the group id + */ + toggleGroup: function (groupId) { + var group = this.localData[groupId]; + if (group.isOpen) { + group.isOpen = false; + group.data = []; + group.offset = 0; + return $.when(groupId); + } + if (!group.isOpen) { + group.isOpen = true; + var def; + if (group.count > 0) { + def = this._fetchUngroupedList(group); + } + return $.when(def).then(function () { + return groupId; + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply a many2one onchange. There is a need for this function because the + * server only gives an id when a onchange modifies a many2one field. For + * this reason, we need (sometimes) to do a /name_get to fetch a + * display_name. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} [data] + * @returns {Deferred} + */ + _applyMany2OneChange: function (record, fieldName, data) { + var self = this; + if (!data) { + record._changes[fieldName] = false; + return $.when(); + } + var rel_data = _.pick(data, 'id', 'display_name'); + var def; + if (!('display_name' in rel_data)) { + var field = record.fields[fieldName]; + def = this.performModelRPC(field.relation, 'name_get', [data.id], { + context: record.context, + }).then(function (result) { + rel_data.display_name = result[0][1]; + }); + } + return $.when(def).then(function () { + var rec = self._makeDataPoint({ + context: record.context, + data: rel_data, + fields: {}, + modelName: record.fields[fieldName].relation, + }); + record._changes[fieldName] = rec.id; + }); + }, + /** + * This method is quite important: it is supposed to perform the /onchange + * rpc and apply the result. + * + * @param {Object} record + * @param {string[]} fields changed fields + * @returns + */ + _applyOnChange: function (record, fields) { + var self = this; + var onchange_spec = this._buildOnchangeSpecs(record); + var idList = record.data.id ? [record.data.id] : []; + var context = data.build_context(record, session.user_context).eval(); + + var rawData = this.get(record.id, {raw: true}).data; + var commands = this._generateX2ManyCommands(record); + var currentData = _.pick(_.extend(rawData, commands), record.fieldNames); + if (fields.length === 1) { + fields = fields[0]; + } + return this.mutex.exec(function () { + var args = [idList, currentData, fields, onchange_spec, context]; + return self.performModelRPC(record.model, 'onchange', args, {}).then(function (result) { + var defs = []; + _.each(result.value, function (val, name) { + var field = record.fields[name]; + if (!field) { return; } // ignore changes of unknown fields + + if (field.type === 'many2one' ) { + // in some case, the value returned by the onchange can + // be false (no value), so we need to avoid creating a + // local record for that. + // FIXME: shouldn't we erase the value in that case? + if (val) { + // when the value isn't false, it can be either + // an array [id, display_name] or just an id. + var data = _.isArray(val) ? + {id: val[0], display_name: val[1]} : + {id: val}; + var rec = self._makeDataPoint({ + context: context, + data: data, + modelName: field.relation, + }); + record._changes[name] = rec.id; + } + } else if (field.type === 'one2many') { + record._changes = record._changes || {}; + var listId = record._changes[name] || record.data[name]; + var list = self.localData[listId]; + var rec; + _.each(val, function (command) { + if (command[0] === 0 || command[0] === 1) { + // CREATE or UPDATE + var params = { + context: context, + fields: list.fields, + fieldAttrs: list.fieldAttrs, + modelName: list.model, + fieldNames: _.keys(command[2]), + }; + if (command[0] === 1) { + params.res_id = command[1]; + } + rec = self._makeDataPoint(params); + var data = command[2]; + _.each(rec.fieldNames, function (name) { + var field = rec.fields[name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: data[name][0], + display_name: data[name][1], + }, + }); + data[name] = r.id; + } + }); + rec._changes = data; + for (var f in rec._changes) { + rec.data[f] = null; + } + list._changes.push(rec.id); + } + if (command[0] === 5) { + // DELETE ALL + list._changes = []; + } + }); + } else { + record._changes[name] = val; + } + }); + return $.when.apply($, defs).then(function () { + return result; + }); + }); + }); + }, + /** + * When an operation is applied to a x2many field, the field widgets + * generate one (or more) command, which describes the exact operation. + * This method tries to interpret these commands and apply them to the + * localData. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} command A command object. It should have a 'operation' + * key. For example, it looks like {operation: ADD, id: 'partner_1'} + * @returns {Deferred} + */ + _applyX2ManyChange: function (record, fieldName, command) { + var self = this; + var list = this.localData[record._changes[fieldName] || record.data[fieldName]]; + var field = record.fields[fieldName]; + var rec; + var defs = []; + list._changes = list._changes || list.data; + + switch (command.operation) { + case 'ADD': + // for now, we are in the context of a one2many field + // the command should look like this: + // { operation: 'ADD', id: localID } + // The corresponding record may contain value for fields that + // are unknown in the list (e.g. fields that are in the + // subrecord form view but not in the kanban or list view), so + // to ensure that onchanges are correctly handled, we extend the + // list's fields with those in the created record + var newRecord = this.localData[command.id]; + _.defaults(list.fields, newRecord.fields); + newRecord.fields = list.fields; + list._changes.push(newRecord.id); + break; + case 'ADD_M2M': + // handle multiple add: command[2] may be a dict of values (1 + // record added) or an array of dict of values + var data = _.isArray(command.ids) ? command.ids : [command.ids]; + var list_records = {}; + _.each(data, function (d) { + rec = self._makeDataPoint({ + context: record.context, + modelName: field.relation, + fields: field.relatedFields, + fieldAttrs: field.fieldAttrs, + res_id: d.id, + }); + list_records[d.id] = rec; + list._changes.push(rec.id); + }); + // read list's records as we only have their ids and optionally their display_name + // (we can't use function readUngroupedList because those records are only in the + // _changes so this is a very specific case) + // this could be optimized by registering the fetched records in the list's _cache + // so that if a record is removed and then re-added, it won't be fetched twice + var def = this + .performModelRPC(list.model, 'read', [_.pluck(data, 'id'), list.fieldNames], {}) + .then(function (records) { + _.each(records, function (record) { + list_records[record.id].data = record; + }); + }); + defs.push(def); + break; + case 'CREATE': + defs.push(this.addDefaultRecord(list.id)); + break; + case 'UPDATE': + var changedRecord = this.localData[command.id]; + changedRecord._changes = changedRecord._changes || {}; + _.extend(changedRecord._changes, _.pick(command.data, list.fieldNames)); + break; + case 'REMOVE': + list._changes = _.without(list._changes, command.id); + break; + case 'REPLACE_WITH': + // this is certainly not optimal... and not sure that it is + // correct if some ids are added and some other are removed + var currentIds = _.map(list._changes, function (localId) { + return self.localData[localId].res_id; + }); + var newIds = _.difference(command.ids, currentIds); + var removedIds = _.difference(currentIds, command.ids); + var addDef, removedDef; + if (newIds.length) { + var values = _.map(newIds, function (id) { + return {id: id}; + }); + + addDef = this._applyX2ManyChange(record, fieldName, { + operation: 'ADD_M2M', + ids: values + }); + } + if (removedIds.length) { + removedDef = this._applyX2ManyChange(record, fieldName, { + operation: 'REMOVE', + ids: removedIds + }); + } + return $.when(addDef, removedDef); + } + + return $.when.apply($, defs); + }, + /** + * Helper method to build a 'spec', that is a description of all fields in + * the view that have a onchange defined on them. + * + * An onchange spec is necessary as an argument to the /onchange route. It + * looks like this: { field: "1", anotherField: "", relation.subField: "1"} + * + * @see _applyOnChange + * + * @param {Object} record resource object of type 'record' + * @returns {Object} an onchange spec + */ + _buildOnchangeSpecs: function (record) { + // TODO: replace this function by some generic tree function in utils + var specs = {}; + _.each(record.fieldNames, function (name) { + var field = record.fields[name]; + specs[name] = (field.onChange) || ""; + _.each(field.views, function (view) { + _.each(view.fields, function (field, subname) { + specs[name + '.' + subname] = (field.onChange) || ""; + }); + }); + }); + return specs; + }, + /** + * Fetch all name_gets for the many2ones in a group + * + * @param {Object} group a valid resource object + * @returns {Deferred} + */ + _fetchMany2OneGroup: function (group) { + var ids = _.uniq(_.pluck(group, 'res_id')); + + return this.performModelRPC(group[0].model, 'name_get', [ids], { + context: group[0].context, + }).then(function (name_gets) { + _.each(group, function (record) { + var nameGet = _.find(name_gets, function (n) { return n[0] === record.res_id;}); + record.data.display_name = nameGet[1]; + }); + }); + }, + _fetchNameGets: function (list, fieldName) { + var self = this; + var model; + var records = []; + var ids = _.map(list._changes || list.data, function (localId) { + var record = self.localData[localId]; + var data = record._changes || record.data; + var many2oneId = data[fieldName]; + var many2oneRecord = self.localData[many2oneId]; + records.push(many2oneRecord); + model = many2oneRecord.model; + return many2oneRecord.res_id; + }); + return this.performModelRPC(model, 'name_get', [ids], { + context: list.context, + }).then(function (name_gets) { + for (var i = 0; i < name_gets.length; i++) { + records[i].data.display_name = name_gets[i][1]; + } + }); + }, + /** + * For a given resource of type 'record', fetch all data. + * + * @param {Object} record local resource + * @param {string[]} [fieldNames] the list of fields to fetch. If not given, + * fetch all the fields in record.fieldNames (+ display_name) + * @returns {Deferred -> Object} resolves to the record + */ + _fetchRecord: function (record, fieldNames) { + var self = this; + fieldNames = fieldNames || _.uniq(record.fieldNames.concat(['display_name'])); + var oldData = record.data; + return this.performModelRPC(record.model, 'read', [record.res_id, fieldNames], { + // context: { 'bin_size': true }, // FIXME: when editing a subrecord in the partner form view, it tries to write the bin_size on the image field + }).then(function (result) { + result = result[0]; + record.data = _.extend({}, record.data, result); + return self._postprocess(record); + }).then(function (record) { + // process many2one: split [id, nameget] and create corresponding record + _.each(fieldNames, function (name) { + var field = record.fields[name]; + var val = record.data[name]; + if (field.type === 'many2one') { + if (val !== false) { + // the many2one value is of the form [id, display_name] + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + display_name: val[1], + id: val[0], + }, + }); + record.data[name] = r.id; + } else { + // no value for the many2one + record.data[name] = false; + } + } + }); + }).then(function () { + return self._fetchX2Manys(record, oldData, fieldNames).then(function () { + return record; + }); + }); + }, + /** + * This method is incorrectly named. It should be named something like + * _fetchMany2OneData. + * + * For a given record, this method fetches all many2ones information, + * batching the requests if possible (for example, if 3 many2ones are in + * relation on the same model, then we can probably fetch them in one rpc) + * + * This method is currently only called by makeDefaultRecord, it should be + * called by the onchange methods at some point. + * + * @todo fix bug: returns a list of deferred, not a deferred + * + * @param {Object} record a valid resource object + * @returns {Deferred} + */ + _fetchRelationalData: function (record) { + var self = this; + var toBeFetched = []; + + // find all many2one related records to be fetched + _.each(record.fieldNames, function (name) { + var field = record.fields[name]; + if (field.type === 'many2one') { + var localId = (record._changes && record._changes[name]) || record.data[name]; + var relatedRecord = self.localData[localId]; + if (!relatedRecord) { + return; + } + toBeFetched.push(relatedRecord); + } + }); + + // group them by model and context. Using the context as key is + // necessary to make sure the correct context is used for the rpc; + var groups = _.groupBy(toBeFetched, function (rec) { + return [rec.model, JSON.stringify(rec.context)].join(); + }); + + return $.when.apply($, _.map(groups, this._fetchMany2OneGroup.bind(this))); + }, + /** + * Fetch all data in a ungrouped list + * + * @param {Object} list a valid resource object + * @returns {Deferred -> Object} resolves to the fecthed list + */ + _fetchUngroupedList: function (list) { + var self = this; + var def; + if (list.static) { + def = this._readUngroupedList(list); + } else { + def = this._searchReadUngroupedList(list); + } + return def.then(function () { + return self._fetchX2ManysBatched(list); + }).then(function () { + return list; + }); + }, + /** + * X2Manys have to be fetched by separate rpcs (their data are stored on + * different models). This method takes a record, look at its x2many fields, + * then, if necessary, create a local resource and fetch the corresponding + * data. + * + * It also tries to reuse data, if it can find an existing list, to prevent + * useless rpcs. + * + * @param {Object} record local resource + * @param {Object} oldData the data fetched previously + * @param {string[]} [fieldNames] list of fields to fetch + * @returns {Deferred} + */ + _fetchX2Manys: function (record, oldData, fieldNames) { + var self = this; + var defs = []; + fieldNames = fieldNames || record.fieldNames; + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + var list; + + // a previously loaded list exists + if (oldData[fieldName]) { + list = self.localData[oldData[fieldName]]; + list.res_ids = record.data[fieldName]; + list.count = list.res_ids.length; + list.offset = list.offset < list.count ? list.offset : 0; + } else { + // need to create a list from scratch + var attrs = record.fieldAttrs[fieldName]; + var rawContext = attrs && attrs.context; + var ids = record.data[fieldName] || []; + list = self._makeDataPoint({ + count: ids.length, + fieldAttrs: field.fieldAttrs, + fields: field.relatedFields, + limit: field.limit, + modelName: field.relation, + res_ids: ids, + static: true, + type: 'list', + parentID: record.id, + rawContext: rawContext, + }); + } + record.data[fieldName] = list.id; + if (!field.__no_fetch) { + defs.push(self._readUngroupedList(list)); + } + } + }); + return $.when.apply($, defs); + }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchX2ManyBatched: function (list, fieldName) { + var self = this; + var field = list.fields[fieldName]; + + // step 1: collect ids + var ids = []; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + ids = _.unique(ids.concat(record.data[fieldName] || [])); + var m2mList = self._makeDataPoint({ + fieldAttrs: field.fieldAttrs, + fields: field.relatedFields, + modelName: field.relation, + res_ids: record.data[fieldName], + static: true, + type: 'list', + }); + record.data[fieldName] = m2mList.id; + }); + + if (!ids.length || field.__no_fetch) { + return $.when(); + } + + // step 2: fetch data from server + return this.performModelRPC(field.relation, 'read', [ids, _.keys(field.relatedFields)], { + context: {}, // FIXME + }).then(function (results) { + // step 3: assign values to correct datapoints + var dataPoints = _.map(results, function (result) { + return self._makeDataPoint({ + modelName: field.relation, + data: result, + fields: field.relatedFields, + fieldAttrs: field.fieldAttrs, + }); + }); + + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var m2mList = self.localData[record.data[fieldName]]; + + m2mList.data = []; + _.each(m2mList.res_ids, function (res_id) { + var dataPoint = _.find(dataPoints, function (d) { + return d.res_id === res_id; + }); + m2mList.data.push(dataPoint.id); + m2mList.count++; + }); + }); + }); + }, + /** + * batch request for x2ms for datapoint of type list + * + * @param {Object} list + * @returns {Deferred} + */ + _fetchX2ManysBatched: function (list) { + var defs = []; + for (var i = 0; i < list.fieldNames.length; i++) { + var field = list.fields[list.fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many') { + defs.push(this._fetchX2ManyBatched(list, list.fieldNames[i])); + } + } + return $.when.apply($, defs); + }, + /** + * Read all x2many fields and generate the commands for the server to create + * or write them... + * + * @param {Object} record + * @returns {Object} a map from some field names to commands + */ + _generateX2ManyCommands: function (record) { + var self = this; + var commands = {}; + var type, list, relData, relIds, removedIds, addedIds, keptIds, relRecord, i; + + var data = _.extend({}, record.data, record._changes); + + for (var fieldName in record.fields) { + type = record.fields[fieldName].type; + + if (type === 'many2many' || type === 'one2many') { + // skip if this field is empty + if (!data[fieldName]) { + commands[fieldName] = null; + continue; + } + list = this.localData[data[fieldName]]; + if (!list._changes) { + commands[fieldName] = null; + // skip if this field hasn't changed + continue; + } + relData = _.map(list._changes, function (localId) { + return self.localData[localId]; + }); + relIds = _.pluck(relData, 'res_id'); + commands[fieldName] = []; + + if (type === 'many2many') { + // deliberately generate a single 'replace' command instead + // of a 'delete' and a 'link' commands with the exact diff + // because 1) performance-wise it doesn't change anything + // and 2) to guard against concurrent updates (policy: force + // an complete override of the actual value of the m2m) + commands[fieldName].push(x2ManyCommands.replace_with(relIds)); + } else if (type === 'one2many') { + removedIds = _.difference(list.res_ids, relIds); + addedIds = _.difference(relIds, list.res_ids); + keptIds = _.intersection(list.res_ids, relIds); + for (i = 0; i < keptIds.length; i++) { + commands[fieldName].push(x2ManyCommands.link_to(keptIds[i])); + } + for (i = 0; i < removedIds.length; i++) { + commands[fieldName].push(x2ManyCommands.delete(removedIds[i])); + } + for (i = 0; i < addedIds.length; i++) { + relRecord = _.findWhere(relData, {res_id: addedIds[i]}); + relRecord = this.get(relRecord.id, {raw: true}); + commands[fieldName].push(x2ManyCommands.create(_.omit(relRecord.data, 'id'))); + } + // FIXME: update of records temporarily disabled as it doesn't work if we updated + // the record in a form view, as the _changes contains all the fields of the + // form view instead of the one that have changed + // _.each(relData, function (relRecord) { + // var new_values = relRecord._changes; + // if (!_.isEmpty(new_values)) { + // commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, new_values)); + // } + // }); + } + } + } + + return commands; + }, + /** + * Every RPC done by the model need to add some context, which is a + * combination of the context of the session, of the record/list, and/or of + * the concerned field. This method combines all these contexts and evaluate + * them with the proper evalcontext. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string} [options.field] if given, the context of the field will + * also be added (and evaluated) to the result + * @returns {Object} the evaluated context + */ + _getContext: function (element, options) { + var context = new data.CompoundContext(session.user_context, element.context); + if (options && options.field) { + var attrs = element.fieldAttrs[options.field]; + if (attrs && attrs.context) { + context.add(attrs.context); + } + } + if (element.rawContext) { + context.add(element.rawContext); + var evalContext = this.get(element.parentID, {raw: true}).data; + context.set_eval_context(evalContext); + } + return context.eval(); + }, + /** + * Helper method for the load entry point. + * + * @see load + * + * @param {Object} dataPoint some local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the fields to fetch for a record + * @returns {Deferred} + */ + _load: function (dataPoint, options) { + if (dataPoint.type === 'record') { + return this._fetchRecord(dataPoint, options && options.fieldNames); + } + if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { + return this._readGroup(dataPoint); + } + if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { + return this._fetchUngroupedList(dataPoint); + } + }, + /** + * Turns a bag of properties into a valid local resource. Also, register + * the resource in the localData object. + * + * @param {Object} params + * @param {Object} [params.fieldAttrs={}] contains the attrs of each field + * @param {Array} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @returns {Object} the resource created + */ + _makeDataPoint: function (params) { + var type = params.type || ('domain' in params && 'list') || 'record'; + + var res_id; + var value; + if (type === 'record') { + res_id = params.res_id || (params.data && params.data.id) || _.uniqueId('virtual_'); + } else { + var value_is_array = params.value instanceof Array; + res_id = value_is_array ? params.value[0] : undefined; + value = value_is_array ? params.value[1] : params.value; + } + params.res_id = res_id; + + var dataPoint = { + context: params.context || {}, + domain: params.domain || [], + groupedBy: params.groupedBy || [], + id: _.uniqueId(params.modelName + '_'), + model: params.modelName, + res_id: res_id, + res_ids: params.res_ids || [], + }; + + this.localData[dataPoint.id] = dataPoint; + + var fields = _.extend({ + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, params.fields); + + _.extend(dataPoint, { + _cache: type === 'list' ? {} : undefined, + _changes: null, + aggregateValues: params.aggregateValues || {}, + count: params.count || dataPoint.res_ids.length, + data: params.data || (type === 'record' ? {} : []), + fieldNames: params.fieldNames || Object.keys(fields), + fields: fields, + fieldAttrs: params.fieldAttrs || {}, + isOpen: params.isOpen, + limit: type === 'record' ? 1 : params.limit, + offset: params.offset || (type === 'record' ? _.indexOf(dataPoint.res_ids, res_id) : 0), + openGroupByDefault: params.openGroupByDefault, + orderedBy: params.orderedBy || [], + parentID: params.parentID, + rawContext: params.rawContext, + static: params.static || false, + type: type, // 'record' | 'list' + value: value, + }); + + return dataPoint; + }, + /** + * Once a record is created and some data has been fetched, we need to do + * quite a lot of computations to determine what needs to be fetched. This + * method is doing that. + * + * @see _fetchRecord @see makeDefaultRecord + * + * @param {any} record + * @returns {Deferred -> Object} resolves to the finished resource + */ + _postprocess: function (record) { + var self = this; + var defs = []; + _.each(record.fieldNames, function (name) { + var field = record.fields[name]; + var attrs = record.fieldAttrs[name]; + if (field.__fetch_status && !field.__status_information) { + var field_values = _.mapObject(record.data, function (val, key) { + var fieldType = record.fields[key].type; + if (fieldType === 'many2one') { + return val instanceof Array && val[0]; + } + return val; + }); + var _domain = new data.CompoundDomain(field.domain || {}).set_eval_context(field_values); + var domain = pyeval.eval('domain', _domain); + var fold_field = pyeval.py_eval(attrs.options || '{}').fold_field; + var fetch_status_information = self.performRPC('/web/dataset/search_read', { + model: field.relation, + fields: ['id'].concat(fold_field ? [fold_field] : []), + domain: domain, + }).then(function (result) { + var ids = _.pluck(result.records, 'id'); + return self.performModelRPC(field.relation, 'name_get', [ids], { + context: self._getContext(record, {field: name}), + }).then(function (name_gets) { + _.each(result.records, function (record) { + var name_get = _.find(name_gets, function (n) { + return n[0] === record.id; + }); + record.display_name = name_get[1]; + }); + field.__status_information = result.records; + }); + }); + defs.push(fetch_status_information); + } + if (field.__always_reload) { + if (record.data[name] instanceof Array) { + defs.push(self.performModelRPC(field.relation, 'name_get', [record.data[name][0]], { + context: self._getContext(record, {field: name}), + }).then(function (result) { + record.data[name] = result[0]; + })); + } + } + if (field.__fetch_selection && !self.many2ones[field.relation]) { + var fetchSelection = self + .performModelRPC(field.relation, 'name_search', [], {}) + .then(function (result) { + self.many2ones[field.relation] = result; + }); + defs.push(fetchSelection); + } + if (field.__fetch_many2manys) { + var fetchMany2Manys = self + .performModelRPC(field.relation, 'name_search', [], {}) + .then(function (result) { + self.many2manys[field.relation] = result; + }); + defs.push(fetchMany2Manys); + } + }); + + return $.when.apply($, defs).then(function () { + return record; + }); + }, + /** + * For a grouped list resource, this method fetches all group data by + * performing a /read_group. It also tries to read open subgroups if they + * were open before. + * + * @param {Object} list valid resource object + * @returns {Deferred<Object>} resolves to the fetched group object + */ + _readGroup: function (list) { + var self = this; + var fields = _.uniq(list.fieldNames.concat(list.groupedBy)); + return this.performModelRPC(list.model, 'read_group', [], { + fields: fields, + context: list.context, + groupby: list.groupedBy, + domain: list.domain, + orderby: false, + lazy: true, + }).then(function (groups) { + var rawGroupBy = list.groupedBy[0].split(':')[0]; + var previousGroups = _.map(list.data, function (groupID) { + return self.localData[groupID]; + }); + list.data = []; + list.count = groups.length; + var defs = []; + + _.each(groups, function (group) { + var aggregateValues = {}; + _.each(group, function (value, key) { + if (_.contains(fields, key) && key !== list.groupedBy[0]) { + aggregateValues[key] = value; + } + }); + var newGroup = self._makeDataPoint({ + modelName: list.model, + count: group[rawGroupBy + '_count'], + domain: group.__domain, + context: list.context, + fields: list.fields, + fieldNames: list.fieldNames, + fieldAttrs: list.fieldAttrs, + value: group[rawGroupBy], + aggregateValues: aggregateValues, + groupedBy: list.groupedBy.slice(1), + orderedBy: list.orderedBy, + limit: list.limit, + openGroupByDefault: list.openGroupByDefault, + type: 'list', // nested groupedBys not handled yet + }); + list.data.push(newGroup.id); + var old_group = _.find(previousGroups, function (g) { + return g.res_id === newGroup.res_id && g.value === newGroup.value; + }); + if (old_group) { + newGroup.isOpen = old_group.isOpen; + } else if (!newGroup.openGroupByDefault) { + newGroup.isOpen = false; + } else { + newGroup.isOpen = '__fold' in group ? !group.__fold : true; + } + if (newGroup.isOpen && newGroup.count > 0) { + defs.push(self._fetchUngroupedList(newGroup)); + } + }); + return $.when.apply($, defs).then(function () { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(arguments, function (group) { + return group ? group.res_ids : []; + })); + return list; + }); + }); + }, + /** + * For 'static' list, such as one2manys in a form view, we can do a /read + * instead of a /search_read. + * + * @param {Object} list a valid resource object + * @returns {Deferred -> Object} resolves to the fetched list object + */ + _readUngroupedList: function (list) { + var self = this; + var def; + var ids = []; + var missingIds = []; + var upper_bound = list.limit ? Math.min(list.offset + list.limit, list.count) : list.count; + for (var i = list.offset; i < upper_bound; i++) { + var id = list.res_ids[i]; + ids.push(id); + if (!list._cache[id]) { + missingIds.push(id); + } + } + if (missingIds.length) { + def = this.performModelRPC(list.model, 'read', [missingIds, list.fieldNames], { + context: {}, // FIXME + }); + } else { + def = $.when(); + } + return def.then(function (records) { + list.data = []; + _.each(ids, function (id) { + var dataPoint; + if (id in list._cache) { + dataPoint = self.localData[list._cache[id]]; + } else { + dataPoint = self._makeDataPoint({ + modelName: list.model, + data: _.findWhere(records, {id: id}), + fields: list.fields, + fieldNames: list.fieldNames, + fieldAttrs: list.fieldAttrs, + }); + + // add many2one records + _.each(dataPoint.fieldNames, function (name) { + var field = dataPoint.fields[name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: dataPoint.data[name][0], + display_name: dataPoint.data[name][1], + } + }); + + dataPoint.data[name] = r.id; + } + }); + list._cache[id] = dataPoint.id; + } + list.data.push(dataPoint.id); + }); + self._sortList(list); + return list; + }); + }, + /** + * Do a /search_read to get data for a list resource. This does a + * /search_read because the data may not be static (for ex, a list view). + * + * @param {Object} list + * @returns {Deferred} + */ + _searchReadUngroupedList: function (list) { + var self = this; + return this.performRPC('/web/dataset/search_read', { + model: list.model, + fields: list.fieldNames, + domain: list.domain || [], + offset: list.offset, + limit: list.limit, + sort: this._serializeSort(list.orderedBy), + }).then(function (result) { + list.count = result.length; + var data = _.map(result.records, function (record) { + var dataPoint = self._makeDataPoint({ + data: record, + fields: list.fields, + fieldNames: list.fieldNames, + fieldAttrs: list.fieldAttrs, + modelName: list.model, + }); + + // add many2one records + _.each(dataPoint.fieldNames, function (name) { + var field = dataPoint.fields[name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: dataPoint.data[name][0], + display_name: dataPoint.data[name][1], + } + }); + + dataPoint.data[name] = r.id; + } + }); + return dataPoint.id; + }); + list.data = data; + list.res_ids = _.pluck(result.records, 'id'); + return list; + }); + }, + /** + * Helper method, generates a string to describe a ordered by sequence for + * SQL. + * + * For example, [{name: 'foo', asc: true}, {name: 'bar', asc: false}] will + * be converted into 'foo ASC, bar DESC' + * + * @param {Object[]} orderBy list of objects {name:..., asc: ...} + * @returns {string} + */ + _serializeSort: function (orderBy) { + return _.map(orderBy, function (order) { + return order.asc ? order.name + ' ASC' : order.name + ' DESC'; + }).join(', '); + }, + /** + * Change the offset of a record. Note that this does not reload the data. + * The offset is used to load a different record in a list of record (for + * example, a form view with a pager. Clicking on next/previous actually + * changes the offset through this method). + * + * @param {string} elementId local id for the resource + * @param {number} offset + */ + _setOffset: function (elementId, offset) { + var element = this.localData[elementId]; + element.offset = offset; + if (element.type === 'record' && element.res_ids.length) { + element.res_id = element.res_ids[offset]; + } + }, + /** + * Do a in-memory sort of a list resource data points. This method assumes + * that the list data has already been fetched. Its intended use is for + * static datasets, such as a one2many in a form view. + * + * @param {Object} list + */ + _sortList: function (list) { + var self = this; + if (list.orderedBy.length) { + // sort records according to ordered_by[0] + var order = list.orderedBy[0]; + list.data.sort(function (r1, r2) { + var data1 = self.localData[r1].data; + var data2 = self.localData[r2].data; + if (data1[order.name] < data2[order.name]) { + return order.asc ? -1 : 1; + } + if (data1[order.name] > data2[order.name]) { + return order.asc ? 1 : -1; + } + return 0; + }); + } + }, + /** + * Helper method. Recursively traverses the data, starting from the element + * record (or list), then following all relations. This is useful when one + * want to determine a property for the current record. + * + * For example, isDirty need to check all relations to find out if something + * has been modified, or not. + * + * @param {Object} element a valid local resource + * @param {callback} fn a function to be called on each visited element + */ + _visitChildren: function (element, fn) { + var self = this; + fn(element); + if (element.type === 'record') { + for (var fieldName in element.data) { + var field = element.fields[fieldName]; + if (_.contains(['one2many', 'many2one', 'many2many'], field.type)) { + var relationalElement = this.localData[element.data[fieldName]]; + + // relationalElement could be empty in the case of a many2one + if (relationalElement) { + self._visitChildren(relationalElement, fn); + } + } + } + } + if (element.type === 'list') { + _.each(element.data, function (elemId) { + var elem = self.localData[elemId]; + self._visitChildren(elem, fn); + }); + } + }, +}); + +return Model; + +}); + diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js new file mode 100644 index 00000000000..22ec34f714b --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -0,0 +1,115 @@ +odoo.define('web.BasicRenderer', function (require) { +"use strict"; + +/** + * The BasicRenderer is an abstract class designed to share code between all + * views that uses a BasicModel. The main goal is to keep track of all field + * widgets, and properly destroy them whenever a rerender is done. + * + * When a concrete view extends a BasicRenderer, it should add each field + * widgets in the this.widget list. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); + +var qweb = core.qweb; + +return AbstractRenderer.extend({ + /** + * @override + */ + init: function (paret, state, params) { + this._super.apply(this, arguments); + this.widgets = []; + this.activeActions = params.activeActions; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a tooltip on a $node, depending on a field description + * + * @param {FieldWidget} widget + * @param {$node} $node + */ + _addFieldTooltip: function (widget, $node) { + // optional argument $node, the jQuery element on which the tooltip + // should be attached if not given, the tooltip is attached on the + // widget's $el + $node = $node.length ? $node : widget.$el; + $node.tooltip({ + delay: { show: 1000, hide: 0 }, + title: function () { + return qweb.render('WidgetLabel.tooltip', { + debug: config.debug, + widget: widget, + }); + } + }); + }, + /** + * Helper method, used to get an object with the 'raw' field values. It is + * important when we want to evaluate if a record matches a given domain. + * + * A basic model record is a tree structure, but we sometimes need 'pure' + * data, so for example, [1,4] for the value of a one2many, not + * [{Object}, {Object}]. + * + * @param {Object} record + * @returns {Object} + */ + _getFieldValues: function (record) { + return _.mapObject(record.data, function (value, name) { + var field = record.fields[name]; + if (field.type === 'many2one') { + return value.data ? value.data.id || false : false; + } + if (field.type === 'one2many' || field.type === 'many2many') { + if (value) { + return _.pluck(value.data, 'res_id'); + } else { + return []; + } + } + if (field.type === 'boolean') { + // we want an explicit false value, not null if it is unset + return value || false; + } + return value; + }); + }, + /** + * Render the view + * + * @override + * @returns {Deferred} + */ + _render: function () { + var oldWidgets = this.widgets; + this.widgets = []; + return this._renderView().then(_.invoke.bind(_, oldWidgets, 'destroy')); + }, + /** + * Actual rendering. Supposed to be overridden by concrete renderers. + * The basic responsabilities of _renderView are: + * - use the xml arch of the view to render a jQuery representation + * - instantiate a widget from the widgets_registry for each field in the + * arch + * + * Note that the 'state' field should contains all necessary information + * for the rendering. The field widgets should be as synchronous as + * possible. + * + * @abstract + * @returns {Deferred} + */ + _renderView: function () { + return $.when(); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js new file mode 100644 index 00000000000..1601cf4c350 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -0,0 +1,171 @@ +odoo.define('web.BasicView', function (require) { +"use strict"; + +/** + * The BasicView is an abstract class designed to share code between views that + * want to use a basicModel. As of now, it is the form view, the list view and + * the kanban view. + * + * The main focus of this class is to process the arch and extract field + * attributes, as well as some other useful informations. + */ + +var AbstractView = require('web.AbstractView'); +var BasicController = require('web.BasicController'); +var BasicModel = require('web.BasicModel'); +var pyeval = require('web.pyeval'); + +/** + * Visit a tree of objects, where each children are in an attribute 'children'. + * For each children, we call the callback function given in arguments. + * + * @todo move this somewhere in web.utils + * + * @param {Object} tree an object describing a tree structure + * @param {function} f a callback + */ +function traverse(tree, f) { + if (f(tree)) { + _.each(tree.children, function (c) { traverse(c, f); }); + } +} + +var BasicView = AbstractView.extend({ + config: _.extend({}, AbstractView.prototype.config, { + Model: BasicModel, + Controller: BasicController, + }), + /** + * process the fields_view to find all fields appearing in the views. + * list those fields' name in this.fields_name, which will be the list + * of fields read when data is fetched. + * this.fields is the list of all field's description (the result of + * the fields_get), where the fields appearing in the fields_view are + * augmented with their attrs and some flags if they require a + * particular handling. + * + * @param {Object} arch + * @param {Object} fields + * @param {Object} params + */ + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var result = this._processFieldsView(arch, fields); + var fieldAttrs = result.fieldAttrs; + var view_fields = result.view_fields; + var fieldNames = _.keys(view_fields); + fields = _.defaults(view_fields, fields); + var self = this; + // process the inner fields_view as well to find the fields they use. + // register those fields' description directly on the view. + // for those inner views, the list of all fields isn't necessary, so + // basically the field_names will be the keys of the fields obj. + // don't use _ to iterate on fields in case there is a 'length' field, + // as _ doesn't behave correctly when there is a length key in the object + for (var field_name in fields) { + var field = fields[field_name]; + _.each(field.views, function (inner_fields_view) { + result = self._processFieldsView(inner_fields_view.arch, inner_fields_view.fields); + inner_fields_view.fieldAttrs = result.fieldAttrs; + inner_fields_view.fields = result.view_fields; + }); + if (field.type === 'one2many' || field.type === 'many2many') { + if (field.views && field.views.tree) { + field.relatedFields = field.views.tree.fields; + field.fieldAttrs = field.views.tree.fieldAttrs; + field.limit = 80; + } else if (field.views && field.views.kanban) { + field.relatedFields = field.views.kanban.fields; + field.fieldAttrs = field.views.kanban.fieldAttrs; + field.limit = 40; + } else { + field.relatedFields = { + color: {type: 'int'}, + display_name: {type: 'char'}, + id: {type: 'integer'}, + }; + field.fieldAttrs = {}; + } + } + } + + this.controllerParams.confirmOnDelete = true; + this.controllerParams.archiveEnabled = 'active' in fields; + this.controllerParams.hasButtons = + 'action_buttons' in params ? params.action_buttons : true; + + this.loadParams.fieldAttrs = fieldAttrs; + this.loadParams.fieldNames = fieldNames; + this.loadParams.fields = fields; + this.loadParams.limit = parseInt(arch.attrs.limit, 10) || params.limit; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Visit all nodes in the arch field and process each fields + * + * @param {Object} arch + * @param {Object} fields + * @returns {Object} An object with 2 keys: view_fields and fieldAttrs + */ + _processFieldsView: function (arch, fields) { + var self = this; + var view_fields = Object.create(null); + var fieldAttrs = Object.create(null); + traverse(arch, function (node) { + if (typeof node === 'string') { + return false; + } + if (node.tag === 'field') { + var attrs = node.attrs || {}; + var field = _.extend({}, fields[node.attrs.name]); + self._processField(field, node, attrs); + view_fields[node.attrs.name] = field; + fieldAttrs[node.attrs.name] = attrs; + return false; + } + return node.tag !== 'arch'; + }); + return { + view_fields: view_fields, + fieldAttrs: fieldAttrs, + }; + }, + /** + * Process a field node, in particular, put a flag on the field to give + * special directives to the BasicModel. + * + * @param {Object} field + * @param {Object} node + * @param {Object} attrs + */ + _processField: function (field, node, attrs) { + if (attrs.options) { + var options = pyeval.py_eval(attrs.options || '{}'); + if (options.always_reload) { + field.__always_reload = true; + } + } + if (field.type === 'many2one') { + if (node.attrs.widget === 'statusbar' || node.attrs.widget === 'radio') { + field.__fetch_status = true; + } else if (node.attrs.widget === 'selection') { + field.__fetch_selection = true; + } + } + if (node.attrs.widget === 'many2many_checkboxes') { + field.__fetch_many2manys = true; + } + if (node.attrs.on_change) { + field.onChange = "1"; + } + }, +}); + +return BasicView; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js new file mode 100644 index 00000000000..dbb574176f9 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -0,0 +1,267 @@ +odoo.define('web.CalendarController', function (require) { +"use strict"; + +/** + * Calendar Controller + * + * This is the controller in the Model-Renderer-Controller architecture of the + * calendar view. Its role is to coordinate the data from the calendar model + * with the renderer, and with the outside world (such as a search view input) + */ + +var AbstractController = require('web.AbstractController'); +var QuickCreate = require('web.CalendarQuickCreate'); +var dialogs = require('web.view_dialogs'); +var Dialog = require('web.Dialog'); +var core = require('web.core'); + +var _t = core._t; +var QWeb = core.qweb; + +var CalendarController = AbstractController.extend({ + defaults: _.extend({}, AbstractController.prototype.defaults, { + confirm_on_delete: true, + }), + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + openCreate: '_onOpenCreate', + openEvent: '_onOpenEvent', + dropRecord: '_onDropRecord', + updateRecord: '_onUpdateRecord', + changeDate: '_onChangeDate', + changeFilter: '_onChangeFilter', + toggleFullWidth: '_onToggleFullWidth', + }), + /** + * @override + * @param {Widget} parent + * @param {AbstractModel} model + * @param {AbstractRenderer} renderer + * @param {Object} params + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.current_start = null; + this.quick_add_pop = params.quick_add_pop; + this.disable_quick_create = params.disable_quick_create; + this.confirm_on_delete = params.confirm_on_delete; + this.formViewId = params.formViewId; + this.readonlyFormViewId = params.readonlyFormViewId; + this.mapping = params.mapping; + this.context = params.context; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + + /** + * Render the buttons according to the CalendarView.buttons template and + * add listeners on it. Set this.$buttons with the produced jQuery element + * + * @param {jQueryElement} [$node] a jQuery node where the rendered buttons + * should be inserted. $node may be undefined, in which case the Calendar + * inserts them into this.options.$buttons or into a div of its template + */ + renderButtons: function ($node) { + var self = this; + this.$buttons = $(QWeb.render("CalendarView.buttons", {'widget': this})); + this.$buttons.on('click', 'button.o_calendar_button_new', function () { + self.trigger_up('switch_view', {view_type: 'form'}); + }); + + _.each(['prev', 'today', 'next'], function (action) { + self.$buttons.on('click', '.o_calendar_button_' + action, function () { + self.model[action](); + self.reload(); + }); + }); + _.each(['day', 'week', 'month'], function (scale) { + self.$buttons.on('click', '.o_calendar_button_' + scale, function () { + self.model.setScale(scale); + self.reload(); + }); + }); + + this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); + + if ($node) { + this.$buttons.appendTo($node); + } else { + this.$('.o_calendar_buttons').replaceWith(this.$buttons); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @param {Object} record + * @param {integer} record.id + */ + _updateRecord: function (record) { + // Cannot modify actual name yet + var data = _.omit(this.model.calendarEventToRecord(record), 'name'); + this.performModelRPC(this.model, 'write', [record.id, data]) + .then(this.reload.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {OdooEvent} event + */ + _onChangeDate: function (event) { + var modelData = this.model.get(); + if (modelData.target_date.toString() === event.data.date) { + switch (modelData.scale) { + case 'month': this.model.setScale('week'); break; + case 'week': this.model.setScale('day'); break; + case 'day': this.model.setScale('month'); break; + } + } else { + this.model.setScale('week'); + } + this.model.setDate(event.data.date, true); + this.reload(); + }, + /** + * @param {OdooEvent} event + */ + _onChangeFilter: function (event) { + if (this.model.changeFilter(event.data) && !event.data.no_reload) { + this.reload(); + } + }, + /** + * @param {OdooEvent} event + */ + _onDropRecord: function (event) { + this._updateRecord(event.data); + }, + /** + * @param {OdooEvent} event + */ + _onOpenCreate: function (event) { + var self = this; + if (this.model.get().scale === "month") { + event.data.allDay = true; + } + var data = this.model.calendarEventToRecord(event.data); + + var context = _.extend({}, this.context); + context.default_name = data.name; + context['default_' + this.mapping.date_start] = data.start; + if (this.mapping.date_stop) { + context['default_' + this.mapping.date_stop] = data.stop; + } + if (this.mapping.date_delay) { + context['default_' + this.mapping.date_delay] = data.duration; + } + if (this.mapping.all_day) { + context['default_' + this.mapping.all_day] = true; + } + + var options = _.extend({}, this.options, {context: context}); + + if(!options.disable_quick_create && !event.data.disable_quick_create) { + if (this.quick != null) { + return this.quick.close(); + } + var model = this.modelName; + var quick = new QuickCreate(this, model, true, options, data, event.data); + quick.on('added', this, this.reload.bind(this)); + quick.open(); + quick.focus(); + return; + } + + new dialogs.FormViewDialog(self, { + res_model: this.modelName, + context: context, + title: _t("Create"), + disable_multiple_selection: true, + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }).open(); + }, + /** + * @param {OdooEvent} event + */ + _onOpenEvent: function (event) { + var self = this; + var id = event.data._id; + id = id && parseInt(id).toString() === id ? parseInt(id) : id; + var open_dialog = function (readonly) { + var options = { + res_model: self.modelName, + res_id: id || null, + context: event.context || self.context, + readonly: readonly, + title: _t("Open: ") + event.data.title, + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }; + if (readonly) { + if (self.readonlyFormViewId) { + options.view_id = parseInt(self.readonlyFormViewId); + } + options.buttons = [ + { + text: _t("Edit"), + classes: 'btn-primary', + close: true, + click: function () { open_dialog(false); } + }, + { + text: _t("Delete"), + click: function () { + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: function () { + self.performModelRPC(self.modelName, 'unlink', [id]) + .then(function () { + self.dialog.destroy(); + self.reload(); + }); + } + }); + }, + }, + {text: _t("Close"), close: true} + ]; + } else if (self.formViewId) { + options.view_id = parseInt(self.formViewId); + } + self.dialog = new dialogs.FormViewDialog(self, options).open(); + }; + open_dialog(true); + }, + /** + * Called when we want to open or close the sidebar. + */ + _onToggleFullWidth: function () { + this.model.toggleFullWidth(); + this.reload(); + }, + /** + * @param {OdooEvent} event + */ + _onUpdateRecord: function (event) { + this._updateRecord(event.data); + }, +}); + +return CalendarController; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js new file mode 100644 index 00000000000..16ba9999ed0 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -0,0 +1,620 @@ +odoo.define('web.CalendarModel', function (require) { +"use strict"; + +var AbstractModel = require('web.AbstractModel'); +var field_utils = require('web.field_utils'); +var session = require('web.session'); +var time = require('web.time'); +var core = require('web.core'); +var data = require('web.data'); + +var _t = core._t; + +var scales = [ + 'day', + 'week', + 'month' +]; + +return AbstractModel.extend({ + init: function () { + this._super.apply(this, arguments); + this.end_date = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Transform fullcalendar event object to OpenERP Data object + */ + calendarEventToRecord: function (event) { + // Normalize event_end without changing fullcalendars event. + var data = {'name': event.title}; + var start = moment(new Date(event.start)); + var end = moment(new Date(event.end)); + + //Bug when we move an all_day event from week or day view, we don't have + // a date.end or duration... + if (end === null || end.diff(start) < 0) { + end = start.clone().add(1, 'h'); + } + + if (event.allDay) { + if (this.scale === 'month') { + if (event.r_start) { + start.hours(event.r_start.hours()) + .minutes(event.r_start.minutes()) + .seconds(event.r_start.seconds()) + .utc(); + end.add(-1, 'days') + .hours(event.r_end.hours()) + .minutes(event.r_end.minutes()) + .seconds(event.r_end.seconds()) + .utc(); + } else { + start.utc(); + end.utc(); + } + } else if (this.mapping.all_day) { + start.utc().startOf('day'); + end.utc().startOf('day'); + } else { + start.utc().hours(7); + end.utc().hours(19); + } + } else { + var offset = start.utcOffset(); + start.utc().add(-offset, 'minutes'); + end.utc().add(-offset, 'minutes'); + } + + if (this.mapping.all_day) { + if (event.record) { + data[this.mapping.all_day] = + (this.scale !== 'month' && event.allDay) || + event.record[this.mapping.all_day] && + end.diff(start) < 10 || + false; + } else { + data[this.mapping.all_day] = event.allDay; + } + } + + data[this.mapping.date_start] = time.datetime_to_str(start.toDate()); + if (this.mapping.date_delay) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } + + if (this.mapping.date_stop) { + data[this.mapping.date_stop] = time.datetime_to_str(end.toDate()); + } + + return data; + }, + /** + * @param {Object} filter + * @returns {boolean} + */ + changeFilter: function (filter) { + var Filter = this.data.filters[filter.fieldName]; + if (filter.value === 'all') { + Filter.all = filter.active; + } + var f = _.find(Filter.filters, function (f) { + return f.value === filter.value; + }); + if (f) { + if (f.active !== filter.active) { + f.active = filter.active; + } else { + return false; + } + } else if (filter.active) { + Filter.filters.push({ + value: filter.value, + active: true, + }); + } + return true; + }, + /** + * @todo I think this is dead code + * + * @param {any} ids + * @param {any} model + * @returns + */ + deleteRecords: function (ids, model) { + return this.performModelRPC(model, 'unlink', [ids], { + context: session.user_context, // todo: combine with view context + }); + }, + /** + * @override + * @returns {Object} + */ + get: function () { + return _.extend({}, this.data, { + fields: this.fields + }); + }, + /** + * @override + * @param {any} params + * @returns {Deferred} + */ + load: function (params) { + var self = this; + this.modelName = params.modelName; + this.fields = params.fields; + this.fieldNames = params.fieldNames; + this.mapping = params.mapping; + this.mode = params.mode; // one of month, week or day + this.scales = params.scales; // one of month, week or day + + // Check whether the date field is editable (i.e. if the events can be + // dragged and dropped) + this.editable = params.editable; + + // display more button when there are too much event on one day + this.eventLimit = params.eventLimit; + + // fields to display color, e.g.: user_id.partner_id + this.field_color = params.field_color; + if (!this.preload_def) { + var def; + if (this.field_color) { + var fields = this.field_color.split('.'); + if (fields.length > 1) { + def = new $.Deferred(); + if (!this.model_color) { + var dataset = new data.DataSetSearch(this, this.fields[fields[0]].relation, params.context, []); + this.loadFieldView(dataset, false, 'form').then(function (fieldView) { + self.model_color = fieldView.fields[fields[1]].relation; + def.resolve(); + }); + } else { + def.resolve(); + } + } + } + + this.preload_def = $.Deferred(); + $.when( + def, + this.performModelRPC(this.modelName, 'check_access_rights', ["write", false]), + this.performModelRPC(this.modelName, 'check_access_rights', ["create", false])) + .then(function (write, create) { + self.write_right = write; + self.create_right = create; + self.preload_def.resolve(); + }); + } + + this.data = { + domain: params.domain, + context: params.context, + // get in arch the filter to display in the sidebar and the field to read + filters: params.filters, + }; + + // Use scale_zoom attribute in xml file to specify zoom timeline (day,week,month) + // by default month. + this.setDate(params.initialDate, true); + this.setScale(params.mode); + + _.each(this.data.filters, function (filter) { + if (filter.avatar_field && !filter.avatar_model) { + filter.avatar_model = self.modelName; + } + }); + + return this.preload_def.then(this._loadCalendar.bind(this)); + }, + next: function () { + this.setDate(moment(this.data.target_date).add(1, this.data.scale)); + }, + prev: function () { + this.setDate(moment(this.data.target_date).add(-1, this.data.scale)); + }, + /** + * @todo: this should not work. it ignores the domain/context + * + * @override + * @param {any} _handle ignored + * @param {any} _params ignored ? really ? + * @returns {Deferred} + */ + reload: function (_handle, params) { + return this._loadCalendar(); + }, + /** + * @param {any} start + * @param {any} highlight + */ + setDate: function (start, highlight) { + this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = new Date(start); + switch (this.data.scale) { + case 'month': + this.data.start_date = moment(this.data.start_date) + .startOf('month') + .startOf('week') + .toDate(); + this.data.end_date = moment(this.data.start_date) + .add(5, 'week') + .endOf('week') + .toDate(); + break; + case 'week': + this.data.start_date = moment(this.data.start_date) + .startOf('week') + .toDate(); + this.data.end_date = moment(this.data.end_date) + .endOf('week') + .toDate(); + break; + default: + this.data.start_date = moment(this.data.start_date) + .startOf('day') + .toDate(); + this.data.end_date = moment(this.data.end_date) + .endOf('day') + .toDate(); + } + if (highlight) { + this.data.highlight_date = this.data.target_date; + } + }, + setScale: function (scale) { + if (!_.contains(scales, scale)) { + scale = "week"; + } + this.data.scale = scale; + this.setDate(this.data.target_date); + }, + today: function () { + this.setDate(new Date()); + }, + toggleFullWidth: function () { + var fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') !== 'true'; + this.call('local_storage', 'setItem', 'calendar_fullWidth', fullWidth); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _getFilterDomain: function () { + var domain = []; + _.each(this.data.filters, function (filter) { + if (filter.all) return; + var nb = {}; + var not = {}; + var contains = {}; + _.each(filter.filters, function (f) { + if (!contains[filter.fieldName]) { + contains[filter.fieldName] = []; + not[filter.fieldName] = []; + nb[filter.fieldName] = 0; + } + if (f.value === 'all' && f.active) { + contains[filter.fieldName].all = true; + } else { + nb[filter.fieldName]++; + } + + if (f.active && contains[filter.fieldName].indexOf(f.value) === -1) { + contains[filter.fieldName].push(f.value); + } + if (!f.active && not[filter.fieldName].indexOf(f.value) === -1) { + not[filter.fieldName].push(f.value); + } + }); + + for (var field in contains) { + if (contains[field].all || contains[field].length === nb[field]) { + continue; + } + if (contains[field].length) { + domain.push([field, 'in', contains[field]]); + } else if (not[field].length && not[field].length !== nb[field]) { + domain.push([field, 'not in', not[field]]); + } + } + }); + return domain; + }, + /** + * @returns {Object} + */ + _getFullCalendarOptions: function () { + return { + defaultView: (this.mode == "month")? "month" : ((this.mode === "week")? "agendaWeek" : ((this.mode === "day")? "agendaDay" : "agendaWeek")), + header: false, + selectable: this.editable && this.create_right, + selectHelper: true, + editable: this.editable, + droppable: true, + navLinks: false, + eventLimit: this.eventLimit, // allow "more" link when too many events + snapMinutes: 15, + eventResizableFromStart: true, + weekNumberCalculation: function (date) { + return moment(date).week(); + }, + weekNumberTitle: _t("W"), + allDayText: _t("All day"), + views: { + week: { + columnFormat: 'ddd ' + time.strftime_to_moment_format(_t.database.parameters.date_format), + } + }, + monthNames: moment.months(), + monthNamesShort: moment.monthsShort(), + dayNames: moment.weekdays(), + dayNamesShort: moment.weekdaysShort(), + }; + }, + _getRangeDomain: function () { + // Build OpenERP Domain to filter object by this.mapping.date_start field + // between given start, end dates. + var format = time.datetime_to_str; + var domain = [[this.mapping.date_start, '<=', format(this.data.end_date)]]; + if (this.mapping.date_stop) { + domain.push([this.mapping.date_stop, '>=', format(this.data.start_date)]); + } else if (!this.mapping.date_delay) { + domain.push([this.mapping.date_start, '>=', format(this.data.start_date)]); + } + return domain; + }, + /** + * @returns {Deferred} + */ + _loadCalendar: function () { + var self = this; + this.data.fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') === 'true'; + this.data.fc_options = this._getFullCalendarOptions(); + + var defs = _.map(this.data.filters, this._loadFilter.bind(this)); + + return $.when.apply($, defs).then(function () { + return self.performRPC('/web/dataset/search_read', { + model: self.modelName, + context: self.data.context, + domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()), + fields: self.fieldNames, + }).then(function (events) { + self.data.data = _.map(events.records, self._recordToCalendarEvent.bind(self)); + return $.when( + self._loadColors(self.data, self.data.data), + self._loadRecordsToFilters(self.data, self.data.data) + ); + }); + }); + }, + /** + * @param {any} element + * @param {any} events + * @returns {Deferred} + */ + _loadColors: function (element, events) { + if (this.field_color) { + var fields = this.field_color.split('.'); + var first = fields[0]; + var self = this; + + if (fields.length === 1) { + _.each(events, function (event) { + var value = event.record[first]; + event.color_index = _.isArray(value) ? value[0] : value; + }); + this.model_color = this.fields[first].relation || element.model; + } else { + var model = this.fields[first].relation; + var ids = []; + _.each(events, function (event) { + ids.push(event.record[first][0]); + }); + ids = _.uniq(ids); + + return self.performModelRPC(model, 'read', [ids, [fields[1]]]).then(function (res) { + res = _.object(_.pluck(res, ['id']), _.pluck(res, [fields[1]])); + _.each(events, function (event) { + var value = res[event.record[first][0]]; + event.color_index = _.isArray(value) ? value[0] : value; + }); + }); + } + } + return $.Deferred().resolve(); + }, + /** + * @param {any} filter + * @returns {Deferred} + */ + _loadFilter: function (filter) { + if (!filter.write_model) { + return; + } + + var field = this.fields[filter.fieldName]; + return this.performRPC('/web/dataset/search_read', { + model: filter.write_model, + domain: [["user_id", "=", session.uid]], + fields: [filter.write_field] + }).then(function (res) { + var records = _.map(res.records, function (record) { + var _value = record[filter.write_field]; + var value = _.isArray(_value) ? _value[0] : _value; + var f = _.find(filter.filters, function (f) {return f.value === value;}); + var formater = field_utils['format_'+ (_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type)]; + return { + 'id': record.id, + 'value': value, + 'label': formater(_value, field), + 'active': !f || f.active, + }; + }); + records.sort(function (f1,f2) { + return _.string.naturalCmp(f2.label, f1.label); + }); + + // add my profile + if (field.relation === 'res.partner' || field.relation === 'res.users') { + var value = field.relation === 'res.partner' ? session.partner_id : session.uid; + var me = _.find(records, function (record) { + return record.value === value; + }); + if (me) { + records.splice(records.indexOf(me), 1); + } else { + var f = _.find(filter.filters, function (f) {return f.value === value;}); + me = { + 'value': value, + 'label': session.name + _t(" [Me]"), + 'active': !f || f.active, + }; + } + records.unshift(me); + } + // add all selection + records.push({ + 'value': 'all', + 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"), + 'active': filter.all, + }); + + filter.filters = records; + }); + }, + /** + * @param {any} element + * @param {any} events + * @returns {Deferred} + */ + _loadRecordsToFilters: function (element, events) { + var self = this; + var new_filters = {}; + var to_read = {}; + _.each(this.data.filters, function (filter, fieldName) { + var field = self.fields[fieldName]; + + new_filters[fieldName] = filter; + if (filter.write_model) { + if (field.relation === self.model_color) { + _.each(filter.filters, function (f) { + f.color_index = f.value; + }); + } + return; + } + + _.each(filter.filters, function (filter) { + filter.display = !filter.active; + }); + + var fs = []; + _.each(events, function (event) { + var data = event.record[fieldName]; + if (!_.contains(['many2many', 'one2many'], field.type)) { + data = [data]; + } else { + to_read[field.relation] = (to_read[field.relation] || []).concat(data); + } + _.each(data, function (_value) { + var value = _.isArray(_value) ? _value[0] : _value; + fs.push({ + 'color': self.model_color === (field.relation || element.model) ? value : false, + 'value': value, + 'label': field_utils['format_'+ field.type](_value, field), + 'avatar_model': field.relation || element.model, + }); + }); + }); + _.each(fs, function (f) { + var f1 = _.findWhere(filter.filters, f); + if (f1) { + f1.display = true; + } else { + f.display = f.active = true; + filter.filters.push(f); + } + }); + }); + + var defs = []; + _.each(to_read, function (ids, model) { + defs.push(self.performModelRPC(model, 'name_get', [_.uniq(ids)]).then(function (res) { + to_read[model] = _.object(res); + })); + }); + return $.when.apply($, defs).then(function () { + _.each(self.data.filters, function (filter) { + if (filter.write_model) { + return; + } + if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) { + _.each(filter.filters, function (f) { + f.label = to_read[f.avatar_model][f.value]; + }); + } + }); + }); + }, + /** + * Transform OpenERP event object to fullcalendar event object + */ + _recordToCalendarEvent: function (evt) { + var date_start; + var date_stop; + var date_delay = evt[this.mapping.date_delay] || 1.0, + all_day = this.mapping.all_day ? evt[this.mapping.all_day] : false, + the_title = '', + attendees = []; + + if (!all_day) { + date_start = time.auto_str_to_date(evt[this.mapping.date_start]); + date_stop = this.mapping.date_stop ? time.auto_str_to_date(evt[this.mapping.date_stop]) : null; + } else { + date_start = time.auto_str_to_date(evt[this.mapping.date_start].split(' ')[0],'start'); + date_stop = this.mapping.date_stop ? time.auto_str_to_date(evt[this.mapping.date_stop].split(' ')[0],'start') : null; + } + + if (!date_stop && date_delay) { + var m_start = moment(date_start).add(date_delay,'hours'); + date_stop = m_start.toDate(); + } + + date_start = moment(date_start); + date_stop = moment(date_stop); + + var r = { + 'record': evt, + 'start': date_start.format('YYYY-MM-DD HH:mm:ss'), + 'end': date_stop.format('YYYY-MM-DD HH:mm:ss'), + 'r_start':date_start.clone(), + 'r_end':date_stop.clone(), + 'title': the_title, + 'allDay': this.mapping.all_day && evt[this.mapping.all_day] || false, + 'id': evt.id, + 'attendees':attendees, + }; + + if (this.mapping.all_day && evt[this.mapping.all_day]) { + // r.start = date_start.format('YYYY-MM-DD'); + // r.end = date_stop.format('YYYY-MM-DD'); + } else if (this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { + // allow to resize in month mode + r.reset_allday = r.allDay; + r.allDay = true; + r.start = date_start.format('YYYY-MM-DD'); + r.end = date_stop.clone().add(1, 'day').startOf('day').format('YYYY-MM-DD'); + } + + return r; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_quick_create.js b/addons/web/static/src/js/views/calendar/calendar_quick_create.js new file mode 100644 index 00000000000..326c36ea9eb --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js @@ -0,0 +1,140 @@ +odoo.define('web.CalendarQuickCreate', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +var _t = core._t; +var QWeb = core.qweb; + +/** + * Quick creation view. + * + * Triggers a single event "added" with a single parameter "name", which is the + * name entered by the user + * + * @class + * @type {*} + */ +var QuickCreate = Dialog.extend({ + /** + * @constructor + * @param {Widget} parent + * @param {string} modelName + * @param {any} buttons + * @param {any} options + * @param {any} data_template + * @param {any} data_calendar + */ + init: function (parent, modelName, buttons, options, data_template, data_calendar) { + this.modelName = modelName; + this._buttons = buttons || false; + this.options = options; + + // Can hold data pre-set from where you clicked on agenda + this.data_template = data_template || {}; + this.$input = $(); + + var self = this; + this._super(parent, { + title: this._getTitle(), + size: 'small', + buttons: this._buttons ? [ + {text: _t("Create"), classes: 'btn-primary', click: function () { + if (!self._quickAdd()) { + self.focus(); + } + }}, + {text: _t("Edit"), click: function () { + data_calendar.disable_quick_create = true; + data_calendar.title = self.$input.val().trim(); + data_calendar.on_save = self.destroy.bind(self); + self.trigger_up('openCreate', data_calendar); + }}, + {text: _t("Cancel"), close: true}, + ] : [], + $content: QWeb.render('CalendarView.quick_create', {widged: this}) + }); + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + var self = this; + + if (this.options.disable_quick_create) { + this.slow_create(); + return; + } + this.on('added', this, function () { + self.close(); + }); + + this.$input = this.$('input').keyup(function enterHandler (e) { + if(e.keyCode === $.ui.keyCode.ENTER) { + self.$input.off('keyup', enterHandler); + if (!self._quickAdd()){ + self.$input.on('keyup', enterHandler); + } + } else if (e.keyCode === $.ui.keyCode.ESCAPE && self._buttons) { + self.close(); + } + }); + + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + focus: function () { + this.$input.focus(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + _getTitle: function () { + var parent = this.getParent(); + if (_.isUndefined(parent)) { + return _t("Create"); + } + var title = (_.isUndefined(parent.field_widget)) ? + (parent.title || parent.string || parent.name) : + (parent.field_widget.string || parent.field_widget.name || ''); + return _t("Create: ") + title; + }, + /** + * Gathers data from the quick create dialog a launch quick_create(data) method + */ + _quickAdd: function () { + var val = this.$input.val().trim(); + return (val)? this._quickCreate({'name': val}, this.options) : false; + }, + /** + * Handles saving data coming from quick create box + */ + _quickCreate: function (data, options) { + var self = this; + return this.performModelRPC(this.modelName, 'create', + [$.extend({}, this.data_template, data)], _.pick(options, 'context')) + .then(function (id) { + self.trigger('added', id); + self.$input.val(""); + }, function (r, event) { + event.preventDefault(); + // This will occurs if there are some more fields required + self.slow_create(data); + }); + }, +}); + +return QuickCreate; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js new file mode 100644 index 00000000000..5062051b8da --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -0,0 +1,450 @@ +odoo.define('web.CalendarRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var relational_fields = require('web.relational_fields'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var field_utils = require('web.field_utils'); +var session = require('web.session'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var utils = require('web.utils'); +var core = require('web.core'); +var QWeb = require('web.QWeb'); + +var _t = core._t; +var qweb = core.qweb; + +var scales = { + day: 'agendaDay', + week: 'agendaWeek', + month: 'month' +}; + +var SidebarFilterM2O = relational_fields.FieldMany2One.extend({ + _getSearchBlacklist: function () { + return this._super.apply(this, arguments).concat(this.filter_ids || []); + }, +}); + +var SidebarFilter = Widget.extend(FieldManagerMixin, { + template: 'CalendarView.sidebar.filter', + custom_events: _.extend({}, FieldManagerMixin.custom_events, { + field_changed: '_onFieldChanged', + }), + /** + * @constructor + * @param {Widget} parent + * @param {Object} options + * @param {string} options.fieldName + * @param {Object[]} options.filters A filter is an object with the + * following keys: id, value, label, active, avatar_model, color, + * can_be_removed + * @param {Object} [options.favorite] this is an object with the following + * keys: fieldName, model, fieldModel + */ + init: function (parent, options) { + this._super.apply(this, arguments); + FieldManagerMixin.init.call(this); + + this.title = options.title; + this.fieldName = options.fieldName; + this.write_model = options.write_model; + this.write_field = options.write_field; + this.avatar_field = options.avatar_field; + this.avatar_model = options.avatar_model; + this.filters = options.filters; + this.label = options.label; + this.getColor = options.getColor; + + if (this.write_model || this.write_field) { + this.many2one = new SidebarFilterM2O(this, + this.write_field, + this.model.get(this.model.makeRecord(this.write_model, [{ + name: this.write_field, + relation: options.fields[this.fieldName].relation, + type: 'many2one', + }])), + { + mode: 'edit', + can_create: false, + }); + } + }, + start: function () { + this._super(); + if (this.many2one) { + this.many2one.appendTo(this.$el); + this.many2one.filter_ids = _.without(_.pluck(this.filters, 'value'), 'all'); + } + this.$el.on('click', '.o_remove', this._onFilterRemove.bind(this)); + this.$el.on('click', '.o_checkbox input', this._onFilterActive.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var self = this; + event.stopPropagation(); + var value = event.data.changes[this.write_field].id; + this.model.performModelRPC(this.write_model, 'create', [{'user_id': session.uid,'partner_id': value,}]) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'value': value, + 'active': true, + }); + }); + }, + _onFilterActive: function (e) { + var $input = $(e.currentTarget); + this.trigger_up('changeFilter', { + 'fieldName': this.fieldName, + 'value': $input.closest('.o_calendar_filter_item').data('value'), + 'active': $input.prop('checked'), + }); + }, + /** + * @param {MouseEvent} e + */ + _onFilterRemove: function (e) { + var self = this; + var $filter = $(e.currentTarget).closest('.o_calendar_filter_item'); + Dialog.confirm(this, _t("Do you really want to delete this filter from favorites ?"), { + confirm_callback: function () { + self.performModelRPC(self.write_model, 'unlink', [[$filter.data('id')]]) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'id': $filter.data('id'), + 'active': false, + 'value': $filter.data('value'), + }); + }); + }, + }); + }, +}); + +return AbstractRenderer.extend({ + template: "CalendarView", + events: _.extend({}, AbstractRenderer.prototype.events, { + 'click .o_calendar_sidebar_toggler': '_onToggleSidebar', + }), + + /** + * @constructor + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.displayFields = params.displayFields; + this.filters = []; + this.color_map = {}; + + if (params.eventTemplate) { + this.qweb = new QWeb(session.debug, {_s: session.origin}); + params.eventTemplate.children = [{ + 'tag': "t", + 'attrs': {'t-name': "calendar-box"}, + 'children': params.eventTemplate.children + }]; + this.qweb.add_template(utils.json_node_to_xml(params.eventTemplate)); + } + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + this._initSidebar(); + this._initCalendar(); + return this._super(); + }, + /** + * @override + */ + destroy: function () { + if (this.$calendar) { + this.$calendar.fullCalendar('destroy'); + } + if (this.$small_calendar) { + this.$small_calendar.datepicker('destroy'); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Note: this is not dead code, it is called by the calendar-box template + * + * @param {any} record + * @param {any} fieldName + * @param {any} imageField + * @returns {string[]} + */ + getAvatars: function (record, fieldName, imageField) { + var field = this.state.fields[fieldName]; + var value = this._format(record, fieldName); + + if (!record[fieldName]) { + return []; + } + if (field.type === 'one2many' || field.type === 'many2many') { + return _.map(record[fieldName], function (id) { + return '<img src="/web/image/'+field.relation+'/'+id+'/'+imageField+'" />'; + }); + } else if (field.type === 'many2one') { + return ['<img src="/web/image/'+field.relation+'/'+record[fieldName][0]+'/'+imageField+'" />']; + } else { + var color = this.getColor(value); + if (isNaN(color)) { + return ['<span class="o_avatar_square" style="background-color:'+color+';"/>']; + } + else { + return ['<span class="o_avatar_square o_calendar_color_'+color+'"/>']; + } + } + }, + /** + * Note: this is not dead code, it is called by two template + * + * @param {any} key + * @returns {integer} + */ + getColor: function (key) { + if (!key) { + return; + } + if (this.color_map[key]) { + return this.color_map[key]; + } + // check if the key is a css color + if (typeof key === 'string' && key.match(/^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i)) { + return this.color_map[key] = key; + } + var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1; + this.color_map[key] = index; + return index; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @param {any} event + * @returns {string} the html for the rendered event + */ + _eventRender: function (event) { + var qweb_context = { + event: event, + record: event.record, + widget: this, + read_only_mode: this.read_only_mode, + user_context: session.user_context, + format: this._format.bind(this), + fields: this.state.fields + }; + this.qweb_context = qweb_context; + if (_.isEmpty(qweb_context.record)) { + return ''; + } else { + return (this.qweb || qweb).render("calendar-box", qweb_context); + } + }, + /** + * @param {any} record + * @param {any} fieldName + * @returns {string} + */ + _format: function (record, fieldName) { + var field = this.state.fields[fieldName]; + return field_utils['format_'+field.type](record[fieldName], field); + }, + /** + * Initialize the main calendar + */ + _initCalendar: function () { + var self = this; + + this.$calendar = this.$(".o_calendar_widget"); + + //Documentation here : http://arshaw.com/fullcalendar/docs/ + var fc_options = $.extend({}, this.state.fc_options, { + eventDrop: function (event) { + self.trigger_up('dropRecord', event); + }, + eventResize: function (event) { + self.trigger_up('updateRecord', event); + }, + eventClick: function (event) { + self.trigger_up('openEvent', event); + self.$calendar.fullCalendar('unselect'); + }, + select: function (target_date, end_date, event, _js_event, _view) { + self.trigger_up('openCreate', {'start': target_date, 'end': end_date}); + self.$calendar.fullCalendar('unselect'); + }, + eventRender: function (event, element) { + var $render = $(self._eventRender(event)); + event.title = $render.find('.o_field_type_char:first').text(); + element.find('.fc-content').html($render.html()); + element.addClass($render.attr('class')); + var display_hour = (event.start.format('HH:mm') === '00:00' ? event.r_start.format('HH:mm') : event.start.format('HH:mm')) + ' - ' + + (event.end && event.end.format('HH:mm') !== '00:00' ? event.end.format('HH:mm') : event.r_end.format('HH:mm')); + if (display_hour === '00:00 - 00:00') { + display_hour = _t('All the day'); + } + element.find('.fc-content .fc-time').text(display_hour); + }, + + unselectAuto: false, + }); + + this.$calendar.fullCalendar(fc_options); + }, + /** + * Initialize the mini calendar in the sidebar + */ + _initCalendarMini: function () { + var self = this; + this.$small_calendar = this.$(".o_calendar_mini"); + this.$small_calendar.datepicker({ + 'onSelect': function (datum, obj) { + self.trigger_up('changeDate', { + date: new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay).toString() + }); + }, + 'dayNamesMin' : this.state.fc_options.dayNamesShort, + 'monthNames': this.state.fc_options.monthNamesShort, + 'firstDay': this.state.fc_options.firstDay, + }); + }, + /** + * Initialize the sidebar + */ + _initSidebar: function () { + this.$sidebar = this.$('.o_calendar_sidebar'); + this.$sidebar_container = this.$(".o_calendar_sidebar_container"); + this._initCalendarMini(); + }, + /** + * Render the calendar view, this is the main entry point. + * + * @override method from AbstractRenderer + * @returns {Deferred} + */ + _render: function () { + var $calendar = this.$calendar; + var $fc_view = $calendar.find('.fc-view'); + var scrollPosition = $fc_view.scrollLeft(); + + $fc_view.scrollLeft(0); + $calendar.fullCalendar('unselect'); + + if (scales[this.state.scale] !== $calendar.data('fullCalendar').getView().type) { + $calendar.fullCalendar('changeView', scales[this.state.scale]); + } + + if (this.target_date !== this.state.target_date.toString()) { + $calendar.fullCalendar('gotoDate', moment(this.state.target_date)); + this.target_date = this.state.target_date.toString(); + } + + var highlightDate = moment(this.state.highlight_date).format('YYYY-MM-DD'); + $calendar.find('.o_target_date').removeClass('o_target_date'); + $calendar.find('.fc-bg .fc-day[data-date="'+highlightDate+'"]') + .addClass('o_target_date'); + + this.$small_calendar.datepicker("setDate", this.state.highlight_date) + .find('.o_selected_range') + .removeClass('o_color o_selected_range'); + var $a; + switch (this.state.scale) { + case 'month': $a = this.$small_calendar.find('td a'); break; + case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active) a'); break; + case 'day': $a = this.$small_calendar.find('a.ui-state-active'); break; + } + $a.addClass('o_selected_range'); + setTimeout(function () { + $a.not('.ui-state-active').addClass('o_color'); + }); + + $fc_view.scrollLeft(scrollPosition); + + var fullWidth = this.state.fullWidth; + this.$('.o_calendar_sidebar_toggler') + .toggleClass('fa-close', !fullWidth) + .toggleClass('fa-chevron-left', fullWidth) + .attr('title', !fullWidth ? _('Close Sidebar') : _('Open Sidebar')); + this.$sidebar_container.toggleClass('o_sidebar_hidden', fullWidth); + this.$sidebar.toggleClass('o_hidden', fullWidth); + + this._renderFilters(); + this.$calendar.appendTo('body'); + this.$calendar.fullCalendar('render'); + this._renderEvents(); + this.$calendar.prependTo(this.$('.o_calendar_view')); + + return this._super.apply(this, arguments); + }, + /** + * Render all events + */ + _renderEvents: function () { + this.$calendar.fullCalendar('removeEvents'); + this.$calendar.fullCalendar('addEventSource', this.state.data); + }, + /** + * Render all filters + */ + _renderFilters: function () { + var self = this; + _.each(this.filters || (this.filters = []), function (filter) { + filter.destroy(); + }); + if (this.state.fullWidth) { + return; + } + _.each(this.state.filters, function (options) { + if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) { + return; + } + options.getColor = self.getColor.bind(self); + options.fields = self.state.fields; + var filter = new SidebarFilter(self, options); + filter.appendTo(self.$sidebar); + self.filters.push(filter); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Toggle the sidebar + */ + _onToggleSidebar: function () { + this.trigger_up('toggleFullWidth'); + }, +}); + +}); + + // dead code ? + // + // calendarMiniChanged: function (datum, obj) { + // this.$calendar.fullCalendar('gotoDate', +obj.currentYear , +obj.currentMonth, +obj.currentDay); + // }, diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js new file mode 100644 index 00000000000..288e908797c --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -0,0 +1,129 @@ +odoo.define('web.CalendarView', function (require) { +"use strict"; + +var AbstractView = require('web.AbstractView'); +var CalendarModel = require('web.CalendarModel'); +var CalendarController = require('web.CalendarController'); +var CalendarRenderer = require('web.CalendarRenderer'); +var core = require('web.core'); +var utils = require('web.utils'); + +var _lt = core._lt; + +// gather the fields to get +var fieldsToGather = [ + "date_start", + "date_delay", + "date_stop", + "all_day", +]; + +var CalendarView = AbstractView.extend({ + display_name: _lt('Calendar'), + icon: 'fa-calendar', + config: { + Model: CalendarModel, + Controller: CalendarController, + Renderer: CalendarRenderer, + }, + init: function (arch, fields, params) { + this._super.apply(this, arguments); + var attrs = arch.attrs; + + if (!attrs.date_start) { + throw new Error(_t("Calendar view has not defined 'date_start' attribute.")); + } + + var mapping = {}; + var fieldNames = fields.display_name ? ['display_name'] : []; + var displayFields = {}; + + _.each(fieldsToGather, function (field) { + if (arch.attrs[field]) { + var fieldName = attrs[field]; + mapping[field] = fieldName; + fieldNames.push(fieldName); + } + }); + + if (attrs.color) { + fieldNames.push(attrs.color.split('.')[0]); + } + + var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit); + + var filters = {}; + + _.each(arch.children, function (child) { + switch (child.tag) { + case "filter": + if (params.sidebar === false) break; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers" + var fieldName = child.attrs.name; + filters[fieldName] = { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + if (child.attrs.avatar_field) { + filters[fieldName].avatar_field = child.attrs.avatar_field; + filters[fieldName].avatar_model = fields[fieldName].relation; + } + if (child.attrs.write_model) { + filters[fieldName].write_model = child.attrs.write_model; + filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields + } + fieldNames.push(child.attrs.name); + break; + case "field": + fieldNames.push(child.attrs.name); + if (!child.attrs.invisible) { + displayFields[child.attrs.name] = child.attrs; + } + } + }); + + if (_.isEmpty(displayFields)) { + displayFields = fields.display_name ? {'display_name': {}} : []; + } + + //if quick_add = False, we don't allow quick_add + //if quick_add = not specified in view, we use the default widgets.QuickCreate + //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate' + this.controllerParams.quick_add_pop = (attrs.quick_add || utils.toBoolElse(attrs.quick_add+'', true)); + this.controllerParams.disable_quick_create = params.disable_quick_create || !this.controllerParams.quick_add_pop; + this.controllerParams.confirm_on_delete = true; + // If this field is set ot true, we don't open the event in form view, but in a popup with the view_id passed by this parameter + this.controllerParams.formViewId = !attrs.form_view_id || !utils.toBoolElse(attrs.form_view_id, true) ? false : attrs.form_view_id; + this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id; + this.controllerParams.mapping = mapping; + this.controllerParams.context = params.context || {}; + + this.rendererParams.displayFields = displayFields; + this.rendererParams.eventTemplate = _.findWhere(arch.children, {'tag': 'template'}); + + this.loadParams.fieldNames = _.uniq(fieldNames); + this.loadParams.mapping = mapping; + this.loadParams.fields = fields; + this.loadParams.editable = !this.controllerParams.read_only_mode && !fields[mapping.date_start].readonly; + this.loadParams.eventLimit = eventLimit; + this.loadParams.field_color = attrs.color; + + this.loadParams.filters = filters; + this.loadParams.mode = attrs.mode; + this.loadParams.scale_zoom = attrs.scale_zoom; + this.loadParams.initialDate = params.initialDate || new Date(); + + // is_action_enabled: function (action) { + // if (action === 'create' && !this.options.creatable) { + // return false; + // } + // return this._super(action); + // }, + + + }, +}); + +return CalendarView; + +}); diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js new file mode 100644 index 00000000000..f1a238e7316 --- /dev/null +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -0,0 +1,164 @@ +odoo.define('web.FieldManagerMixin', function (require) { +"use strict"; + +/** + * The FieldManagerMixin is a mixin, designed to do the plumbing between field + * widgets and a basicmodel. Field widgets can be used outside of a view. In + * that case, someone needs to listen to events bubbling up from the widgets and + * calling the correct methods on the model. This is the field_manager's job. + */ + +var BasicModel = require('web.BasicModel'); + +return { + custom_events: { + field_changed: '_onFieldChanged', + name_create: '_onNameCreate', + name_get: '_onNameGet', + name_search: '_onNameSearch', + load: '_onLoad', + }, + /** + * A FieldManagerMixin can be initialized with an instance of a basicModel. + * If not, it will simply uses its own. + * + * @param {BasicModel} [model] + */ + init: function (model) { + this.model = model || new BasicModel(this); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method will be called whenever a field value has changed (and has + * been confirmed by the model). + * + * @abstract + * @param {string} id basicModel Id for the changed record + * @param {string[]} fields the fields (names) that have been changed + * @param {OdooEvent} event the event that triggered the change + */ + _confirmChange: function (id, fields, event) { + // to be implemented + }, + /** + * This method will be called whenever a save has been triggered by a change + * in some controlled field value. For example, when a priority widget is + * being changed in a readonly form. + * + * @see _onFieldChanged + * @abstract + * @param {string} id The basicModel ID for the saved record + */ + _confirmSave: function (id) { + // to be implemented, if necessary + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This is the main job of the FMM: deciding what to do when a controlled + * field changes. Most of the time, it notifies the model that a change + * just occurred, then confirm the change. + * + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var self = this; + // in case of field changed in relational record (e.g. in the form view of a one2many + // subrecord), the field_changed event must be stopped as soon as is it handled by a + // field_manager (i.e. the one of the subrecord's form view), otherwise it bubbles up to + // the main form view but its model doesn't have any data related to the given dataPointID + event.stopPropagation(); + var dataPointID = event.data.dataPointID; + this.model.notifyChanges(dataPointID, event.data.changes).then(function (result) { // FIXME if datamodel is not a BasicModel, notifyChanges does not exists + if (event.data.force_save) { + self.model.save(dataPointID).then(function () { + self._confirmSave(dataPointID); + }); + } else { + self._confirmChange(dataPointID, result, event); + } + }); + }, + /** + * Some widgets need to trigger a reload of their data. For example, a + * one2many with a pager needs to be able to fetch the next page. To do + * that, it can trigger a load event. This will then ask the model to + * actually reload the data, then call the on_success callback. + * + * @param {OdooEvent} event + * @param {number} [event.data.limit] + * @param {number} [event.data.offset] + * @param {function} [event.data.on_success] callback + */ + _onLoad: function (event) { + var self = this; + var data = event.data; + if (!data.on_success) { return; } + var params = {}; + if ('limit' in data) { + params.limit = data.limit; + } + if ('offset' in data) { + params.offset = data.offset; + } + this.model.reload(data.id, params).then(function (db_id) { + data.on_success(self.model.get(db_id)); + }); + }, + /** + * When a name_create event arrives, the nameCreate method from the model + * should be called. + * + * @param {OdooEvent} event + */ + _onNameCreate: function (event) { + var data = event.data; + if (!data.on_success) { return; } + this.model + .nameCreate(data.model, data.name) + .then(data.on_success) + .fail(function () { + if (data.on_fail) { + data.on_fail(); + } + }); + }, + /** + * When a name_get event arrives, the name_get method from the model should + * be called. + * + * @param {OdooEvent} event + */ + _onNameGet: function (event) { + var data = event.data; + if (!data.on_success) { return; } + this.model.name_get(data.model, data.ids) + .then(data.on_success); // fixme: handle context + }, + /** + * Some field widgets need to perform a namesearch. For example, a many2one + * widget when it needs to fetch its autocompletion data. + * + * @todo Note: I think that this is wrong, the field widget should just perform + * a name_search rpc on its own, no need for this. + * + * @deprecated Just do a regular name_search + * @param {OdooEvent} event + */ + _onNameSearch: function (event) { + var data = event.data; + if (!data.on_success) { return; } + this.model + .nameSearch(data.model, data.search_val, data.domain, data.operator, data.limit) + .then(data.on_success); + }, +}; + +}); diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js new file mode 100644 index 00000000000..d1badda7993 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -0,0 +1,642 @@ +odoo.define('web.FormController', function (require) { +"use strict"; + +var BasicController = require('web.BasicController'); +var dialogs = require('web.view_dialogs'); +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var Sidebar = require('web.Sidebar'); + +var _t = core._t; +var qweb = core.qweb; + +var FormController = BasicController.extend({ + // className: "o_form_view", + custom_events: _.extend({}, BasicController.prototype.custom_events, { + open_record: '_onOpenRecord', + toggle_column_order: '_onToggleColumnOrder', + form_button_clicked: '_onFormButtonClicked', + bounce_edit: '_onBounceEdit', + add_one2many_record: '_onAddOne2ManyRecord', + }), + /** + * @override + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.actionButtons = params.actionButtons; + this.footerToButtons = params.footerToButtons; + this.defaultButtons = params.defaultButtons; + this.hasSidebar = params.hasSidebar; + this.toolbar = params.toolbar; + this.mode = params.mode; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method is supposed to focus the first active control, I think. It + * is currently only called by the FormViewDialog. + * + * @todo To be implemented + */ + autofocus: function () { + }, + /** + * This method switches the form view in edit mode, with a new record. + * + * @returns {Deferred} + */ + createRecord: function () { + var self = this; + var record = this.model.get(this.handle, {raw: true}); + return this.model.makeDefaultRecord(this.modelName, { + context: this.context, + fieldNames: record.fieldNames, + fields: record.fields, + res_ids: record.res_ids, + }).then(function (handle) { + self.handle = handle; + self._updateEnv(); + self._toEditMode(); + }); + }, + /** + * Returns the current res_id, wrapped in a list. This is only used by the + * sidebar (and the debugmanager) + * + * @todo This should be private. Need to change sidebar code + * + * @returns {number[]} either [current res_id] or [] + */ + getSelectedIds: function () { + var env = this.model.get(this.handle, {env: true}); + // FIX ME : fix sidebar widget + return env.currentId ? [env.currentId] : []; + }, + /** + * @override method from AbstractController + * @returns {string} + */ + getTitle: function () { + var dataPoint = this.model.get(this.handle, {raw: true}); + return dataPoint.data.display_name || _t('New'); + }, + /** + * Render buttons for the control panel. The form view can be rendered in + * a dialog, and in that case, if we have buttons defined in the footer, we + * have to use them instead of the standard buttons. + * + * @override method from AbstractController + * @param {jQueryElement} $node + */ + renderButtons: function ($node) { + var $footer = this.$('footer'); + if (!this.defaultButtons && (!this.footerToButtons || !$footer.length)) { + return; + } + this.$buttons = $('<div/>'); + if (this.footerToButtons && $footer.length) { + this.$buttons.append($footer.detach().contents()); + } else { + this.$buttons.append(qweb.render("FormView.buttons", {widget: this})); + this.$buttons.find('.o_form_button_edit') + .click(this._toEditMode.bind(this)); + this.$buttons.find('.o_form_button_save') + .click(this.saveRecord.bind(this)); + this.$buttons.find('.o_form_button_cancel') + .click(this._onDiscardChange.bind(this)); + this.$buttons.find('.o_form_button_create') + .click(this.createRecord.bind(this)); + + this._updateButtons(); + } + this.$buttons.appendTo($node); + }, + /** + * The form view has to prevent a click on the pager if the form is dirty + * + * @override method from BasicController + * @param {jQueryElement} $node + * @param {Object} options + */ + renderPager: function ($node, options) { + options = _.extend({}, options, { + validate: this._canBeDiscarded.bind(this), + }); + this._super($node, options); + }, + /** + * Instantiate and render the sidebar if a sidebar is requested + * Sets this.sidebar + * @param {jQuery} [$node] a jQuery node where the sidebar should be + * inserted + **/ + renderSidebar: function ($node) { + if (!this.sidebar && this.hasSidebar) { + this.sidebar = new Sidebar(this, { + editable: this.is_action_enabled('edit') + }); + if (this.toolbar) { + this.sidebar.add_toolbar(this.toolbar); + } + var otherItems = []; + if (this.is_action_enabled('delete')) { + otherItems.push({ + label: _t('Delete'), + callback: this._deleteRecords.bind(this, [this.handle]), + }); + } + if (this.is_action_enabled('create')) { + otherItems.push({ + label: _t('Duplicate'), + callback: this._onDuplicateRecord.bind(this), + }); + } + this.sidebar.add_items('other', otherItems); + this.sidebar.appendTo($node); + + // Show or hide the sidebar according to the view mode + this._updateSidebar(); + } + }, + /** + * Save the current record + * + * @param {Object} options + * @param {boolean} [options.stayInEdit=false] if true, don't switch to + * readonly mode after saving the record + * @param {boolean} [options.reload=true] if true, reload the record after + * saving + * @returns {Deferred} + */ + saveRecord: function (options) { + var self = this; + options = options || {}; + var stayInEdit = 'stayInEdit' in options ? options.stayInEdit : false; + var shouldReload = 'reload' in options ? options.reload : true; + var db_id = this.handle; + if (!this.model.isDirty(db_id) && !this.model.isNew(db_id)) { + if (!stayInEdit) { + this._toReadOnlyMode(); + } + return $.Deferred().resolve(); + } else { + var invalid_fields = this.renderer.checkInvalidFields(); + if (invalid_fields.length) { + this._notifyInvalidFields(invalid_fields); + return $.Deferred().reject(); + } else { + return this.model.save(db_id, {reload: shouldReload}) + .then(function (element) { + if (!stayInEdit) { + self._toReadOnlyMode(); + } + self.trigger_up('saved', _.clone(element.data)); + }); + } + } + }, + /** + * We need to check for all 'mode' change, because the information is not + * available from the model. + * + * @override + * @param {Object} params + * @returns {Deferred} + */ + update: function (params) { + this.mode = params.mode || this.mode; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * When the user clicks on a form button, this function determines what + * should happen. + * + * @param {Object} attrs the attrs of the button clicked + * @param {Object} [record] the current state of the view + * @returns {Deferred} + */ + _callButtonAction: function (attrs, record) { + var self = this; + var def = $.Deferred(); + var db_id = this.handle; + record = record || this.model.get(db_id); + var recordID = record.data.id; + this.trigger_up('execute_action', { + action_data: _.extend({}, attrs, { + context: data.build_context(record, attrs.context) + }), + model: record.model, + record_id: recordID, + on_close: function (reason) { + if (!_.isObject(reason)) { + return self.load_record(recordID) + .then(self.update_state.bind(self)); + } + }, + on_fail: function () { + self.reload(); + }, + on_success: def.resolve.bind(def), + }); + return def; + }, + /** + * Determines if we can discard the current change. If the model is not + * dirty, that is not a problem. However, if it is dirty, we have to ask + * the user for confirmation. + * + * @returns {Deferred} If the deferred is resolved, we assume the changes + * can be discarded. If it has been rejected, then we cannot discard. + */ + _canBeDiscarded: function () { + if (!this.model.isDirty(this.handle)) { + return $.when(); + } + var message = _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?"); + var def = $.Deferred(); + var options = { + title: _t("Warning"), + confirm_callback: function () { + this.on('closed', null, def.resolve.bind(def)); + }, + cancel_callback: def.reject.bind(def) + }; + var dialog = Dialog.confirm(this, message, options); + dialog.$modal.on('hidden.bs.modal', def.reject.bind(def)); + return def; + }, + /** + * @override method from FieldManagerMixin + * @param {string} id it is ignored, because we already know it. + * @param {string[]} fields + * @param {OdooEvent} event the event that triggered the change + */ + _confirmChange: function (id, fields, event) { + var record = this.model.get(this.handle); + this.renderer.updateWidgets(fields, record, event); + }, + /** + * When a save operation has been confirmed from the model, this method is + * called. + * + * @override method from field manager mixin + * @param {string} id + */ + _confirmSave: function (id) { + if (id === this.handle) { + this.reload(); + } else { + // a subrecord changed, so update the corresponding relational field + // i.e. the one whose value is a record with the given id or a list + // having a record with the given id in its data + var record = this.model.get(this.handle); + var fieldsChanged = _.findKey(record.data, function (d) { + return _.isObject(d) && + (d.id === id || _.findWhere(d.data, {id: id})); + }); + this.renderer.updateWidgets([fieldsChanged], record); + } + }, + /** + * Helper function to display a warning that some field have an invalid + * value. This is used when a save operation cannot be completed. + * + * @param {string[]} invalidFields list of field names + */ + _notifyInvalidFields: function (invalidFields) { + var record = this.model.get(this.handle, {raw: true}); + var fields = record.fields; + var warnings = invalidFields.map(function (field_name) { + var fieldStr = fields[field_name].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("The following fields are invalid:"), warnings.join('')); + }, + /** + * We just add the current ID to the state pushed. This allows the web + * client to add it in the url, for example. + * + * @override method from AbstractController + * @param {Object} [state] + */ + _pushState: function (state) { + state = state || {}; + var env = this.model.get(this.handle, {env: true}); + state.id = env.currentId; + this._super(state); + }, + /** + * Change the view mode to 'edit' + * + * @see _toReadOnlyMode + */ + _toEditMode: function () { + this.$el.addClass('o_form_editable'); + this.update({mode: "edit"}, {reload: false}); + }, + /** + * Change the view mode to 'readonly' + * + * @see _toEditMode + */ + _toReadOnlyMode: function () { + this.$el.removeClass('o_form_editable'); + this.update({mode: "readonly"}, {reload: false}); + }, + /** + * Updates the controller's title according to the new state + * + * @override + * @param {Object} state + * @returns {Deferred} + */ + _update: function (state) { + var title = this.getTitle(); + this.set('title', title); + this._updateButtons(); + this._updateSidebar(); + return this._super.apply(this, arguments); + }, + /** + * @todo move this in _update + */ + _updateButtons: function () { + if (this.$buttons) { + var edit_mode = (this.mode === 'edit'); + this.$buttons.find('.o_form_buttons_edit') + .toggleClass('o_hidden', !edit_mode); + this.$buttons.find('.o_form_buttons_view') + .toggleClass('o_hidden', edit_mode); + } + }, + /** + * Show or hide the sidebar according to the actual_mode + * @todo move this in _update + */ + _updateSidebar: function () { + if (this.sidebar) { + this.sidebar.do_toggle(this.mode === 'readonly'); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} event + */ + _onAddOne2ManyRecord: function (event) { + var field = event.data.field; + new dialogs.FormViewDialog(this, { + res_model: field.relation, + domain: event.data.domain, + context: event.data.context, + on_save: event.data.on_save, + title: _t('Create: ') + field.string, + initial_view: 'form', + fields_view: field.views ? field.views.form : undefined, + form_view_options: {'not_interactible_on_create': true}, + model: this.model, + }).open(); + }, + /** + * Bounce the 'Edit' button. + */ + _onBounceEdit: function () { + if (this.$buttons) { + this.$buttons.find('.o_form_button_edit').openerpBounce(); + } + }, + /** + * If the user clicks on 'Discard', we have to check if the changes can be + * discarded, then actually do it. + */ + _onDiscardChange: function () { + var self = this; + this._canBeDiscarded().then(function () { + self.model.discardChanges(self.handle); + if (!self.model.isNew(self.handle)) { + self._toReadOnlyMode(); + } else { + self.do_action('history_back'); + } + }); + }, + /** + * Called when the user clicks on 'Duplicate Record' in the sidebar + */ + _onDuplicateRecord: function () { + var self = this; + this.model.duplicateRecord(this.handle) + .then(function (handle) { + self.handle = handle; + self._updateEnv(); + self._toEditMode(); + }); + }, + /** + * This method comes from the field manager mixin. We force to save directly + * the changes if the form is in readonly, because in that case the changes + * come from widgets that are editable even in readonly (e.g. Priority). + * + * @override + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + if (this.mode === 'readonly') { + event.data.force_save = true; + } + this._super.apply(this, arguments); + }, + /** + * @param {OdooEvent} event + */ + _onFormButtonClicked: function (event) { + // stop the event's propagation as a form controller might have other + // form controllers in its descendants (e.g. in a FormViewDialog) + event.stopPropagation(); + var self = this; + var def; + + var attrs = event.data.attrs; + if (attrs.confirm) { + var d = $.Deferred(); + Dialog.confirm(this, attrs.confirm, { confirm_callback: function () { + self._callButtonAction(attrs, event.data.record); + }}).on("closed", null, function () { + d.resolve(); + }); + def = d.promise(); + } else if (attrs.special) { + def = this._callButtonAction(attrs, event.data.record); + } else { + def = this.saveRecord().then(function () { + return self._callButtonAction(attrs, event.data.record); + }); + } + def.then(function () { + self.reload(); + }); + + if (event.data.show_wow) { + def.then(function () { + self.show_wow(); + }); + } + }, + /** + * Open a record in a form view dialog + * + * @param {OdooEvent} event + */ + _onOpenRecord: function (event) { + var self = this; + var record = this.model.get(event.data.id); + var model = record.model; + var res_id = record.res_id; + var fieldsViewDef; + if (event.data.form_view) { + fieldsViewDef = $.when(event.data.form_view); + } else { + var context = event.data.context; + var domain = event.data.domain; + var dataset = new data.DataSetSearch(this, model, context, domain); + fieldsViewDef = this.loadFieldView(dataset, false, 'form'); + } + fieldsViewDef.then(function (form_fields_view) { + new dialogs.FormViewDialog(self, { + res_model: model, + res_id: res_id, + fields_view: form_fields_view, + context: data.build_context(record), + readonly: event.data.readonly, + title: _t("Open: ") + event.data.string, + on_save: event.data.on_save, + on_saved: event.data.on_saved, + }).open(); + }); + }, + /** + * This method is called when someone tries to sort a column, most likely + * in a x2many list view + * + * @param {OdooEvent} event + */ + _onToggleColumnOrder: function (event) { + this.model.setSort(event.data.id, event.data.name); + var field = event.data.field; + var state = this.model.get(this.handle); + this.renderer.updateWidgets([field], state); + }, + +}); + +return FormController; + +}); + + // load_record: function (id) { + // var index = _.indexOf(this.datasetIds, this.currentId); + // return this.model.load({ + // // context: this.options.context, + // id: id, + // model: this.modelName, + // offset: index, + // res_ids: this.datasetIds, + // count: this.datasetIds.length, + // }); + // }, + // do_show: function (view_options) { + // var defs = []; + // defs.push(this._super.apply(this, arguments)); + // if (this.has_been_started) { + // var dataset = view_options && view_options.dataset; + // this.dataset_ids = dataset ? dataset.res_ids : this.dataset.ids; + // this.current_id = dataset ? dataset.current_id : this.dataset.ids[this.dataset.index]; + // defs.push(this.load_record(this.current_id).then(this.update_state.bind(this))); + // } + // this.has_been_started = true; + // return $.when.apply($, defs); + // }, + // _onDeletedRecords: function (ids) { + // var index = this.dataset_ids.indexOf(this.current_id); + // this.dataset_ids.splice(index, 1); + // this.current_id = this.dataset_ids[index]; + // if (!this.current_id) { + // this.current_id = this.dataset_ids[this.dataset_ids.length - 1]; + // } + // if (!this.current_id) { + // this.do_action('history_back'); + // } else { + // this.load_record(this.current_id).then(this.update_state.bind(this)); + // } + // }, + // _callButtonAction: function (attrs, record) { + // var self = this; + // var def = $.Deferred(); + // record = record || this.model.get(this.db_id); + // var record_id = record.data.id; + // this.trigger_up('execute_action', { + // action_data: _.extend({}, attrs, { context: data.build_context(record, attrs.context) }), + // dataset: this.dataset, + // record_id: record_id, + // on_close: function (reason) { + // if (!_.isObject(reason)) { + // return self.load_record(record_id).then(self.update_state.bind(self)); + // } + // }, + // on_fail: function () { + // self.load_record(record_id).then(function (db_id) { + // self.update_state(db_id); + // def.resolve(); + // }); + // }, + // on_success: def.resolve.bind(def), + // }); + // return def; + // }, + // show_wow: function () { + // var others = ['o_wow_peace', 'o_wow_heart']; + // var wow_class = Math.random() <= 0.9 ? 'o_wow_thumbs' : _.sample(others); + // var $body = $('body'); + // $body.addClass(wow_class); + // setTimeout(function () { + // $body.removeClass(wow_class); + // }, 1000); + // }, + // _onFieldChanged: function (event) { + // if (this.mode === 'readonly') { + // event.data.force_save = true; + // } + // this._super.apply(this, arguments); + // }, + // update_renderer: function () { + // var state = this.model.get(this.db_id); + // this.renderer.update(state, {mode: this.mode}); + // }, + // do_action: function (event) { + // this.do_action(event.data.action, event.data.options || {}).then(function (result) { + // if (event.data.on_success) { + // event.data.on_success(result); + // } + // }); + // }, + + // this.dataset_ids = this.options.dataset ? this.options.dataset.res_ids : this.dataset.ids; + // this.current_id = this.options.dataset ? this.options.dataset.current_id : this.dataset.ids[this.dataset.index]; + // this.options.context = _.extend({}, this.options.action.context, this.options.context); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js new file mode 100644 index 00000000000..91887d45b5d --- /dev/null +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -0,0 +1,603 @@ +odoo.define('web.FormRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var Domain = require('web.Domain'); +var field_registry = require('web.field_registry'); + +var _t = core._t; +var qweb = core.qweb; + +var FIELD_CLASSES = { + 'one2many': 'o_field_one2many', +}; + +return BasicRenderer.extend({ + className: "o_form_view", + /** + * @override method from BasicRenderer + * @param {Object} params + * @param {string} params.mode either 'readonly' on 'edit' + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.idsForLabels = {}; + this.widgets = []; + this.mode = params.mode; + this.field_values = this._getFieldValues(this.state); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method has two responsabilities: find every invalid fields in the + * current form, and making sure that they are displayed as invalid, by + * toggling the o_form_invalid css class. It has to be done both on the + * widget, and on the label, if there is a label. + * + * @returns {string[]} the list of invalid field names + */ + checkInvalidFields: function () { + var self = this; + var invalid_fields = []; + _.each(this.widgets, function (widget) { + var is_valid = widget.isValid(); + if (!is_valid) { + invalid_fields.push(widget.name); + } + widget.$el.toggleClass('o_form_invalid', !is_valid); + var idForLabel = self.idsForLabels[widget.name]; + var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); + $label.toggleClass('o_form_invalid', !is_valid); + }); + return invalid_fields; + }, + /** + * returns the active tab pages for each notebook + * + * @todo currently, this method is unused... + * + * @see setLocalState + * @returns {Object} a map from notebook name to the active tab index + */ + getLocalState: function () { + var state = {}; + this.$('div.o_notebook').each(function () { + var $notebook = $(this); + var name = $notebook.data('name'); + var index = -1; + $notebook.find('li').each(function (i) { + if ($(this).hasClass('active')) { + index = i; + } + }); + state[name] = index; + }); + return state; + }, + /** + * restore active tab pages for each notebook + * + * @todo make sure this method is called + * + * @param {Object} state the result from a getLocalState call + */ + setLocalState: function (state) { + this.$('div.o_notebook').each(function () { + var $notebook = $(this); + var name = $notebook.data('name'); + if (name in state) { + $notebook.find('> ul > li > a[data-toggle="tab"]') + .eq(state[name]) + .click(); + } + }); + }, + /** + * @override method from AbstractRenderer + * @param {Object} state a valid state given by the model + * @param {Object} params + * @param {string} [params.mode] new mode, either 'edit' or 'readonly' + * @param {string[]} [params.fieldNames] if given, the renderer will only + * update the fields in this list + * @returns {Deferred} + */ + updateState: function (state, params) { + this.mode = (params && 'mode' in params) ? params.mode : this.mode; + + // if fieldNames are given, we update the corresponding field widget. + // I think this is wrong, and the caller could directly call the + // updateWidgets method + if (params.fieldNames) { + // only update the given fields + return this.updateWidgets(params.fieldNames, state); + } + this.field_values = this._getFieldValues(state); + return this._super.apply(this, arguments); + }, + /** + * Update some field widgets with a new state. We could rerender the form + * view from scratch, but then it would not be as efficient, and we might + * lose some local state, such as the input focus cursor, or the scrolling + * position. + * + * @param {string[]} fields list of fields to be updated + * @param {Object} state new state + * @param {OdooEvent} [event] the event that triggered the change + * @returns {Deferred} + */ + updateWidgets: function (fields, state, event) { + this.state = state; + this.field_values = this._getFieldValues(this.state); + var defs = []; + _.each(this.widgets, function (widget) { + if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) { + defs.push(widget.reset(state, event)); + } + }); + // re-evalute the modifiers with the new state + this._evaluateModifiers(); + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds the adequate classnames to a field widget's $el. + * + * @param {Object} widget a field widget + * @param {Object} field description of the field + */ + _addFieldClassNames: function (widget, field) { + widget.$el.addClass('o_form_field'); + // classname 'o_form_field_empty' hides the widget, so we apply it in + // on existing records for fields with no value, in readonly mode or if the + // field is readonly + if (this.state.data.id && !widget.isSet() && (this.mode === 'readonly' || field.readonly)) { + widget.$el.addClass('o_form_field_empty'); + } + }, + _addOnClickAction: function ($el, node) { + var self = this; + $el.click(function () { + self.trigger_up('form_button_clicked', { + attrs: node.attrs, + show_wow: self.$el.hasClass('o_wow'), // TODO: implement this (in view) + }); + }); + }, + _evaluateModifiers: function () { + var self = this; + _.each(this.nodeModifiers, function (d) { + if ('invisible' in d.modifiers) { + var is_invisible = new Domain(d.modifiers.invisible).compute(self.field_values); + d.$el.toggleClass('o_form_invisible', is_invisible); + if (d.onEvalCallback) { + d.onEvalCallback({invisible: is_invisible}); + } + } + }); + }, + _getIDForLabel: function (name) { + var idForLabel = this.idsForLabels[name]; + if (!idForLabel) { + idForLabel = _.uniqueId('o_field_input_'); + this.idsForLabels[name] = idForLabel; + } + return idForLabel; + }, + _handleAttributes: function ($el, node, onEvalCallback) { + // register the modifiers with their corresponding $el so that they can + // be re-evaluated at each field changed + this.nodeModifiers.push({ + $el: $el, + modifiers: JSON.parse(node.attrs.modifiers || "{}"), + onEvalCallback: onEvalCallback, + }); + if (node.attrs.class) { + $el.addClass(node.attrs.class); + } + }, + _renderButtonBox: function (node) { + var $result = $('<' + node.tag + '>', { 'class': 'o_not_full' }); + // Avoid to show buttons if we are in create mode (edit mode without res_id) + if (this.mode === 'edit' && !this.state.res_id) { + return $result; + } + var buttons = _.map(node.children, this._renderStatButton.bind(this)); + var buttons_partition = _.partition(buttons, function ($button) { + return $button.is('.o_form_invisible'); + }); + var invisible_buttons = buttons_partition[0]; + var visible_buttons = buttons_partition[1]; + + // Get the unfolded buttons according to window size + var nb_buttons = [2, 4, 6, 7][config.device.size_class]; + var unfolded_buttons = visible_buttons.slice(0, nb_buttons).concat(invisible_buttons); + + // Get the folded buttons + var folded_buttons = visible_buttons.slice(nb_buttons); + if(folded_buttons.length === 1) { + unfolded_buttons = buttons; + folded_buttons = []; + } + + // Toggle class to tell if the button box is full (LESS requirement) + var full = (visible_buttons.length > nb_buttons); + $result.toggleClass('o_full', full).toggleClass('o_not_full', !full); + + // Add the unfolded buttons + _.each(unfolded_buttons, function ($button) { + $button.appendTo($result); + }); + + // Add the dropdown with folded buttons if any + if(folded_buttons.length) { + $result.append($("<button>", { + type: 'button', + 'class': "btn btn-sm oe_stat_button o_button_more dropdown-toggle", + 'data-toggle': "dropdown", + text: _t("More"), + })); + + var $ul = $("<ul>", {'class': "dropdown-menu o_dropdown_more", role: "menu"}); + _.each(folded_buttons, function ($button) { + $('<li>').appendTo($ul).append($button); + }); + $ul.appendTo($result); + } + + this._handleAttributes($result, node); + return $result; + }, + _renderFieldWidget: function (node) { + var name = node.attrs.name; + var field = this.state.fields[name]; + var widget_keys = ['form.' + field.type, field.type, 'abstract']; + if (node.attrs.widget) { + widget_keys = ['form.' + node.attrs.widget, node.attrs.widget].concat(widget_keys); + } + var Widget = field_registry.getAny(widget_keys); + + if (node.attrs.widget && !field_registry.contains(node.attrs.widget)) { + console.warn('missing widget: ' + node.attrs.widget + ' for field', name, 'of type', field.type); + } + + var modifiers = JSON.parse(node.attrs.modifiers || "{}"); + var is_readonly = new Domain(modifiers.readonly).compute(this.field_values); + var is_required = field.required || new Domain(modifiers.required).compute(this.field_values); + var options = { + mode: is_readonly ? 'readonly' : this.mode, + required: is_required, + idForLabel: this._getIDForLabel(name), + widgets_registry: field_registry, + }; + var widget = new Widget(this, name, this.state, options); + this.widgets.push(widget); + var def = widget.__widgetRenderAndInsert(function () {}); + if (def.state() === 'pending') { + this.defs.push(def); + } + widget.$el.addClass(FIELD_CLASSES[field.type]); + this._handleAttributes(widget.$el, node); + this._addFieldClassNames(widget, field); + + return widget; + }, + _renderGenericTag: function (node) { + var $result = $('<' + node.tag + '>'); + _.each(node.attrs, function (attr, name) { + if (name !== 'class' && name !== 'modifiers') { + $result.attr(name, attr); + } + }); + this._handleAttributes($result, node); + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + _renderHeaderButton: function (node) { + var $button = $('<button>') + .text(node.attrs.string) + .addClass('btn btn-sm btn-default'); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + return $button; + }, + _renderInnerField: function (node) { + var field = this.state.fields[node.attrs.name]; + var field_descr = node.attrs.string || field.string; + var hasLabel = !node.attrs.nolabel; + var widget = this._renderFieldWidget(node); + if (hasLabel) { + var $label = $('<label>') + .addClass('o_form_label') + .attr('for', this._getIDForLabel(node.attrs.name)) + .text(field_descr); + var isReadOnly = this.mode === 'readonly' || field.readonly; + if (this.state.data.id && !widget.isSet() && isReadOnly) { + $label.addClass('o_form_label_empty'); + } + this._handleAttributes($label, node); + return $('<tr>') + .append($('<td class="o_td_label">').append($label)) + .append($('<td style="width: 100%">').append(widget.$el)); + } else { + var style = { + width: 100/2 + '%' + }; + return $('<tr>') + .append($('<td colspan="1">').css(style).append(widget.$el)); + } + }, + _renderInnerGroup: function (node) { + var $result = $('<table class="o_group o_inner_group"/>'); + this._handleAttributes($result, node); + if (node.attrs.string) { + var $sep = $('<tr><td colspan="2" style="width:100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>'); + $result.append($sep); + } + var children = node.children; + for (var i = 0; i < children.length; i++) { + if (children[i].tag === 'field') { + var fieldNodes = [children[i]]; + if (children[i].attrs.nolabel && children[i+1] && + children[i+1].tag === 'field' && children[i+1].attrs.nolabel) { + fieldNodes.push(children[i+1]); + i++; + } + $result = this._renderInnerGroupRow($result, fieldNodes); + } else if (children[i].tag === 'label') { + var label = children[i]; + // If there is a "for" attribute, we expect to have an id concerned in the next node. + if (label.attrs.for) { + var linked_node = children[i+1]; + $result = this._renderInnerGroupLabel($result, label, linked_node); + i++; // Skip the rendering of the next node because we just did it. + } else { + $result = this._renderInnerGroupLabel($result, label); + } + } else { + var $td = $('<td colspan="2" style="width:100%;">').append(this._renderNode(children[i])); + $result.append($('<tr>').append($td)); + } + } + return $result; + }, + _renderInnerGroupLabel: function ($result, label, linked_node) { + var $first = $('<td class="o_td_label">') + .append(this._renderNode(label)); + var $second = linked_node ? $('<td>').append(this._renderNode(linked_node)) : $('<td>'); + var $tr = $('<tr>').append($first).append($second); + return $result.append($tr); + }, + _renderInnerGroupRow: function ($result, nodes) { + var $tr = $('<tr>'); + for (var i = 0; i < nodes.length; i++) { + $tr.append(this._renderInnerField(nodes[i]).contents()); + } + return $result.append($tr); + }, + /** + * Render a node, from the arch of the view. It is a generic method, that + * will dispatch on specific other methods. The rendering of a node is a + * jQuery element (or a string), with the correct classes, attrs, and + * content. + * + * For fields, it will return the $el of the field widget. Note that this + * method is synchronous, field widgets are instantiated and appended, but + * if they are asynchronous, they register their deferred in this.defs, and + * the _renderView method will properly wait. + * + * @param {Object} node + * @returns {jQueryElement | string} + */ + _renderNode: function (node) { + var renderer = this['_renderTag' + _.str.capitalize(node.tag)]; + if (renderer) { + return renderer.call(this, node); + } + if (node.tag === 'div' && node.attrs.name === 'button_box') { + return this._renderButtonBox(node); + } + if (_.isString(node)) { + return node; + } + return this._renderGenericTag(node); + }, + _renderStatButton: function (node) { + var $button = $('<button>').addClass('btn btn-sm oe_stat_button'); + if (node.attrs.icon) { + $('<div>') + .addClass('fa fa-fw o_button_icon') + .addClass(node.attrs.icon) + .appendTo($button); + } + $button.append(_.map(node.children, this._renderNode.bind(this))); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + return $button; + }, + _renderTabHeader: function (page, page_id) { + var $a = $('<a>', { + 'data-toggle': 'tab', + disable_anchor: 'true', + href: '#' + page_id, + role: 'tab', + text: page.attrs.string, + }); + return $('<li>').append($a); + }, + _renderTabPage: function (page, page_id) { + var $result = $('<div class="tab-pane" id="' + page_id + '">'); + $result.append(_.map(page.children, this._renderNode.bind(this))); + return $result; + }, + _renderTagButton: function (node) { + var widget = { + node: node, + string: (node.attrs.string || '').replace(/_/g, '') + }; + if (node.attrs.icon) { + widget.fa_icon = node.attrs.icon.indexOf('fa-') === 0; + } + var $button = $(qweb.render('WidgetButton', {widget: widget})); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + return $button; + }, + _renderTagField: function (node) { + return this._renderFieldWidget(node).$el; + }, + _renderTagForm: function (node) { + var $result = $('<div/>'); + if (node.attrs.class) { + $result.addClass(node.attrs.class); + } + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + _renderTagGroup: function (node) { + var self = this; + var is_outer_group = _.some(node.children, function (child) { + return child.tag === 'group'; + }); + if (!is_outer_group) { + return this._renderInnerGroup(node); + } + + var $result = $('<div class="o_group"/>'); + var $child; + _.each(node.children, function (child) { + if (child.tag === 'group') { + $child = self._renderInnerGroup(child); + } else { + $child = self._renderNode(child); + } + $child.addClass('o_group_col_6'); + $child.appendTo($result); + }); + + return $result; + }, + _renderTagHeader: function (node) { + var self = this; + var $statusbar = $('<div>').addClass('o_form_statusbar'); + var $buttons = $('<div>').addClass('o_statusbar_buttons'); + $statusbar.append($buttons); + _.each(node.children, function (child) { + if (child.tag === 'button') { + $buttons.append(self._renderHeaderButton(child)); + } + if (child.tag === 'field') { + var widget = self._renderFieldWidget(child); + $statusbar.append(widget.$el); + } + }); + return $statusbar; + }, + _renderTagLabel: function (node) { + var text; + if ('string' in node.attrs) { // allow empty string + text = node.attrs.string; + } else if (node.attrs.for) { + text = this.state.fields[node.attrs.for].string; + } else { + return this._renderGenericTag(node); + } + var $result = $('<label>') + .addClass('o_form_label') + .attr('for', this._getIDForLabel(node.attrs.for)) + .text(text); + this._handleAttributes($result, node); + return $result; + }, + _renderTagNotebook: function (node) { + var self = this; + var $headers = $('<ul class="nav nav-tabs">'); + var $pages = $('<div class="tab-content nav nav-tabs">'); + _.each(node.children, function (child, index) { + var pageID = _.uniqueId('notebook_page_'); + var $header = self._renderTabHeader(child, pageID); + var $page = self._renderTabPage(child, pageID); + if (index === 0) { + $header.addClass('active'); + $page.addClass('active'); + } + self._handleAttributes($header, child, function (attrs) { + // if the active tab is invisible, activate the first visible tab instead + if (attrs.invisible && $header.hasClass('active')) { + $header.removeClass('active'); + $page.removeClass('active'); + var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()'); + $firstVisibleTab.addClass('active'); + $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active'); + } + }); + $headers.append($header); + $pages.append($page); + }); + return $('<div class="o_notebook">') + .data('name', node.attrs.name || '_default_') + .append($headers) + .append($pages); + }, + _renderTagSeparator: function (node) { + return $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + }, + _renderTagSheet: function (node) { + this.has_sheet = true; + var $result = $('<div>').addClass('o_form_sheet_bg'); + var $sheet = $('<div>').addClass('o_form_sheet'); + $sheet.append(_.map(node.children, this._renderNode.bind(this))); + $result.append($sheet); + return $result; + }, + /** + * Main entry point for the rendering. From here, we call _renderNode on + * the root of the arch, then, when every deferred (from the field widgets) + * are done, it will resolves itself. + * + * @override method from BasicRenderer + * @returns {Deferred} + */ + _renderView: function () { + var self = this; + this.defs = []; + this.nodeModifiers = []; + + // render the form and evaluate the modifiers + var $form = this._renderNode(this.arch).addClass(this.className); + this._evaluateModifiers(); + + var defs = this.defs; + delete this.defs; + return $.when.apply($, defs).then(function () { + self.$el.html($form.contents()); + + // necessary to allow all sub widgets to use their dimensions in layout + // related activities, such as autoresize on fieldtexts + core.bus.trigger('DOM_updated'); + + self.$el.toggleClass('o_form_nosheet', !self.has_sheet); + self.$el.toggleClass('o_form_editable', self.mode === 'edit'); + self.$el.toggleClass('o_form_readonly', self.mode === 'readonly'); + // Attach the tooltips on the fields' label + _.each(self.widgets, function (widget) { + if (core.debug || widget.attrs.help || widget.field.help) { + var idForLabel = self.idsForLabels[widget.name]; + var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); + self._addFieldTooltip(widget, $label); + } + }); + + }); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js new file mode 100644 index 00000000000..ca667f6886d --- /dev/null +++ b/addons/web/static/src/js/views/form/form_view.js @@ -0,0 +1,44 @@ +odoo.define('web.FormView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var FormRenderer = require('web.FormRenderer'); +var FormController = require('web.FormController'); +var core = require('web.core'); + +var _lt = core._lt; + +var FormView = BasicView.extend({ + display_name: _lt('Form'), + icon: 'fa-edit', + multi_record: false, + searchable: false, + + config: _.extend({}, BasicView.prototype.config, { + Renderer: FormRenderer, + Controller: FormController, + }), + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); + this.loadParams.type = 'record'; + + this.controllerParams.hasSidebar = params.sidebar; + this.controllerParams.toolbar = false; + this.controllerParams.footerToButtons = params.footer_to_buttons; + if ('action' in params) { + this.controllerParams.footerToButtons = params.action.flags.footer_to_buttons; + } + var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; + this.controllerParams.defaultButtons = defaultButtons; + this.controllerParams.mode = mode; + + this.rendererParams.mode = mode; + this.model = params.model; + }, +}); + +return FormView; + +}); diff --git a/addons/web/static/src/js/views/form_common.js b/addons/web/static/src/js/views/form_common.js deleted file mode 100644 index 273da34f678..00000000000 --- a/addons/web/static/src/js/views/form_common.js +++ /dev/null @@ -1,1115 +0,0 @@ -odoo.define('web.form_common', function (require) { -"use strict"; - -var core = require('web.core'); -var data = require('web.data'); -var data_manager = require('web.data_manager'); -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); - if (!this.no_rerender) { - 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].trim()) || data.noDisplayContent, - 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: 'o_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: 'o_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: 'o_m2o_dropdown_option' - }); - } - else if (values.length === 0) { - values.push({ - label: _t("No results to show..."), - action: function() {}, - }); - } - - 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; - new SelectCreateDialog(this, _.extend({}, (this.options || {}), { - res_model: self.field.relation, - domain: self.build_domain(), - context: new data.CompoundContext(self.build_context(), context || {}), - 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, - on_selected: function(element_ids) { - self.add_id(element_ids[0]); - self.focus(); - } - })).open(); - }, - /** - * 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('o_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(.o_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(.o_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"] || ""); - this.$el.attr('style', this.node.attrs.style); - }, - 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: 1000, 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(); - }); - - this.$translate = (_t.database.multi_lang && this.field.translate) ? $('<button/>', { - type: 'button', - }).addClass('o_field_translate fa fa-globe btn btn-link') : $(); - }, - - renderElement: function() { - var self = this; - this._super(); - this.$el.addClass('o_form_field'); - this.$label = this.view ? this.view.$('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._toggle_label(); - }); - this.on("change:effective_readonly", this, function() { - this._toggle_label(); - }); - this.render_value(); - this._toggle_label(); - - if (this.view) { - this.$translate - .insertAfter(this.$el) - .on('click', _.bind(this.on_translate, this)); - } - }, - _toggle_label: function() { - var empty = this.get('effective_readonly') && this.is_false(); - this.$label.toggleClass('o_form_label_empty', empty).toggleClass('o_form_label_false', this.get('effective_readonly') && this.get('value') === false); - this.$el.toggleClass('o_form_field_empty', empty); - }, - /** - * Private. Do not use. - */ - _set_required: function() { - this.$el.toggleClass('o_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() { - var show_translate = (!this.get('effective_readonly') && this.field_manager.get('actual_mode') !== "create"); - this.$translate.toggleClass('o_translate_active', !!show_translate); - - this.$el.add(this.$label) - .toggleClass('o_form_invalid', !this.disable_utility_classes && !!this.field_manager.get('display_invalid_fields') && !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.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, - height: height, - }); - }, - commit_value: function() { - return $.when(); - }, -}); - -/** - * Class with everything which is common between FormViewDialog and SelectCreateDialog. - */ -var ViewDialog = Dialog.extend({ // FIXME should use ViewManager - /** - * options: - * -readonly: only applicable when not in creation mode, default to false - * - alternative_form_view - * - write_function - * - read_function - * - create_function - * - parent_view - * - child_name - * - form_view_options - */ - init: function(parent, options) { - options = options || {}; - options.dialogClass = options.dialogClass || ''; - options.dialogClass += ' o_act_window'; - - this._super(parent, $.extend(true, {}, options)); - - this.res_model = options.res_model || null; - this.res_id = options.res_id || null; - this.domain = options.domain || []; - this.context = options.context || {}; - this.options = _.extend(this.options || {}, options || {}); - - this.on_selected = options.on_selected || (function() {}); - }, - - init_dataset: function() { - var self = this; - this.created_elements = []; - this.dataset = new data.ProxyDataSet(this, this.res_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; - - this.on('closed', this, this.select); - }, - - select: function() { - if (this.created_elements.length > 0) { - this.on_selected(this.created_elements); - this.created_elements = []; - } - } -}); - -/** - * Create and edit dialog (displays a form view record and leave once saved) - */ -var FormViewDialog = ViewDialog.extend({ - init: function(parent, options) { - var self = this; - - var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection; - var readonly = _.isNumber(options.res_id) && options.readonly; - - if(!options || !options.buttons) { - options = options || {}; - options.buttons = [ - {text: (readonly ? _t("Close") : _t("Discard")), classes: "btn-default o_form_button_cancel", close: true, click: function() { - self.view_form.trigger('on_button_cancel'); - }} - ]; - - if(!readonly) { - options.buttons.splice(0, 0, {text: _t("Save") + ((multi_select)? " " + _t(" & Close") : ""), classes: "btn-primary", click: function() { - self.view_form.onchanges_mutex.def.then(function() { - if (!self.view_form.warning_displayed) { - $.when(self.view_form.save()).done(function() { - self.view_form.reload_mutex.exec(function() { - self.trigger('record_saved'); - self.close(); - }); - }); - } - }); - } - }); - - if(multi_select) { - options.buttons.splice(1, 0, {text: _t("Save & New"), classes: "btn-primary", click: function() { - $.when(self.view_form.save()).done(function() { - self.view_form.reload_mutex.exec(function() { - self.view_form.on_button_new(); - }); - }); - }}); - } - } - } - - this._super(parent, options); - }, - - open: function() { - var self = this; - var _super = this._super.bind(this); - this.init_dataset(); - - if (this.res_id) { - this.dataset.ids = [this.res_id]; - this.dataset.index = 0; - } else { - this.dataset.index = null; - } - var options = _.clone(this.options.form_view_options) || {}; - if (this.res_id !== null) { - options.initial_mode = this.options.readonly ? "view" : "edit"; - } - _.extend(options, { - $buttons: this.$buttons, - }); - var FormView = core.view_registry.get('form'); - var fields_view_def; - if (this.options.alternative_form_view) { - fields_view_def = $.when(this.options.alternative_form_view); - } else { - fields_view_def = data_manager.load_fields_view(this.dataset, this.options.view_id, 'form', false); - } - fields_view_def.then(function (fields_view) { - self.view_form = new FormView(self, self.dataset, fields_view, options); - var fragment = document.createDocumentFragment(); - self.view_form.appendTo(fragment).then(function () { - self.view_form.do_show().then(function() { - _super().$el.append(fragment); - self.view_form.autofocus(); - }); - }); - }); - - return this; - }, -}); - -var SelectCreateListView = ListView.extend({ - do_add_record: function () { - this.popup.create_edit_record(); - }, - select_record: function(index) { - if (this.popup.options.readonly) return; - this.popup.on_selected([this.dataset.ids[index]]); - this.popup.close(); - }, - do_select: function(ids, records) { - this._super.apply(this, arguments); - this.popup.on_click_element(ids); - } -}); - -/** - * Search dialog (displays a list of records and permits to create a new one by switching to a form view) - */ -var SelectCreateDialog = ViewDialog.extend({ - /** - * options: - * - initial_ids - * - initial_view: form or search (default search) - * - disable_multiple_selection - * - list_view_options - */ - init: function(parent, options) { - this._super(parent, options); - - _.defaults(this.options, { initial_view: "search" }); - this.initial_ids = this.options.initial_ids; - }, - - open: function() { - if(this.options.initial_view !== "search") { - return this.create_edit_record(); - } - - var _super = this._super.bind(this); - this.init_dataset(); - 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_; - } - }); - data_manager - .load_views(this.dataset, [[false, 'list'], [false, 'search']], {}) - .then(this.setup.bind(this, search_defaults)) - .then(function (fragment) { - _super().$el.append(fragment); - }); - return this; - }, - - setup: function(search_defaults, fields_views) { - var self = this; - if (this.searchview) { - this.searchview.destroy(); - } - var fragment = document.createDocumentFragment(); - var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment); - var $pager = $('<div/>').addClass('o_pager').appendTo($header); - var options = { - $buttons: $('<div/>').addClass('o_search_options').appendTo($header), - search_defaults: search_defaults, - }; - - this.searchview = new SearchView(this, this.dataset, fields_views.search, options); - this.searchview.on('search_data', this, function(domains, contexts, groupbys) { - if (this.initial_ids) { - this.do_search(domains.concat([[["id", "in", this.initial_ids]], this.domain]), - contexts.concat(this.context), groupbys); - this.initial_ids = undefined; - } else { - this.do_search(domains.concat([this.domain]), contexts.concat(this.context), groupbys); - } - }); - return this.searchview.prependTo($header).then(function() { - self.searchview.toggle_visibility(true); - - self.view_list = new SelectCreateListView(self, - self.dataset, fields_views.list, - _.extend({'deletable': false, - 'selectable': !self.options.disable_multiple_selection, - 'import_enabled': false, - '$buttons': self.$buttons, - 'disable_editable_mode': true, - 'pager': true, - }, 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.on('list_view_loaded', self, function() { - this.on_view_list_loaded(); - }); - - var buttons = [ - {text: _t("Cancel"), classes: "btn-default o_form_button_cancel", close: true} - ]; - if(!self.options.no_create) { - buttons.splice(0, 0, {text: _t("Create"), classes: "btn-primary", click: function() { - self.create_edit_record(); - }}); - } - if(!self.options.disable_multiple_selection) { - buttons.splice(0, 0, {text: _t("Select"), classes: "btn-primary o_selectcreatepopup_search_select", disabled: true, close: true, click: function() { - self.on_selected(self.selected_ids); - }}); - } - self.set_buttons(buttons); - - return self.view_list.appendTo(fragment).then(function() { - self.view_list.do_show(); - self.view_list.render_pager($pager); - if (self.options.initial_facet) { - self.searchview.query.reset([self.options.initial_facet], { - preventSearch: true, - }); - } - self.searchview.do_search(); - - return fragment; - }); - }); - }, - 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) { - this.selected_ids = ids || []; - this.$footer.find(".o_selectcreatepopup_search_select").prop('disabled', this.selected_ids.length <= 0); - }, - create_edit_record: function() { - this.close(); - return new FormViewDialog(this.__parentedParent, this.options).open(); - }, - on_view_list_loaded: function() {}, -}); - -return { - // mixins - FieldManagerMixin: FieldManagerMixin, - ReinitializeWidgetMixin: ReinitializeWidgetMixin, - ReinitializeFieldMixin: ReinitializeFieldMixin, - CompletionFieldMixin: CompletionFieldMixin, - - // misc - FormWidget: FormWidget, - DefaultFieldManager: DefaultFieldManager, - InvisibilityChanger: InvisibilityChanger, - NotebookInvisibilityChanger: NotebookInvisibilityChanger, - commands: commands, - AbstractField: AbstractField, - - // Dialogs - ViewDialog: ViewDialog, - FormViewDialog: FormViewDialog, - SelectCreateDialog: SelectCreateDialog, -}; - -}); diff --git a/addons/web/static/src/js/views/form_relational_widgets.js b/addons/web/static/src/js/views/form_relational_widgets.js deleted file mode 100644 index 63d04aaeef3..00000000000 --- a/addons/web/static/src/js/views/form_relational_widgets.js +++ /dev/null @@ -1,1726 +0,0 @@ -odoo.define('web.form_relational', function (require) { -"use strict"; - -var ControlPanel = require('web.ControlPanel'); -var core = require('web.core'); -var data = require('web.data'); -var Dialog = require('web.Dialog'); -var common = require('web.form_common'); -var ListView = require('web.ListView'); -require('web.ListEditor'); // one must be sure that the include of ListView are done (for eg: add start_edition methods) -var Model = require('web.DataModel'); -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', - buttons: [ - {text: _t('Create'), classes: 'btn-primary', click: function(e) { - if (this.$("input").val() !== ''){ - this.getParent()._quick_create(this.$("input").val()); - this.close(); - } else { - e.preventDefault(); - this.$("input").focus(); - } - }}, - - {text: _t('Create and edit'), classes: 'btn-primary', close: true, click: function() { - this.getParent()._search_create_popup("form", undefined, this.getParent()._create_context(this.$("input").val())); - }}, - - {text: _t('Cancel'), close: true} - ] - }); - }, - start: function() { - var text = _.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name); - this.$("p").text(text); - this.$("input").val(this.getParent().$input.val()); - }, -}); - -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.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.$dropdown) { - this.$dropdown.off('click'); - delete this.$dropdown; - } - if (this.$input) { - if (this.$input.data('ui-autocomplete')) { - this.$input.autocomplete("destroy"); - } - 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.$("input"); - - this.init_error_displayer(); - self.$input.on('focus', function() { - self.hide_error_displayer(); - }); - - this.$dropdown = this.$(".o_dropdown_button"); - this.$follow_button = this.$(".o_external_button"); - - this.$follow_button.click(function(ev) { - ev.preventDefault(); - if (!self.get('value')) { - self.focus(); - return; - } - 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){ - var pop = new common.FormViewDialog(self, { - res_model: self.field.relation, - res_id: self.get("value"), - context: self.build_context(), - title: _t("Open: ") + self.string, - view_id: view_id, - readonly: !self.can_write - }).open(); - 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.$input.on('click', function() { - 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; - } - var has_changed = (self.get("value") === false || self.display_value["" + self.get("value")] !== self.$input.val()); - if (used && has_changed && ! 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.$input && 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.ViewDialog; - })) { 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(); - }, - autoFocus: true, - html: true, - // disabled to solve a bug, but may cause others - //close: anyoneLoosesFocus, - minLength: 0, - delay: 200, - }); - // set position for list of suggestions box - this.$input.autocomplete( "option", "position", { my : "left top", at: "left bottom" } ); - // 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(null); - 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"), - _.str.sprintf(_t("No value found for the field %s for value %s"), self.field.string, 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 noValue = (str === null); - if (!this.get("effective_readonly")) { - this.$input.val(noValue ? "" : (str.split("\n")[0].trim() || $(data.noDisplayContent).text())); - this.current_display = this.$input.val(); - this.$follow_button.toggle(!this.is_false()); - this.$el.toggleClass('o_with_button', !!this.$follow_button && this.$follow_button.length > 0 && !this.is_false()); - } else { - this.$el.html(noValue ? "" : (_.escape(str.trim()).split("\n").join("<br/>") || data.noDisplayContent)); - // Define callback to perform when clicking on the field - if (!this.options.no_open) { - // Remove potential previously added event handler - this.$el.off('click'); - // Ensure that the callback is performed only once even with multiple clicks - var execute_formview_action_once = _.once(this.execute_formview_action.bind(this)); - this.$el.click(function (ev) { - ev.preventDefault(); - execute_formview_action_once(); - }); - } - } - }, - execute_formview_action: function() { - var self = this; - var context = self.build_context().eval(); - (new Model(self.field.relation)).call('get_formview_action', [[self.get("value")], context]).then(function(action) { - self.do_action(action); - }); - }, - 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; - }, -}); - -/** - * A Abstract field for one2many and many2many field - * For all fields on2many or many2many: - * - this.get('value') contains a list of ids and virtual ids - * - get_value() return an odoo write command list - */ -var AbstractManyField = common.AbstractField.extend({ - init: function(field_manager, node) { - var self = this; - this._super(field_manager, node); - this.dataset = new X2ManyDataSet(this, this.field.relation, this.build_context()); - this.dataset.x2m = this; - this.dataset.parent_view = this.view; - this.dataset.child_name = this.name; - this.set('value', []); - this.starting_ids = []; - this.mutex = new utils.Mutex(); - this.view.on("load_record", this, this._on_load_record); - this.dataset.on('dataset_changed', this, function() { - var options = _.clone(_.last(arguments)); - if (!_.isObject(options) || _.isArray(options)) { - options = {}; - } - // don't trigger changes if all commands are not resolved - // the editable lists change the dataset without call AbstractManyField methods - if (!self.internal_dataset_changed && !options.internal_dataset_changed) { - self.trigger("change:commands", options); - } - }); - this.on("change:commands", this, function (options) { - self._inhibit_on_change_flag = !!options._inhibit_on_change_flag; - self.set({'value': self.dataset.ids.slice()}); - self._inhibit_on_change_flag = false; - }); - }, - - _on_load_record: function (record) { - this.starting_ids = []; - // don't set starting_ids for the new record - if (record.id && record[this.name] && (!isNaN(record.id) || record.id.indexOf(this.dataset.virtual_id_prefix) === -1)) { - this.starting_ids = this.get('value').slice(); - } - this.trigger("load_record", record); - }, - - set_value: function(ids) { - ids = (ids || []).slice(); - if (_.find(ids, function(id) { return typeof(id) === "string"; } )) { - throw new Error("set_value of '"+this.name+"' must receive an list of ids without virtual ids.", ids); - } - if (_.find(ids, function(id) { return typeof(id) !== "number"; } )) { - return this.send_commands(ids, {'_inhibit_on_change_flag': this._inhibit_on_change_flag}); - } - this.dataset.reset_ids(ids); - return $.when(this._super(ids)); - }, - - internal_set_value: function(ids) { - if (_.isEqual(ids, this.get("value"))) { - return; - } - var tmp = this.no_rerender; - this.no_rerender = true; - var def = this.data_replace(ids.slice()); - this.no_rerender = tmp; - return def; - }, - - commit_value: function() { - return this.mutex.def; - }, - - /* - *@value: data {object} contains all value to send to the db - * options {object} options sent to the dataset (like the default values) - *@return deferred resolve with the created virtual id - */ - data_create: function (data, options) { - return this.send_commands([COMMANDS.create(data)], options); - }, - - /* - *@value: id {int or string} id or virtual id of the record to update - * data {object} contains all value to send to the db - * options {object} options sent to the dataset - *@return deferred - */ - data_update: function (id, data, options) { - return this.send_commands([COMMANDS.update(id, data)], options); - }, - - /* - *@value: id {int or string} id or virtual id of the record to add - * options {object} options sent to the dataset - *@return deferred - */ - data_link: function (id, options) { - return this.send_commands([COMMANDS.link_to(id)], options); - }, - - /* - *@value: ids {array} list of ids or virtual ids of the record to add - * options {object} options sent to the dataset - *@return deferred - */ - data_link_multi: function (ids, options) { - return this.send_commands(_.map(ids, function (id) { return COMMANDS.link_to(id); }), options); - }, - - /* - *@value: id {int or string} id or virtual id of the record to unlink or delete (function of field type) - *@return deferred - */ - data_delete: function (id) { - return this.send_commands([COMMANDS.delete(id)]); - }, - - /* - *@value: id {int or string} id or virtual id of the record to removes relation (unlink or delete function of field type) - *@return deferred - */ - data_forget: function (id) { - return this.send_commands([COMMANDS.forget(id)]); - }, - - /* - *@value: ids {array} list of ids or virtual ids of the record who replace the previous list - * options {object} options sent to the dataset - *@return deferred - */ - data_replace: function (ids, options) { - return this.send_commands([COMMANDS.replace_with(ids)], options); - }, - - /* - *@value: ids {array} list of ids or virtual ids of the record to read - * fields {array} list of the field to read - * options {object} options sent to the dataset - *@return deferred resolve with the records - */ - data_read: function (ids, fields, options) { - return this.dataset.read_ids(ids, fields, options); - }, - - /** - *Compute the write command list into the dataset - *@value: command_list {array} command list - * options {object} options for the datasets (eg: the default values) - *@return : deferred - */ - send_commands: function (command_list, options) { - var self = this; - var def = $.Deferred(); - var dataset = this.dataset; - var res = true; - options = options || {}; - var internal_options = _.extend({}, options, {'internal_dataset_changed': true}); - - _.each(command_list, function(command) { - self.mutex.exec(function() { - var id = command[1]; - switch (command[0]) { - case COMMANDS.CREATE: - var data = _.clone(command[2]); - delete data.id; - return dataset.create(data, internal_options).then(function (id) { - dataset.ids.push(id); - res = id; - }); - case COMMANDS.UPDATE: - return dataset.write(id, command[2], internal_options).then(function () { - if (dataset.ids.indexOf(id) === -1) { - dataset.ids.push(id); - res = id; - } - }); - case COMMANDS.FORGET: - return dataset.unlink([id]); - case COMMANDS.DELETE: - return dataset.unlink([id]); - case COMMANDS.LINK_TO: - if (dataset.ids.indexOf(id) === -1) { - return dataset.add_ids([id], internal_options); - } - return; - case COMMANDS.DELETE_ALL: - return dataset.reset_ids([], {keep_read_data: true}); - case COMMANDS.REPLACE_WITH: - dataset.ids = []; - return dataset.alter_ids(command[2], internal_options); - default: - throw new Error("send_commands to '"+self.name+"' receive a non command value." + - "\n" + JSON.stringify(command_list)); - } - }); - }); - - this.mutex.def.then(function () { - self.trigger("change:commands", options); - def.resolve(res); - }); - return def; - }, - - /** - *return list of commands: create and update (and delete_all if need) (function of the field type) - */ - get_value: function() { - var self = this, - is_one2many = this.field.type === "one2many", - not_delete = this.options.not_delete, - starting_ids = this.starting_ids.slice(), - replace_with_ids = [], - add_ids = [], - command_list = [], - id, index, record; - - _.each(this.get('value'), function (id) { - index = starting_ids.indexOf(id); - if (index !== -1) { - starting_ids.splice(index, 1); - } - var record = self.dataset.get_cache(id); - if (!_.isEmpty(record.changes)) { - var values = _.clone(record.changes); - // format many2one values - for (var k in values) { - if ((values[k] instanceof Array) && values[k].length === 2 && typeof values[k][0] === "number" && typeof values[k][1] === "string") { - values[k] = values[k][0]; - } - } - if (record.to_create) { - command_list.push(COMMANDS.create(values)); - } else { - command_list.push(COMMANDS.update(record.id, values)); - } - return; - } - if (!is_one2many || not_delete || self.dataset.delete_all) { - replace_with_ids.push(id); - } else { - command_list.push(COMMANDS.link_to(id)); - } - }); - if ((!is_one2many || not_delete || self.dataset.delete_all) && (replace_with_ids.length || starting_ids.length)) { - _.each(command_list, function (command) { - if (command[0] === COMMANDS.UPDATE) { - replace_with_ids.push(command[1]); - } - }); - command_list.unshift(COMMANDS.replace_with(replace_with_ids)); - } - - _.each(starting_ids, function(id) { - if (is_one2many && !not_delete) { - command_list.push(COMMANDS.delete(id)); - } else if (is_one2many && !self.dataset.delete_all) { - command_list.push(COMMANDS.forget(id)); - } - }); - - return command_list; - }, - - is_valid: function () { - return this.mutex.def.state() === "resolved" && this._super(); - }, - - is_false: function() { - return _(this.get('value')).isEmpty(); - }, - - destroy: function () { - this.view.off("load_record", this, this._on_load_record); - this._super(); - } -}); - -var FieldX2Many = AbstractManyField.extend({ - multi_selection: false, - disable_utility_classes: true, - x2many_views: {}, - view_options: {}, - default_view: 'tree', - init: function(field_manager, node) { - this._super(field_manager, node); - - this.is_loaded = $.Deferred(); - this.initial_is_loaded = this.is_loaded; - this.is_started = false; - this.set_value([]); - }, - start: function() { - this._super.apply(this, arguments); - var self = this; - - this.load_views(); - var destroy = function() { - self.is_loaded = self.is_loaded.then(function() { - self.renderElement(); - 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", this, destroy); - this.is_started = true; - this.reload_current_view(); - }, - load_views: function() { - var self = this; - - var view_types = this.node.attrs.mode; - view_types = !!view_types ? view_types.split(",") : [this.default_view]; - var views = []; - _.each(view_types, function(view_type) { - if (! _.include(["list", "tree", "graph", "kanban"], view_type)) { - throw new Error(_.str.sprintf(_t("View type '%s' is not supported in X2Many."), view_type)); - } - var view = { - view_id: false, - view_type: view_type === "tree" ? "list" : view_type, - fields_view: self.field.views && self.field.views[view_type], - options: {}, - }; - if(view.view_type === "list") { - _.extend(view.options, { - action_buttons: false, // to avoid 'Save' and 'Discard' buttons to appear in X2M fields - 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 === "kanban") { - _.extend(view.options, { - action_buttons: true, - 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 X2ManyViewManager(this, this.dataset, views, this.view_options, this.x2many_views); - this.viewmanager.x2m = self; - var def = $.Deferred().done(function() { - self.initial_is_loaded.resolve(); - }); - this.viewmanager.on("controller_inited", self, function(view_type, controller) { - controller.x2m = 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 == "graph") { - self.reload_current_view(); - } - def.resolve(); - }); - this.viewmanager.on("switch_mode", self, function(n_mode) { - $.when(self.commit_value()).done(function() { - if (n_mode === "list") { - utils.async_when().done(function() { - self.reload_current_view(); - }); - } - }); - }); - utils.async_when().done(function () { - self.$el.addClass('o_view_manager_content'); - self.alive(self.viewmanager.attachTo(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") { - view.controller.current_min = 1; - return view.controller.reload_content(); - } 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_) { - var self = this; - this._super(value_).then(function () { - if (self.is_started && !self.no_rerender) { - return self.reload_current_view(); - } - }); - }, - commit_value: function() { - var view = this.get_active_view(); - if (view && view.type === "list" && view.controller.__focus) { - return $.when(this.mutex.def, view.controller._on_blur_one2many()); - } - return this.mutex.def; - }, - 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; - }, - is_false: function() { - return _(this.dataset.ids).isEmpty(); - }, -}); - -var X2ManyDataSet = data.BufferedDataSet.extend({ - get_context: function() { - this.context = this.x2m.build_context(); - var self = this; - _.each(arguments, function(context) { - self.context.add(context); - }); - return this.context; - }, -}); - -var X2ManyViewManager = ViewManager.extend({ - custom_events: { - // Catch event scrollTo to prevent scrolling to the top when using the - // pager of List and Kanban views in One2Many fields - 'scrollTo': function() {}, - }, - init: function(parent, dataset, views, flags, x2many_views) { - // By default, render buttons and pager in X2M fields, but no sidebar - flags = _.extend({}, flags, { - headless: false, - search_view: false, - action_buttons: true, - pager: true, - sidebar: false, - }); - this.control_panel = new ControlPanel(parent, "X2ManyControlPanel"); - this.set_cp_bus(this.control_panel.get_bus()); - this._super(parent, dataset, views, flags); - this.registry = core.view_registry.extend(x2many_views); - }, - start: function() { - this.control_panel.prependTo(this.$el); - return this._super(); - }, - switch_mode: function(mode, unused) { - if (mode !== 'form') { - return this._super(mode, unused); - } - var self = this; - var id = self.x2m.dataset.index !== null ? self.x2m.dataset.ids[self.x2m.dataset.index] : null; - var pop = new common.FormViewDialog(this, { - res_model: self.x2m.field.relation, - res_id: id, - context: self.x2m.build_context(), - title: _t("Open: ") + self.x2m.string, - create_function: function(data, options) { - return self.x2m.data_create(data, options); - }, - write_function: function(id, data, options) { - return self.x2m.data_update(id, data, options).done(function() { - self.x2m.reload_current_view(); - }); - }, - alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined, - parent_view: self.x2m.view, - child_name: self.x2m.name, - read_function: function(ids, fields, options) { - return self.x2m.data_read(ids, fields, options); - }, - form_view_options: {'not_interactible_on_create':true}, - readonly: self.x2m.get("effective_readonly") - }).open(); - pop.on("elements_selected", self, function() { - self.x2m.reload_current_view(); - }); - }, -}); - -var X2ManyListView = ListView.extend({ - is_valid: function () { - if (!this.fields_view || !this.editable()){ - return true; - } - if (_.isEmpty(this.records.records)){ - return true; - } - var fields = this.editor.form.fields; - var current_values = {}; - _.each(fields, function(field){ - field._inhibit_on_change_flag = true; - field.__no_rerender = field.no_rerender; - field.no_rerender = true; - current_values[field.name] = field.get('value'); - }); - var ids = _.map(this.records.records, function (item) { return item.attributes.id; }); - var cached_records = _.filter(this.dataset.cache, function(item){return _.contains(ids, item.id) && !_.isEmpty(item.values) && !item.to_delete;}); - var valid = _.every(cached_records, function(record){ - _.each(fields, function(field){ - var value = record.values[field.name]; - field._inhibit_on_change_flag = true; - field.no_rerender = true; - field.set_value(_.isArray(value) && _.isArray(value[0]) ? [COMMANDS.delete_all()].concat(value) : value); - }); - return _.every(fields, function(field){ - field.process_modifiers(); - field._check_css_flags(); - return field.is_valid(); - }); - }); - _.each(fields, function(field){ - field.set('value', current_values[field.name], {silent: true}); - field._inhibit_on_change_flag = false; - field.no_rerender = field.__no_rerender; - }); - return valid; - }, - render_pager: function($node, options) { - options = _.extend(options || {}, { - single_page_hidden: true, - }); - this._super($node, options); - }, - display_nocontent_helper: function () { - return false; - }, -}); - -/** - * ListView.List subclass adding an "Add an item" row to replace the Create - * button in the ControlPanel. - */ -var X2ManyList = ListView.List.extend({ - pad_table_to: function (count) { - if (!this.view.is_action_enabled('create') || this.view.x2m.get('effective_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': 'o_form_field_x2many_list_row_add' - }).append( - $('<a>', {href: '#'}).text(_t("Add an item")) - .click(function (e) { - e.preventDefault(); - e.stopPropagation(); - var def; - if (self.view.editable()) { - // 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; - } - def = self.view.save_edition(); - } - $.when(def).done(self.view.do_add_record.bind(self)); - })); - - 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 One2ManyListView = X2ManyListView.extend({ - init: function () { - this._super.apply(this, arguments); - this.options = _.extend(this.options, { - GroupsType: One2ManyGroups, - ListType: X2ManyList - }); - this.on('edit:after', this, this.proxy('_after_edit')); - this.on('save:before cancel:before', this, this.proxy('_before_unedit')); - - /* detect if the user try to exit the one2many widget */ - core.bus.on('click', this, this._on_click_outside); - - this.dataset.on('dataset_changed', this, function () { - this._dataset_changed = true; - this.dataset.x2m._dirty_flag = true; - }); - this.dataset.x2m.on('load_record', this, function () { - this._dataset_changed = false; - }); - - this.on('warning', this, function(e) { // In case of editable list view, we do not want any warning which comes from the editor - if (this.editable()) { - e.stop_propagation(); - } - }); - }, - do_add_record: function () { - if (this.editable()) { - this._super.apply(this, arguments); - } else { - var self = this; - new common.SelectCreateDialog(this, { - res_model: self.x2m.field.relation, - domain: self.x2m.build_domain(), - context: self.x2m.build_context(), - title: _t("Create: ") + self.x2m.string, - initial_view: "form", - alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined, - create_function: function(data, options) { - return self.x2m.data_create(data, options); - }, - read_function: function(ids, fields, options) { - return self.x2m.data_read(ids, fields, options); - }, - parent_view: self.x2m.view, - child_name: self.x2m.name, - form_view_options: {'not_interactible_on_create':true}, - on_selected: function() { - self.x2m.reload_current_view(); - } - }).open(); - } - }, - do_activate_record: function(index, id) { - var self = this; - new common.FormViewDialog(self, { - res_model: self.x2m.field.relation, - res_id: id, - context: self.x2m.build_context(), - title: _t("Open: ") + self.x2m.string, - write_function: function(id, data, options) { - return self.x2m.data_update(id, data, options).done(function() { - self.x2m.reload_current_view(); - }); - }, - alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined, - parent_view: self.x2m.view, - child_name: self.x2m.name, - read_function: function(ids, fields, options) { - return self.x2m.data_read(ids, fields, options); - }, - form_view_options: {'not_interactible_on_create':true}, - readonly: !this.is_action_enabled('edit') || self.x2m.get("effective_readonly") - }).open(); - }, - do_button_action: function (name, id, callback) { - if (!_.isNumber(id)) { - this.do_warn(_t("Action Button"), - _t("The o2m record must be saved before an action can be used")); - return; - } - var parent_form = this.x2m.view; - var self = this; - this.save_edition().then(function () { - if (parent_form) { - return parent_form.save(); - } else { - return $.when(); - } - }).done(function () { - var ds = self.x2m.dataset; - var changed_records = _.find(ds.cache, function(record) { - return record.to_create || record.to_delete || !_.isEmpty(record.changes); - }); - if (!self.x2m.options.reload_on_button && !changed_records) { - self.handle_button(name, id, callback); - } else { - self.handle_button(name, id, function(){ - self.x2m.view.reload(); - }); - } - }); - }, - start_edition: function (record, options) { - if (!this.__focus) { - this._on_focus_one2many(); - } - return this._super(record, options); - }, - reload_content: function () { - var self = this; - if (self.__focus) { - self._on_blur_one2many(); - return this._super().then(function () { - var record_being_edited = self.records.get(self.editor.form.datarecord.id); - if (record_being_edited) { - self.start_edition(record_being_edited); - } - }); - } - return this._super(); - }, - _on_focus_one2many: function () { - if(!this.editor.is_editing()) { - return; - } - this.dataset.x2m.internal_dataset_changed = true; - this._dataset_changed = false; - this.__focus = true; - }, - _on_click_outside: function(e) { - if(this.__ignore_blur || !this.editor.is_editing()) { - return; - } - - var $target = $(e.target); - - // If click on a button, a ui-autocomplete dropdown or modal-backdrop, it is not considered as a click outside - var click_outside = ($target.closest('.ui-autocomplete,.btn,.modal-backdrop').length === 0); - - // Check if click inside the current list editable - var $o2m = $target.closest(".o_list_editable"); - if($o2m.length && $o2m[0] === this.el) { - click_outside = false; - } - - // Check if click inside a modal which is on top of the current list editable - var $modal = $target.closest(".modal"); - if($modal.length) { - var $currentModal = this.$el.closest(".modal"); - if($currentModal.length === 0 || $currentModal[0] !== $modal[0]) { - click_outside = false; - } - } - - if (click_outside) { - this._on_blur_one2many(); - } - }, - _on_blur_one2many: function() { - if(this.__ignore_blur) { - return $.when(); - } - - this.__ignore_blur = true; - this.__focus = false; - this.dataset.x2m.internal_dataset_changed = false; - - var self = this; - return this.save_edition(true).done(function () { - if (self._dataset_changed) { - self.dataset.trigger('dataset_changed'); - } - }).always(function() { - self.__ignore_blur = false; - }); - }, - _after_edit: function () { - this.editor.form.on('blurred', this, this._on_blur_one2many); - - // The form's blur thing may be jiggered during the edition setup, - // potentially leading to the x2m 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_blur_one2many); - }, - 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.x2m.get('effective_readonly')) { - this._super.apply(this, arguments); - } - } -}); - -var FieldOne2Many = FieldX2Many.extend({ - init: function() { - this._super.apply(this, arguments); - this.x2many_views = { - kanban: core.view_registry.get('one2many_kanban'), - list: One2ManyListView, - }; - }, - start: function() { - this.$el.addClass('o_form_field_one2many'); - return this._super.apply(this, arguments); - }, - commit_value: function() { - var self = this; - return this.is_loaded.then(function() { - var view = self.viewmanager.active_view; - if(view.type === "list" && view.controller.editable()) { - return self.mutex.def.then(function () { - return view.controller.save_edition(); - }); - } - return self.mutex.def; - }); - }, - is_false: function() { - return false; - }, -}); - -var Many2ManyListView = X2ManyListView.extend({ - init: function () { - this._super.apply(this, arguments); - this.options = _.extend(this.options, { - ListType: X2ManyList, - }); - this.on('edit:after', this, this.proxy('_after_edit')); - this.on('save:before cancel:before', this, this.proxy('_before_unedit')); - }, - do_add_record: function () { - var self = this; - - new common.SelectCreateDialog(this, { - res_model: this.model, - domain: new data.CompoundDomain(this.x2m.build_domain(), ["!", ["id", "in", this.x2m.dataset.ids]]), - context: this.x2m.build_context(), - title: _t("Add: ") + this.x2m.string, - alternative_form_view: this.x2m.field.views ? this.x2m.field.views.form : undefined, - no_create: this.x2m.options.no_create || !this.is_action_enabled('create'), - on_selected: function(element_ids) { - return self.x2m.data_link_multi(element_ids).then(function() { - self.x2m.reload_current_view(); - }); - } - }).open(); - }, - do_activate_record: function(index, id) { - var self = this; - var pop = new common.FormViewDialog(this, { - res_model: this.model, - res_id: id, - context: this.x2m.build_context(), - title: _t("Open: ") + this.x2m.string, - alternative_form_view: this.x2m.field.views ? this.x2m.field.views.form : undefined, - readonly: !this.is_action_enabled('edit') || self.x2m.get("effective_readonly"), - }).open(); - pop.on('write_completed', self, function () { - self.dataset.evict_record(id); - self.reload_content(); - }); - }, - do_button_action: function(name, id, callback) { - var self = this; - var _sup = _.bind(this._super, this); - if (! this.x2m.options.reload_on_button) { - return _sup(name, id, callback); - } else { - return this.x2m.view.save().then(function() { - return _sup(name, id, function() { - self.x2m.view.reload(); - }); - }); - } - }, - _after_edit: function () { - this.editor.form.on('blurred', this, this._on_blur_many2many); - }, - _before_unedit: function () { - this.editor.form.off('blurred', this, this._on_blur_many2many); - }, - _on_blur_many2many: function() { - return this.save_edition().done(function () { - if (self._dataset_changed) { - self.dataset.trigger('dataset_changed'); - } - }); - }, - -}); - -var FieldMany2Many = FieldX2Many.extend({ - init: function() { - this._super.apply(this, arguments); - this.x2many_views = { - list: Many2ManyListView, - kanban: core.view_registry.get('many2many_kanban'), - }; - }, - start: function() { - this.$el.addClass('o_form_field_many2many'); - return this._super.apply(this, arguments); - } -}); - -var FieldMany2ManyKanban = FieldMany2Many.extend({ - default_view: 'kanban', - init: function() { - this._super.apply(this, arguments); - this.view_options = _.extend({}, this.view_options, { - 'create_text': _t("Add"), - }); - } -}); - -var FieldMany2ManyTags = AbstractManyField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, { - className: "o_form_field_many2manytags", - tag_template: "FieldMany2ManyTag", - - events: { - 'click .o_delete': function(e) { - this.remove_id($(e.target).parent().data('id')); - }, - 'click .badge': 'open_color_picker', - 'mousedown .o_colorpicker span': 'update_color', - 'focusout .o_colorpicker': 'close_color_picker', - }, - - init: function(field_manager, node) { - this._super(field_manager, node); - common.CompletionFieldMixin.init.call(this); - this.set({"value": []}); - }, - willStart: function () { - var self = this; - return this.dataset.call('fields_get', []).then(function(fields) { - self.fields = fields; - }); - }, - commit_value: function() { - this.dataset.cancel_read(); - return this._super(); - }, - initialize_content: function() { - if(!this.get("effective_readonly")) { - this.many2one = new FieldMany2One(this.field_manager, this.node); - this.many2one.options.no_open = true; - this.many2one.on('changed_value', this, function() { - var newValue = this.many2one.get('value'); - if(newValue) { - this.add_id(newValue); - this.many2one.set({'value': false}); - } - }); - - this.many2one.prependTo(this.$el); - - var self = this; - this.many2one.$('input').on('keydown', function(e) { - if(!$(e.target).val() && e.which === 8) { - var $badges = self.$('.badge'); - if($badges.length) { - self.remove_id($badges.last().data('id')); - } - } - }); - this.many2one.get_search_blacklist = function () { - return self.get('value'); - }; - } - }, - destroy_content: function() { - if(this.many2one) { - this.many2one.destroy(); - this.many2one = undefined; - } - }, - get_render_data: function(ids){ - this.dataset.cancel_read(); - var fields = this.fields.color ? ['display_name', 'name', 'color'] : ['display_name', 'name']; // TODO master: remove useless 'name' - return this.dataset.read_ids(ids, fields); - }, - render_tag: function(data) { - this.$('.badge').remove(); - this.$el.prepend(QWeb.render(this.tag_template, {elements: data, readonly: this.get('effective_readonly')})); - }, - render_value: function() { - var self = this; - var values = this.get("value"); - var handle_names = function(_data) { - _.each(_data, function(el) { - el.display_name = el.display_name.trim() ? _.str.escapeHTML(el.display_name) : data.noDisplayContent; - }); - self.render_tag(_data); - }; - if (!values || values.length > 0) { - return this.alive(this.get_render_data(values)).done(handle_names); - } else { - handle_names([]); - } - }, - add_id: function(id) { - this.set({'value': _.uniq(this.get('value').concat([id]))}); - }, - remove_id: function(id) { - this.set({'value': _.without(this.get("value"), id)}); - }, - focus: function () { - if(!this.get("effective_readonly")) { - return this.many2one.focus(); - } - return false; - }, - set_dimensions: function(height, width) { - if(this.many2one) { - this.many2one.$el.css('height', 'auto'); - } - this.$el.css({ - width: width, - minHeight: height, - }); - if(this.many2one) { - this.many2one.$el.css('height', this.$el.height()); - } - }, - open_color_picker: function(ev){ - - if (this.fields.color) { - this.$color_picker = $(QWeb.render('FieldMany2ManyTag.colorpicker', { - 'widget': this, - 'tag_id': $(ev.currentTarget).data('id'), - })); - - $(ev.currentTarget).append(this.$color_picker); - this.$color_picker.dropdown('toggle'); - this.$color_picker.attr("tabindex", 1).focus(); - } - }, - close_color_picker: function(){ - this.$color_picker.remove(); - }, - update_color: function(ev) { - ev.preventDefault(); - - var color = $(ev.currentTarget).data('color'); - var id = $(ev.currentTarget).data('id'); - - var self = this; - this.dataset.call('write', [id, {'color': color}]).done(function(){ - self.dataset.cache[id].from_read = {}; - self.dataset.evict_record(id); - var tag = self.$el.find("span.badge[data-id='" + id + "']"); - var old_color = tag.data('color'); - tag.removeClass('o_tag_color_' + old_color); - tag.data('color', color); - tag.addClass('o_tag_color_' + color); - }); - }, -}); - -/** - * 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 = AbstractManyField.extend(common.ReinitializeFieldMixin, { - template: "FieldBinaryFileUploader", - events: { - 'click .o_attach': function(e) { - this.$('.o_form_input_file').click(); - }, - 'change .o_form_input_file': function(event) { - event.stopPropagation(); - var files = event.target.files, - attachments = this.get('value'); - - if(this.node.attrs.blockui){ - framework.blockUI(); - } - _.each(files, function(file){ - var attachment = _.findWhere(_.values(this.data), {filename: file.name}); - if(attachment && !attachment.no_unlink){ - this.ds_file.unlink([attachment.id]); - attachments = _.without(attachments, attachment.id); - this.data = _.omit(this.data, attachment.id); - } - this.files_uploading.push(file); - }.bind(this)); - this.set({value: attachments}); - this.$('form.o_form_binary_form').submit(); - this.$(".oe_fileupload").hide(); - this.render_value(); - }, - 'click .oe_delete': function(e) { - e.preventDefault(); - e.stopPropagation(); - - var file_id = $(e.currentTarget).data("id"); - if(file_id) { - var files = _.without(this.get('value'), file_id); - if(!this.data[file_id].no_unlink) { - this.ds_file.unlink([file_id]); - this.data = _.omit(this.data, file_id); - } - this.set({'value': files}); - } - }, - }, - init: function(field_manager, node) { - this._super.apply(this, arguments); - 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.files_uploading = []; - 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)); - }, - get_file_url: function(attachment) { - return '/web/content/' + attachment.id + '?download=true'; - }, - read_name_values : function() { - var self = this; - // don't reset know values - var ids = this.get('value'); - var _value = _.filter(ids, function(id) { return self.data[id] === undefined; }); - // send request for get_name - if(_value.length) { - return this.ds_file.call('read', [_value, ['id', 'name', 'datas_fname', 'mimetype']]) - .then(process_data); - } else { - return $.when(ids); - } - - function process_data(datas) { - _.each(datas, function(data) { - data.no_unlink = true; - data.url = self.get_file_url(data); - self.data[data.id] = data; - }); - return ids; - } - }, - render_value: function() { - var self = this; - this.read_name_values().then(function (ids) { - self.$('.oe_placeholder_files, .oe_attachments') - .replaceWith($(QWeb.render('FieldBinaryFileUploader.files', {'widget': self, 'values': ids}))); - self.$(".oe_fileupload").show(); - - // display image thumbnail - self.$(".o_image[data-mimetype^='image']").each(function () { - var $img = $(this); - if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) { - $img.css('background-image', "url('" + $img.data('src') + "')"); - } - }); - }); - }, - on_file_loaded: function(e, result) { - var attachments = this.get('value'), - files = Array.prototype.slice.call(arguments, 1); - this.files_uploading = []; // files has been uploaded clear uploading - - if(this.node.attrs.blockui) { // unblock UI - framework.unblockUI(); - } - var upload_error = _.filter(files, function(attachment) {return attachment.error;}); - if (upload_error.length) { - this.do_warn(_t('Uploading Error'), upload_error[0].error); - } - _.each(files, function(file){ - if(!file.error){ - attachments.push(file.id); - file.url = this.get_file_url(file); - this.data[file.id] = file; - } - }.bind(this)); - this.set({value: _.clone(attachments)}); - this.render_value(); - }, -}); - -/* - 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 = AbstractManyField.extend(common.ReinitializeFieldMixin, { - className: "oe_form_many2many_checkboxes", - init: function() { - this._super.apply(this, arguments); - 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.$el.html(QWeb.render("FieldMany2ManyCheckBoxes", {widget: this})); - 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:checked").map(function() { return +$(this).data("record-id"); }).get(); - if (!_.isEqual(new_value, this.get("value"))) { - this.internal_set_value(new_value); - } - }, - is_false: function() { - return false; - }, -}); - -core.form_widget_registry - .add('many2one', FieldMany2One) - .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); - -core.one2many_view_registry - .add('list', One2ManyListView); - -return { - FieldMany2ManyTags: FieldMany2ManyTags, - AbstractManyField: AbstractManyField, - FieldMany2One: FieldMany2One, -}; - -}); diff --git a/addons/web/static/src/js/views/form_upgrade_widgets.js b/addons/web/static/src/js/views/form_upgrade_widgets.js deleted file mode 100644 index 57f7ec858a3..00000000000 --- a/addons/web/static/src/js/views/form_upgrade_widgets.js +++ /dev/null @@ -1,101 +0,0 @@ -odoo.define('web.form_upgrade_widgets', function (require) { -"use strict"; - -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var form_widgets = require('web.form_widgets'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); - -var _t = core._t; -var QWeb = core.qweb; - -/** - * This widget is intended to be used in config settings. - * When checked, an upgrade popup is showed to the user. - */ -var AbstractFieldUpgrade = { - events: { - 'click input': 'on_click_input', - }, - - start: function() { - this._super.apply(this, arguments); - this.get_enterprise_label().after($("<span>", { - text: "Enterprise", - 'class': "label label-primary oe_inline" - })); - }, - - open_dialog: function() { - var message = $(QWeb.render('EnterpriseUpgrade')); - - var buttons = [ - { - text: _t("Upgrade now"), - classes: 'btn-primary', - close: true, - click: this.confirm_upgrade, - }, - { - text: _t("Cancel"), - close: true, - }, - ]; - - return new Dialog(this, { - size: 'medium', - buttons: buttons, - $content: $('<div>', { - html: message, - }), - title: _t("Odoo Enterprise"), - }).open(); - }, - - confirm_upgrade: function() { - new Model("res.users").call("search_count", [[["share", "=", false]]]).then(function(data) { - framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data); - }); - }, - - get_enterprise_label: function() {}, - on_click_input: function() {}, -}; - -var UpgradeBoolean = form_widgets.FieldBoolean.extend(AbstractFieldUpgrade, { - template: "FieldUpgradeBoolean", - - get_enterprise_label: function() { - return this.$label; - }, - - on_click_input: function() { - if(this.$checkbox.prop("checked")) { - this.open_dialog().on('closed', this, function() { - this.$checkbox.prop("checked", false); - }); - } - }, -}); - -var UpgradeRadio = form_widgets.FieldRadio.extend(AbstractFieldUpgrade, { - get_enterprise_label: function() { - // override the margin:0px - this.$('label').addClass('mr4'); - return this.$('label').last(); - }, - on_click_input: function(event) { - if($(event.target).val() === "1") { - this.open_dialog().on('closed', this, function() { - this.$('input').first().prop("checked", true); - }); - } - }, -}); - -core.form_widget_registry - .add('upgrade_boolean', UpgradeBoolean) - .add('upgrade_radio', UpgradeRadio); - -}); diff --git a/addons/web/static/src/js/views/form_view.js b/addons/web/static/src/js/views/form_view.js deleted file mode 100644 index 0471f6d3156..00000000000 --- a/addons/web/static/src/js/views/form_view.js +++ /dev/null @@ -1,1653 +0,0 @@ -odoo.define('web.FormView', function (require) { -"use strict"; - -var common = require('web.form_common'); -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var data = require('web.data'); -var Dialog = require('web.Dialog'); -var FormRenderingEngine = require('web.FormRenderingEngine'); -var Model = require('web.DataModel'); -var Pager = require('web.Pager'); -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; - -/** - * Properties: - * - actual_mode: always "view", "edit" or "create". Read-only property. Determines - * the mode used by the view. - */ -var FormView = View.extend(common.FieldManagerMixin, { - className: "o_form_view", - defaults: _.extend({}, View.prototype.defaults, { - not_interactible_on_create: false, - initial_mode: "view", - disable_autofocus: false, - footer_to_buttons: false, - }), - display_name: _lt('Form'), - icon: 'fa-edit', - multi_record: false, - // Indicates that this view is not searchable, and thus that no search view should be displayed. - searchable: false, - - /** - * Called each time the form view is attached into the DOM - */ - on_attach_callback: function() { - this.trigger('attached'); - this.autofocus(); - }, - /** - * Called each time the form view is detached from the DOM - */ - on_detach_callback: function() { - this.trigger('detached'); - }, - init: function() { - var self = this; - this._super.apply(this, arguments); - this.fields = {}; - this.fields_order = []; - this.datarecord = {}; - 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 = []; - this.is_initialized = $.Deferred(); - this.record_loaded = $.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); - this.set({actual_mode: this.options.initial_mode}); - this.has_been_loaded.done(function() { - self.on("change:actual_mode", self, self.toggle_buttons); - self.on("change:actual_mode", self, self.toggle_sidebar); - }); - self.on("load_record", self, self.load_record); - core.bus.on('clear_uncommitted_changes', this, function(chain_callbacks) { - var self = this; - chain_callbacks(function() { - return self.can_be_discarded(); - }); - }); - }, - start: function() { - if (this.$pager) { - this.$pager.off(); - } - var self = this; - - 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(this.fields_view); - this.rendering_engine.render_to(this.$el); - - this.$el.on('mousedown.formBlur', function () { - self.__clicked_inside = true; - }); - - this.has_been_loaded.resolve(); - - // Add bounce effect on button 'Edit' when click on readonly page view. - this.$(".oe_title,.o_group").on('click', function (e) { - if(self.get("actual_mode") === "view" && self.$buttons && !$(e.target).is('[data-toggle]')) { - self.$buttons.find(".o_form_button_edit").openerpBounce(); - core.bus.trigger('click', e); - } - }); - return this._super(); - }, - destroy: function() { - _.each(this.get_widgets(), function(w) { - w.off('focused blurred'); - w.destroy(); - }); - if (this.$el) { - this.$el.off('.formBlur'); - } - this._super(); - }, - /** - * Render the buttons according to the FormView.buttons template and add listeners on it. - * Set this.$buttons with the produced jQuery element - * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted - * $node may be undefined, in which case the FormView inserts them into this.options.$buttons - * if it exists - */ - render_buttons: function($node) { - this.$buttons = $('<div/>'); - - var $footer = this.$('footer'); - if (this.options.action_buttons !== false || this.options.footer_to_buttons && $footer.children().length === 0) { - this.$buttons.append(QWeb.render("FormView.buttons", {'widget': this})); - } - if (this.options.footer_to_buttons) { - $footer.appendTo(this.$buttons); - } - - // Show or hide the buttons according to the view mode - this.toggle_buttons(); - this.$buttons.on('click', '.o_form_button_create', this.on_button_create); - this.$buttons.on('click', '.o_form_button_edit', this.on_button_edit); - this.$buttons.on('click', '.o_form_button_save', this.on_button_save); - this.$buttons.on('click', '.o_form_button_cancel', this.on_button_cancel); - - this.$buttons.appendTo($node); - }, - /** - * Instantiate and render the sidebar if a sidebar is requested - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - **/ - render_sidebar: function($node) { - if (!this.sidebar && this.options.sidebar) { - this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); - if (this.fields_view.toolbar) { - this.sidebar.add_toolbar(this.fields_view.toolbar); - } - this.sidebar.add_items('other', _.compact([ - this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.on_button_delete }, - this.is_action_enabled('create') && { label: _t('Duplicate'), callback: this.on_button_duplicate } - ])); - - this.sidebar.appendTo($node); - - // Show or hide the sidebar according to the view mode - this.toggle_sidebar(); - } - }, - /** - * Instantiate and render the pager and add listeners on it. - * Set this.pager - * @param {jQuery} [$node] a jQuery node where the pager should be inserted - * $node may be undefined, in which case the FormView inserts the pager into this.options.$pager - */ - render_pager: function($node) { - if (this.options.pager) { - var self = this; - var options = { - validate: _.bind(this.can_be_discarded, this), - }; - - this.pager = new Pager(this, this.dataset.ids.length, this.dataset.index + 1, 1, options); - this.pager.on('pager_changed', this, function (new_state) { - this.pager.disable(); - this.dataset.index = new_state.current_min - 1; - this.trigger('pager_action_executed'); - $.when(this.reload()).then(function () { - self.pager.enable(); - }); - }); - - this.pager.appendTo($node = $node || this.options.$pager); - - // Hide the pager in create mode - if (this.get("actual_mode") === "create") { - this.pager.do_hide(); - } - } - }, - /** - * Show or hide the buttons according to the actual_mode - */ - toggle_buttons: function() { - var view_mode = this.get("actual_mode") === "view"; - if (this.$buttons) { - this.$buttons.find('.o_form_buttons_view').toggle(view_mode); - this.$buttons.find('.o_form_buttons_edit').toggle(!view_mode); - } - }, - /** - * Show or hide the sidebar according to the actual_mode - */ - toggle_sidebar: function() { - if (this.sidebar) { - this.sidebar.do_toggle(this.get("actual_mode") === "view"); - } - }, - update_pager: function() { - if (this.pager) { - // Hide the pager in create mode - if (this.get("actual_mode") === "create") { - this.pager.do_hide(); - } else { - this.pager.update_state({size: this.dataset.ids.length, current_min: this.dataset.index + 1}); - } - } - }, - 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 {Boolean} [options.mode=undefined] If specified, switch the form to specified mode. Can be "edit" or "view". - * @param {Boolean} [options.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 || {}; - this.$el.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'); - fields.push('__last_update'); - return self.dataset.read_index(fields, { - context: { 'bin_size': 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); - core.bus.trigger('form_view_shown', self); - }); - }, - 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.record_loaded = $.Deferred(); - _(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); - }); - this._actualize_mode(); // call after updating the fields as it may trigger a re-rendering - this.set({ 'title' : record.id ? record.display_name : _t("New") }); - this.update_pager(); // the mode must be actualized before updating the pager - return $.when.apply(null, set_values).then(function() { - if (!record.id) { - // trigger onchange for new record after x2many with non-embedded views are loaded - var fields_loaded = _.pluck(self.fields, 'is_loaded'); - $.when.apply(null, fields_loaded).done(function() { - self.do_onchange(null); - }); - } - self.on_form_changed(); - self.rendering_engine.init_fields().then(function() { - self.is_initialized.resolve(); - self.record_loaded.resolve(); - 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.removeClass('oe_form_dirty'); - }); - }); - }, - /** - * 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', _.clone(r)); - }); - } - return $.when().then(this.trigger.bind(this, 'load_record', {})); - }, - on_form_changed: function() { - this.trigger("view_content_has_changed"); - }, - do_notify_change: function() { - this.$el.addClass('oe_form_dirty'); - }, - _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); - - // we get the list of first-level fields of x2many firstly by - // getting them from the field embedded views, then if no embedded - // view is present for a loaded view, we get them from the default - // view that has been loaded - - // gather embedded view objects - var views = _.clone(field.field.views); - // also gather default view objects - if (field.viewmanager) { - _.each(field.viewmanager.views, function(view, view_type) { - // add default view if it was not embedded and it is loaded - if (views[view_type] === undefined && view.controller) { - views[view_type] = view.controller.fields_view; - } - }); - } - _.each(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; - if (self._onchange_specs === undefined) { - self._build_onchange_specs(); - } - 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(self.dataset.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; - }); - - var def = $.when(!_.isEmpty(result.value) && this._internal_set_values(result.value)); - - // FIXME XXX a list of warnings? - if (!_.isEmpty(result.warning)) { - this.warning_displayed = true; - var dialog = new Dialog(this, { - size: 'medium', - title:result.warning.title, - $content: QWeb.render("CrashManager.warning", result.warning) - }); - dialog.open(); - dialog.on('closed', this, function () { - this.warning_displayed = false; - }); - } - - return def; - } 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 onchanges_mutex () {return self.onchanges_mutex.def;} - function iterate() { - var mutex = new utils.Mutex(); - mutex.exec(onchanges_mutex); - _.each(self.fields, function(field) { - mutex.exec(function(){ - return field.commit_value(); - }); - mutex.exec(onchanges_mutex); - }); - - return 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"); - this.trigger('to_view_mode'); - }, - /** - * 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"); - this.trigger('to_edit_mode'); - }, - /** - * 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"; - } - - var viewMode = (mode === "view"); - this.$el.toggleClass('o_form_readonly', viewMode).toggleClass('o_form_editable', !viewMode); - - this.render_value_defs = []; - this.set({actual_mode: mode}); - - if(!viewMode) { - 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; - } - } - } - } - }, - disable_button: function () { - this.$('.oe_form_buttons,.o_statusbar_buttons').add(this.$buttons).find('button').addClass('o_disabled').prop('disabled', true); - this.is_disabled = true; - }, - enable_button: function () { - this.$('.oe_form_buttons,.o_statusbar_buttons').add(this.$buttons).find('button.o_disabled').removeClass('o_disabled').prop('disabled', false); - this.is_disabled = false; - }, - on_button_save: function() { - var self = this; - if (this.is_disabled) { - return; - } - this.disable_button(); - return this.save().then(function(result) { - self.trigger("save", result); - return self.reload().then(function() { - self.to_view_mode(); - core.bus.trigger('form_view_saved', self); - }).always(function() { - self.enable_button(); - }); - }).fail(function(){ - self.enable_button(); - }); - }, - on_button_cancel: function() { - var self = this; - this.can_be_discarded().then(function() { - if (self.get('actual_mode') === 'create') { - self.trigger('history_back'); - } else { - self.to_view_mode(); - $.when.apply(null, self.render_value_defs).then(function(){ - self.trigger('load_record', self.datarecord); - }); - } - }); - this.trigger('on_button_cancel'); - return false; - }, - on_button_new: function() { - return $.when(this.has_been_loaded) - .then(this.can_be_discarded.bind(this)) - .then(this.load_defaults.bind(this)); - }, - 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.reload(); - self.update_pager(); - } else { - self.do_action('history_back'); - } - def.resolve(); - }); - } else { - utils.async_when().done(function () { - def.reject(); - }); - } - }); - return def.promise(); - }, - can_be_discarded: function(message) { - if (!this.$el.is('.oe_form_dirty')) { - return $.Deferred().resolve(); - } - - message = message || _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?"); - - var self = this; - var def = $.Deferred(); - var options = { - title: _t("Warning"), - confirm_callback: function() { - self.$el.removeClass('oe_form_dirty'); - this.on('closed', null, function() { // 'this' is the dialog widget - def.resolve(); - }); - }, - cancel_callback: function() { - def.reject(); - }, - }; - var dialog = Dialog.confirm(this, message, options); - dialog.$modal.on('hidden.bs.modal', function() { - def.reject(); - }); - return def; - }, - /** - * 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; - var def_process_save = $.Deferred(); - try { - var form_invalid = false, - values = {}, - first_invalid_field = null, - readonly_values = {}, - deferred = []; - - $.when.apply($, deferred).always(function () { - - _.each(self.fields, function (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); - } - } - - }); - - // Heuristic to assign a proper sequence number for new records that - // are added in a dataset containing other lines with existing sequence numbers - if (!self.datarecord.id && self.fields.sequence && - !_.has(values, 'sequence') && !_.isEmpty(self.dataset.cache)) { - // Find current max or min sequence (editable top/bottom) - var current = _[prepend_on_create ? "min" : "max"]( - _.map(self.dataset.cache, function(o){return o.values.sequence}) - ); - values['sequence'] = prepend_on_create ? current - 1 : current + 1; - } - if (form_invalid) { - self.set({'display_invalid_fields': true}); - first_invalid_field.focus(); - self.on_invalid(); - def_process_save.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) { - self.display_translation_alert(values); - 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) { - self.display_translation_alert(values); - return self.record_saved(r); - }, null); - } - save_deferral.then(function(result) { - def_process_save.resolve(result); - }).fail(function() { - def_process_save.reject(); - }); - } - }); - } catch (e) { - console.error(e); - return def_process_save.reject(); - } - return def_process_save; - }, - 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.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'); - fields.push('__last_update'); - return self.dataset.read_index(fields, - { - context: { 'bin_size': 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()); - }, - /* - * Show a warning message if the user modified a translated field. For each - * field, the notification provides a link to edit the field's translations. - */ - display_translation_alert: function(values) { - if (_t.database.multi_lang) { - var alert_fields = _.filter(this.translatable_fields, function(field) { - return _.has(values, field.name) - }); - if (alert_fields.length) { - var $content = $(QWeb.render('translation-alert', { - fields: alert_fields, - lang: _t.database.parameters.name - })); - // bind click event on "Update translations" links - $content.find('.oe_field_translate').click(function(ev) { - ev.preventDefault(); - _.find(alert_fields, {'name': ev.target.name}).on_translate(); - }); - // show notification - this.once('to_view_mode', this, function () { - this.notify_in_form($content); - }); - } - } - }, - /** - * Add a notification box inside the form. The notification automatically - * goes away (by default when switching to edit mode or changing the form - * content.) - * - * @param {jQuery} $content - * @param {Object} [options] - * @param {String} [options.type], one of: "success", "info", "warning", "danger" - * @param {String} [options.events], when to remove notification - */ - notify_in_form: function ($content, options) { - options = options || {}; - var type = options.type || 'info'; - var $box = $(QWeb.render('notification-box', {type: type})); - $box.append($content); - // create handler to remove notification box - var events = options.events || 'to_edit_mode view_content_has_changed'; - this.once(events, null, function() { - $box.remove(); - }); - // add content inside notification box on top of the sheet/form - var $target = this.$('.o_form_sheet_bg').length ? this.$('.o_form_sheet_bg') : this.$el; - $target.prepend($box); - }, - open_defaults_dialog: function () { - var self = this; - var display = function (field, value) { - var FieldSelection = core.form_widget_registry.get('selection'); - var 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"), - buttons: [ - {text: _t("Close"), close: true}, - {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('o_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.model, - field_to_set, - self.fields[field_to_set].get_value(), - all_users, - true, - condition || false - ]).done(function () { d.close(); }); - }} - ] - }); - d.args = { - fields: fields, - conditions: conditions - }; - 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) { - var fields = this.fields; - if (!fields.id) { - fields = _.extend({id: {value: this.datarecord.id || false}}, fields); - } - return data.compute_domain(expression, 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()); - }, -}); - -core.view_registry.add('form', FormView); - -return FormView; - -}); - - -odoo.define('web.FormRenderingEngine', function (require) { -"use strict"; - -var common = require('web.form_common'); -var core = require('web.core'); -var utils = require('web.utils'); - -var _t = core._t; -var QWeb = core.qweb; - -/** - * Default rendering engine for the form view. - * - * It is necessary to set the view using set_view() before usage. - */ -return core.Class.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 $(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.contents().appendTo(this.$target); - this.handle_common_properties(this.$target, this.$form); - - 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') || self.fvg.fields[name].type )); - } - 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('.o_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('o_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; - - // Processes a group containing nested groups - // Renders a div for each nested group, into a div that is the outer group - // The nested groups's width is a percentage according to col and colspan attributes - var _process_outer_group = function(col) { - var $new_group = self.render_element('FormRenderingOuterGroup', $group.getAttributes()); - $group.children().each(function() { - var $child = $(this); - var tagName = $child[0].tagName.toLowerCase(); - var colspan = parseInt($child.attr('colspan') || 1, 10); - - if (tagName === 'newline') { - $new_group.append($('<br>')); // Skip to the next line - return; - } - // compute child's classname from col and colspan attributes - $child.addClass('o_group_col_' + Math.min(Math.floor(colspan/col*12), 12)); - $new_group.append($child); - - children.push($child[0]); - }); - return $new_group; - }; - // Processes a group containing no nested group - // Renders an HTML table - var _process_inner_group = function(col) { - var $new_group = self.render_element('FormRenderingInnerGroup', $group.getAttributes()); - $new_group.find('td').attr('colspan', col); // Group's title spans all column - var row_col = col; - var $row = null; - - $group.children().each(function() { - var $child = $(this); - var tagName = $child[0].tagName.toLowerCase(); - var colspan = parseInt($child.attr('colspan') || 1, 10); - - if (tagName === 'newline') { - $row = null; // Start with a new row - return; - } - row_col -= colspan; - if (!$row || row_col < 0) { // Append a new row to the group - $row = $('<tr>').appendTo($new_group); - row_col = col-colspan; - } - $('<td>').attr('colspan', colspan).append($child).appendTo($row); - - children.push($child[0]); - }); - - // Some kind of an hack - // Add width on table's cell to avoid automatic layout of table which - // makes first col to take full width available (typically, first col contains label) - $new_group.find('tr').each(function() { - var $tr = $(this); - var nonlabel_width = 100/(col - $tr.find('td > label:first-child').length); - $tr.find('td').each(function() { - var $td = $(this); - var $child = $td.children(':first'); - if ($child.is("label")) { - $td.addClass("o_td_label"); - } else { - $td.css("width", (nonlabel_width*parseInt($td.attr('colspan')))+"%"); - } - }); - - }); - return $new_group; - }; - - // Preprocess field children - $group.children('field').each(function() { - self.preprocess_field($(this)); - }); - - // Process group - var nested_groups = $group.children('group').length; // Detect possible nested groups - var children = []; - var col = parseInt($group.attr('col') || 2, 10); - var $new_group; - if (nested_groups) { - $new_group = _process_outer_group(col); - } else { - $new_group = _process_inner_group(col); - } - this.handle_common_properties($new_group, $group); - - // Process group's children - $group.before($new_group).remove(); - _.each(children, function(el) { - self.process($(el)); - }); - - 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(.o_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('o_field_input_') : undefined, - }; - 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)); - } -}); - -}); diff --git a/addons/web/static/src/js/views/form_widgets.js b/addons/web/static/src/js/views/form_widgets.js deleted file mode 100644 index b644a79ba22..00000000000 --- a/addons/web/static/src/js/views/form_widgets.js +++ /dev/null @@ -1,1803 +0,0 @@ -odoo.define('web.form_widgets', function (require) { -"use strict"; - -var ajax = require('web.ajax'); -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var data = require('web.data'); -var datepicker = require('web.datepicker'); -var dom_utils = require('web.dom_utils'); -var Priority = require('web.Priority'); -var ProgressBar = require('web.ProgressBar'); -var Dialog = require('web.Dialog'); -var DomainSelector = require("web.DomainSelector"); -var DomainSelectorDialog = require("web.DomainSelectorDialog"); -var common = require('web.form_common'); -var formats = require('web.formats'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); -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._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) { - this.fa_icon = this.node.attrs.icon.indexOf('fa-') === 0; - } - }, - 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; - if (this.view.is_disabled) { - return; - } - this.force_disabled = true; - this.check_disable(); - this.view.disable_button(); - this.execute_action().always(function() { - self.view.enable_button(); - self.force_disabled = false; - self.check_disable(); - if (self.$el.hasClass('o_wow')) { - self.show_wow(); - } - }); - }, - execute_action: function() { - var self = this; - var exec_action = function() { - if (self.node.attrs.confirm) { - var def = $.Deferred(); - Dialog.confirm(self, self.node.attrs.confirm, { confirm_callback: self.on_confirmed }) - .on("closed", 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(); - } - }).fail(function () { - 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' : ''); - }, - show_wow: function() { - var class_to_add = 'o_wow_thumbs'; - if (Math.random() > 0.9) { - var other_classes = ['o_wow_peace', 'o_wow_heart']; - class_to_add = other_classes[Math.floor(Math.random()*other_classes.length)]; - } - - var $body = $('body'); - $body.addClass(class_to_add); - setTimeout(function() { - $body.removeClass(class_to_add); - }, 1000); - } -}); - -var FieldChar = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: 'FieldChar', - events: { - 'change': '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() { - if(!this.get('effective_readonly') && !this.$input) { - this.$input = this.$el; - } - this.setupFocus(this.$el); - }, - destroy_content: function() { - this.$input = undefined; - }, - store_dom_value: function () { - if (this.$input && 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.$input) { - this.$input.val(show_value); - } else { - if (this.password) { - show_value = new Array(show_value.length + 1).join('*'); - } - this.$el.text(show_value); - } - }, - is_syntax_valid: function() { - if (this.$input) { - try { - this.parse_value(this.$input.val(), ''); - } 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() { - if (this.$input) { - return this.$input.focus(); - } - return false; - }, -}); - -var KanbanSelection = common.AbstractField.extend({ - template: "FormSelection", - events: { - 'click a': function(e) { - e.preventDefault(); - }, - 'mouseup a': function(e) { - e.stopPropagation(); - }, - 'click li': 'set_kanban_selection' - }, - start: function () { - // hook on form view content changed: recompute the states, because it may be related to the current stage - this.view.on('view_content_has_changed', this, function () { - this.render_value(); - }); - return this._super(); - }, - prepare_dropdown_selection: function() { - var self = this; - var _data = []; - var current_stage_id = self.view.datarecord.stage_id && self.view.datarecord.stage_id[0]; - var stage_data = { - id: current_stage_id, - legend_normal: self.view.datarecord.legend_normal || undefined, - legend_blocked : self.view.datarecord.legend_blocked || undefined, - legend_done: self.view.datarecord.legend_done || undefined, - }; - _.map(self.field.selection || [], function(selection_item) { - var value = { - 'name': selection_item[0], - 'tooltip': selection_item[1], - }; - if (selection_item[0] === 'normal') { - value.state_name = stage_data.legend_normal ? stage_data.legend_normal : selection_item[1]; - } else if (selection_item[0] === 'done') { - value.state_class = 'oe_kanban_status_green'; - value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1]; - } else { - value.state_class = 'oe_kanban_status_red'; - value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1]; - } - _data.push(value); - }); - return _data; - }, - render_value: function() { - this._super(); - this.states = this.prepare_dropdown_selection(); - var self = this; - - // Adapt "FormSelection" - var current_state = _.find(this.states, function(state) { - return state.name === self.get('value'); - }); - this.$('.oe_kanban_status') - .removeClass('oe_kanban_status_red oe_kanban_status_green') - .addClass(current_state.state_class); - - // Render "FormSelection.Items" and move it into "FormSelection" - var $items = $(QWeb.render('FormSelection.items', { - states: _.without(this.states, current_state) - })); - var $dropdown = this.$el.find('.dropdown-menu'); - $dropdown.children().remove(); // remove old items - $items.appendTo($dropdown); - }, - /* 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; - ev.preventDefault(); - 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 FieldPriority = common.AbstractField.extend({ - events: { - 'mouseup': function(e) { - e.stopPropagation(); - }, - }, - start: function() { - this.priority = new Priority(this, { - readonly: this.get('readonly'), - value: this.get('value'), - values: this.field.selection || [], - }); - - this.priority.on('update', this, function(update) { - /* 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. */ - var view = this.view; - if(view.get('actual_mode') === 'view') { - var write_values = {}; - write_values[this.name] = update.value; - view.dataset._model.call('write', [ - [view.datarecord.id], - write_values, - view.dataset.get_context() - ]).done(function() { - view.reload(); - }); - } else { - this.set_value(update.value); - } - }); - - this.on('change:readonly', this, function() { - this.priority.readonly = this.get('readonly'); - var $div = $('<div/>').insertAfter(this.$el); - this.priority.replace($div); - this.setElement(this.priority.$el); - }); - - var self = this; - return $.when(this._super(), this.priority.appendTo('<div>').then(function() { - self.priority.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.priority.$el); - })); - }, - is_false: function() { - return false; - }, - render_value: function() { - this.priority.set_value(this.get('value')); - }, -}); - -var FieldID = FieldChar.extend({ - process_modifiers: function () { - this._super(); - this.set({ readonly: true }); - }, -}); - -var FieldEmail = FieldChar.extend({ - template: 'FieldEmail', - prefix: 'mailto', - init: function() { - this._super.apply(this, arguments); - this.clickable = true; - }, - render_value: function() { - this._super(); - if (this.get("effective_readonly") && this.clickable) { - this.$el.attr('href', this.prefix + ':' + this.get('value')); - } - } -}); - -var FieldUrl = FieldEmail.extend({ - render_value: function() { - this._super(); - if(this.get("effective_readonly")) { - 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.attr('href', tmp).text(text); - } - } -}); - -var FieldFloat = FieldChar.extend({ - 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; - } - }, - initialize_content: function() { - this._super(); - this.$el.addClass('o_form_field_number'); - }, - set_value: function(value_) { - if (value_ === false || value_ === undefined) { - value_ = 0; // As in GTK client, floats default to 0 - } - if (this.digits !== undefined && this.digits.length === 2) { - value_ = utils.round_decimals(value_, this.digits[1]); - } - this._super(value_); - } -}); - -/// The "Domain" field allows the user to construct a technical-prefix domain thanks to -/// a tree-like interface and see the selected records in real time. -/// In debug mode, an input is also there to be able to enter the prefix char domain -/// directly (or to build advanced domains the tree-like interface does not allow to). -var FieldDomain = common.AbstractField.extend(common.ReinitializeFieldMixin).extend({ - template: "FieldDomain", - events: { - "click .o_domain_show_selection_button": function (e) { - e.preventDefault(); - this._showSelection(); - }, - "click .o_form_field_domain_dialog_button": function (e) { - e.preventDefault(); - this.openDomainDialog(); - }, - }, - custom_events: { - "domain_changed": function (e) { - if (this.options.in_dialog) return; - this.set_value(this.domainSelector.getDomain(), true); - }, - "domain_selected": function (e) { - this.set_value(e.data.domain); - }, - }, - init: function () { - this._super.apply(this, arguments); - - this.valid = true; - this.debug = session.debug; - this.options = _.defaults(this.options || {}, { - in_dialog: false, - model: undefined, // this option is mandatory ! - fs_filters: {}, // Field selector filters (to only show a subset of available fields @see FieldSelector) - }); - }, - start: function() { - this.model = _get_model.call(this); // TODO get the model another way ? - this.field_manager.on("view_content_has_changed", this, function () { - var currentModel = this.model; - this.model = _get_model.call(this); - if (currentModel !== this.model) { - this.render_value(); - } - }); - - return this._super.apply(this, arguments); - - function _get_model() { - if (this.field_manager.fields[this.options.model]) { - return this.field_manager.get_field_value(this.options.model); - } - return this.options.model; - } - }, - initialize_content: function () { - this._super.apply(this, arguments); - this.$panel = this.$(".o_form_field_domain_panel"); - this.$showSelectionButton = this.$panel.find(".o_domain_show_selection_button"); - this.$recordsCountDisplay = this.$showSelectionButton.find(".o_domain_records_count"); - this.$errorMessage = this.$panel.find(".o_domain_error_message"); - this.$modelMissing = this.$(".o_domain_model_missing"); - }, - set_value: function (value, noDomainSelectorRender) { - this._noDomainSelectorRender = !!noDomainSelectorRender; - this._super.apply(this, arguments); - this._noDomainSelectorRender = false; - }, - render_value: function() { - this._super.apply(this, arguments); - - // If there is no set model, the field should only display the corresponding error message - this.$panel.toggleClass("o_hidden", !this.model); - this.$modelMissing.toggleClass("o_hidden", !!this.model); - if (!this.model) { - if (this.domainSelector) { - this.domainSelector.destroy(); - this.domainSelector = undefined; - } - return; - } - - var domain = pyeval.eval("domain", this.get("value") || "[]"); - - // Recreate domain widget with new domain value - if (!this._noDomainSelectorRender) { - if (this.domainSelector) { - this.domainSelector.destroy(); - } - this.domainSelector = new DomainSelector(this, this.model, domain, { - readonly: this.get("effective_readonly") || this.options.in_dialog, - fs_filters: this.options.fs_filters, - debugMode: session.debug, - }); - this.domainSelector.prependTo(this.$el); - } - - // Show number of selected records - new Model(this.model).call("search_count", [domain], { - context: this.build_context(), - }).then((function (data) { - this.valid = true; - return data; - }).bind(this), (function (error, e) { - e.preventDefault(); - this.valid = false; - }).bind(this)).always((function (data) { - this.$recordsCountDisplay.text(data || 0); - this.$showSelectionButton.toggleClass("hidden", !this.valid); - this.$errorMessage.toggleClass("hidden", this.valid); - }).bind(this)); - }, - is_syntax_valid: function() { - return this.field_manager.get("actual_mode") === "view" || this.valid; - }, - _showSelection: function() { - return new common.SelectCreateDialog(this, { - title: _t("Selected records"), - res_model: this.model, - domain: this.get("value") || "[]", - no_create: true, - readonly: true, - disable_multiple_selection: true, - }).open(); - }, - openDomainDialog: function () { - new DomainSelectorDialog(this, this.model, this.get("value") || "[]", { - readonly: this.get("effective_readonly"), - fs_filters: this.options.fs_filters, - debugMode: session.debug, - }).open(); - }, -}); - -var FieldDate = common.AbstractField.extend(common.ReinitializeFieldMixin, { - tagName: "span", - className: "o_form_field_date", - build_widget: function() { - return new datepicker.DateWidget(this); - }, - initialize_content: function() { - if (this.datewidget) { - this.datewidget.destroy(); - this.datewidget = undefined; - } - - if (!this.get("effective_readonly")) { - this.datewidget = this.build_widget(); - this.datewidget.on('datetime_changed', this, function() { - this.internal_set_value(this.datewidget.get_value()); - }); - - var self = this; - this.datewidget.appendTo('<div>').done(function() { - self.datewidget.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.datewidget.$el); - self.datewidget.$input.addClass('o_form_input'); - self.setupFocus(self.datewidget.$input); - }); - } - }, - render_value: function() { - if (this.get("effective_readonly")) { - this.$el.text(formats.format_value(this.get('value'), this, '')); - } else { - this.datewidget.set_value(this.get('value')); - } - }, - is_syntax_valid: function() { - return this.get("effective_readonly") || !this.datewidget || this.datewidget.is_valid(); - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function() { - if (!this.get("effective_readonly")) { - return this.datewidget.$input.focus(); - } - return false; - }, -}); - -var FieldDatetime = FieldDate.extend({ - build_widget: function() { - return new datepicker.DateTimeWidget(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': 'store_dom_value', - }, - initialize_content: function() { - if (!this.get("effective_readonly")) { - this.auto_sized = false; - this.setupFocus(this.$el); - } - }, - commit_value: function () { - if (!this.get("effective_readonly")) { - this.store_dom_value(); - } - return this._super(); - }, - store_dom_value: function () { - this.internal_set_value(formats.parse_value(this.$el.val(), this)); - }, - render_value: function() { - if (this.get("effective_readonly")) { - var txt = this.get("value") || ''; - this.$el.text(txt); - } else { - var show_value = formats.format_value(this.get('value'), this, ''); - this.$el.val(show_value); - dom_utils.autoresize(this.$el, {parent: this}); - } - }, - is_syntax_valid: function() { - if (!this.get("effective_readonly")) { - try { - formats.parse_value(this.$el.val(), this, ''); - } catch(e) { - return false; - } - } - return true; - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function($el) { - if(!this.get("effective_readonly")) { - return this.$el.focus(); - } - return false; - }, - set_dimensions: function(height, width) { - this.$el.css({ - width: width, - minHeight: height, - }); - }, -}); - -var FieldBoolean = common.AbstractField.extend({ - template: 'FieldBoolean', - events: { - 'click': function() { - this.internal_set_value(this.$checkbox.prop('checked')); - } - }, - start: function() { - this.$checkbox = this.$('input'); - - this.$checkbox.prop('disabled', this.get("effective_readonly")); - this.on("change:effective_readonly", this, function() { - this.$checkbox.prop('disabled', this.get("effective_readonly")); - }); - - this.setupFocus(this.$checkbox); - - return this._super(); - }, - render_value: function() { - this.$checkbox.prop('checked', this.get('value')); - }, - focus: function() { - return this.$checkbox.focus(); - }, - set_dimensions: function(height, width) {}, // Checkboxes have a fixed height and width (even in list editable) - is_false: function() { - return false; - }, -}); - -/** - This widget is intended to be used on stat button boolean fields. - It is a read-only field that will display a simple string "<label of value>". -*/ -var FieldBooleanButton = common.AbstractField.extend({ - className: 'o_stat_info', - init: function() { - this._super.apply(this, arguments); - switch (this.options["terminology"]) { - case "active": - this.string_true = _t("Active"); - this.hover_true = _t("Deactivate"); - this.string_false = _t("Inactive"); - this.hover_false = _t("Activate"); - break; - case "archive": - this.string_true = _t("Active"); - this.hover_true = _t("Archive"); - this.string_false = _t("Archived"); - this.hover_false = _t("Restore"); - break; - case "close": - this.string_true = _t("Active"); - this.string_false = _t("Closed"); - this.hover_true = _t("Close"); - this.hover_false = _t("Open"); - break; - default: - var opt_terms = this.options["terminology"] || {}; - if (typeof opt_terms === 'string') { - opt_terms = {}; //unsupported terminology - } - this.string_true = _t(opt_terms.string_true) || _t("On"); - this.hover_true = _t(opt_terms.hover_true) || _t("Switch Off"); - this.string_false = _t(opt_terms.string_false) || _t("Off"); - this.hover_false = _t(opt_terms.hover_false) || _t("Switch On"); - } - }, - render_value: function() { - this._super(); - this.$el.html(QWeb.render("BooleanButton", {widget: this})); - }, - is_false: function() { - return false; - }, -}); - -// The progressbar field expects a float from 0 to 100. -var FieldProgressBar = common.AbstractField.extend(common.ReinitializeFieldMixin, { - initialize_content: function() { - if(this.progressbar) { - this.progressbar.destroy(); - } - - this.progressbar = new ProgressBar(this, { - readonly: this.get('effective_readonly'), - edit_on_click: true, - value: this.get('value') || 0, - }); - - var self = this; - this.progressbar.appendTo('<div>').done(function() { - self.progressbar.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.progressbar.$el); - - self.progressbar.on('update', self, function(update) { - self.set('value', update.changed_value); - }); - }); - }, - render_value: function() { - this.progressbar.set_value(this.get('value')); - }, - is_false: function() { - return false; - }, -}); - -// The PercentPie field expects a float from 0 to 100. -var FieldPercentPie = common.AbstractField.extend({ - template: 'FieldPercentPie', - start: function() { - this.$left_mask = this.$('.o_mask').first(); - this.$right_mask = this.$('.o_mask').last(); - this.$pie_value = this.$('.o_pie_value'); - return this._super(); - }, - render_value: function() { - var value = this.get('value') || 0, degValue = 360*value/100; - - this.$right_mask.toggleClass('o_full', degValue >= 180); - - var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)'; - var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)'; - this.$left_mask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg}); - this.$right_mask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg}); - - this.$pie_value.html(Math.round(value) + '%'); - }, - is_false: function() { - return false; - } -}); - -var FieldSelection = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: 'FieldSelection', - events: { - 'change': '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 - if(!this.get('effective_readonly')) { - var ischanging = false; - this.$el - .change(function () { ischanging = true; }) - .click(function () { ischanging = false; }) - .keyup(function (e) { - if (e.which !== 13 || !ischanging) { return; } - e.stopPropagation(); - ischanging = false; - }); - this.setupFocus(this.$el); - } - }, - commit_value: function () { - this.store_dom_value(); - return this._super(); - }, - store_dom_value: function () { - if (!this.get('effective_readonly')) { - this.internal_set_value(JSON.parse(this.$el.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.$el.empty(); - for(var i = 0 ; i < values.length ; i++) { - this.$el.append($('<option/>', { - value: JSON.stringify(values[i][0]), - html: values[i][1] - })) - } - this.$el.val(JSON.stringify(found[0])); - } else { - this.$el.text(found[1]); - } - }, - focus: function() { - if (!this.get("effective_readonly")) { - return this.$el.focus(); - } - return false; - }, -}); - -/** - This widget is intended to display a warning near a label of a 'timezone' field - indicating if the browser timezone is identical (or not) to the selected timezone. - This widget depends on a field given with the param 'tz_offset_field', which contains - the time difference between UTC time and local time, in minutes. -*/ -var TimezoneMismatch = FieldSelection.extend({ - initialize_content: function() { - this._super.apply(this, arguments); - this.tz_offset_field = (this.options && this.options.tz_offset_field) || this.tz_offset_field || 'tz_offset'; - this.set({"tz_offset": this.field_manager.get_field_value(this.tz_offset_field)}); - this.on("change:tz_offset", this, this.render_value); - }, - start: function() { - this._super.apply(this, arguments); - // trigger a render_value when tz_offset field change - this.field_manager.on("field_changed:" + this.tz_offset_field, this, function() { - this.set({"tz_offset": this.field_manager.get_field_value(this.tz_offset_field)}); - }); - }, - check_timezone: function() { - var user_offset = this.get('tz_offset'); - if (user_offset) { - var offset = -(new Date().getTimezoneOffset()); - var browser_offset = (offset < 0) ? "-" : "+"; - browser_offset += _.str.sprintf("%02d", Math.abs(offset / 60)); - browser_offset += _.str.sprintf("%02d", Math.abs(offset % 60)); - return (browser_offset !== user_offset); - } - return false; - }, - render_value: function() { - this._super.apply(this, arguments); - this.$label.next('.o_tz_warning').remove(); - if(this.check_timezone()){ - var options = _.extend({ - delay: { show: 501, hide: 0 }, - title: _t("Timezone Mismatch : The timezone of your browser doesn't match the selected one. The time in Odoo is displayed according to your field timezone."), - }); - $('<span/>').addClass('fa fa-exclamation-triangle o_tz_warning').insertAfter(this.$label).tooltip(options); - } - } -}); - -var LabelSelection = FieldSelection.extend({ - init: function(field_manager, node) { - this._super(field_manager, node); - this.classes = this.options && this.options.classes || {}; - }, - render_value: function() { - this._super.apply(this, arguments); - if (this.get("effective_readonly")) { - var bt_class = this.classes[this.get('value')] || 'default'; - this.$el.html($('<span/>', {html: this.$el.html()}).addClass('label label-' + bt_class)); - } - }, -}); - -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.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'); - value = ((value instanceof Array)? value[0] : value); - return _.isUndefined(value) ? false : value; - }, - render_value: function () { - var self = this; - if(this.get('effective_readonly')) { - this.$el.html(this.get('value')? this.get('value')[1] : ""); - } else { - this.$("input").prop("checked", false).filter(function () {return this.value == self.get_value();}).prop("checked", true); - } - } -}); - -var FieldReference = common.AbstractField.extend(common.ReinitializeFieldMixin, { - className: 'o_row', - 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; - this.fm = new common.DefaultFieldManager(this); - this.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(this.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.$el); - 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(this.fm, { attrs: { - name: 'Referenced Document', - modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), - context: this.build_context().eval(), - }}); - this.m2o.on("change:value", this, this.data_changed); - this.m2o.appendTo(this.$el); - 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.do_toggle(!!this.get('value')[0]); - this.reference_ready = true; - }, - is_false: function() { - return !this.get_value(); - }, -}); - -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; - try { - this.max_file_size = utils.parse_human_size(this.options.max_file_size); - } catch(e) { - this.max_file_size = 0; - } - if (!this.useFileAPI) { - this.fileupload_id = _.uniqueId('o_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() { - this.$inputFile = this.$('.o_form_input_file'); - this.$inputFile.change(this.on_file_change); - var self = this; - this.$('.o_select_file_button').click(function() { - self.$inputFile.click(); - }); - this.$('.o_clear_file_button').click(this.on_clear); - }, - 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 (this.max_file_size && file.size > this.max_file_size) { - var msg = _t("The selected file exceed the maximum file size of %s."); - this.do_warn(_t("File upload"), _.str.sprintf(msg, utils.human_size(this.max_file_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.$('form.o_form_binary_form input[name=session_id]').val(this.session.session_id); - this.$('form.o_form_binary_form').submit(); - } - this.$('.o_form_binary_progress').show(); - this.$('button').hide(); - } - }, - on_file_uploaded: function(size, name) { - 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.on_file_uploaded_and_valid.apply(this, arguments); - } - this.$('.o_form_binary_progress').hide(); - this.$('button').show(); - }, - on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { - this.binary_value = true; - this.set_filename(name); - this.set_value(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/content', - 'data': { - 'model': this.view.dataset.model, - 'id': this.view.datarecord.id, - 'field': this.name, - 'filename_field': filename_fieldname, - 'filename': filename_field ? filename_field.get('value') : null, - 'download': true, - 'data': utils.is_bin_size(value) ? null : value, - }, - 'complete': framework.unblockUI, - 'error': c.rpc_error.bind(c) - }); - ev.stopPropagation(); - } - }, - set_filename: function(value) { - var filename = this.node.attrs.filename; - if (filename) { - var field = this.field_manager.fields[filename]; - if (field) { - field.set_value(value); - field._dirty_flag = true; - } - } - }, - on_clear: function() { - this.binary_value = false; - this.set_filename(''); - this.set_value(false); // FIXME do not really remove the value - } -}); - -var FieldBinaryFile = FieldBinary.extend({ - template: 'FieldBinaryFile', - initialize_content: function() { - var self = this; - this._super(); - if (this.get("effective_readonly")) { - this.$el.click(function(ev) { - if (self.get('value')) { - self.on_save_as(ev); - } - return false; - }); - } else { - this.$input = this.$('.o_form_input').eq(0); - this.$input.on('click', function() { - self.$inputFile.click(); - }); - } - }, - render_value: function() { - var filename = this.view.datarecord[this.node.attrs.filename]; - if (this.get("effective_readonly")) { - this.do_toggle(!!this.get('value')); - if (this.get('value')) { - this.$el.empty().append($("<span/>").addClass('fa fa-download')); - if (filename) { - this.$el.append(" " + filename); - } - } - } else { - if(this.get('value')) { - this.$el.children().removeClass('o_hidden'); - this.$('.o_select_file_button').first().addClass('o_hidden'); - this.$input.val(filename || this.get('value')); - } else { - this.$el.children().addClass('o_hidden'); - this.$('.o_select_file_button').first().removeClass('o_hidden'); - } - } - } -}); - -var FieldBinaryImage = FieldBinary.extend({ - template: 'FieldBinaryImage', - placeholder: "/web/static/src/img/placeholder.png", - render_value: function() { - var url = this.placeholder; - if(this.get('value')) { - if(!utils.is_bin_size(this.get('value'))) { - url = 'data:image/png;base64,' + this.get('value'); - } else { - url = session.url('/web/image', { - model: this.view.dataset.model, - id: JSON.stringify(this.view.datarecord.id || null), - field: (this.options.preview_image)? this.options.preview_image : this.name, - unique: (this.view.datarecord.__last_update || '').replace(/[^0-9]/g, ''), - }); - } - } - - var $img = $(QWeb.render("FieldBinaryImage-img", {widget: this, url: url})); - - var self = this; - $img.click(function(e) { - if(self.view.get("actual_mode") == "view") { - var $button = $(".o_form_button_edit"); - $button.openerpBounce(); - e.stopPropagation(); - } - }); - this.$('> img').remove(); - if (self.options.size) { - $img.css("width", "" + self.options.size[0] + "px"); - $img.css("height", "" + self.options.size[1] + "px"); - } - this.$el.prepend($img); - $img.on('error', function() { - self.on_clear(); - $img.attr('src', self.placeholder); - self.do_warn(_t("Image"), _t("Could not display the selected image.")); - }); - }, - 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_ - }); - } - }, - is_false: function() { - return false; - }, - set_dimensions: function(height, width) { - this.$el.css({ - maxWidth: width, - minHeight: height, - }); - }, -}); - -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.bind_stage_click(); - } - this._super(); - }, - bind_stage_click: function () { - // This function is overriden in the enterprise webclient - this.$el.on('click','li[data-id]',this.on_click_stage); - }, - 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 () { - 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: _.debounce(function (ev) { - var self = this; - var $li = $(ev.currentTarget); - var ul = $li.closest('.oe_form_field_status'); - if (this.view.is_disabled) { - return; - } - var val; - if (ul.attr('disabled')) { - return; - } - if (this.field.type === "many2one") { - val = parseInt($li.data("id"), 10); - } else { - val = $li.data("id"); - } - if (val !== self.get('value')) { - if (!this.view.datarecord.id || - this.view.datarecord.id.toString().match(data.BufferedDataSet.virtual_id_regex)) { - // don't save, only set value for not-yet-saved many2ones - self.set_value(val); - } - else { - this.view.recursive_save().done(function() { - var change = {}; - change[self.name] = val; - ul.attr('disabled', true); - self.view.dataset.write(self.view.datarecord.id, change).done(function() { - self.view.reload(); - }).always(function() { - ul.removeAttr('disabled'); - }); - }); - } - } - }, 300, true), -}); - -var FieldMonetary = FieldFloat.extend({ - template: 'FieldMonetary', - init: function() { - this._super.apply(this, arguments); - this.set({"currency": false}); - var currency_field = (this.options && this.options.currency_field) || this.field.currency_field || 'currency_id'; - if (currency_field) { - this.field_manager.on("field_changed:" + currency_field, this, function() { - this.set({"currency": this.field_manager.get_field_value(currency_field)}); - }); - } - this.on("change:currency", this, this.get_currency_info); - this.get_currency_info(); - }, - start: function() { - var tmp = this._super(); - this.on("change:currency_info", this, this.update); - return tmp; - }, - initialize_content: function() { - if(!this.get('effective_readonly')) { - this.$input = this.$('input'); - this.add_symbol(); - } else { - this.$input = undefined; - } - - this._super(); - }, - add_symbol: function() { - var currency = this.get('currency_info'); - if(currency) { - var before = (currency.position === 'before'); - this.$el[(before)? 'prepend' : 'append']($('<span/>', {html: currency.symbol})); - } - }, - get_currency_info: function() { - var self = this; - if (this.get("currency") === false) { - this.set({"currency_info": null}); - return; - } - return self.set({"currency_info": session.get_currency(self.get("currency"))}); - }, - update: function() { - if (this.view.options.is_list_editable) { - return; - } else { - return this.reinitialize(); - } - }, - render_value: function() { - this._super(); - if(this.get('effective_readonly')) { - this.add_symbol(); - } - }, - get_digits_precision: function() { - return this.node.attrs.digits || this.field.digits || (this.get('currency_info') && this.get('currency_info').digits); - }, - parse_value: function(val, def) { - return formats.parse_value(val, {type: "float", digits: this.get_digits_precision()}, def); - }, - format_value: function(val, def) { - return formats.format_value(val, {type: "float", digits: this.get_digits_precision()}, 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.$el.addClass('o_stat_info'); - }, -}); - -/** - 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({ - template: "toggle_button", - events: { - 'click': 'set_toggle_button' - }, - render_value: function () { - var class_name = this.get_value() ? 'o_toggle_button_success' : 'text-muted'; - this.$('i').attr('class', ('fa fa-circle ' + class_name)); - }, - set_toggle_button: function () { - var self = this; - var toggle_value = !this.get_value(); - 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(); - }, - is_false: function() { - return false; - }, -}); - -/** - This widget is intended to be used on Text fields. It will provide Ace Editor for editing XML and Python. -*/ - -var AceEditor = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: "AceEditor", - willStart: function() { - if (!window.ace && !this.loadJS_def) { - this.loadJS_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () { - return $.when(ajax.loadJS('/web/static/lib/ace/mode-python.js'), - ajax.loadJS('/web/static/lib/ace/mode-xml.js') - ); - }); - } - return $.when(this._super(), this.loadJS_def); - }, - initialize_content: function () { - if (! this.get("effective_readonly")) { - var self = this; - - this.aceEditor = ace.edit(this.$('.ace-view-editor')[0]); - this.aceEditor.setOptions({"maxLines": Infinity}); - this.aceEditor.$blockScrolling = true; - - var scrollIntoViewIfNeeded = _.throttle(function () { - var node = self.aceEditor.renderer.textarea; - if (node.scrollIntoViewIfNeeded) { - node.scrollIntoViewIfNeeded(false); - } else { - var offsetParent = node.offsetParent; - while (offsetParent) { - var elY = 0; - var elH = node.offsetHeight+20; - var parent = node; - while (offsetParent && parent) { - elY += node.offsetTop; - // get if a parent have a scrollbar - parent = node.parentNode; - while (parent != offsetParent && - (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(window.getComputedStyle(parent).overflowY) === -1)) { - parent = parent.parentNode; - } - node = parent; - if (parent !== offsetParent) { - elY -= parent.offsetTop; - parent = null; - } - offsetParent = node.offsetParent; - } - - if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(window.getComputedStyle(node).overflowY) !== -1) && - (node.scrollTop + node.clientHeight) < (elY + elH)) { - node.scrollTop = (elY + elH) - node.clientHeight; - } - } - } - }); - var $moveTextAreaToCursor = this.aceEditor.renderer.$moveTextAreaToCursor; - self.aceEditor.renderer.$moveTextAreaToCursor = function() { - $moveTextAreaToCursor.call(this); - if (parseInt($(self.aceEditor.renderer.textarea).css('top'), 10) >= 0) { - scrollIntoViewIfNeeded(); - } - }; - - this.aceSession = this.aceEditor.getSession(); - this.aceSession.setUseWorker(false); - this.aceSession.setMode("ace/mode/"+(this.options.mode || 'xml')); - - this.aceEditor.on("blur", function() { - if (self.aceSession.getUndoManager().hasUndo()) { - self.set_value(self.aceSession.getValue()); - } - }); - } - }, - destroy_content: function() { - if (this.aceEditor) { - this.aceEditor.destroy(); - } - }, - render_value: function() { - if (! this.get("effective_readonly")) { - var value = formats.format_value(this.get('value'), this); - this.aceSession.setValue(value); - - } else { - var txt = this.get("value") || ''; - this.$(".oe_form_text_content").text(txt); - } - }, - focus: function() { - return this.aceEditor.focus(); - }, -}); - -/** - * 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('domain', FieldDomain) - .add('date', FieldDate) - .add('datetime', FieldDatetime) - .add('selection', FieldSelection) - .add('radio', FieldRadio) - .add('reference', FieldReference) - .add('boolean', FieldBoolean) - .add('boolean_button', FieldBooleanButton) - .add('toggle_button', FieldToggleBoolean) - .add('float', FieldFloat) - .add('percentpie', FieldPercentPie) - .add('integer', FieldFloat) - .add('float_time', FieldFloat) - .add('progressbar', FieldProgressBar) - .add('image', FieldBinaryImage) - .add('binary', FieldBinaryFile) - .add('statusbar', FieldStatus) - .add('monetary', FieldMonetary) - .add('priority', FieldPriority) - .add('kanban_state_selection', KanbanSelection) - .add('statinfo', StatInfo) - .add('timezone_mismatch', TimezoneMismatch) - .add('label_selection', LabelSelection) - .add('ace', AceEditor); - -/** - * 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 { - FieldBoolean: FieldBoolean, - FieldChar: FieldChar, - FieldEmail: FieldEmail, - FieldFloat: FieldFloat, - FieldRadio: FieldRadio, - FieldStatus: FieldStatus, - FieldMonetary: FieldMonetary, - WidgetButton: WidgetButton -}; - -}); diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js new file mode 100644 index 00000000000..040311a3919 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -0,0 +1,128 @@ +odoo.define('web.GraphController', function (require) { +"use strict"; +/*--------------------------------------------------------- + * Odoo Graph view + *---------------------------------------------------------*/ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); + +var qweb = core.qweb; + +var GraphController = AbstractController.extend({ + className: 'o_graph', + /** + * @override + * @param {Widget} parent + * @param {GraphModel} model + * @param {GraphRenderer} renderer + * @param {Object} params + * @param {string[]} params.measures + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.measures = params.measures; + }, + /** + * @todo check if this can be removed (mostly duplicate with + * AbstractController method) + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off(); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Render the buttons according to the GraphView.buttons and + * add listeners on it. + * Set this.$buttons with the produced jQuery element + * + * @param {jQuery} [$node] a jQuery node where the rendered buttons should + * be inserted $node may be undefined, in which case the GraphView does + * nothing + */ + renderButtons: function ($node) { + if ($node) { + var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; + this.$buttons = $(qweb.render('GraphView.buttons', context)); + this.$measureList = this.$buttons.find('.o_graph_measures_list'); + this.$buttons.find('button').tooltip(); + this.$buttons.click(this._onButtonClick.bind(this)); + this._updateButtons(); + this.$buttons.appendTo($node); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @todo remove this and directly calls update. Update should be overridden + * and modified to call _updateButtons + * + * @param {string} mode one of 'pie', 'line' or 'bar' + */ + _setMode: function (mode) { + this.update({mode: mode}); + this._updateButtons(); + }, + /** + * @todo same as _setMode + * + * @param {string} measure should be a valid (and aggregatable) field name + */ + _setMeasure: function (measure) { + var self = this; + this.update({measure: measure}).then(function () { + self._updateButtons(); + }); + }, + /** + * makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that) + */ + _updateButtons: function () { + var state = this.model.get(); + this.$buttons.find('.o_graph_button').removeClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="' + state.mode + '"]') + .addClass('active'); + this.$measureList.find('li').each(function (index, li) { + $(li).toggleClass('selected', $(li).data('field') === state.measure); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Do what need to be done when a button from the control panel is clicked. + * + * @param {MouseEvent} event + */ + _onButtonClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_graph_button')) { + this._setMode($target.data('mode')); + } else if ($target.parents('.o_graph_measures_list').length) { + event.preventDefault(); + event.stopPropagation(); + var parent = $target.parent(); + var field = parent.data('field'); + this._setMeasure(field); + } + }, +}); + +return GraphController; + +}); diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js new file mode 100644 index 00000000000..73578f5c3bc --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -0,0 +1,188 @@ +odoo.define('web.GraphModel', function (require) { +"use strict"; + +/** + * The graph model is responsible for fetching and processing data from the + * server. It basically just do a read_group and format/normalize data. + */ + +var core = require('web.core'); +var AbstractModel = require('web.AbstractModel'); + +var _t = core._t; + +return AbstractModel.extend({ + /** + * @override + * @param {Widget} parent + */ + init: function () { + this._super.apply(this, arguments); + this.chart = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * We defend against outside modifications by extending the chart data. It + * may be overkill. + * + * @todo Adding the fields parameter looks wrong. If the renderer or the + * controller need the fields, they should get it via their init method. + * + * @returns {Object} + */ + get: function () { + return _.extend({}, this.chart, { + fields: this.fields + }); + }, + /** + * Initial loading. + * + * @todo All the work to fall back on the graph_groupbys keys in the context + * should be done by the graphView I think. + * + * @param {Object} params + * @param {string[]} params.groupedBy + * @param {string} params.initialMode one of 'pie', 'bar', 'line + * @param {string} params.initialMeasure a valid field name + * @param {string[]} params.initialGroupBys a list of valid field names + * @param {Object} params.context + * @param {string[]} params.domain + * @returns {Deferred} The deferred does not return a handle, we don't need + * to keep track of various entities. + */ + load: function (params) { + this.initialGroupBys = params.initialGroupBys; + this.fields = params.fields; + this.modelName = params.modelName; + if (!params.groupedBy.length) { + var groupedBy = params.context.graph_groupbys || + (params.groupedBy.length && params.groupedBy) || + this.initialGroupBys; + params.groupedBy = groupedBy.slice(0); + } + this.chart = { + data: [], + groupedBy: params.groupedBy, + measure: params.initialMeasure, + mode: params.initialMode, + domain: params.domain, + context: params.context, + }; + return this._loadGraph(); + }, + /** + * Reload data. It is similar to the load function. Note that we ignore the + * handle parameter, we always expect our data to be in this.chart object. + * + * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This + * is insane. + * + * @param {any} handle ignored! + * @param {Object} params + * @param {string[]} [params.domain] + * @param {string[]} [params.groupBy] + * @param {string} [params.mode] one of 'bar', 'pie', 'line' + * @param {string} [params.measure] a valid field name + * @returns {Deferred} + */ + reload: function (handle, params) { + if ('domain' in params) { + this.chart.domain = params.domain; + } + if ('groupBy' in params) { + if (!params.groupBy.length) { + this.chart.groupedBy = this.initialGroupBys; + } else { + this.chart.groupedBy = params.groupBy; + } + } + if ('measure' in params) { + this.chart.measure = params.measure; + } + if ('mode' in params) { + this.chart.mode = params.mode; + return $.when(); + } + return this._loadGraph(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch and process graph data. It is basically a read_group with correct + * fields. We have to do some light processing to separate date groups + * in the field list, because they can be defined with an aggregation + * function, such as my_date:week + * + * @returns {Deferred} + */ + _loadGraph: function () { + var fields = _.map(this.chart.groupedBy, function (groupBy) { + return groupBy.split(':')[0]; + }); + if (this.chart.measure !== '__count__') { + fields = fields.concat(this.chart.measure); + } + return this.performModelRPC(this.modelName, 'read_group', [], { + context: this.chart.context, + domain: this.chart.domain, + fields: fields, + groupby: this.chart.groupedBy, + lazy: false, + }).then(this._processData.bind(this)); + }, + /** + * Since read_group is insane and returns its result on different keys + * depending of some input, we have to normalize the result. + * The final chart data is added to this.chart object. + * + * @todo This is not good for race conditions. The processing should get + * the object this.chart in argument, or an array or something. We want to + * avoid writing on a this.chart object modified by a subsequent read_group + * + * @param {any} raw_data result from the read_group + */ + _processData: function (raw_data) { + var self = this; + var is_count = this.chart.measure === '__count__'; + var data_pt, labels; + + this.chart.data = []; + for (var i = 0; i < raw_data.length; i++) { + data_pt = raw_data[i]; + labels = _.map(this.chart.groupedBy, function (field) { + return self._sanitizeValue(data_pt[field], field); + }); + this.chart.data.push({ + value: is_count ? data_pt.__count : data_pt[this.chart.measure], + labels: labels + }); + } + }, + /** + * Helper function (for _processData), turns various values in a usable + * string form, that we can display in the interface. + * + * @param {any} value some value received by the read_group rpc + * @param {string} field the name of the corresponding field + * @returns {string} + */ + _sanitizeValue: function (value, field) { + if (value === false) return _t("Undefined"); + if (value instanceof Array) return value[1]; + if (field && this.fields[field] && (this.fields[field].type === 'selection')) { + var selected = _.where(this.fields[field].selection, {0: value})[0]; + return selected ? selected[1] : value; + } + return value; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js new file mode 100644 index 00000000000..e5fe00cc473 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -0,0 +1,334 @@ +odoo.define('web.GraphRenderer', function (require) { +"use strict"; + +/** + * The graph renderer turns the data from the graph model into a nice looking + * svg chart. This code uses the nvd3 library. + * + * Note that we use a custom build for the nvd3, with only the model we actually + * use. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var formats = require('web.formats'); + +var _t = core._t; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'line']; + +// hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); + +return AbstractRenderer.extend({ + className: "o_graph_svg_container", + /** + * @override + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + * @param {boolean} params.stacked + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.stacked = params.stacked; + this.$el.css({minWidth: '100px', minHeight: '100px'}); + }, + /** + * @override + */ + destroy: function () { + nv.utils.offWindowResize(this.to_remove); + this._super(); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render the chart. + * + * Note that This method is synchronous, but the actual rendering is done + * asynchronously (in a setTimeout). The reason for that is that nvd3/d3 + * needs to be in the DOM to correctly render itself. So, we trick Odoo by + * returning immediately, then wait a tiny interval before actually + * displaying the data. + * + * @returns {Deferred} The _super deferred is actually resolved immediately + */ + _render: function () { + if (this.to_remove) { + nv.utils.offWindowResize(this.to_remove); + } + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.$el.empty(); + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } else if (!this.state.data.length) { + this.$el.empty(); + this.$el.append(qweb.render('GraphView.error', { + title: _t("No data to display"), + description: _t("No data available for this chart. " + + "Try to add some records, or make sure that " + + "there is no active filter in the search bar."), + })); + } else { + var self = this; + setTimeout(function () { + self.$el.empty(); + var chart = self['_render' + _.str.capitalize(self.state.mode) + 'Chart'](); + if (chart) { + self.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + chart.tooltip.chartContainer(self.el); + } + }, 0); + } + return this._super.apply(this, arguments); + }, + + /** + * Helper function to set up data properly for the multiBarChart model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderBarChart: function () { + // prepare data for bar chart + var self = this; + var data, values; + var measure = this.state.fields[this.state.measure].string; + + // zero groupbys + if (this.state.groupedBy.length === 0) { + data = [{ + values: [{ + x: measure, + y: this.state.data[0].value}], + key: measure + }]; + } + // one groupby + if (this.state.groupedBy.length === 1) { + values = this.state.data.map(function (datapt) { + return {x: datapt.labels, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + } + if (this.state.groupedBy.length > 1) { + var xlabels = [], + series = [], + label, serie, value; + values = {}; + for (var i = 0; i < this.state.data.length; i++) { + label = this.state.data[i].labels[0]; + serie = this.state.data[i].labels[1]; + value = this.state.data[i].value; + if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { + xlabels.push(label); + } + series.push(this.state.data[i].labels[1]); + if (!(serie in values)) {values[serie] = {};} + values[serie][label] = this.state.data[i].value; + } + series = _.uniq(series); + data = []; + var current_serie, j; + for (i = 0; i < series.length; i++) { + current_serie = {values: [], key: series[i]}; + for (j = 0; j < xlabels.length; j++) { + current_serie.values.push({ + x: xlabels[j], + y: values[series[i]][xlabels[j]] || 0, + }); + } + data.push(current_serie); + } + } + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(0); + + var chart = nv.models.multiBarChart(); + chart.options({ + margin: {left: 120, bottom: 60}, + delay: 250, + transition: 10, + showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showXAxis: true, + showYAxis: true, + rightAlignYAxis: false, + stacked: this.stacked, + reduceXTicks: false, + rotateLabels: -20, + showControls: (this.state.groupedBy.length > 1) + }); + chart.yAxis.tickFormat(function (d) { + var measure_field = self.state.fields[self.measure]; + return formats.format_value(d, { + type: 'float', + digits: measure_field && measure_field.digits || [69, 2], + }); + }); + + chart(svg); + return chart; + }, + /** + * Helper function to set up data properly for the pieChart model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderPieChart: function () { + var data = []; + var all_negative = true; + var some_negative = false; + var all_zero = true; + + this.state.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); + }); + if (some_negative && !all_negative) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot mix positive and negative numbers. " + + "Try to change your domain to only display positive results"), + })); + return; + } + if (all_zero) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot display all zero numbers.. " + + "Try to change your domain to display positive results"), + })); + return; + } + if (this.state.groupedBy.length) { + data = this.state.data.map(function (datapt) { + return {x:datapt.labels.join("/"), y: datapt.value}; + }); + } + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(100); + + var legend_right = config.device.size_class > config.device.SIZES.XS; + + var chart = nv.models.pieChart().labelType('percent'); + chart.options({ + delay: 250, + showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH, + legendPosition: legend_right ? 'right' : 'top', + transition: 100, + color: d3.scale.category10().range(), + }); + + chart(svg); + return chart; + }, + /** + * Helper function to set up data properly for the line model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderLineChart: function () { + if (this.state.data.length < 2) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Not enough data points"), + description: "You need at least two data points to display a line chart." + })); + return; + } + var self = this; + var data = []; + var tickValues; + var tickFormat; + var measure = this.state.fields[this.state.measure].string; + + if (this.state.groupedBy.length === 1) { + var values = this.state.data.map(function (datapt, index) { + return {x: index, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + tickValues = this.state.data.map(function (d, i) { return i;}); + tickFormat = function (d) {return self.state.data[d].labels;}; + } + if (this.state.groupedBy.length > 1) { + data = []; + var data_dict = {}; + var tick = 0; + var tickLabels = []; + var serie, tickLabel; + var identity = function (p) {return p;}; + tickValues = []; + for (var i = 0; i < this.state.data.length; i++) { + if (this.state.data[i].labels[0] !== tickLabel) { + tickLabel = this.state.data[i].labels[0]; + tickValues.push(tick); + tickLabels.push(tickLabel); + tick++; + } + serie = this.state.data[i].labels[1]; + if (!data_dict[serie]) { + data_dict[serie] = { + values: [], + key: serie, + }; + } + data_dict[serie].values.push({ + x: tick, y: this.state.data[i].value, + }); + data = _.map(data_dict, identity); + } + tickFormat = function (d) {return tickLabels[d];}; + } + + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(0); + + var chart = nv.models.lineChart(); + chart.options({ + margin: {left: 120, bottom: 60}, + useInteractiveGuideline: true, + showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showXAxis: true, + showYAxis: true, + }); + chart.xAxis.tickValues(tickValues) + .tickFormat(tickFormat); + chart.yAxis.tickFormat(function (d) { + return formats.format_value(d, { + type : 'float', + digits : self.state.fields[self.state.measure] && self.state.fields[self.state.measure].digits || [69, 2], + }); + }); + + chart(svg); + return chart; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js new file mode 100644 index 00000000000..3422a0922d3 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -0,0 +1,69 @@ +odoo.define('web.GraphView', function (require) { +"use strict"; + +/** + * The Graph View is responsible to display a graphical (meaning: chart) + * representation of the current dataset. As of now, it is currently able to + * display data in three types of chart: bar chart, line chart and pie chart. + */ + +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var GraphModel = require('web.GraphModel'); +var Controller = require('web.GraphController'); +var GraphRenderer = require('web.GraphRenderer'); + +var _t = core._t; +var _lt = core._lt; + +var GraphView = AbstractView.extend({ + display_name: _lt('Graph'), + icon: 'fa-bar-chart', + config: { + Model: GraphModel, + Controller: Controller, + Renderer: GraphRenderer, + }, + /** + * @override + */ + init: function (arch, fields) { + this._super.apply(this, arguments); + + var initialMeasure = '__count__'; + var initialGroupBys = []; + fields.__count__ = {string: _t("Count"), type: "integer"}; + arch.children.forEach(function (field) { + var name = field.attrs.name; + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + if (field.attrs.type === 'measure') { + initialMeasure = name; + } else { + initialGroupBys.push(name); + } + }); + + var measures = {__count__: {string: _t("Count"), type: "integer"}}; + _.each(fields, function (field, name) { + if (name !== 'id' && field.store === true) { + if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') { + measures[name] = field; + } + } + }); + + this.controllerParams.measures = measures; + this.rendererParams.stacked = arch.attrs.stacked !== "False"; + + this.loadParams.initialMode = arch.attrs.type || 'bar'; + this.loadParams.initialMeasure = initialMeasure; + this.loadParams.initialGroupBys = initialGroupBys; + this.loadParams.fields = fields; + }, +}); + +return GraphView; + +}); diff --git a/addons/web/static/src/js/views/graph_view.js b/addons/web/static/src/js/views/graph_view.js deleted file mode 100644 index 308c1359248..00000000000 --- a/addons/web/static/src/js/views/graph_view.js +++ /dev/null @@ -1,144 +0,0 @@ -odoo.define('web.GraphView', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Graph view - *---------------------------------------------------------*/ - -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var GraphWidget = require('web.GraphWidget'); -var View = require('web.View'); - -var _lt = core._lt; -var _t = core._t; -var QWeb = core.qweb; - -var GraphView = View.extend({ - className: 'o_graph', - display_name: _lt('Graph'), - icon: 'fa-bar-chart', - require_fields: true, - - init: function () { - this._super.apply(this, arguments); - - this.measures = []; - this.active_measure = '__count__'; - this.initial_groupbys = []; - this.widget = undefined; - }, - willStart: function () { - var self = this; - var fields_def = data_manager.load_fields(this.dataset).then(this.prepare_fields.bind(this)); - this.fields_view.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); - } - }); - return $.when(this._super(), fields_def); - }, - /** - * Render the buttons according to the GraphView.buttons and - * add listeners on it. - * Set this.$buttons with the produced jQuery element - * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted - * $node may be undefined, in which case the GraphView does nothing - */ - render_buttons: function ($node) { - if ($node) { - var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; - this.$buttons = $(QWeb.render('GraphView.buttons', context)); - this.$measure_list = this.$buttons.find('.o_graph_measures_list'); - this.update_measure(); - this.$buttons.find('button').tooltip(); - this.$buttons.click(this.on_button_click.bind(this)); - - this.$buttons.find('.o_graph_button[data-mode="' + this.widget.mode + '"]').addClass('active'); - - this.$buttons.appendTo($node); - } - }, - update_measure: function () { - var self = this; - this.$measure_list.find('li').each(function (index, li) { - $(li).toggleClass('selected', $(li).data('field') === self.active_measure); - }); - }, - do_show: function () { - this.do_push_state({}); - 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' || field.type === 'monetary') { - self.measures[name] = field; - } - } - }); - this.measures.__count__ = {string: _t("Count"), type: "integer"}; - }, - do_search: function (domain, context, group_by) { - if (!this.widget) { - this.initial_groupbys = context.graph_groupbys || (group_by.length ? group_by : this.initial_groupbys); - this.widget = new GraphWidget(this, this.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, - stacked: this.fields_view.arch.attrs.stacked !== "False" - }); - // append widget - 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('o_graph_button')) { - this.widget.set_mode($target.data('mode')); - this.$buttons.find('.o_graph_button.active').removeClass('active'); - $target.addClass('active'); - } - else if ($target.parents('.o_graph_measures_list').length) { - var parent = $target.parent(); - var field = parent.data('field'); - this.active_measure = field; - event.preventDefault(); - event.stopPropagation(); - this.update_measure(); - this.widget.set_measure(this.active_measure); - } - }, - destroy: function () { - if (this.$buttons) { - this.$buttons.find('button').off(); // remove jquery's tooltip() handlers - } - return this._super.apply(this, arguments); - }, -}); - -core.view_registry.add('graph', GraphView); - -return GraphView; - -}); diff --git a/addons/web/static/src/js/views/graph_widget.js b/addons/web/static/src/js/views/graph_widget.js deleted file mode 100644 index f778c7201ec..00000000000 --- a/addons/web/static/src/js/views/graph_widget.js +++ /dev/null @@ -1,340 +0,0 @@ -odoo.define('web.GraphWidget', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var Model = require('web.DataModel'); -var formats = require('web.formats'); -var Widget = require('web.Widget'); - -var _t = core._t; -var QWeb = core.qweb; - -// hide top legend when too many item for device size -var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); - -return Widget.extend({ - className: "o_graph_svg_container", - init: function (parent, model, options) { - this._super(parent); - this.context = options.context; - this.fields = options.fields; - this.fields.__count__ = {string: _t("Count"), type: "integer"}; - this.model = new Model(model, {group_by_no_leaf: true}); - - this.domain = options.domain || []; - this.groupbys = options.groupbys || []; - this.mode = options.mode || "bar"; - this.measure = options.measure || "__count__"; - this.stacked = options.stacked; - }, - start: function () { - return this.load_data().then(this.proxy('display_graph')); - }, - update_data: function (domain, groupbys) { - this.domain = domain; - this.groupbys = groupbys; - return this.load_data().then(this.proxy('display_graph')); - }, - set_mode: function (mode) { - this.mode = mode; - this.display_graph(); - }, - set_measure: function (measure) { - this.measure = measure; - return this.load_data().then(this.proxy('display_graph')); - }, - load_data: function () { - var fields = this.groupbys.slice(0); - if (this.measure !== '__count__'.slice(0)) - fields = fields.concat(this.measure); - return this.model - .query(fields) - .filter(this.domain) - .context(this.context) - .lazy(false) - .group_by(this.groupbys.slice(0,2)) - .then(this.proxy('prepare_data')); - }, - prepare_data: function () { - var raw_data = arguments[0], - is_count = this.measure === '__count__'; - var data_pt, j, values, value; - - this.data = []; - for (var i = 0; i < raw_data.length; i++) { - data_pt = raw_data[i].attributes; - values = []; - if (this.groupbys.length === 1) data_pt.value = [data_pt.value]; - for (j = 0; j < data_pt.value.length; j++) { - values[j] = this.sanitize_value(data_pt.value[j], data_pt.grouped_on[j]); - } - value = is_count ? data_pt.length : data_pt.aggregates[this.measure]; - this.data.push({ - labels: values, - value: value - }); - } - }, - sanitize_value: function (value, field) { - if (value === false) return _t("Undefined"); - if (value instanceof Array) return value[1]; - if (field && this.fields[field] && (this.fields[field].type === 'selection')) { - var selected = _.where(this.fields[field].selection, {0: value})[0]; - return selected ? selected[1] : value; - } - return value; - }, - display_graph: function () { - if (this.to_remove) { - nv.utils.offWindowResize(this.to_remove); - } - this.$el.empty(); - if (!this.data.length) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("No data to display"), - description: _t("No data available for this chart. " + - "Try to add some records, or make sure that " + - "there is no active filter in the search bar."), - })); - } else { - var chart = this['display_' + this.mode](); - if (chart) { - chart.tooltip.chartContainer(this.$el[0]); - } - } - }, - display_bar: function () { - // prepare data for bar chart - var data, values, - measure = this.fields[this.measure].string, - self = this; - - // zero groupbys - if (this.groupbys.length === 0) { - data = [{ - values: [{ - x: measure, - y: this.data[0].value}], - key: measure - }]; - } - // one groupby - if (this.groupbys.length === 1) { - values = this.data.map(function (datapt) { - return {x: datapt.labels, y: datapt.value}; - }); - data = [ - { - values: values, - key: measure, - } - ]; - } - if (this.groupbys.length > 1) { - var xlabels = [], - series = [], - label, serie, value; - values = {}; - for (var i = 0; i < this.data.length; i++) { - label = this.data[i].labels[0]; - serie = this.data[i].labels[1]; - value = this.data[i].value; - if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { - xlabels.push(label); - } - series.push(this.data[i].labels[1]); - if (!(serie in values)) {values[serie] = {};} - values[serie][label] = this.data[i].value; - } - series = _.uniq(series); - data = []; - var current_serie, j; - for (i = 0; i < series.length; i++) { - current_serie = {values: [], key: series[i]}; - for (j = 0; j < xlabels.length; j++) { - current_serie.values.push({ - x: xlabels[j], - y: values[series[i]][xlabels[j]] || 0, - }); - } - data.push(current_serie); - } - } - var svg = d3.select(this.$el[0]).append('svg'); - svg.datum(data); - - svg.transition().duration(0); - - var chart = nv.models.multiBarChart(); - chart.options({ - margin: {left: 120, bottom: 60}, - delay: 250, - transition: 10, - showLegend: _.size(data) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - rightAlignYAxis: false, - stacked: this.stacked, - reduceXTicks: false, - rotateLabels: -20, - showControls: (this.groupbys.length > 1) - }); - chart.yAxis.tickFormat(function(d) { - return formats.format_value(d, { - type : 'float', - digits : self.fields[self.measure] && self.fields[self.measure].digits || [69, 2], - }); - }); - - chart(svg); - this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - - return chart; - }, - display_pie: function () { - var data = [], - 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); - }); - if (some_negative && !all_negative) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("Invalid data"), - description: _t("Pie chart cannot mix positive and negative numbers. " + - "Try to change your domain to only display positive results"), - })); - return; - } - if (all_zero) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("Invalid data"), - description: _t("Pie chart cannot display all zero numbers.. " + - "Try to change your domain to display positive results"), - })); - return; - } - if (this.groupbys.length) { - data = this.data.map(function (datapt) { - return {x:datapt.labels.join("/"), y: datapt.value}; - }); - } - var svg = d3.select(this.$el[0]).append('svg'); - svg.datum(data); - - svg.transition().duration(100); - - var legend_right = config.device.size_class > config.device.SIZES.XS; - - var chart = nv.models.pieChart().labelType('percent'); - chart.options({ - delay: 250, - showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH, - legendPosition: legend_right ? 'right' : 'top', - transition: 100, - color: d3.scale.category10().range(), - }); - - chart(svg); - this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - - return chart; - }, - display_line: function () { - if (this.data.length < 2) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("Not enough data points"), - description: "You need at least two data points to display a line chart." - })); - return; - } - var self = this, - data = [], - tickValues, - tickFormat, - measure = this.fields[this.measure].string; - if (this.groupbys.length === 1) { - var values = this.data.map(function (datapt, index) { - return {x: index, y: datapt.value}; - }); - data = [ - { - values: values, - key: measure, - } - ]; - tickValues = this.data.map(function (d, i) { return i;}); - tickFormat = function (d) {return self.data[d].labels;}; - } - if (this.groupbys.length > 1) { - data = []; - var data_dict = {}, - tick = 0, - tickLabels = [], - serie, tickLabel, - identity = function (p) {return p;}; - tickValues = []; - for (var i = 0; i < this.data.length; i++) { - if (this.data[i].labels[0] !== tickLabel) { - tickLabel = this.data[i].labels[0]; - tickValues.push(tick); - tickLabels.push(tickLabel); - tick++; - } - serie = this.data[i].labels[1]; - if (!data_dict[serie]) { - data_dict[serie] = { - values: [], - key: serie, - }; - } - data_dict[serie].values.push({ - x: tick, y: this.data[i].value, - }); - data = _.map(data_dict, identity); - } - tickFormat = function (d) {return tickLabels[d];}; - } - - var svg = d3.select(this.$el[0]).append('svg'); - svg.datum(data); - - svg.transition().duration(0); - - var chart = nv.models.lineChart(); - chart.options({ - margin: {left: 120, bottom: 60}, - useInteractiveGuideline: true, - showLegend: _.size(data) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - }); - chart.xAxis.tickValues(tickValues) - .tickFormat(tickFormat); - chart.yAxis.tickFormat(function(d) { - return formats.format_value(d, { - type : 'float', - digits : self.fields[self.measure] && self.fields[self.measure].digits || [69, 2], - }); - }); - - chart(svg); - this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - - return chart; - }, - destroy: function () { - nv.utils.offWindowResize(this.to_remove); - return this._super(); - } -}); - -}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js new file mode 100644 index 00000000000..1e010e1ab6d --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -0,0 +1,268 @@ +odoo.define('web.KanbanColumn', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var view_dialogs = require('web.view_dialogs'); +var quick_create = require('web.kanban_quick_create'); +var KanbanRecord = require('web.KanbanRecord'); +var Widget = require('web.Widget'); + +var _t = core._t; +var QWeb = core.qweb; +var RecordQuickCreate = quick_create.RecordQuickCreate; + +var KanbanColumn = Widget.extend({ + template: "KanbanView.Group", + + events: { + 'click .o_column_edit': 'edit_column', + 'click .o_column_delete': 'delete_column', + 'click .o_column_archive': 'archive_records', + 'click .o_column_unarchive': 'unarchive_records', + 'click .o_kanban_quick_add': 'add_quick_create', + 'click .o_kanban_load_more': 'load_more', + 'click .o_kanban_toggle_fold': 'toggle_fold', + }, + + custom_events: { + 'kanban_record_delete': 'delete_record', + 'cancel_quick_create': 'cancel_quick_create', + 'quick_create_add_record': 'quick_create_add_record', + }, + + init: function (parent, data, options, record_options) { + this._super(parent); + this.db_id = data.id; + this.data_records = data.data; + + var value = data.value; + this.id = data.res_id || value; + var field = data.fields[data.groupedBy[0]]; // fixme: grouped by field might not be in the fvg + if (field && field.type === "selection") { + value = _.find(field.selection, function (s) { return s[0] === data.value; })[1]; // fixme: same process done in list_renderer + } + // todo: handle group_by_m2o (nameget) + this.title = value || _t('Undefined'); + this.folded = !data.isOpen; + this.has_active_field = _.contains(data.fieldNames, 'active'); + this.size = data.count; + this.values = data.values; + this.fields = data.fields; + this.records = []; + + this.quick_create = options.quick_create; + this.grouped_by_m2o = options.grouped_by_m2o; + this.editable = options.editable; + this.deletable = options.deletable; + this.draggable = record_options.draggable; + this.records_editable = options.records_editable; + this.records_deletable = options.records_deletable; + this.relation = options.relation; + this.offset = 0; + this.remaining = this.size - this.data_records.length; + + this.record_options = _.extend(_.clone(record_options), { + group_info: this.values, + }); + + if (data.options && data.options.group_by_tooltip) { + var self = this; + this.tooltip_info = _.map(data.options.group_by_tooltip, function (key, value) { + return (self.values && self.values[value] && "<div>" +key + "<br>" + self.values[value] + "</div>") || ''; + }).join(''); + } else { + this.tooltip_info = ""; + } + }, + + start: function () { + var self = this; + this.$header = this.$('.o_kanban_header'); + + for (var i = 0; i < this.data_records.length; i++) { + this.add_record(this.data_records[i], {no_update: true}); + } + this.$header.tooltip(); + + if (config.device.size_class > config.device.SIZES.XS && this.draggable !== false) { + // deactivate sortable in mobile mode. It does not work anyway, + // and it breaks horizontal scrolling in kanban views. Someday, we + // should find a way to use the touch events to make sortable work. + this.$el.sortable({ + connectWith: '.o_kanban_group', + revert: 0, + delay: 0, + items: '> .o_kanban_record:not(.o_updating)', + helper: 'clone', + cursor: 'move', + over: function () { + self.$el.addClass('o_kanban_hover'); + self.update_column(); + }, + out: function () { + self.$el.removeClass('o_kanban_hover'); + }, + update: function (event, ui) { + var record = ui.item.data('record'); + var index = self.records.indexOf(record); + record.$el.removeAttr('style'); // jqueryui sortable add display:block inline + ui.item.addClass('o_updating'); + if (index >= 0) { + if ($.contains(self.$el[0], record.$el[0])) { + // resequencing records + self.trigger_up('kanban_column_resequence', {ids: self.get_ids()}); + } + } else { + // adding record to this column + self.trigger_up('kanban_column_add_record', {record: record, ids: self.get_ids()}); + } + } + }); + } + this.$el.click(function (event) { + if (self.folded) { + self.toggle_fold(event); + } + }); + this.update_column(); + }, + + is_empty: function () { + return !this.records.length; + }, + + add_record: function (data, options) { + var record = new KanbanRecord(this, data, this.record_options); + this.records.push(record); + if (options.position === 'before') { + record.insertAfter(this.quick_create_widget ? this.quick_create_widget.$el : this.$header); + } else { + var $load_more = this.$('.o_kanban_load_more'); + if ($load_more.length) { + record.insertBefore($load_more); + } else { + record.appendTo(this.$el); + } + } + if (!options.no_update) { + this.update_column(); + } + }, + + get_ids: function () { + var ids = []; + this.$('.o_kanban_record').each(function (index, r) { + ids.push($(r).data('record').id); + }); + return ids; + }, + + update_column: function () { + var title = this.folded ? this.title + ' (' + this.size + ')' : this.title; + this.$header.find('.o_column_title').text(title); + this.$header.find('.o-kanban-count').text(this.records.length); + + this.$el.toggleClass('o_column_folded', this.folded); + var tooltip = this.size + _t(' records'); + tooltip = '<p>' + tooltip + '</p>' + this.tooltip_info; + this.$header.tooltip({html: true}).attr('data-original-title', tooltip); + if (!this.remaining) { + this.$('.o_kanban_load_more').remove(); + } else { + this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this})); + } + }, + + archive_records: function (event) { + event.preventDefault(); + this.trigger_up('kanban_column_archive_records', {archive: true}); + }, + + unarchive_records: function (event) { + event.preventDefault(); + this.trigger_up('kanban_column_archive_records', {archive: false}); + }, + + toggle_fold: function (event) { + event.preventDefault(); + this.trigger_up('column_toggle_fold'); + }, + + delete_column: function (event) { + event.preventDefault(); + var buttons = [ + { + text: _t("Ok"), + classes: 'btn-primary', + close: true, + click: this.trigger_up.bind(this, 'kanban_column_delete') + }, + {text: _t("Cancel"), close: true} + ]; + new Dialog(this, { + size: 'medium', + buttons: buttons, + $content: $('<div>', { + text: _t("Are you sure that you want to remove this column ?") + }), + }).open(); + }, + + edit_column: function (event) { + event.preventDefault(); + var dialog = new view_dialogs.FormViewDialog(this, { + res_model: this.relation, + res_id: this.id, + title: _t("Edit Column"), + }).open(); + + dialog.on('saved', null, this.trigger_up.bind(this, 'reload')); + }, + + delete_record: function (event) { + var self = this; + event.data.parent_id = this.db_id; + event.data.after = function cleanup() { + var index = self.records.indexOf(event.data.record); + self.records.splice(index, 1); + self.update_column(); + }; + }, + + add_quick_create: function () { + if (this.quick_create_widget) { + return; + } + var self = this; + var width = this.records.length ? this.records[0].$el.innerWidth() : this.$el.width() - 8; + this.quick_create_widget = new RecordQuickCreate(this, width); + this.quick_create_widget.insertAfter(this.$header); + this.quick_create_widget.$el.focusout(function () { + var hasFocus = (self.quick_create_widget.$(':focus').length > 0); + if (! hasFocus && self.quick_create_widget) { + self.cancel_quick_create(); + } + }); + }, + + cancel_quick_create: function () { + this.quick_create_widget.destroy(); + this.quick_create_widget = undefined; + }, + + quick_create_add_record: function (event) { + this.trigger_up('quick_create_record', event.data); + }, + + load_more: function (event) { + event.preventDefault(); + this.trigger_up('kanban_load_more'); + }, + +}); + +return KanbanColumn; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js new file mode 100644 index 00000000000..df944c2bd03 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -0,0 +1,358 @@ +odoo.define('web.KanbanController', function (require) { +"use strict"; + +/** + * The KanbanController is the class that coordinates the kanban model and the + * kanban renderer. It also makes sure that update from the search view are + * properly interpreted. + */ + +var BasicController = require('web.BasicController'); +var core = require('web.core'); +var data = require('web.data'); +var form_common = require('web.view_dialogs'); + +var _t = core._t; +var qweb = core.qweb; + +var KanbanController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + quick_create_add_column: '_onAddColumn', + quick_create_record: '_onQuickCreateRecord', + resequence_columns: '_onResequenceColumn', + kanban_do_action: '_onOpenAction', + kanban_record_delete: '_onRecordDelete', + kanban_record_update: '_onUpdateRecord', + kanban_column_delete: '_onDeleteColumn', + kanban_column_add_record: '_onAddRecordToColumn', + kanban_column_resequence: '_onColumnResequence', + kanban_column_archive_records: '_onArchiveRecords', + kanban_load_more: '_onLoadMore', + column_toggle_fold: '_onToggleColumn', + }), + /** + * @override + * @param {Object} params + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.on_create = params.on_create; + this.hasButtons = params.hasButtons; + + // true iff grouped by an m2o field and group_create action enabled + this.create_column_enabled = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {jQueryElement} $node + */ + renderButtons: function ($node) { + var self = this; + if (this.hasButtons && this.is_action_enabled('create')) { + this.$buttons = $(qweb.render("KanbanView.buttons", {})); + this.$buttons.on('click', 'button.o-kanban-button-new', function () { + var data = self.model.get(self.handle, {raw: true}); + if (data.groupedBy.length > 0 && data.count > 0 && self.on_create === 'quick_create') { + // Activate the quick create in the first column + self.renderer.add_quick_create(); + } else if (self.on_create && self.on_create !== 'quick_create') { + // Execute the given action + self.do_action(self.on_create, { + on_close: self.reload.bind(self), + additional_context: data.context, + }); + } else { + // Open the form view + self.trigger_up('switch_view', { + view_type: 'form', + res_id: undefined + }); + } + }); + this._updateButtons(); + this.$buttons.appendTo($node); + } + }, + /** + * Override update method to recompute create_column_enabled. + * + * @returns {Deferred} + */ + update: function () { + var state = this.model.get(this.handle, {raw: true}); + var group_by_field = state.fields[state.groupedBy[0]]; + var grouped_by_m2o = group_by_field && (group_by_field.type === 'many2one'); + var groupCreate = this.is_action_enabled('group_create'); + this.create_column_enabled = grouped_by_m2o && groupCreate; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override method comes from field manager mixin + * @param {string} id local id from the basic record data + */ + _confirmSave: function (id) { + this.renderer.update_record(this.model.get(id)); + }, + /** + * @todo this is dead code right now. resurrect this? + * + * @returns {boolean} + */ + _hasContent: function () { + return this._super.apply(this, arguments) || this.create_column_enabled; + }, + /** + * This method calls the server to ask for a resequence. Note that this + * does not rerender the user interface, because in most case, the + * resequencing operation has already been displayed by the renderer. + * + * @param {string} column_id + * @param {string[]} ids + * @returns {Deferred} + */ + _resequenceRecords: function (column_id, ids) { + return this.model.resequence(this.modelName, ids, column_id); + }, + /** + * In grouped mode, set 'Create' button as btn-default if there is no column + * (except if we can't create new columns) + * + * @override from abstract controller + */ + _updateButtons: function () { + if (this.$buttons) { + var data = this.model.get(this.handle, {raw: true}); + var create_muted = data.count === 0 && this.create_column_enabled; + this.$buttons.find('.o-kanban-button-new') + .toggleClass('btn-primary', !create_muted) + .toggleClass('btn-default', create_muted); + } + }, + /** + * @override + * @param {Object} state + * @returns {Deferred} + */ + _update: function (state) { + var hasNoContent = state.count === 0 && !this.create_column_enabled; + this.$el.toggleClass('o_kanban_nocontent', hasNoContent); + this._toggleNoContentHelper(hasNoContent); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This handler is called when an event (from the quick create add column) + * event bubbles up. When that happens, we need to ask the model to create + * a group and to update the renderer + * + * @param {OdooEvent} event + */ + _onAddColumn: function (event) { + var self = this; + this.model.createGroup(event.data.value, this.handle).then(function () { + self.update({}, {reload: false}); + self.trigger_up('scrollTo', {selector: '.o_column_quick_create'}); + }); + }, + /** + * @param {OdooEvent} event + */ + _onAddRecordToColumn: function (event) { + var self = this; + var record = event.data.record; + var column = event.target; + this.alive(this.model.moveRecord(record.db_id, column.db_id, this.handle)) + .then(function (column_db_ids) { + return self._resequenceRecords(column.db_id, event.data.ids) + .then(function () { + _.each(column_db_ids, function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + }); + }).fail(this.reload.bind(this)); + }, + /** + * The interface allows in some case the user to archive a column. This is + * what this handler is for. + * + * @param {OdooEvent} event + */ + _onArchiveRecords: function (event) { + var self = this; + var active_value = !event.data.archive; + var column = event.target; + var record_ids = []; + _.each(column.records, function (kanban_record) { + if (kanban_record.record.active.value !== active_value) { + record_ids.push(kanban_record.db_id); + } + }); + if (record_ids.length) { + this.model + .toggleActive(record_ids, active_value, column.db_id) + .then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + } + }, + /** + * @param {OdooEvent} event + */ + _onColumnResequence: function (event) { + this._resequenceRecords(event.target.db_id, event.data.ids); + }, + /** + * @param {OdooEvent} event + */ + _onDeleteColumn: function (event) { + var self = this; + var column = event.target; + var state = this.model.get(this.handle, {raw: true}); + var relatedModelName = state.fields[state.groupedBy[0]].relation; + this.model + .deleteRecords([column.db_id], relatedModelName, this.handle) + .done(function () { + if (column.is_empty()) { + self.renderer.remove_widget(column); + self._updateButtons(); + } else { + self.reload(); + } + }); + }, + /** + * This method comes from the field manager mixin. Since there is no + * onchange for kanban, we force a save for every change + * + * @override + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + event.data.force_save = true; + this._super.apply(this, arguments); + }, + /** + * @param {OdooRevent} event + */ + _onLoadMore: function (event) { + var self = this; + var column = event.target; + this.model.load_more(column.db_id).then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + }, + /** + * @param {OdooEvent} event + */ + _onOpenAction: function (event) { + var self = this; + var record = event.target; + if (event.data.context) { + event.data.context = new data.CompoundContext(event.data.context) + .set_eval_context({ + active_id: event.target.id, + active_ids: [event.target.id], + active_model: this.modelName, + }); + } + this.trigger_up('execute_action', { + action_data: event.data, + model: this.modelName, + record_id: event.target.id, + on_close: function () { + self.model.reload(record.db_id).then(function (db_id) { + var data = self.model.get(db_id); + record.update(data); + }); + }, + }); + }, + /** + * @param {OdooEvent} event + */ + _onQuickCreateRecord: function (event) { + var self = this; + var column = event.target; + var data = this.model.get(column.db_id); + function add_record (records) { + return self.model + .addRecordToGroup(data.id, records[0]) + .then(function (db_id) { + column.add_record(self.model.get(db_id), {position: 'before'}); + }); + } + data.context['default_' + data.groupedBy[0]] = column.id; + this.model + .nameCreate(data.model, event.data.value, data.context) + .then(add_record) + .fail(function (event) { + event.preventDefault(); + var popup = new form_common.SelectCreatePopup(this); + popup.select_element( + data.model, + { + title: _t("Create: "), + initial_view: "form", + disable_multiple_selection: true, + }, + [], + { default_name: event.data.value } + ); + popup.on("elements_selected", null, add_record); + }); + }, + /** + * @param {OdooEvent} event + */ + _onRecordDelete: function (event) { + this._deleteRecords([event.data.id]); + }, + /** + * @param {OdooEvent} event + */ + _onResequenceColumn: function (event) { + var grouped_by = this.model.get(this.handle).groupedBy[0]; + var model = this.fields_view.fields[grouped_by].relation; + this.model.resequence(model, event.data.ids, this.handle); + }, + /** + * @param {OdooEvent} event + */ + _onToggleColumn: function (event) { + var self = this; + var column = event.target; + this.model.toggleGroup(column.db_id).then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + }, + /** + * @param {OdooEvent} event + */ + _onUpdateRecord: function (event) { + var record = event.target; + this.alive(this.model.save(record.db_id, event.data)) + .then(record.update.bind(record)); + }, +}); + +return KanbanController; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js new file mode 100644 index 00000000000..8970343c3a9 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -0,0 +1,126 @@ +odoo.define('web.KanbanModel', function (require) { +"use strict"; + +var BasicModel = require('web.BasicModel'); + +return BasicModel.extend({ + load: function (params) { + this.defaultGroupedBy = params.groupBy; + params.groupedBy = params.groupedBy.length ? params.groupedBy : this.defaultGroupedBy; + return this._super(params); + }, + reload: function (id, options) { + // if the groupBy is given in the options and if it is an empty array, + // fallback on the default groupBy + if (options && options.groupBy && !options.groupBy.length) { + options.groupBy = this.defaultGroupedBy; + } + return this._super(id, options); + }, + addRecordToGroup: function (group_id, res_id) { + var group = this.localData[group_id]; + var new_record = this._makeDataPoint({ + res_id: res_id, + modelName: group.model, + fields: group.fields, + fieldNames: group.fieldNames, + fieldAttrs: group.fieldAttrs, + }); + group.data.unshift(new_record); + group.count++; + return this._fetchRecord(new_record) + // .then(this._fetch_relational_data.bind(this)) + .then(function (result) { + return result.id; + }); + }, + moveRecord: function (record_id, group_id, parent_id) { + var self = this; + var parent = this.localData[parent_id]; + var new_group = this.localData[group_id]; + var changes = {}; + var groupedFieldName = parent.groupedBy[0]; + var groupedField = parent.fields[groupedFieldName]; + if (groupedField.type === 'many2one') { + changes[groupedFieldName] = { + id: new_group.res_id, + display_name: new_group.value, + }; + } else { + changes[groupedFieldName] = new_group.value; + } + return this.notifyChanges(record_id, changes).then(function () { + return self.save(record_id); + }).then(function (result) { + // Remove record from its current group + var old_group; + for (var i = 0; i < parent.count; i++) { + old_group = self.localData[parent.data[i]]; + var index = _.indexOf(old_group.data, record_id); + if (index >= 0) { + old_group.data.splice(index, 1); + old_group.count--; + break; + } + } + // Add record to its new group + new_group.data.push(result.id); + new_group.count++; + return [old_group.id, new_group.id]; + }); + }, + resequence: function (modelName, res_ids, parent_id) { + if ((res_ids.length <= 1)) { + return $.when(parent_id); // there is nothing to sort + } + var self = this; + var data = self.localData[parent_id]; + var def; + if (data.fields.sequence) { + def = this.performRPC('/web/dataset/resequence', { + model: modelName, + ids: res_ids, + // fixme: correctly handle context + }); + } + return $.when(def).then(function () { + data.data = _.sortBy(data.data, function (d) { + return _.indexOf(res_ids, d.res_id); + }); + return parent_id; + }); + }, + createGroup: function (name, parentID) { + var self = this; + var parent = this.localData[parentID]; + var groupBy = parent.groupedBy[0]; + var groupByField = parent.fields[groupBy]; + if (!groupByField || groupByField.type !== 'many2one') { + return $.Deferred().reject(); // only supported when grouped on m2o + } + return this.performModelRPC(groupByField.relation, 'name_create', [name], { + context: parent.context, // todo: combine with view context + }).then(function (result) { + var newGroup = self._makeDataPoint({ + modelName: parent.model, + context: parent.context, + domain: parent.domain.concat([[groupBy,"=",result[0]]]), + fields: parent.fields, + fieldNames: parent.fieldNames, + groupedBy: parent.groupedBy, + isOpen: true, + limit: parent.limit, + openGroupByDefault: true, + orderedBy: parent.orderedBy, + value: result, + }); + + // newGroup.is_open = true; + parent.data.push(newGroup.id); + parent.count++; + return newGroup.id; + }); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_quick_create.js new file mode 100644 index 00000000000..961ad1bfac3 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_quick_create.js @@ -0,0 +1,140 @@ +odoo.define('web.kanban_quick_create', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +var RecordQuickCreate = Widget.extend({ + template: "KanbanView.QuickCreate", + + events: { + 'click .o_kanban_cancel': function () { + this.cancel(); + }, + 'click .o_kanban_add': function () { + this.add_record(); + }, + 'keypress input': function (event) { + if (event.keyCode === 13) { + this.add_record(); + } + }, + 'keydown': function (event) { + if (event.keyCode === 27) { + this.cancel(); + } + }, + 'mousedown .o_kanban_add': 'suppress', + 'mousedown .o_kanban_cancel': 'suppress', + }, + + init: function (parent, width) { + this._super.apply(this, arguments); + this.width = width; + }, + + start: function () { + this.$el.css({width: this.width}); + this.$input = this.$('input'); + this.$input.focus(); + return this._super.apply(this, arguments); + }, + + cancel: function () { + this.trigger_up('cancel_quick_create'); + }, + + add_record: function () { + var value = this.$input.val(); + this.$input.val(''); + if (/^\s*$/.test(value)) { return; } + this.trigger_up('quick_create_add_record', {value: value}); + this.$input.focus(); + }, + suppress: function (e) { + e.preventDefault(); + }, +}); + +var ColumnQuickCreate = Widget.extend({ + template: 'KanbanView.ColumnQuickCreate', + + events: { + 'click': 'toggle', + 'click .o_kanban_add': function (event) { + event.stopPropagation(); + this.add_column(); + }, + 'click .o_kanban_cancel': function () { + this.folded = true; + this.update(); + }, + 'click .o_kanban_quick_create': function (event) { + event.stopPropagation(); + }, + 'keypress input': function (event) { + if (event.keyCode === 13) { + this.add_column(); + } + }, + 'keydown': function (event) { + if (event.keyCode === 27) { + this.folded = true; + this.update(); + } + }, + 'focusout': function () { + var hasFocus = this.$(':focus').length > 0; + if (hasFocus) { return; } + + this.folded = true; + this.$input.val(''); + this.update(); + }, + 'mousedown .o_kanban_add': 'suppress', + 'mousedown .o_kanban_cancel': 'suppress', + }, + + init: function () { + this._super.apply(this, arguments); + this.folded = true; + }, + + start: function () { + this.$header = this.$('.o_column_header'); + this.$quick_create = this.$('.o_kanban_quick_create'); + this.$input = this.$('input'); + return this._super.apply(this, arguments); + }, + + toggle: function () { + this.folded = !this.folded; + this.update(); + if (!this.folded) { + this.$input.focus(); + this.trigger_up('scrollTo', {selector: '.o_column_quick_create'}); + } + }, + + update: function () { + this.$header.toggle(this.folded); + this.$quick_create.toggle(!this.folded); + }, + + add_column: function () { + var name = this.$input.val(); + this.$input.val(''); + if (/^\s*$/.test(name)) { return; } + this.trigger_up('quick_create_add_column', {value: name}); + this.$input.focus(); + }, + suppress: function (e) { + e.preventDefault(); + }, +}); + +return { + RecordQuickCreate: RecordQuickCreate, + ColumnQuickCreate: ColumnQuickCreate, +}; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js new file mode 100644 index 00000000000..b6713fdfc39 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -0,0 +1,403 @@ +odoo.define('web.KanbanRecord', function (require) { +"use strict"; + +var core = require('web.core'); +var Domain = require('web.Domain'); +var field_registry = require('web.field_registry'); +var field_utils = require('web.field_utils'); +var formats = require('web.formats'); +var framework = require('web.framework'); +var session = require('web.session'); +var time = require('web.time'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var KanbanRecord = Widget.extend({ + template: 'KanbanView.record', + + events: { + 'click .oe_kanban_action': 'on_kanban_action_clicked', + 'click .o_kanban_manage_toggle_button': 'toggle_manage_pane', + }, + + custom_events: { + 'kanban_update_record': 'update_record', + }, + + init: function (parent, state, options) { + this._super(parent); + + this.fields = state.fields; + this.fieldAttrs = state.fieldAttrs; + this.recordData = state.data; + this.options = options; + this.editable = options.editable; + this.deletable = options.deletable; + this.draggable = options.draggable; + this.read_only_mode = options.read_only_mode; + this.model = options.model; + this.group_info = options.group_info; + this.qweb = options.qweb; + this.sub_widgets = {}; + + this.init_content(state); + }, + + renderElement: function () { + this._super(); + this.setup_color(); + this.setup_color_picker(); + this.$el.addClass('o_kanban_record'); + this.$el.data('record', this); + if (this.$el.hasClass('oe_kanban_global_click') || this.$el.hasClass('oe_kanban_global_click_edit')) { + this.$el.on('click', this.proxy('on_global_click')); + } + }, + + start: function () { + this.add_widgets(); + this.render_m2m_tags(); + this.attach_tooltip(); + return this._super.apply(this, arguments); + }, + + init_content: function (state) { + var self = this; + this.state = state; + this.id = state.res_id; + this.db_id = state.id; + this.values = {}; + _.each(state.data, function (v, k) { + self.values[k] = { + value: v + }; + }); + this.record = this.transform_record(state.data); + var qweb_context = { + record: this.record, + widget: this, + read_only_mode: this.read_only_mode, + user_context: session.user_context, + formats: formats, + }; + for (var p in this) { + if (_.str.startsWith(p, 'kanban_')) { + qweb_context[p] = _.bind(this[p], this); + } + } + this.qweb_context = qweb_context; + this.content = this.qweb.render('kanban-box', qweb_context); + }, + + add_widgets: function () { + var self = this; + this.$("field").each(function () { + var $field = $(this); + var field_name = $field.attr("name"); + var field_widget = $field.attr("widget"); + if (field_widget) { + // a widget is specified for that field + var widget = self.sub_widgets[field_name]; + if (!widget) { + // the widget doesn't exist yet, so instanciate it + var Widget = field_registry.getAny(['kanban.' + field_widget, field_widget]); + + // some field's attrs might be record dependent (they start with + // 't-att-') and should thus be evaluated, which is done by qweb + // we here replace those attrs in the dict of attrs of the state + // by their evaluted value, to make it transparent from the + // field's widgets point of view + // that dict being shared between records, we don't modify it + // in place + var attrs = Object.create(null); + _.each(self.state.fieldAttrs[field_name], function (value, key) { + if (_.str.startsWith(key, 't-att-')) { + key = key.slice(6); + value = $field.attr(key); + } + attrs[key] = value; + }); + self.state.fieldAttrs[field_name] = attrs; + + widget = new Widget(self, field_name, self.state, self.options); + self.sub_widgets[field_name] = widget; + self._set_field_display(widget, field_name); + widget.replace($field); + } else { + // a widget already exists for that field, so reset it with the new state + widget.reset(self.state); + $field.replaceWith(widget.$el); + } + } else { + // no widget specified for that field, so simply use a formatter + // note: we could have used the widget corresponding to the field's type, but + // it is much more efficient to use a formatter + var field = self.state.fields[field_name]; + var recordData = self.state.data; + var value = recordData[field_name]; + var options = { data: recordData }; + var formatted_value = field_utils.format_field(value, field, options); + $field.replaceWith(formatted_value); + } + }); + }, + + _set_field_display: function (widget, field_name) { + // attribute display + if (this.fieldAttrs[field_name].display === 'right') { + widget.$el.addClass('pull-right'); + } else if (this.fieldAttrs[field_name].display === 'full') { + widget.$el.addClass('o_text_block'); + } + + // attribute bold + if (this.fieldAttrs[field_name].bold) { + widget.$el.addClass('o_text_bold'); + } + }, + + render_m2m_tags: function () { + var self = this; + _.each(this.recordData, function (values, field_name) { + if (self.fields[field_name].type !== 'many2many') { return; } + var rel_ids = self.record[field_name].raw_value; + var $m2m_tags = self.$('.o_form_field_many2manytags[name=' + field_name + ']'); + _.each(rel_ids, function (id) { + var m2m = _.findWhere(values.data, {res_id: id}).data; + if (typeof m2m.color !== 'undefined' && m2m.color !== 10) { // 10th color is invisible + $('<span>') + .addClass('o_tag o_tag_color_' + m2m.color) + .attr('title', _.str.escapeHTML(m2m.name)) + .appendTo($m2m_tags); + } + }); + }); + // We use boostrap tooltips for better and faster display + this.$('span.o_tag').tooltip({delay: {'show': 50}}); + }, + + transform_record: function (record) { + var self = this; + var new_record = {}; + _.each(this.state.fieldNames, function (name) { + var value = record[name]; + var r = _.clone(self.fields[name] || {}); + + if ((r.type === 'date' || r.type === 'datetime') && value) { + r.raw_value = time.auto_str_to_date(value); + } else if (r.type === 'one2many' || r.type === 'many2one' || r.type === 'many2many') { + r.raw_value = value ? value.res_ids : []; + } else { + r.raw_value = value; + } + + if (r.type) { + var formatter = field_utils['format_' + r.type]; + r.value = formatter(value, self.fields[name], record, self.state); + } else { + r.value = value; + } + + new_record[name] = r; + }); + return new_record; + }, + + update: function (record) { + // detach the widgets because the record will empty its $el, which will + // remove all event handlers on its descendants, and we want to keep + // those handlers alive as we will re-use these widgets + _.invoke(_.pluck(this.sub_widgets, '$el'), 'detach'); + this.init_content(record); + this.renderElement(); + this.add_widgets(); + this.render_m2m_tags(); + this.attach_tooltip(); + }, + + attach_tooltip: function () { + var self = this; + this.$('[tooltip]').each(function () { + var $el = $(this); + var tooltip = $el.attr('tooltip'); + if (tooltip) { + $el.tooltip({ + 'html': true, + 'title': self.qweb.render(tooltip, self.qweb_context) + }); + } + }); + }, + + kanban_image: function (model, field, id, cache, options) { + options = options || {}; + var url; + if (this.record[field] && this.record[field].value && !utils.is_bin_size(this.record[field].value)) { + url = 'data:image/png;base64,' + this.record[field].value; + } else if (this.record[field] && ! this.record[field].value) { + url = "/web/static/src/img/placeholder.png"; + } else { + if (_.isArray(id)) { id = id[0]; } + if (!id) { id = undefined; } + if (options.preview_image) + field = options.preview_image; + var unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, ''); + url = session.url('/web/image', {model: model, field: field, id: id, unique: unique}); + if (cache !== undefined) { + // Set the cache duration in seconds. + url += '&cache=' + parseInt(cache, 10); + } + } + return url; + }, + + kanban_getcolor: function (variable) { + if (typeof(variable) === 'number') { + return Math.round(variable) % 10; + } + if (typeof(variable) === 'string') { + var index = 0; + for (var i=0; i<variable.length; i++) { + index += variable.charCodeAt(i); + } + return index % 10; + } + return 0; + }, + + kanban_color: function (variable) { + var color = this.kanban_getcolor(variable); + return 'oe_kanban_color_' + color; + }, + + on_global_click: function (ev) { + if ($(ev.target).parents('.o_dropdown_kanban').length) { + return; + } + var trigger = true; + var elem = ev.target; + var ischild = true; + var children = []; + while (elem) { + var events = $._data(elem, 'events'); + if (elem === ev.currentTarget) { + ischild = false; + } + var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== "tooltip"); + if (ischild) { + children.push(elem); + if (test_event) { + // do not trigger global click if one child has a click event registered + trigger = false; + } + } + if (trigger && test_event) { + _.each(events.click, function (click_event) { + if (click_event.selector) { + // For each parent of original target, check if a + // delegated click is bound to any previously found children + _.each(children, function (child) { + if ($(child).is(click_event.selector)) { + trigger = false; + } + }); + } + }); + } + elem = elem.parentElement; + } + if (trigger) { + this.on_card_clicked(ev); + } + }, + + /* actions when user click on the block with a specific class + * open on normal view : oe_kanban_global_click + * open on form/edit view : oe_kanban_global_click_edit + */ + on_card_clicked: function () { + if (this.$el.hasClass('oe_kanban_global_click_edit') && this.$el.data('routing')) { + framework.redirect(this.$el.data('routing') + "/" + this.id); + } else if (this.$el.hasClass('oe_kanban_global_click_edit')) { + this.trigger_up('edit_record', {id: this.db_id}); + } else { + this.trigger_up('open_record', {id: this.db_id}); + } + }, + + toggle_manage_pane: function (event){ + event.preventDefault(); + this.$('.o_kanban_card_content').toggleClass('o_visible o_invisible'); + this.$('.o_kanban_card_manage_pane').toggleClass('o_visible o_invisible'); + this.$('.o_kanban_manage_button_section').toggleClass(this.kanban_color((this.values['color'] && this.values['color'].value) || 0)); + }, + + on_kanban_action_clicked: function (ev) { + ev.preventDefault(); + + var $action = $(ev.currentTarget); + var type = $action.data('type') || 'button'; + + switch (type) { + case 'edit': + this.trigger_up('open_record', {id: this.db_id, mode: 'edit'}); + break; + case 'open': + this.trigger_up('open_record', {id: this.db_id}); + break; + case 'delete': + this.trigger_up('kanban_record_delete', {id: this.db_id, record: this}); + break; + case 'action': + case 'object': + this.trigger_up('kanban_do_action', $action.data()); + break; + default: + this.do_warn("Kanban: no action for type : " + type); + } + }, + + kanban_compute_domain: function (d) { + return new Domain(d).compute(this.values); + }, + + update_record: function (event) { + this.trigger_up('kanban_record_update', event.data); + }, + + /* + * If an attribute `color` is set on the kanban record, + * this will add the corresponding color class. + */ + setup_color: function () { + var color_field = this.$el.attr('color'); + if (color_field && color_field in this.fields) { + this.$el.addClass(this.kanban_color(this.values[color_field].value)); + } + }, + + setup_color_picker: function () { + var self = this; + var $colorpicker = this.$('ul.oe_kanban_colorpicker'); + if (!$colorpicker.length) { + return; + } + $colorpicker.html(QWeb.render('KanbanColorPicker', { + widget: this + })); + $colorpicker.on('click', 'a', function (ev) { + ev.preventDefault(); + var color_field = $colorpicker.data('field') || 'color'; + var data = {}; + data[color_field] = $(this).data('color'); + self.trigger_up('kanban_record_update', data); + }); + }, + +}); + +return KanbanRecord; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js new file mode 100644 index 00000000000..4259e35d88a --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -0,0 +1,255 @@ +odoo.define('web.KanbanRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); +var KanbanColumn = require('web.KanbanColumn'); +var KanbanRecord = require('web.KanbanRecord'); +var quick_create = require('web.kanban_quick_create'); +var QWeb = require('web.QWeb'); +var session = require('web.session'); +var utils = require('web.utils'); + +var ColumnQuickCreate = quick_create.ColumnQuickCreate; + +var qweb = core.qweb; + +function find_in_node(node, predicate) { + if (predicate(node)) { + return node; + } + if (!node.children) { + return undefined; + } + for (var i = 0; i < node.children.length; i++) { + if (find_in_node(node.children[i], predicate)) { + return node.children[i]; + } + } +} + +function qweb_add_if(node, condition) { + if (node.attrs[qweb.prefix + '-if']) { + condition = _.str.sprintf("(%s) and (%s)", node.attrs[qweb.prefix + '-if'], condition); + } + node.attrs[qweb.prefix + '-if'] = condition; +} + +function transform_qweb_template(node, fields) { + // Process modifiers + if (node.tag && node.attrs.modifiers) { + var modifiers = JSON.parse(node.attrs.modifiers || "{}"); + if (modifiers.invisible) { + qweb_add_if(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible))); + } + } + switch (node.tag) { + case 'field': + var ftype = fields[node.attrs.name].type; + ftype = node.attrs.widget ? node.attrs.widget : ftype; + if (ftype === 'many2many') { + node.tag = 'div'; + node.attrs['class'] = (node.attrs['class'] || '') + ' oe_form_field o_form_field_many2manytags o_kanban_tags'; + } else if (field_registry.contains(ftype)) { + // do nothing, the kanban record will handle it + } else { + node.tag = 'span'; + var child_node = { + attrs: {}, + children: [], + tag: qweb.prefix, + }; + child_node.attrs[qweb.prefix + '-esc'] = 'record.' + node.attrs.name + '.value'; + node.children.push(child_node); + + // set class according to field attribute + var child_classes = []; + if (node.attrs.display === 'right') { + child_classes.push('pull-right'); + } else if (node.attrs.display === 'full') { + child_classes.push('o_text_block'); + } + if (node.attrs.bold) { + child_classes.push('o_text_bold'); + } + node.attrs['class'] = child_classes.join(' '); + } + break; + case 'button': + case 'a': + var type = node.attrs.type || ''; + if (_.indexOf('action,object,edit,open,delete,url,set_cover'.split(','), type) !== -1) { + _.each(node.attrs, function (v, k) { + if (_.indexOf('icon,type,name,args,string,context,states,kanban_states'.split(','), k) !== -1) { + node.attrs['data-' + k] = v; + delete(node.attrs[k]); + } + }); + if (node.attrs['data-string']) { + node.attrs.title = node.attrs['data-string']; + } + if (node.tag === 'a' && node.attrs['data-type'] !== "url") { + node.attrs.href = '#'; + } else { + node.attrs.type = 'button'; + } + + var action_classes = " oe_kanban_action oe_kanban_action_" + node.tag; + if (node.attrs['t-attf-class']) { + node.attrs['t-attf-class'] += action_classes; + } else if (node.attrs['t-att-class']) { + node.attrs['t-att-class'] += " + '" + action_classes + "'"; + } else { + node.attrs['class'] = (node.attrs['class'] || '') + action_classes; + } + } + break; + } + if (node.children) { + for (var i = 0, ii = node.children.length; i < ii; i++) { + transform_qweb_template(node.children[i], fields); + } + } +} + +return BasicRenderer.extend({ + className: "o_kanban_view", + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.qweb = new QWeb(session.debug, {_s: session.origin}); + var templates = find_in_node(this.arch, function (n) { return n.tag === 'templates';}); + transform_qweb_template(templates, state.fields); + this.qweb.add_template(utils.json_node_to_xml(templates)); + + this.record_options = _.extend({}, params.record_options, { qweb: this.qweb }); + this.column_options = _.extend({}, params.column_options, { qweb: this.qweb }); + }, + update: function (state, fields) { + this.fields = fields; + return this._super(state); + }, + update_record: function (record_state) { + var is_grouped = !!this.state.groupedBy.length; + var record; + + if (is_grouped) { + // if grouped, this.widgets are kanban columns + // so we need to find the kanban record inside + _.each(this.widgets, function (widget) { + record = record || _.findWhere(widget.records, {db_id: record_state.id}); + }); + } else { + record = _.findWhere(this.widgets, {db_id: record_state.id}); + } + + if (record) { + record.update(record_state); + } + }, + update_column: function (column_db_id, data) { + var column = _.findWhere(this.widgets, {db_id: column_db_id}); + this.widgets.splice(_.indexOf(this.widgets, column), 1); // remove column from widgets' list + var new_column = new KanbanColumn(this, data, this.column_options, this.record_options); + this.widgets.push(new_column); + return new_column.insertAfter(column.$el).then(column.destroy.bind(column)); + }, + _renderView: function () { + var is_grouped = !!this.state.groupedBy.length; + this.$el.toggleClass('o_kanban_grouped', is_grouped); + this.$el.toggleClass('o_kanban_ungrouped', !is_grouped); + this.$el.empty(); + + var fragment = document.createDocumentFragment(); + if (is_grouped) { + this._renderGrouped(fragment); + } else { + this._render_ungrouped(fragment); + } + this.$el.append(fragment); + return this._super.apply(this, arguments); + }, + _render_ungrouped: function (fragment) { + var self = this; + _.each(this.state.data, function (record) { + var kanban_record = new KanbanRecord(self, record, self.record_options); + self.widgets.push(kanban_record); + kanban_record.appendTo(fragment); + }); + + // add empty invisible divs to make sure that all kanban records are left aligned + for (var i = 0, ghost_div; i < 6; i++) { + ghost_div = $("<div>").addClass("o_kanban_record o_kanban_ghost"); + ghost_div.appendTo(fragment); + } + }, + _renderGrouped: function (fragment) { + var self = this; + var group_by_field_attrs = this.state.fields[this.state.groupedBy[0]]; + // Deactivate the drag'n'drop if the groupedBy field: + // - is a date or datetime since we group by month or + // - is readonly + var draggable = true; + if (group_by_field_attrs) { + if (group_by_field_attrs.type === "date" || group_by_field_attrs.type === "datetime") { + draggable = false; + } else if (group_by_field_attrs.readonly !== undefined) { + draggable = !(group_by_field_attrs.readonly); + } + } + var grouped_by_m2o = group_by_field_attrs && (group_by_field_attrs.type === 'many2one'); + var grouped_by_field = grouped_by_m2o && group_by_field_attrs.relation; + this.column_options = _.extend(this.column_options, { + draggable: draggable, + grouped_by_m2o: grouped_by_m2o, + relation: grouped_by_field, + }); + + // Render columns + _.each(this.state.data, function (group) { + var column = new KanbanColumn(self, group, self.column_options, self.record_options); + if (!group.value) { + column.prependTo(fragment); // display the 'Undefined' group first + self.widgets.unshift(column); + } else { + column.appendTo(fragment); + self.widgets.push(column); + } + }); + + // Enable column sorting + this.$el.sortable({ + axis: 'x', + items: '> .o_kanban_group', + handle: '.o_kanban_header', + cursor: 'move', + revert: 150, + delay: 100, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function () { + var ids = []; + self.$('.o_kanban_group').each(function (index, u) { + ids.push($(u).data('id')); + }); + self.trigger_up('resequence_columns', {ids: ids}); + }, + }); + + // Column quick create + if (this.column_options.group_creatable && grouped_by_m2o) { + this.column_quick_create = new ColumnQuickCreate(this); + this.column_quick_create.appendTo(fragment); + } + }, + add_quick_create: function () { + this.widgets[0].add_quick_create(); + }, + remove_widget: function (widget) { + this.widgets.splice(this.widgets.indexOf(widget), 1); + widget.destroy(); + } +}); + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js new file mode 100644 index 00000000000..5ba06b08dad --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -0,0 +1,83 @@ +odoo.define('web.KanbanView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var KanbanModel = require('web.KanbanModel'); +var KanbanRenderer = require('web.KanbanRenderer'); +var KanbanController = require('web.KanbanController'); + +var _lt = core._lt; + +var KanbanView = BasicView.extend({ + accesskey: "k", + className: "o_kanban", + display_name: _lt("Kanban"), + icon: 'fa-th-large', + mobile_friendly: true, + config: { + Model: KanbanModel, + Controller: KanbanController, + Renderer: KanbanRenderer, + }, + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var activeActions = this.controllerParams.activeActions; + activeActions = _.extend(activeActions, { + group_edit: arch.attrs.group_edit ? JSON.parse(arch.attrs.group_edit) : true, + group_delete: arch.attrs.group_delete ? JSON.parse(arch.attrs.group_delete) : true, + }); + + this.rendererParams.column_options = { + editable: activeActions.group_edit, + deletable: activeActions.group_delete, + group_creatable: true, + quick_create: params.isQuickCreateEnabled || this._isQuickCreateEnabled(arch), + }; + this.rendererParams.record_options = { + editable: activeActions.edit, + deletable: activeActions.delete, + model: this.model, // required by includes of KanbanRecord + read_only_mode: params.readOnlyMode, + }; + + this.controllerParams.on_create = arch.attrs.on_create; + + this.controllerParams.readOnlyMode = false; + this.controllerParams.hasButtons = true; + + this.loadParams.limit = this.loadParams.limit || 40; + this.loadParams.openGroupByDefault = true; + this.loadParams.type = 'list'; + + this.loadParams.groupBy = arch.attrs.default_group_by ? [arch.attrs.default_group_by] : (params.groupBy || []); + }, + _isQuickCreateEnabled: function (arch) { + if (!this.controllerParams.activeActions.create) { + return false; + } + if (arch.attrs.quick_create !== undefined) { + return JSON.parse(arch.attrs.quick_create); + } + return true; + } + + +}); + +return KanbanView; + +}); + + // config: _.extend({}, AbstractView.prototype.config, { + // openGroupByDefault: true, + // }), + // FIXME: adapt those options + // defaults: _.extend(BasicController.prototype.defaults, { + // quick_creatable: true, + // creatable: true, + // read_only_mode: false, + // confirm_on_delete: true, + // }), + diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js new file mode 100644 index 00000000000..881a1eb24ec --- /dev/null +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -0,0 +1,353 @@ +odoo.define('web.ListController', function (require) { +"use strict"; + +/** + * The List Controller controls the list renderer and the list model. Its role + * is to allow these two components to communicate properly, and also, to render + * and bind all extra buttons/pager in the control panel. + */ + +var core = require('web.core'); +var BasicController = require('web.BasicController'); +var DataExport = require('web.DataExport'); +var pyeval = require('web.pyeval'); +var Sidebar = require('web.Sidebar'); + +var _t = core._t; +var qweb = core.qweb; + +var ListController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + list_button_clicked: '_onListButtonClicked', + change_mode: '_onChangeMode', + selection_changed: '_onSelectionChanged', + toggle_column_order: '_onToggleColumnOrder', + toggle_group: '_onToggleGroup', + }), + /** + * @constructor + * @override + * @param {Object} params + * @param {Object} params + * @param {boolean} params.editable + * @param {boolean} params.hasSidebar + * @param {boolean} params.hasToolbar + * @param {boolean} params.noLeaf + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.hasSidebar = params.hasSidebar; + this.hasToolbar = params.hasToolbar; + this.editable = params.editable; + this.noLeaf = params.noLeaf; + this.selectedRecords = []; // there is no selected record by default + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Calculate the active domain of the list view. This should be done only + * if the header checkbox has been checked. This is done by evaluating the + * search results, and then adding the dataset domain (i.e. action domain). + * + * @todo This is done only for the sidebar. The full mechanism is wrong, + * this method should be private, most of the code in the sidebar should be + * moved to the controller, and we should not use the getParent method... + * + * @returns {Deferred<array[]>} a deferred that resolve to the active domain + */ + getActiveDomain: function () { + // TODO: this method should be synchronous... + var self = this; + if (this.$('thead .o_list_record_selector input').prop('checked')) { + var searchView = this.getParent().searchview; // fixme + var searchData = searchView.build_search_data(); + var userContext = this.getSession().user_context; + var results = pyeval.eval_domains_and_contexts({ + domains: searchData.domains, + contexts: [userContext].concat(searchData.contexts), + group_by_seq: searchData.groupbys || [] + }); + return $.when(self.dataset.domain.concat(results.domain || [])); + } else { + return $.Deferred().resolve(); + } + }, + /** + * Returns the list of currently selected res_ids (with the check boxes on + * the left) + * + * @todo This should be private. Need to change sidebar code + * + * @returns {number[]} list of res_ids + */ + getSelectedIds: function () { + var self = this; + return _.map(this.selectedRecords, function (db_id) { + return self.model.get(db_id, {raw: true}).res_id; + }); + }, + /** + * Display and bind all buttons in the control panel + * + * @override + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { + if (!this.noLeaf && this.hasButtons) { + this.$buttons = $(qweb.render("ListView.buttons", {'widget': this})); + this.$buttons.find('.o_list_button_add') + .click(this._onCreateRecord.bind(this)); + this.$buttons.find('.o_list_button_save') + .click(this._onSave.bind(this)); + this.$buttons.find('.o_list_button_discard') + .click(this._onDiscard.bind(this)); + this.$buttons.appendTo($node); + } + }, + /** + * Render the sidebar (the 'action' menu in the control panel, right of the + * main buttons) + * + * @param {jQuery Node} $node + */ + renderSidebar: function ($node) { + if (this.hasSidebar && !this.sidebar) { + this.sidebar = new Sidebar(this, { + editable: this.is_action_enabled('edit') + }); + if (this.hasToolbar) { + this.sidebar.add_toolbar(this.fields_view.toolbar); + } + var other = [{ + label: _t("Export"), + callback: this._onExportData.bind(this) + }]; + if (this.archiveEnabled) { + other.push({ + label: _t("Archive"), + callback: this._onToggleArchiveState.bind(this, true) + }); + other.push({ + label: _t("Unarchive"), + callback: this._onToggleArchiveState.bind(this, false) + }); + } + if (this.is_action_enabled('delete')) { + other.push({ + label: _t('Delete'), + callback: this._onDeleteSelectedRecords.bind(this) + }); + } + this.sidebar.add_items('other', other); + this.sidebar.appendTo($node); + + this._toggleSidebar(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _archive: function (ids, archive) { + if (ids.length === 0) { + return $.when(); + } + return this.model + .toggleActive(ids, !archive, this.handle) + .then(this.update_state.bind(this)); + }, + /** + * This function is the hook called by the field manager mixin to confirm + * that a change just occured (after the eventual onchange have been + * applied). + * + * @param {string} id a basicmodel id, which is a record in the list view + * @param {string[]} fields the changed fields + */ + _confirmChange: function (id, fields) { + var state = this.model.get(this.handle); + this.renderer.confirmChange(state, id, fields); + }, + /** + * This function is the hook called by the field manager mixin to confirm + * that a record has been saved. + * + * @override + * @param {string} id a basicmodel valid resource handle. It is supposed to + * be a record from the list view. + */ + _confirmSave: function (id) { + var state = this.model.get(this.handle); + this.renderer.confirmSave(state, id); + }, + /** + * Display the sidebar (the 'action' menu in the control panel) if we have + * some selected records. + */ + _toggleSidebar: function () { + if (this.sidebar) { + this.sidebar.do_toggle(this.selectedRecords.length > 0); + } + }, + /** + * @override + * @param {Object} state + * @returns {Deferred} + */ + _update: function (state) { + this._toggleNoContentHelper(state.count === 0); + this._toggleSidebar(); + return this._super.apply(this, arguments); + }, + /** + * This helper simply makes sure that the control panel buttons matches the + * current mode. + * + * @param {string} mode either 'readonly' or 'edit' + */ + _updateButtons: function (mode) { + if (this.$buttons) { + this.$buttons.toggleClass('o-editing', mode === 'edit'); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles a click on a button by performing its action. + * + * @param {OdooEvent} event + */ + _onListButtonClicked: function (event) { + this.trigger_up('execute_action', { + action_data: event.data.attrs, + model: this.modelName, + record_id: event.data.record.res_id, + }); + }, + /** + * This event is triggered when a list renderer goes from mode = readonly to + * edit, and vice versa. In that case, we need to make sure that the buttons + * displayed in the control panel are correct. + * + * @param {OdooEvent} event + */ + _onChangeMode: function (event) { + this._updateButtons(event.data.mode); + }, + /** + * When the user clicks on the 'create' button, two things can happen. We + * can switch to the form view with no active res_id, so it is in 'create' + * mode, or we can edit inline. + * + * @param {MouseEvent} event + */ + _onCreateRecord: function (event) { + // we prevent the event propagation because we don't want this event to + // trigger a click on the main bus, which would be then caught by the + // list editable renderer and would unselect the newly created row + event.stopPropagation(); + + var self = this; + if (this.editable) { + this.model.addDefaultRecord(this.handle).then(function (recordID) { + self._toggleNoContentHelper(false); + var state = self.model.get(self.handle); + self.renderer.updateState(state); + self.renderer.editRecord(recordID); + }); + } else { + this.trigger_up('switch_view', {view_type: 'form', res_id: undefined}); + } + }, + /** + * Called when the 'delete' action is clicked on in the side bar. + */ + _onDeleteSelectedRecords: function () { + this._deleteRecords(this.selectedRecords); + }, + /** + * Handler called when the user clicked on the 'Discard' button. + */ + _onDiscard: function () { + this.model.discardChanges(this.handle); + this.update(this.handle, {reload: false}); + }, + /** + * Opens the Export Dialog + */ + _onExportData: function () { + new DataExport(this, this.dataset).open(); + }, + /** + * changes of the list editable are automatically saved when unselecting the + * row, which is done when clicking on 'Save' (anywhere outside the row + * actually), so this function should only switch back to readonly mode + * + * @param {MouseEvent} event + */ + _onSave: function (event) { + // we prevent the event propagation because we don't want this event to + // trigger a click on the main bus, which would be then caught by the + // list editable renderer and would unselect the newly created row + // event.stopPropagation(); + this._updateButtons('readonly'); + }, + /** + * When the current selection changes (by clicking on the checkboxes on the + * left), we need to display (or hide) the 'sidebar'. + * + * @param {OdooEvent} event + */ + _onSelectionChanged: function (event) { + this.selectedRecords = event.data.selection; + this._toggleSidebar(); + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the sidebar. + * + * @param {boolean} archive + */ + _onToggleArchiveState: function (archive) { + this._archive(this.selectedRecords, archive); + }, + /** + * When the user clicks on one of the sortable column headers, we need to + * tell the model to sort itself properly, to update the pager and to + * rerender the view. + * + * @param {OdooEvent} event + */ + _onToggleColumnOrder: function (event) { + var data = this.model.get(this.handle); + if (!data.groupedBy) { + this.pager.updateState({current_min: 1}); + } + this.model + .setSort(data.id, event.data.name) + .reload(data.id) + .then(this.update.bind(this)); + }, + /** + * In a grouped list view, each group can be clicked on to open/close them. + * This method just transfer the request to the model, then update the + * renderer. + * + * @param {OdooEvent} event + */ + _onToggleGroup: function (event) { + this.model + .toggleGroup(event.data.group.id) + .then(this.update.bind(this, {}, {reload: false})); + }, +}); + +return ListController; + +}); diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js new file mode 100644 index 00000000000..85efc1b6d52 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -0,0 +1,649 @@ +odoo.define('web.EditableListRenderer', function (require) { +"use strict"; + +/** + * Editable List renderer + * + * The list renderer is reasonably complex, so we split it in two files. This + * file simply 'includes' the basic ListRenderer to add all the necessary + * behavior to enable editing records. + * + * Unlike Odoo v10 and before, this list renderer is independant from the form + * view. It uses the same widgets, but the code is totally stand alone. + */ + +var core = require('web.core'); +var Domain = require('web.Domain'); +var ListRenderer = require('web.ListRenderer'); +var field_registry = require('web.field_registry'); + +var _t = core._t; + +ListRenderer.include({ + custom_events: { + field_changed: '_onFieldChanged', + move_down: '_onMoveDown', + move_up: '_onMoveUp', + move_left: '_onMoveLeft', + move_right: '_onMoveRight', + move_next: '_onMoveNext', + move_next_line: '_onMoveNextLine', + }, + events: _.extend({}, ListRenderer.prototype.events, { + 'click tbody td': '_onCellClick', + 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick', + 'click tfoot': '_onFooterClick', + 'click tr .o_list_record_delete': '_onTrashIconClick', + 'click .o_form_field_x2many_list_row_add a': '_onAddRecord', + }), + /** + * @override + * @param {Object} params + * @param {string} params.mode either 'readonly' or 'edit' + * @param {boolean} params.addCreateLine + * @param {boolean} params.addTrashIcon + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.mode = params.mode; + + // if addCreateLine is true, the renderer will add a 'Add an item' link + // at the bottom of the list view + this.addCreateLine = params.addCreateLine; + + // if addTrashIcon is true, there will be a small trash icon at the end + // of each line, so the user can delete a record. + this.addTrashIcon = params.addTrashIcon; + + this.currentRow = null; + this.currentCol = null; + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + if (this.mode === 'edit') { + this.$el.css({height: '100%'}); + core.bus.on('click', this, this._onWindowClicked.bind(this)); + } + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method is called by the controller, when a change has been confirmed + * by the model. In that situation, we need to properly update the cell + * widgets. (it is necessary, because the value could have been changed by + * an onchange). + * + * Note that it could happen that the changedRecordID is not found in the + * current state. This is possible if a change in a record caused an + * onchange which erased the current record. In that case, we simply + * rerender the list. + * + * @param {Object} state a record resource (the new full state) + * @param {string} changedRecordID the local id for the changed record + * @param {string[]} changedFields list of modified fields + */ + confirmChange: function (state, changedRecordID, changedFields) { + this.state = state; + var rowIndex = _.findIndex(this.state.data, {id: changedRecordID}); + if (rowIndex > -1) { + this._updateRow(rowIndex, changedFields); + } else { + this._render(); + } + }, + /** + * This method is called by the controller when the user has clicked 'save', + * and the model as actually saved the current changes. We then need to + * set the correct values for each cell. The difference with confirmChange + * is that the edited line is now in readonly mode, so we can just set its + * new value. + * + * @param {Object} state the new full state for the list + * @param {string} savedRecordID the id for the saved record + */ + confirmSave: function (state, savedRecordID) { + this.state = state; + var index = _.findIndex(this.state.data, {id: savedRecordID}); + for (var j = 0; j < this.columns.length; j++) { + this._setCellValue(index, j, false); + } + }, + /** + * Edit a given record in the list + * + * @param {string} recordID + */ + editRecord: function (recordID) { + var index = _.findIndex(this.state.data, function (record) { + return record.id === recordID; + }); + this._selectCell(index, 0); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Find the td in a row corresponding the a field with a given index. The + * main problem is that in some cases, we have a 'selector' checkbox, so the + * correct td is sometimes at position i, sometimes at i+1 + * + * @param {jQueryElement} $row the $tr for the row we are interested in + * @param {number} index the index of the field/column + * @returns {jQueryElement} the $td for the index-th field + */ + _findTd: function ($row, index) { + return $row.find('td').eq(index + (this.hasSelectors ? 1 : 0)); + }, + /** + * Returns the current number of columns. The editable renderer may add a + * trash icon on the right of a record, so we need to take this into account + * + * @override + * @returns {number} + */ + _getNumberOfCols: function () { + var n = this._super(); + if (this.addTrashIcon) { + n++; + } + return n; + }, + /** + * Determine if a given cell is readonly. A cell is considered readonly if + * the field is readonly, or the widget is statically readonly (like the + * sequence widget) of if there are a 'readonly' attr, with a matching + * domain. + * + * @param {Object} record a basic model record + * @param {Object} field a field description + * @param {Object} node a node object (from the arch) + * @returns {boolean} + */ + _isReadonly: function (record, field, node) { + if (field.readonly || (node.Widget && node.Widget.prototype.readonly)) { + return true; + } + if ('readonly' in node.modifiers) { + var fieldValues = this._getFieldValues(record); + return new Domain(node.modifiers.readonly).compute(fieldValues); + } + return false; + }, + /** + * A cell is editable if its field isn't invisible and if it isn't readonly. + * + * @param {Object} record a basic model record + * @param {Object} field a field description + * @param {Object} node a node object (from the arch) + * @returns {boolean} + */ + _isEditable: function (record, field, node) { + return !this._isInvisible(record, node) && !this._isReadonly(record, field, node); + }, + /** + * The renderer needs to support reordering lines. This is only active in + * edit mode. The hasHandle attribute is used when there is a sequence + * widget. + * + * @override + * @returns {jQueryElement} + */ + _renderBody: function () { + var $body = this._super(); + if (this.mode === 'edit' && this.hasHandle) { + $body.sortable({ + axis: 'y', + items: '> tr.o_data_row', + helper: 'clone', + handle: '.o_row_handle', + }); + } + return $body; + }, + /** + * Add all the necessary styling classes to displayed cells. Also, we need + * to add the current index to each cell data attribute, so we can get it + * back when we need to select a cell. + * + * @override + * @param {Object} record + * @param {Object} node + * @param {integer} index + * @returns {jQueryElement} + */ + _renderBodyCell: function (record, node, index) { + if (this.mode === 'readonly') { + return this._super.apply(this, arguments); + } + var $cell = this._super(record, node); + if (node.tag === 'field') { + var field = this.state.fields[node.attrs.name]; + if (field.required) { + $cell.addClass('o_required_field'); + } + if (this._isReadonly(record, field, node)) { + $cell.addClass('o_readonly'); + } + } + return $cell.data('index', index); + }, + /** + * Helper method, to instantiate and render a field widget. It is only + * called by _selectCell, when editing a row. It kind of looks like the + * _renderBodyCell method of ListRenderer, but it has to properly tag the + * widget and to do other subtly different tasks, so it is awkward to try to + * merge them. + * + * @private + * @param {jQueryElement} $row + * @param {Object} node + * @param {integer} rowIndex + * @param {integer} colIndex + * @returns {AbstractField} the instance of the desired widget + */ + _renderFieldWidget: function ($row, node, rowIndex, colIndex) { + if (node.tag === 'button') { + return; + } + var record = this.state.data[rowIndex]; + var field = this.state.fields[node.attrs.name]; + if (!this._isEditable(record, field, node)) { + return; + } + var $td = this._findTd($row, colIndex); + // force the width of the cell to its current value so that it won't + // change if its content changes (to prevent the list view from + // flickering). + $td.width($td.width()); + var widget_keys = ['list.' + field.type, field.type]; + if (node.attrs.widget) { + widget_keys.unshift(node.attrs.widget); + widget_keys.unshift('list.' + node.attrs.widget); + } + var Widget = field_registry.getAny(widget_keys); + var widget = new Widget(this, node.attrs.name, record, { + mode: 'edit' + }); + if (widget.replace_element) { + $td.empty(); + } + $td.addClass('o_edit_mode'); + widget.appendTo($td); + widget.__rowIndex = rowIndex; + widget.__colIndex = colIndex; + this.widgets.push(widget); + return widget; + }, + /** + * Editable rows are extended with their index, and possibly a trash icon on + * their right, to allow deleting the corresponding record. + * + * @override + * @param {any} record + * @param {any} index + * @returns {jQueryElement} + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + if (this.mode === 'edit') { + $row.data('index', index); + } + if (this.addTrashIcon) { + var $icon = $('<span>').addClass('fa fa-trash-o').attr('name', 'delete'); + var $td = $('<td>').addClass('o_list_record_delete').append($icon); + $row.append($td); + } + return $row; + }, + /** + * If the editable list view has the parameter addCreateLine, we need to + * add a last row with the necessary control. + * + * @override + * @returns {jQueryElement} + */ + _renderRows: function () { + var $rows = this._super(); + if (this.addCreateLine) { + var $a = $('<a href="#">').text(_t("Add an item")); + var $td = $('<td>') + .attr('colspan', this._getNumberOfCols()) + .addClass('o_form_field_x2many_list_row_add') + .append($a); + var $tr = $('<tr>').append($td); + $rows.push($tr); + } + return $rows; + }, + /** + * This is one of the trickiest method in the editable renderer. It has to + * do a lot of stuff: it has to determine which cell should be selected (if + * the terget cell is readonly, we need to find another suitable cell), then + * unselect the current row, and activate the line where the selected cell + * is, if necessary. + * + * @param {integer} rowIndex + * @param {integer} colIndex + */ + _selectCell: function (rowIndex, colIndex) { + var self = this; + + // do nothing if user tries to select current cell + if (rowIndex === this.currentRow && colIndex === this.currentCol) { + return; + } + + // make sure the colIndex is on an editable field. otherwise, find + // next editable field, or do nothing if no such field exists + var data = this.state.data; + var fields = this.state.fields; + var col, field, isEditable, record; + for (var i = 0; i < this.columns.length; i++) { + col = this.columns[colIndex]; + record = data[rowIndex]; + field = fields[col.attrs.name]; + isEditable = this._isEditable(record, field, col); + if (isEditable) { + break; + } + colIndex = (colIndex + 1) % this.columns.length; + } + if (!isEditable) { + return; + } + + // if we are just changing active cell in the same row, activate the + // corresponding widget and return + if (rowIndex === this.currentRow && colIndex !== this.currentCol) { + var w = _.findWhere(this.widgets, { + __rowIndex: rowIndex, + __colIndex: colIndex + }); + this.currentCol = colIndex; + w.activate(); + return; + } + + // if this is a different column, then we need to prepare for it by + // unselecting the current row. This will trigger the save. + if (rowIndex !== this.currentRow) { + this._unselectRow(); + } + + // instantiate column widgets + var $row = this.$('tbody tr').eq(rowIndex); + $row.addClass('o_selected_row'); + + _.each(this.columns, function (node, index) { + var widget = self._renderFieldWidget($row, node, rowIndex, index); + if (widget && index === colIndex) { + widget.activate(); + } + }); + + this.currentRow = rowIndex; + this.currentCol = colIndex; + this.trigger_up('change_mode', {mode: 'edit'}); + }, + /** + * Set the value of a cell. This method can be called when the value of a + * cell was modified, for example afte an onchange. + * + * @param {integer} rowIndex + * @param {integer} colIndex + * @param {boolean} keepDirtyFlag + * @param {boolean} forceDirtyFlag + */ + _setCellValue: function (rowIndex, colIndex, keepDirtyFlag, forceDirtyFlag) { + var record = this.state.data[rowIndex]; + var node = this.columns[colIndex]; + var $td = this._findTd(this.$('tbody tr').eq(rowIndex), colIndex); + var $new_td = this._renderBodyCell(record, node, colIndex); + if (keepDirtyFlag && $td.hasClass('o_field_dirty') || forceDirtyFlag) { + $new_td.addClass('o_field_dirty'); + } + $td.replaceWith($new_td); + }, + _updateRow: function (index, changed_fields) { + var self = this; + var record = this.state.data[index]; + _.each(changed_fields, function (field_name) { + var widget = _.findWhere(self.widgets, {name: field_name}); + if (widget && widget.__rowIndex === index) { + widget.reset(record); + widget.$el.parent().addClass('o_field_dirty'); + } else { + var column_index = _.findIndex(self.columns, function (column) { + return column.attrs.name === field_name; + }); + self._setCellValue(index, column_index, true, true); + } + }); + }, + /** + * This method is called whenever we click/move outside of a row that was + * in edit mode. This is the moment we save all accumulated changes on that + * row, if needed. + */ + _unselectRow: function () { + var self = this; + if (this.currentRow === null) { + return; + } + // trigger the save (if the record isn't dirty, the datamodel won't save + // anything) + this.trigger_up('field_changed', { + dataPointID: this.state.data[this.currentRow].id, + changes: {}, + force_save: true, + }); + this.trigger_up('change_mode', {mode: 'readonly'}); + var widgets = _.where(this.widgets, {__rowIndex: this.currentRow}); + _.each(widgets, function (widget) { + self._setCellValue(self.currentRow, widget.__colIndex, true); + widget.destroy(); + }); + self.widgets = _.without(self.widgets, widgets); + var $row = this.$('tbody tr').eq(this.currentRow); + $row.removeClass('o_selected_row'); + $row.find('td').removeClass('o_edit_mode'); + this.currentRow = null; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method is called when we click on the 'Add an Item' button in a sub + * list such as a one2many in a form view. + * + * @param {MouseEvent} event + */ + _onAddRecord: function (event) { + // we don't want the browser to navigate to a the # url + event.preventDefault(); + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + event.stopPropagation(); + + this.trigger_up('add_record'); + }, + /** + * When the user clicks on a cell, we simply select it. + * + * @param {MouseEvent} event + */ + _onCellClick: function (event) { + var $td = $(event.currentTarget); + var rowIndex = $td.parent().data('index'); + var colIndex = $td.data('index'); + if (colIndex !== undefined && rowIndex !== undefined) { + this._selectCell(rowIndex, colIndex); + event.stopPropagation(); + } + // FIXME: this seems like dead code + // if (this.state.groupedBy.length) { + // var id = $td.parent().data('id'); + // if (id) { + // this.trigger_up('record_selected', {id:id}); + // } + // } + }, + /** + * We need to manually unselect row, because noone else would do it + */ + _onEmptyRowClick: function (event) { + this._unselectRow(); + }, + /** + * We react to field_changed events by adding the .o_field_dirty css class, + * which changes its style to a visible orange outline. It allows the user + * to have a visual feedback on which fields changed, and on which fields + * have been saved. + * + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var $td = event.target.$el.parent(); + $td.addClass('o_field_dirty'); + }, + /** + * Clicking on a footer should unselect (and save) the currently selected + * row. It has to be done this way, because this is a click inside this.el, + * and _onWindowClicked ignore those clicks. + */ + _onFooterClick: function () { + this._unselectRow(); + }, + /** + * Move the cursor on the cell just down the current cell. + */ + _onMoveDown: function () { + if (this.currentRow < this.state.data.length - 1) { + this._selectCell(this.currentRow + 1, this.currentCol); + } + }, + /** + * Move the cursor on the left, if possible + */ + _onMoveLeft: function () { + if (this.currentCol > 0) { + this._selectCell(this.currentRow, this.currentCol - 1); + } + }, + /** + * Move the cursor on the next available cell, so either the next available + * cell on the right, or the first on the next line. This method is called + * when the user press the TAB key. + */ + _onMoveNext: function () { + if (this.currentCol < this.columns.length - 1) { + this._selectCell(this.currentRow, this.currentCol + 1); + } else if (this.currentRow < this.state.data.length - 1) { + this._selectCell(this.currentRow + 1, 0); + } + }, + /** + * Move the cursor one the beginning of the next line, if possible. This is + * called when the user press the ENTER key. + */ + _onMoveNextLine: function () { + if (this.currentRow < this.state.data.length - 1) { + this._selectCell(this.currentRow + 1, 0); + } + }, + /** + * Move the cursor one cell right, if possible + */ + _onMoveRight: function () { + if (this.currentCol < this.columns.length - 1) { + this._selectCell(this.currentRow, this.currentCol + 1); + } + }, + /** + * Move the cursor one line up + */ + _onMoveUp: function () { + if (this.currentRow > 0) { + this._selectCell(this.currentRow - 1, this.currentCol); + } + }, + /** + * Triggers a delete event. I don't know why we stop the propagation of the + * event. + * + * @param {MouseEvent} event + */ + _onTrashIconClick: function (event) { + event.stopPropagation(); + var id = $(event.target).closest('tr').data('id'); + this.trigger_up('list_record_delete', {id: id}); + }, + /** + * When a click happens outside the list view, or outside a currently + * selected row, we want to unselect it. + * + * This is quite tricky, because in many cases, such as an autocomplete + * dropdown opened by a many2one in a list editable row, we actually don't + * want to unselect (and save) the current row. + * + * So, we try to ignore clicks on subelements of the renderer that are + * appended in the body, outside the table) + * + * @param {MouseEvent} event + */ + _onWindowClicked: function (event) { + // ignore clicks if this renderer is not in the dom. + if (!document.contains(this.el)) { + return; + } + + // there is currenctly no selected row + if (this.currentRow === null) { + return; + } + + // ignore clicks in autocomplete dropdowns + if ($(event.target).parents('.ui-autocomplete').length) { + return; + } + + // ignore clicks in modals, except if the list is in a modal, and the + // click is performed in that modal + var $list_modal = this.$el.closest('.modal'); + if ($list_modal.length) { + var $click_modal = $(event.target).parents('.modal'); + if ($click_modal.prop('id') === $list_modal.prop('id')) { + return; + } + } + + // ignore clicks if target is no longer in dom. For example, a click on + // the 'delete' trash icon of a m2m tag. + if (!document.contains(event.target)) { + return; + } + + // ignore clicks if target is inside the list. In that case, they are + // handled directly by the renderer. + if (this.el.contains(event.target) && this.el !== event.target) { + return; + } + + this._unselectRow(); + }, + +}); + +}); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js new file mode 100644 index 00000000000..e3ec4637658 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -0,0 +1,583 @@ +odoo.define('web.ListRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var Domain = require('web.Domain'); +var field_registry = require('web.field_registry'); +var field_utils = require('web.field_utils'); +var session = require('web.session'); +var utils = require('web.utils'); + +var _t = core._t; + +// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes +var DECORATIONS = [ + 'decoration-bf', + 'decoration-it', + 'decoration-danger', + 'decoration-info', + 'decoration-muted', + 'decoration-primary', + 'decoration-success', + 'decoration-warning' +]; + +var FIELD_CLASSES = { + 'monetary': 'o_list_number', + 'text': 'o_list_text', + 'float': 'o_list_number', +}; + +var ListRenderer = BasicRenderer.extend({ + className: 'table-responsive', + events: { + 'click tbody tr': '_onRowClicked', + 'click tbody .o_list_record_selector': '_onSelectRecord', + 'click thead th.o_column_sortable': '_onSortColumn', + 'click .o_group_header': '_onToggleGroup', + 'click thead .o_list_record_selector input': '_onToggleSelection', + }, + /** + * @constructor + * @param {Widget} parent + * @param {any} state + * @param {Object} params + * @param {boolean} params.hasSelectors + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + var self = this; + this.hasHandle = false; + this.columns = _.reject(this.arch.children, function (c) { + if (c.attrs.invisible === '1') { + return true; + } + var modifiers = JSON.parse(c.attrs.modifiers || '{}'); + if (modifiers.tree_invisible) { + return true; + } + if (c.attrs.widget === 'handle') { + self.hasHandle = true; + } + var keys = ['list.' + c.attrs.widget, c.attrs.widget]; + c.Widget = field_registry.getAny(keys); + c.modifiers = modifiers; + return false; + }); + this.rowDecorations = _.chain(this.arch.attrs) + .pick(function (value, key) { + return DECORATIONS.indexOf(key) >= 0; + }).mapObject(function (value) { + return py.parse(py.tokenize(value)); + }).value(); + this.hasSelectors = params.hasSelectors; + this.selection = []; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method does a in-memory computation of the aggregate values, for + * each columns that corresponds to a numeric field with a proper aggregate + * function. + * + * The result of these computations is stored in the 'aggregate' key of each + * column of this.columns. This will be then used by the _renderFooter + * method to display the appropriate amount. + */ + _computeAggregates: function () { + var self = this; + var data = []; + if (this.selection.length) { + utils.traverse_records(this.state, function (record) { + if (_.contains(self.selection, record.id)) { + data.push(record); // find selected records + } + }); + } else { + data = this.state.data; + } + if (data.length === 0) { + return; + } + + _.each(this.columns, function (column) { + var attrs = column.attrs; + var field = self.state.fields[attrs.name]; + if (!field) { + return; + } + var type = field.type; + if (type !== 'integer' && type !== 'float' && type !== 'monetary') { + return; + } + var func = (attrs.sum && 'sum') || (attrs.avg && 'avg') || + (attrs.max && 'max') || (attrs.min && 'min'); + if (func) { + var count = 0; + var aggregate_value = (func === 'max') ? -Infinity : (func === 'min') ? Infinity : 0; + _.each(data, function (d) { + count += 1; + var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name]; + if (func === 'avg') { + aggregate_value += value; + } else if (func === 'sum') { + aggregate_value += value; + } else if (func === 'max') { + aggregate_value = Math.max(aggregate_value, value); + } else if (func === 'min') { + aggregate_value = Math.min(aggregate_value, value); + } + }); + if (func === 'avg') { + aggregate_value = aggregate_value / count; + } + column.aggregate = { + help: attrs[func], + value: aggregate_value, + }; + } + }); + }, + /** + * Each line can be decorated according to a few simple rules. The arch + * description of the list may have one of the decoration-X attribute with + * a domain as value. Then, for each record, we check if the domain matches + * the record, and add the text-X css class to the element. This method is + * concerned with the computation of the list of css classes for a given + * record. + * + * @param {Object} record a basic model record + * @returns {string[]} a list of css classes + */ + _computeDecorationClassNames: function (record) { + var context = _.extend({}, record.data, { + uid: session.uid, + current_date: moment().format('YYYY-MM-DD') + // TODO: time, datetime, relativedelta + }); + return _.chain(this.rowDecorations) + .pick(function (expr) { + return py.PY_isTrue(py.evaluate(expr, context)); + }).map(function (expr, decoration) { + return decoration.replace('decoration', 'text'); + }).value(); + }, + /** + * When a list view is grouped, we need to display the name of each group in + * the 'title' row. This is the purpose of this method. + * + * @param {any} value + * @param {Object} field a field description + * @returns {string} + */ + _formatValue: function (value, field) { + if (field && field.type === 'selection') { + var choice = _.find(field.selection, function (c) { + return c[0] === value; + }); + return choice[1]; + } + return value || _t('Undefined'); + }, + /** + * return the number of visible columns. Note that this number depends on + * the state of the renderer. For example, in editable mode, it could be + * one more that in non editable mode, because there may be a visible 'trash + * icon'. + * + * @returns {number} + */ + _getNumberOfCols: function () { + var n = this.columns.length; + return this.hasSelectors ? n+1 : n; + }, + /** + * Determine if a given cell is invisible. A cell is considered invisible + * if there is an 'invisible' attr, with a matching domain. + * + * @param {Object} record a basic model record + * @param {Object} node a node object (from the arch) + * @returns {boolean} + */ + _isInvisible: function (record, node) { + if ('invisible' in node.modifiers) { + var fieldValues = this._getFieldValues(record); + return new Domain(node.modifiers.invisible).compute(fieldValues); + } + return false; + }, + /** + * Render a list of <td>, with aggregates if available. It can be displayed + * in the footer, or for each open groups. + * + * @param {any} aggregate_values + * @returns {jQueryElement[]} a list of <td> with the aggregate values + */ + _renderAggregateCells: function (aggregate_values) { + var self = this; + return _.map(this.columns, function (column) { + var $cell = $('<td>'); + if (column.attrs.name in aggregate_values) { + var field = self.state.fields[column.attrs.name]; + var value = aggregate_values[column.attrs.name].value; + var help = aggregate_values[column.attrs.name].help; + var formattedValue = field_utils['format_' + field.type](value, field, {}); + $cell.addClass('o_list_number').attr('title', help).html(formattedValue); + } + return $cell; + }); + }, + /** + * Render the main body of the table, with all its content. Note that it + * has been decided to always render at least 4 rows, even if we have less + * data. The reason is that lists with 0 or 1 lines don't really look like + * a table. + * + * @returns {jQueryElement} a jquery element <tbody> + */ + _renderBody: function () { + var $rows = this._renderRows(); + while ($rows.length < 4) { + $rows.push(this._renderEmptyRow()); + } + return $('<tbody>').append($rows); + }, + /** + * Render a cell for the table. For most cells, we only want to display the + * formatted value, with some appropriate css class. However, when the + * node was explicitely defined with a 'widget' attribute, then we + * instantiate the corresponding widget. + * + * @param {Object} record + * @param {Object} node + * @returns {jQueryElement} a <td> element + */ + _renderBodyCell: function (record, node) { + var self = this; + var $td = $('<td>'); + if (this._isInvisible(record, node)) { + return $td; + } + if (node.tag === 'button') { + var $button = $('<button type="button">').addClass('o_icon_button'); + $button.append($('<i>').addClass('fa').addClass(node.attrs.icon)) + .prop('title', node.attrs.string) + .click(function (e) { + e.stopPropagation(); + self.trigger_up('list_button_clicked', { + attrs: node.attrs, + record: record, + }); + }); + $td.append($button); + $td.addClass('o_list_button').click(function (e) { + // prevent opening or editing the record on cell click + e.stopPropagation(); + }); + return $td; + } + var field = this.state.fields[node.attrs.name]; + var value = record.data[node.attrs.name]; + if (node.Widget) { + var widget = new node.Widget(this, node.attrs.name, record, { + mode: 'readonly', + }); + widget.appendTo($td); + $td.addClass('o_' + node.attrs.widget + '_cell'); + return $td; + } + $td.addClass(FIELD_CLASSES[field.type]); + var formatted_value = field_utils.format_field(value, field, { data: record.data }); + return $td.html(formatted_value); + }, + /** + * Render a complete empty row. This is used to fill in the blanks when we + * have less than 4 lines to display. + * + * @returns {jQueryElement} a <tr> element + */ + _renderEmptyRow: function () { + var $td = $('<td> </td>').attr('colspan', this._getNumberOfCols()); + return $('<tr>').append($td); + }, + /** + * Render the footer. It is a <tfoot> with a single row, containing all + * aggregates, if applicable. + * + * @param {boolean} isGrouped if the view is grouped, we have to add an + * extra <td> + * @returns {jQueryElement} a <tfoot> element + */ + _renderFooter: function (isGrouped) { + var aggregates = {}; + _.each(this.columns, function (column) { + if ('aggregate' in column) { + aggregates[column.attrs.name] = column.aggregate; + } + }); + var $cells = this._renderAggregateCells(aggregates); + if (isGrouped) { + $cells.unshift($('<td>')); + } + if (this.hasSelectors) { + $cells.unshift($('<td>')); + } + return $('<tfoot>').append($('<tr>').append($cells)); + }, + /** + * Render the row that represent a group + * + * @param {Object} group + * @returns {jQueryElement} a <tr> element + */ + _renderGroupRow: function (group) { + var aggregate_values = _.mapObject(group.aggregateValues, function (value) { + return { value: value }; + }); + var $cells = this._renderAggregateCells(aggregate_values); + if (this.hasSelectors) { + $cells.unshift($('<td>')); + } + var field = this.state.fields[group.groupedBy[0]]; + var name = this._formatValue(group.value, field); + var $th = $('<th>') + .addClass('o_group_name') + .text(name + ' (' + group.count + ')'); + if (group.count > 0) { + var $arrow = $('<span style="padding-right: 5px;">') + .addClass('fa') + .toggleClass('fa-caret-right', !group.isOpen) + .toggleClass('fa-caret-down', group.isOpen); + $th.prepend($arrow); + } + return $('<tr>') + .addClass('o_group_header') + .data('group', group) + .append($th) + .append($cells); + }, + /** + * Render all groups in the view. We assume that the view is in grouped + * mode. + * + * Note that each group is rendered inside a <tbody>, which contains a group + * row, then possibly a bunch of rows for each record. + * + * @returns {jQueryElement[]} a list of <tbody> + */ + _renderGroups: function () { + var self = this; + var result = []; + var $tbody = $('<tbody>'); + _.each(this.state.data, function (group) { + if (!$tbody) { + $tbody = $('<tbody>'); + } + $tbody.append(self._renderGroupRow(group)); + if (group.data.length) { + result.push($tbody); + var $records = _.map(group.data, function (record) { + return self._renderRow(record).prepend($('<td>')); + }); + result.push($('<tbody>').append($records)); + $tbody = null; + } + }); + if ($tbody) { + result.push($tbody); + } + return result; + }, + /** + * Render the main header for the list view. It is basically just a <thead> + * with the name of each fields + * + * @param {boolean} isGrouped + * @returns {jQueryElement} a <thead> element + */ + _renderHeader: function (isGrouped) { + var $tr = $('<tr>') + .append(_.map(this.columns, this._renderHeaderCell.bind(this))); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('th')); + } + if (isGrouped) { + $tr.prepend($('<th>').html(' ')); + } + return $('<thead>').append($tr); + }, + /** + * Render a single <th> with the informations for a column. If it is not a + * field, the th will be empty. Otherwise, it will contains all relevant + * information for the field. + * + * @param {Object} node + * @returns {jQueryElement} a <th> element + */ + _renderHeaderCell: function (node) { + var order = this.state.orderedBy; + var isNodeSorted = order[0] && order[0].name === node.attrs.name; + var field = this.state.fields[node.attrs.name]; + var $th = $('<th>'); + if (!field) { + return $th; + } + var description; + if (node.Widget) { + description = node.Widget.prototype.description; + } + if (description === undefined) { + description = node.attrs.string || field.string; + } + $th + .text(description) + .data('name', node.attrs.name) + .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false) + .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false) + .addClass(field.sortable && 'o_column_sortable'); + + if (field.type === 'float' || field.type === 'monetary') { + $th.css({textAlign: 'right'}); + } + + if (config.debug) { + var fieldDescr = { + field: field, + name: node.attrs.name, + string: description || node.attrs.name, + record: this.state, + attrs: node.attrs, + }; + this._addFieldTooltip(fieldDescr, $th); + } + return $th; + }, + /** + * Render a row, corresponding to a record. + * + * @param {Object} record + * @returns {jQueryElement} a <tr> element + */ + _renderRow: function (record) { + var decorations = this._computeDecorationClassNames(record); + var $cells = _.map(this.columns, this._renderBodyCell.bind(this, record)); + var $tr = $('<tr class="o_data_row">') + .data('id', record.id) + .addClass(decorations.length && decorations.join(' ')) + .append($cells); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('td')); + } + return $tr; + }, + /** + * Render all rows. This method should only called when the view is not + * grouped. + * + * @returns {jQueryElement[]} a list of <tr> + */ + _renderRows: function () { + return _.map(this.state.data, this._renderRow.bind(this)); + }, + _renderSelector: function (tag) { + var $content = $('<div class="o_checkbox"><input type="checkbox"><span/></div>'); + return $('<' + tag + ' width="1">') + .addClass('o_list_record_selector') + .append($content); + }, + /** + * @override + * returns {Deferred} + */ + _renderView: function () { + var self = this; + var $table = $('<table>').addClass('o_list_view table table-condensed table-striped'); + this.$el.empty().append($table); + var is_grouped = !!this.state.groupedBy.length; + this._computeAggregates(); + $table.toggleClass('o_list_view_grouped', is_grouped); + $table.toggleClass('o_list_view_ungrouped', !is_grouped); + if (is_grouped) { + $table + .append(this._renderHeader(true)) + .append(this._renderGroups()) + .append(this._renderFooter(true)); + } else { + $table + .append(this._renderHeader()) + .append(this._renderBody()) + .append(this._renderFooter()); + } + if (this.selection.length) { + var $checked_rows = this.$('tr').filter(function (index, el) { + return _.contains(self.selection, $(el).data('id')); + }); + $checked_rows.find('.o_list_record_selector input').prop('checked', true); + } + return this._super(); + }, + _updateSelection: function () { + var $selectedRows = this.$('tbody .o_list_record_selector input:checked') + .closest('tr'); + this.selection = _.map($selectedRows, function (row) { + return $(row).data('id'); + }); + this.trigger_up('selection_changed', { selection: this.selection }); + this._computeAggregates(); + this.$('tfoot').replaceWith(this._renderFooter(!!this.state.groupedBy.length)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {MouseEvent} event + */ + _onRowClicked: function (event) { + var id = $(event.currentTarget).data('id'); + if (id) { + this.trigger_up('open_record', {id:id, target: event.target}); + } + }, + /** + * @param {MouseEvent} event + */ + _onSelectRecord: function (event) { + event.stopPropagation(); + this._updateSelection(); + if (!$(event.currentTarget).find('input').prop('checked')) { + this.$('thead .o_list_record_selector input').prop('checked', false); + } + }, + /** + * @param {MouseEvent} event + */ + _onSortColumn: function (event) { + var name = $(event.currentTarget).data('name'); + this.trigger_up('toggle_column_order', {id: this.state.id, name: name}); + }, + /** + * @param {MouseEvent} event + */ + _onToggleGroup: function (event) { + var group = $(event.currentTarget).data('group'); + if (group.count) { + this.trigger_up('toggle_group', {group: group}); + } + }, + _onToggleSelection: function (event) { + var checked = $(event.currentTarget).prop('checked') || false; + this.$('tbody .o_list_record_selector input').prop('checked', checked); + this._updateSelection(); + }, +}); + +return ListRenderer; + +}); diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js new file mode 100644 index 00000000000..a616e9704df --- /dev/null +++ b/addons/web/static/src/js/views/list/list_view.js @@ -0,0 +1,58 @@ +odoo.define('web.ListView', function (require) { +"use strict"; + +/** + * The list view is one of the core and most basic view: it is used to look at + * a list of records in a table. + * + * Note that a list view is not instantiated to display a one2many field in a + * form view. Only a ListRenderer is used in that case. + */ + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var ListRenderer = require('web.ListRenderer'); +var ListController = require('web.ListController'); + +var _lt = core._lt; + +var ListView = BasicView.extend({ + accesskey: "l", + display_name: _lt('List'), + icon: 'fa-list-ul', + config: _.extend({}, BasicView.prototype.config, { + Renderer: ListRenderer, + Controller: ListController, + }), + /** + * @override + * + * @param {Object} arch + * @param {Object} fields + * @param {Object} params + * @param {boolean} params.sidebar + * @param {boolean} [params.hasSelectors=true] + */ + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + this.controllerParams.editable = arch.attrs.editable; + this.controllerParams.hasSidebar = params.sidebar; + this.controllerParams.noContentHelp = params.action && params.action.help; + this.controllerParams.activeActions.delete = true; + this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; + + this.rendererParams.arch = arch; + this.rendererParams.hasSelectors = + 'hasSelectors' in params ? params.hasSelectors : true; + this.rendererParams.mode = + arch.attrs.editable && !params.readonly ? "edit" : "readonly"; + + this.loadParams.limit = this.loadParams.limit || 80; + this.loadParams.type = 'list'; + }, +}); + +return ListView; + +}); diff --git a/addons/web/static/src/js/views/list_common.js b/addons/web/static/src/js/views/list_common.js deleted file mode 100644 index 0d96b251ec2..00000000000 --- a/addons/web/static/src/js/views/list_common.js +++ /dev/null @@ -1,405 +0,0 @@ -odoo.define('web.list_common', 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 - * @param {Boolean} [options.silent=false] - * @returns this - */ - remove: function (record, options) { - 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--; - if (!options || !options.silent) { - 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/views/list_view.js b/addons/web/static/src/js/views/list_view.js deleted file mode 100644 index 83e1b7bf900..00000000000 --- a/addons/web/static/src/js/views/list_view.js +++ /dev/null @@ -1,2075 +0,0 @@ - -odoo.define('web.ListView', function (require) { -"use strict"; - -var core = require('web.core'); -var data = require('web.data'); -var data_manager = require('web.data_manager'); -var DataExport = require('web.DataExport'); -var formats = require('web.formats'); -var common = require('web.list_common'); -var Model = require('web.DataModel'); -var Pager = require('web.Pager'); -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; - -// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes -var row_decoration = [ - 'decoration-bf', - 'decoration-it', - 'decoration-danger', - 'decoration-info', - 'decoration-muted', - 'decoration-primary', - 'decoration-success', - 'decoration-warning' -]; - -var ListView = View.extend({ - _template: 'ListView', - accesskey: "l", - require_fields: true, - defaults: _.extend({}, View.prototype.defaults, { - // records can be selected one by one - selectable: true, - // list rows can be deleted - deletable: false, - // whether the column headers should be displayed - header: true, - // display addition button, with that label - addable: _lt("Create"), - // whether the list view can be sorted, note that once a view has been - // sorted it can not be reordered anymore - sortable: true, - // whether the view rows can be reordered (via vertical drag & drop) - reorderable: true, - action_buttons: true, - //whether the editable property of the view has to be disabled - disable_editable_mode: false, - }), - display_name: _lt('List'), - events: { - 'click thead th.o_column_sortable[data-id]': 'sort_by_column', - 'click .oe_view_nocontent': function() { - if (this.$buttons) { - this.$buttons.width(this.$buttons.width() + 1).openerpBounce(); - } - }, - }, - icon: 'fa-list-ul', - - /** - * Core class for list-type displays. - * - * As a view, needs a number of view-related parameters to be correctly - * instantiated, provides options and overridable methods for behavioral - * customization. - * - * See constructor parameters and method documentations for information on - * the default behaviors and possible options for the list view. - * - * @constructs ListView - * @extends View - * - * @param parent parent object - * @param {DataSet} dataset the dataset the view should work with - * @param {String} view_id the listview's identifier, if any - * @param {Object} options A set of options used to configure the view - * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox) - * @param {Boolean} [options.header=true] should the list's header be displayed - * @param {Boolean} [options.deletable=true] are the list rows deletable - * @param {void|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button. - * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers - * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows - */ - init: function() { - var self = this; - this._super.apply(this, arguments); - this.options = _.defaults(this.options, { - GroupsType: ListView.Groups, - ListType: ListView.List, - }); - - this.previous_colspan = null; - this.decoration = null; - - this.columns = []; - - this.records = new common.Collection(); - - this.set_groups(new (this.options.GroupsType)(this)); - - if (this.dataset instanceof data.DataSetStatic) { - this.groups.datagroup = new StaticDataGroup(this.dataset); - } else { - this.groups.datagroup = new DataGroup( - this, this.model, - this.dataset.get_domain(), - this.dataset.get_context()); - this.groups.datagroup.sort = this.dataset._sort; - } - - this.records.bind('change', function (event, record, key) { - if (!_(self.aggregate_columns).chain() - .pluck('name').contains(key).value()) { - return; - } - self.compute_aggregates(); - }); - - this.no_leaf = false; - this.grouped = false; - - if (!this.options.$pager || !this.options.$pager.length) { - this.options.$pager = false; - } - - this.options.deletable = this.options.deletable && this.is_action_enabled('delete'); - this.name = "" + this.fields_view.arch.attrs.string; - - // the view's number of records per page (|| section) - this._limit = (this.options.limit || - this.defaults.limit || - (this.getParent().action || {}).limit || - parseInt(this.fields_view.arch.attrs.limit, 10) || - 80); - // the index of the first displayed record (starting from 1) - this.current_min = 1; - - // Sort - var default_order = this.fields_view.arch.attrs.default_order; - var unsorted = !this.dataset._sort.length; - if (unsorted && default_order && !this.grouped) { - this.dataset.set_sort(default_order.split(',')); - } - }, - willStart: function() { - var self = this; - // Retrieve the decoration defined on the model's list view - this.decoration = _.pick(this.fields_view.arch.attrs, function(value, key) { - return row_decoration.indexOf(key) >= 0; - }); - this.decoration = _.mapObject(this.decoration, function(value) { - return py.parse(py.tokenize(value)); - }); - var fields_def = data_manager.load_fields(this.dataset).then(function(fields_get) { - self.fields_get = fields_get; - }); - return $.when(this._super(), fields_def); - }, - /** - * Set a custom Group construct as the root of the List View. - * - * @param {instance.web.ListView.Groups} groups - */ - set_groups: function (groups) { - var self = this; - if (this.groups) { - $(this.groups).unbind("selected deleted action row_link"); - delete this.groups; - } - - this.groups = groups; - $(this.groups).bind({ - 'selected': function (e, ids, records, deselected) { - self.do_select(ids, records, deselected); - }, - 'deleted': function (e, ids) { - self.do_delete(ids); - }, - 'action': function (e, action_name, id, callback) { - self.do_button_action(action_name, id, callback); - }, - 'row_link': function (e, id, dataset, view) { - self.do_activate_record(dataset.index, id, dataset, view); - } - }); - }, - /** - * Computes and returns the classnames for the provided record (from the - * ``@decoration`` attribute) - * - * @param {Record} record record for the current row - * @returns {String} classnames - */ - compute_decoration_classnames: function (record) { - var classnames= ''; - var context = _.extend({}, record.attributes, { - uid: session.uid, - current_date: moment().format('YYYY-MM-DD') - // TODO: time, datetime, relativedelta - }); - - _.each(this.decoration, function(expr, decoration) { - if (py.PY_isTrue(py.evaluate(expr, context))) { - classnames += ' ' + decoration.replace('decoration', 'text'); - } - }); - return classnames; - }, - /** - * Renders the table itself and inserts its content - * Computes and displays the aggregates - * Hooks up the selection of records event - * Adds adequate classname on the sorted column - */ - load_list: function() { - var self = this; - - // Render the table and append its content - this.$el.html(QWeb.render(this._template, this)); - this.$el.addClass(this.fields_view.arch.attrs['class']); - if (this.grouped) { - this.$('.o_list_view').addClass('o_list_view_grouped'); - } - this.$('.o_list_view').append(this.groups.elements); - - // Compute the aggregates and display them in the list's footer - this.compute_aggregates(); - - // Head hook - // Selecting records - this.$('thead .o_list_record_selector input').click(function() { - self.$('tbody .o_list_record_selector input').prop('checked', $(this).prop('checked') || false); - var selection = self.groups.get_selection(); - $(self.groups).trigger('selected', [selection.ids, selection.records]); - }); - - // Sort - if (this.dataset._sort.length) { - if (this.dataset._sort[0].indexOf('-') === -1) { - this.$('th[data-id=' + this.dataset._sort[0] + ']').addClass("o-sort-down"); - } else { - this.$('th[data-id=' + this.dataset._sort[0].split('-')[1] + ']').addClass("o-sort-up"); - } - } - - this.trigger('list_view_loaded', data, this.grouped); - return $.when(); - }, - /** - * Render the buttons according to the ListView.buttons template and - * add listeners on it. - * Set this.$buttons with the produced jQuery element - * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted - * $node may be undefined, in which case the ListView inserts them into this.options.$buttons - * if it exists - */ - render_buttons: function($node) { - if (!this.$buttons) { - this.$buttons = $(QWeb.render("ListView.buttons", {'widget': this})); - this.$buttons.on('click', '.o_list_button_add', this.proxy('do_add_record')); - this.$buttons.appendTo($node); - } - }, - /** - * Instantiate and render the sidebar. - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - * $node may be undefined, in which case the ListView inserts the sidebar in - * this.options.$sidebar or in a div of its template - **/ - render_sidebar: function($node) { - if (!this.sidebar && this.options.sidebar) { - this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); - if (this.fields_view.toolbar) { - this.sidebar.add_toolbar(this.fields_view.toolbar); - } - this.sidebar.add_items('other', _.compact([ - { label: _t("Export"), callback: this.on_sidebar_export }, - this.fields_view.fields.active && {label: _t("Archive"), callback: this.do_archive_selected}, - this.fields_view.fields.active && {label: _t("Restore"), callback: this.do_unarchive_selected}, - this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.do_delete_selected } - ])); - - $node = $node || this.options.$sidebar; - this.sidebar.appendTo($node); - - // Hide the sidebar by default (it will be shown as soon as a record is selected) - this.sidebar.do_hide(); - } - }, - /** - * Instantiate and render the pager and add listeners on it. - * Set this.pager - * @param {jQuery} [$node] a jQuery node where the pager should be inserted - * $node may be undefined, in which case the ListView inserts the pager into this.options.$pager - */ - render_pager: function($node, options) { - if (!this.pager && this.options.pager) { - this.pager = new Pager(this, this.dataset.size(), 1, this._limit, options); - this.pager.appendTo($node || this.options.$pager); - - this.pager.on('pager_changed', this, function (new_state) { - var self = this; - var limit_changed = (this._limit !== new_state.limit); - - this._limit = new_state.limit; - this.current_min = new_state.current_min; - this.reload_content().then(function() { - // Reset the scroll position to the top on page changed only - if (!limit_changed) { - self.set_scrollTop(0); - self.trigger_up('scrollTo', {offset: 0}); - } - }); - }); - } - }, - /** - * Updates the pager based on the provided dataset's information - * - * Horrifying side-effect: sets the dataset's data on this.dataset? - * - * @param {instance.web.DataSet} [dataset] - * @param {int} [current_min] the min pager value - */ - update_pager: function (dataset, current_min) { - this.dataset.ids = dataset.ids; - // Not exactly clean - if (dataset._length !== undefined) { - this.dataset._length = dataset._length; - } - if (this.pager && !this.grouped) { - var new_state = { size: this.dataset.size(), limit: this._limit }; - if (current_min) { - new_state.current_min = current_min; - } - this.pager.update_state(new_state); - } - }, - sort_by_column: function (e) { - e.stopPropagation(); - var $column = $(e.currentTarget); - var col_name = $column.data('id'); - var field = this.fields_view.fields[col_name]; - // test whether the field is sortable - if (field && !field.sortable) { - return false; - } - this.dataset.sort(col_name); - if($column.hasClass("o-sort-down") || $column.hasClass("o-sort-up")) { - $column.toggleClass("o-sort-up o-sort-down"); - } else { - $column.addClass("o-sort-down"); - } - $column.siblings('.o_column_sortable').removeClass("o-sort-up o-sort-down"); - - return this.reload_content(); - }, - /** - * Sets up the listview's columns: merges view and fields data, move - * grouped-by columns to the front of the columns list and make them all - * visible. - * - * @param {Object} fields fields_view_get's fields section - * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed - */ - setup_columns: function (fields, grouped) { - 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 for_(id, fields[id], field); - })); - if (grouped) { - this.columns.unshift(new ListView.MetaColumn('_group')); - } - - this.visible_columns = _.filter(this.columns, function (column) { - return column.invisible !== '1'; - }); - - this.aggregate_columns = _(this.visible_columns).invoke('to_aggregate'); - }, - /** - * Used to handle a click on a table row, if no other handler caught the - * event. - * - * The default implementation asks the list view's view manager to switch - * to a different view (by calling - * :js:func:`~instance.web.ViewManager.on_mode_switch`), using the - * provided record index (within the current list view's dataset). - * - * If the index is null, ``switch_to_record`` asks for the creation of a - * new record. - * - * @param {Number|void} index the record index (in the current dataset) to switch to - * @param {String} [view="page"] the view type to switch to - */ - select_record:function (index, view) { - view = view || index === null || index === undefined ? 'form' : 'form'; - this.dataset.index = index; - _.delay(_.bind(function () { - this.do_switch_view(view); - }, this)); - }, - do_show: function () { - this._super(); - if (this.sidebar) { - // Hide the sidebar by default (will be shown once a record is selected) - this.sidebar.do_hide(); - } - }, - /** - * re-renders the content of the list view - * - * @returns {$.Deferred} promise to content reloading - */ - reload_content: synchronized(function () { - var self = this; - this.setup_columns(this.fields_view.fields, this.grouped); - this.$('tbody .o_list_record_selector input').prop('checked', false); - this.records.reset(); - var reloaded = $.Deferred(); - this.groups.render(function () { - if (self.dataset.index === null) { - if (self.records.length) { - self.dataset.index = 0; - } - } else if (self.dataset.index >= self.records.length) { - self.dataset.index = self.records.length ? 0 : null; - } - self.load_list().then(function () { - if (!self.grouped && self.display_nocontent_helper()) { - self.no_result(); - } - reloaded.resolve(); - }); - }); - this.do_push_state({ - min: this.current_min, - limit: this._limit - }); - return reloaded.promise(); - }), - reload: function () { - return this.reload_content(); - }, - reload_record: function (record) { - var self = this; - var fields = this.fields_view.fields; - return this.dataset.read_ids( - [record.get('id')], - _.pluck(_(this.columns).filter(function (r) { - return r.tag === 'field'; - }), 'name'), - {check_access_rule: true} - ).then(function (records) { - var values = records[0]; - if (!values) { - self.records.remove(record); - return; - } - // _.each is broken if a field "length" is present - for (var key in values) { - if (fields[key] && fields[key].type === 'many2many') - record.set(key + '__display', false, {silent: true}); - record.set(key, values[key], {silent: true}); - } - record.trigger('change', record); - - /* When a record is reloaded, there is a rendering lag because of the addition/suppression of - a table row. Since the list view editable need to wait for the end of this rendering lag before - computing the position of the editable fields, a 100ms delay is added. */ - var def = $.Deferred(); - setTimeout(function() { - def.resolve(records); - }, 100); - return def; - }); - }, - - do_load_state: function(state, warm) { - var reload = false; - if (state.min && this.current_min !== state.min) { - this.current_min = state.min; - reload = true; - } - if (state.limit) { - if (_.isString(state.limit)) { - state.limit = null; - } - if (state.limit !== this._limit) { - this._limit = state.limit; - reload = true; - } - } - if (reload) { - 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 - * - * @param {Object} results results of evaluating domain and process for a search - */ - do_search: function (domain, context, group_by) { - this.current_min = 1; - this.groups.datagroup = new DataGroup( - this, this.model, domain, context, group_by); - this.groups.datagroup.sort = this.dataset._sort; - - if (_.isEmpty(group_by) && !context['group_by_no_leaf']) { - group_by = null; - } - this.no_leaf = !!context['group_by_no_leaf']; - this.grouped = !!group_by; - - // Hide the pager in grouped mode - if (this.pager && this.grouped) { - this.pager.do_hide(); - } - return this.reload_content(); - }, - /** - * Handles the signal to delete lines from the records list - * - * @param {Array} ids the ids of the records to delete - */ - do_delete: function (ids) { - if (!(ids.length && confirm(_t("Do you really want to remove these records?")))) { - return; - } - var self = this; - - return $.when(this.dataset.unlink(ids)).done(function () { - _(ids).each(function (id) { - self.records.remove(self.records.get(id)); - }); - // Hide the table if there is no more record in the dataset - if (self.display_nocontent_helper()) { - self.no_result(); - } else { - if (self.records.length && self.current_min === 1) { - // Reload the list view if we delete all the records of the first page - self.reload(); - } else if (self.records.length && self.dataset.size() > 0) { - // Load previous page if the current one is empty - self.pager.previous(); - } - // Reload the list view if we are not on the last page - if (self.current_min + self._limit - 1 < self.dataset.size()) { - self.reload(); - } - } - self.update_pager(self.dataset); - self.compute_aggregates(); - }); - }, - /** - * Handles the signal indicating that a new record has been selected - * - * @param {Array} ids selected record ids - * @param {Array} records selected record values - */ - do_select: function (ids, records, deselected) { - // uncheck header hook if at least one row has been deselected - if (deselected) { - this.$('thead .o_list_record_selector input').prop('checked', false); - } - - if (!ids.length) { - this.dataset.index = 0; - if (this.sidebar) { - this.sidebar.do_hide(); - } - this.compute_aggregates(); - return; - } - - this.dataset.index = _(this.dataset.ids).indexOf(ids[0]); - if (this.sidebar) { - this.sidebar.do_show(); - } - - this.compute_aggregates(_(records).map(function (record) { - return {count: 1, values: record}; - })); - }, - /** - * Handles action button signals on a record - * - * @param {String} name action name - * @param {Object} id id of the record the action should be called on - * @param {Function} callback should be called after the action is executed, if non-null - */ - do_button_action: function (name, id, callback) { - this.handle_button(name, id, callback); - }, - /** - * Base handling of buttons, can be called when overriding do_button_action - * in order to bypass parent overrides. - * - * The callback will be provided with the ``id`` as its parameter, in case - * handle_button's caller had to alter the ``id`` (or even create one) - * while not being ``callback``'s creator. - * - * This method should not be overridden. - * - * @param {String} name action name - * @param {Object} id id of the record the action should be called on - * @param {Function} callback should be called after the action is executed, if non-null - */ - handle_button: function (name, id, callback) { - var action = _.detect(this.columns, function (field) { - return field.name === name; - }); - if (!action) { return; } - if ('confirm' in action && !window.confirm(action.confirm)) { - return; - } - - var c = new data.CompoundContext(); - c.set_eval_context(_.extend({ - active_id: id, - active_ids: [id], - active_model: this.model - }, this.records.get(id).toContext())); - if (action.context) { - c.add(action.context); - } - action.context = c; - this.do_execute_action( - action, this.dataset, id, _.bind(callback, null, id)); - }, - /** - * Handles the activation of a record (clicking on it) - * - * @param {Number} index index of the record in the dataset - * @param {Object} id identifier of the activated record - * @param {instance.web.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups) - */ - do_activate_record: function (index, id, dataset, view) { - this.dataset.ids = dataset.ids; - this.select_record(index, view); - }, - /** - * Handles signal for the addition of a new record (can be a creation, - * can be the addition from a remote source, ...) - * - * The default implementation is to switch to a new record on the form view - */ - do_add_record: function () { - this.select_record(null); - }, - /** - * Handles deletion of all selected lines - */ - do_delete_selected: function () { - var ids = this.groups.get_selection().ids; - if (ids.length) { - this.do_delete(this.groups.get_selection().ids); - } else { - this.do_warn(_t("Warning"), _t("You must select at least one record.")); - } - }, - /** - * Handles archiving/unarchiving of selected lines - */ - do_archive_selected: function () { - var records = this.groups.get_selection().records; - this.do_archive(records, true); - }, - do_unarchive_selected: function () { - var records = this.groups.get_selection().records; - this.do_archive(records, false); - }, - do_archive: function (records, archive) { - var active_value = !archive; - var record_ids = []; - _.each(records, function(record) { - if (record.active != active_value) { - record_ids.push(record.id); - } - }); - if (record_ids.length) { - this.dataset.call('write', [record_ids, {active: active_value}]) - .done(_.bind(this.reload, this)); - } - }, - /** - * Computes the aggregates for the current list view, either on the - * records provided or on the records of the internal - * :js:class:`~instance.web.ListView.Group`, by calling - * :js:func:`~instance.web.ListView.group.get_records`. - * - * Then displays the aggregates in the table through - * :js:method:`~instance.web.ListView.display_aggregates`. - * - * @param {Array} [records] - */ - compute_aggregates: function (records) { - var columns = _(this.aggregate_columns).filter(function (column) { - return column['function']; }); - if (_.isEmpty(columns)) { return; } - - if (_.isEmpty(records)) { - records = this.groups.get_records(); - } - records = _(records).compact(); - - var count = 0, sums = {}; - _(columns).each(function (column) { - switch (column['function']) { - case 'max': - sums[column.id] = -Infinity; - break; - case 'min': - sums[column.id] = Infinity; - break; - default: - sums[column.id] = 0; - } - }); - _(records).each(function (record) { - count += record.count || 1; - _(columns).each(function (column) { - var field = column.id, - value = record.values[field]; - switch (column['function']) { - case 'sum': - sums[field] += value; - break; - case 'avg': - sums[field] += record.count * value; - break; - case 'min': - if (sums[field] > value) { - sums[field] = value; - } - break; - case 'max': - if (sums[field] < value) { - sums[field] = value; - } - break; - } - }); - }); - - var aggregates = {}; - _.each(_.filter(columns, function (column) { - if (column.currency_field && records.length > 0 && records[0].values['currency_id']) { - var currency_ids = _.map(records, function(record) {return record.values['currency_id'][0]}); - if (_.every(currency_ids, function (currency_id){return currency_id === currency_ids[0]})) { - return column; - } - } else { - return column; - } - }), function (column) { - var field = column.id; - switch (column['function']) { - case 'avg': - aggregates[field] = {value: sums[field] / count}; - break; - default: - aggregates[field] = {value: sums[field]}; - } - }); - - this.display_aggregates(aggregates); - }, - display_aggregates: function (aggregation) { - var $footer_cells = this.$('tfoot td'); - _(this.aggregate_columns).each(function (column) { - if (!column['function']) { - return; - } - - $footer_cells.filter(_.str.sprintf('[data-field=%s]', column.id)) - .addClass('o_list_number') - .html(column.format(aggregation, { process_modifiers: false })); - }); - }, - get_selected_ids: function() { - var ids = this.groups.get_selection().ids; - return ids; - }, - /** - * Calculate the active domain of the list view. This should be done only - * if the header checkbox has been checked. This is done by evaluating the - * search results, and then adding the dataset domain (i.e. action domain). - */ - get_active_domain: function () { - var self = this; - if (this.$('thead .o_list_record_selector input').prop('checked')) { - var search_view = this.getParent().searchview; - var search_data = search_view.build_search_data(); - 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; - }); - } - else { - return $.Deferred().resolve(); - } - }, - /** - * Adds padding columns at the start or end of all table rows (including - * field names row) - * - * @param {Number} count number of columns to add - * @param {Object} options - * @param {"before"|"after"} [options.position="after"] insertion position for the new columns - * @param {Object} [options.except] content row to not pad - */ - pad_columns: function (count, options) { - options = options || {}; - // padding for action/pager header - var $first_header = this.$('thead tr:first th'); - var colspan = $first_header.attr('colspan'); - if (colspan) { - if (!this.previous_colspan) { - this.previous_colspan = colspan; - } - $first_header.attr('colspan', parseInt(colspan, 10) + count); - } - // Padding for column titles, footer and data rows - var $rows = this.$el - .find('.oe_list_header_columns, tr:not(thead tr)') - .not(options['except']); - var newcols = new Array(count+1).join('<td class="oe_list_padding"></td>'); - if (options.position === 'before') { - $rows.prepend(newcols); - } else { - $rows.append(newcols); - } - }, - /** - * Removes all padding columns of the table - */ - unpad_columns: function () { - this.$('.oe_list_padding').remove(); - if (this.previous_colspan) { - this.$el - .find('thead tr:first th') - .attr('colspan', this.previous_colspan); - this.previous_colspan = null; - } - }, - display_nocontent_helper: function () { - return (this.dataset.size() === 0); - }, - no_result: function () { - this.$('.oe_view_nocontent').remove(); - if (this.groups.group_by || - !this.options.action || - !this.options.action.help) { - return; - } - this.$('table:first').hide(); - this.$el.prepend( - $('<div class="oe_view_nocontent">').html(this.options.action.help) - ); - } -}); -core.view_registry.add('list', ListView); - -ListView.List = Class.extend({ - /** - * List display for the ListView, handles basic DOM events and transforms - * them in the relevant higher-level events, to which the list view (or - * other consumers) can subscribe. - * - * Events on this object are registered via jQuery. - * - * Available events: - * - * `selected` - * Triggered when a row is selected (using check boxes), provides an - * array of ids of all the selected records. - * `deleted` - * Triggered when deletion buttons are hit, provide an array of ids of - * all the records being marked for suppression. - * `action` - * Triggered when an action button is clicked, provides two parameters: - * - * * The name of the action to execute (as a string) - * * The id of the record to execute the action on - * `row_link` - * Triggered when a row of the table is clicked, provides the index (in - * the rows array) and id of the selected record to the handle function. - * - * @constructs instance.web.ListView.List - * @extends instance.web.Class - * - * @param {Object} opts display options, identical to those of :js:class:`instance.web.ListView` - */ - init: function (group, opts) { - var self = this; - this.group = group; - this.view = group.view; - this.session = this.view.session; - - this.options = opts.options; - this.columns = opts.columns; - this.dataset = opts.dataset; - this.records = opts.records; - - this.record_callbacks = { - 'remove': function (event, record) { - var id = record.get('id'); - self.dataset.remove_ids([id]); - var $row = self.$current.children('[data-id=' + id + ']'); - var index = $row.data('index'); - $row.remove(); - }, - 'reset': function () { return self.on_records_reset(); }, - 'change': function (event, record, attribute, value, old_value) { - var $row; - if (attribute === 'id') { - if (old_value) { - throw new Error(_.str.sprintf( _t("Setting 'id' attribute on existing record %s"), - JSON.stringify(record.attributes) )); - } - self.dataset.add_ids([value], self.records.indexOf(record)); - // Set id on new record - $row = self.$current.children('[data-id=false]'); - } else { - $row = self.$current.children( - '[data-id=' + record.get('id') + ']'); - } - if ($row.length) { - var $newRow = $(self.render_record(record)); - $newRow.find('.o_list_record_selector input').prop('checked', !!$row.find('.o_list_record_selector input').prop('checked')); - $row.replaceWith($newRow); - } - }, - 'add': function (ev, records, record, index) { - var $new_row = $(self.render_record(record)); - var id = record.get('id'); - if (id) { self.dataset.add_ids([id], index); } - - if (index === 0) { - $new_row.prependTo(self.$current); - } else { - var previous_record = records.at(index-1), - $previous_sibling = self.$current.children( - '[data-id=' + previous_record.get('id') + ']'); - $new_row.insertAfter($previous_sibling); - } - } - }; - _(this.record_callbacks).each(function (callback, event) { - this.records.bind(event, callback); - }, this); - - this.$current = $('<tbody>') - .delegate('input[readonly=readonly]', 'click', function (e) { - /* - Against all logic and sense, as of right now @readonly - apparently does nothing on checkbox and radio inputs, so - the trick of using @readonly to have, well, readonly - checkboxes (which still let clicks go through) does not - work out of the box. We *still* need to preventDefault() - on the event, otherwise the checkbox's state *will* toggle - on click - */ - e.preventDefault(); - }) - .delegate('td.o_list_record_selector', 'click', function (e) { - e.stopPropagation(); - var selection = self.get_selection(); - var checked = $(e.currentTarget).find('input').prop('checked'); - $(self).trigger( - 'selected', [selection.ids, selection.records, ! checked]); - }) - .delegate('td.o_list_record_delete', 'click', function (e) { - e.stopPropagation(); - var $row = $(e.target).closest('tr'); - $(self).trigger('deleted', [[self.row_id($row)]]); - // IE Edge go crazy when we use confirm dialog and remove the focused element - if(document.hasFocus && !document.hasFocus()) { - $('<input />').appendTo('body').focus().remove(); - } - }) - .delegate('td button', 'click', function (e) { - e.stopPropagation(); - var $target = $(e.currentTarget), - field = $target.closest('td').data('field'), - $row = $target.closest('tr'), - record_id = self.row_id($row); - - if ($target.attr('disabled')) { - return; - } - $target.attr('disabled', 'disabled'); - - // note: $.data converts data to number if it's composed only - // of digits, nice when storing actual numbers, not nice when - // storing strings composed only of digits. Force the action - // name to be a string - $(self).trigger('action', [field.toString(), record_id, function (id) { - $target.removeAttr('disabled'); - return self.reload_record(self.records.get(id)); - }]); - }) - .delegate('a', 'click', function (e) { - e.stopPropagation(); - }) - .delegate('tr', 'click', function (e) { - var row_id = self.row_id(e.currentTarget); - if (row_id) { - e.stopPropagation(); - if (!self.dataset.select_id(row_id)) { - throw new Error(_t("Could not find id in dataset")); - } - self.row_clicked(e); - } - }); - }, - row_clicked: function (e, view) { - $(this).trigger( - 'row_link', - [this.dataset.ids[this.dataset.index], - this.dataset, view]); - }, - render_cell: function (record, column) { - var value; - if(column.type === 'reference') { - value = record.get(column.id); - var ref_match; - // Ensure that value is in a reference "shape", otherwise we're - // going to loop on performing name_get after we've resolved (and - // set) a human-readable version. m2o does not have this issue - // because the non-human-readable is just a number, where the - // human-readable version is a pair - if (value && (ref_match = /^([\w\.]+),(\d+)$/.exec(value))) { - // reference values are in the shape "$model,$id" (as a - // string), we need to split and name_get this pair in order - // to get a correctly displayable value in the field - var model = ref_match[1], - id = parseInt(ref_match[2], 10); - new data.DataSet(this.view, model).name_get([id]).done(function(names) { - if (!names.length) { return; } - record.set(column.id + '__display', names[0][1]); - }); - } - } else if (column.type === 'many2one') { - value = record.get(column.id); - // m2o values are usually name_get formatted, [Number, String] - // pairs, but in some cases only the id is provided. In these - // cases, we need to perform a name_get call to fetch the actual - // displayable value - if (typeof value === 'number' || value instanceof Number) { - // fetch the name, set it on the record (in the right field) - // and let the various registered events handle refreshing the - // row - new data.DataSet(this.view, column.relation) - .name_get([value]).done(function (names) { - if (!names.length) { return; } - record.set(column.id, names[0]); - }); - } - } else if (column.type === 'many2many') { - value = record.get(column.id); - // non-resolved (string) m2m values are arrays - if (value instanceof Array && !_.isEmpty(value) - && !record.get(column.id + '__display')) { - var ids; - // they come in two shapes: - if (value[0] instanceof Array) { - _.each(value, function(command) { - switch (command[0]) { - case 4: ids.push(command[1]); break; - case 5: ids = []; break; - case 6: ids = command[2]; break; - default: throw new Error(_.str.sprintf( _t("Unknown m2m command %s"), command[0])); - } - }); - } else { - // 2. an array of ids - ids = value; - } - new Model(column.relation) - .call('name_get', [ids, this.dataset.get_context()]).done(function (names) { - // FIXME: nth horrible hack in this poor listview - record.set(column.id + '__display', - _(names).pluck(1).join(', ')); - record.set(column.id, ids); - }); - // temporary empty display name - record.set(column.id + '__display', false); - } - } - return column.format(record.toForm().data, { - model: this.dataset.model, - id: record.get('id') - }); - }, - render: function () { - var self = this; - this.$current.html( - QWeb.render('ListView.rows', _.extend({}, this, { - render_cell: function () { - return self.render_cell.apply(self, arguments); } - }))); - this.pad_table_to(4); - }, - pad_table_to: function (count) { - if (this.records.length >= count || - _(this.columns).any(function(column) { return column.meta; })) { - return; - } - var cells = []; - if (this.options.selectable) { - cells.push('<td class="o_list_record_selector"></td>'); - } - _(this.columns).each(function(column) { - if (column.invisible === '1') { - return; - } - cells.push('<td title="' + column.string + '"> </td>'); - }); - if (this.options.deletable) { - cells.push('<td class="o_list_record_delete"></td>'); - } - cells.unshift('<tr>'); - cells.push('</tr>'); - - var row = cells.join(''); - this.$current - .children('tr:not([data-id])').remove().end() - .append(new Array(count - this.records.length + 1).join(row)); - }, - /** - * Gets the ids of all currently selected records, if any - * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves. - */ - get_selection: function () { - var result = {ids: [], records: []}; - if (!this.options.selectable) { - return result; - } - var records = this.records; - this.$current.find('td.o_list_record_selector input:checked') - .closest('tr').each(function () { - var record = records.get($(this).data('id')); - result.ids.push(record.get('id')); - result.records.push(record.attributes); - }); - return result; - }, - /** - * Returns the identifier of the object displayed in the provided table - * row - * - * @param {Object} row the selected table row - * @returns {Number|String} the identifier of the row's object - */ - row_id: function (row) { - return $(row).data('id'); - }, - /** - * Death signal, cleans up records's callbacks - */ - on_records_reset: function () { - _(this.record_callbacks).each(function (callback, event) { - this.records.unbind(event, callback); - }, this); - }, - get_records: function () { - return this.records.map(function (record) { - return {count: 1, values: record.attributes}; - }); - }, - /** - * Reloads the provided record by re-reading its content from the server. - * - * @param {Record} record - * @returns {$.Deferred} promise to the finalization of the reloading - */ - reload_record: function (record) { - return this.view.reload_record(record); - }, - /** - * Renders a list record to HTML - * - * @param {Record} record index of the record to render in ``this.rows`` - * @returns {String} QWeb rendering of the selected record - */ - render_record: function (record) { - var self = this; - var index = this.records.indexOf(record); - return QWeb.render('ListView.row', { - columns: this.columns, - options: this.options, - record: record, - row_parity: (index % 2 === 0) ? 'even' : 'odd', - view: this.view, - render_cell: function () { - return self.render_cell.apply(self, arguments); } - }); - } -}); - -ListView.Groups = Class.extend({ - passthrough_events: 'action deleted row_link', - /** - * Grouped display for the ListView. Handles basic DOM events and interacts - * with the :js:class:`~DataGroup` bound to it. - * - * Provides events similar to those of - * :js:class:`~instance.web.ListView.List` - * - * @constructs instance.web.ListView.Groups - * @extends instance.web.Class - * - * @param {instance.web.ListView} view - * @param {Object} [options] - * @param {Collection} [options.records] - * @param {Object} [options.options] - * @param {Array} [options.columns] - */ - init: function (view, options) { - options = options || {}; - this.view = view; - this.records = options.records || view.records; - this.options = options.options || view.options; - this.columns = options.columns || view.columns; - this.datagroup = null; - - this.$row = null; - this.children = {}; - - this.pager = null; // group pager - - var self = this; - this.records.bind('reset', function () { - return self.on_records_reset(); }); - }, - make_fragment: function () { - return document.createDocumentFragment(); - }, - /** - * Returns a DOM node after which a new tbody can be inserted, so that it - * follows the provided row. - * - * Necessary to insert the result of a new group or list view within an - * existing groups render, without losing track of the groups's own - * elements - * - * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body - * @returns {HTMLTableSectionElement} element after which a tbody can be inserted - */ - point_insertion: function (row) { - var $row = $(row); - var red_letter_tboday = $row.closest('tbody')[0]; - - var $next_siblings = $row.nextAll(); - if ($next_siblings.length) { - var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday); - - $root_kanal.append($next_siblings); - this.elements.splice( - _.indexOf(this.elements, red_letter_tboday), - 0, - $root_kanal[0]); - } - return red_letter_tboday; - }, - /** - * Renders the group pager and append it to the group's header - */ - render_group_pager: function () { - var self = this; - if (this.datagroup.length > this.view._limit) { - this.pager = new Pager(this, this.datagroup.length, 1, this.view._limit); - this.pager.on('pager_changed', this, function (state) { - self.view._limit = state.limit; - self.current_min = state.current_min; - self.render().then(function() { - self.$row.closest('tbody').next().replaceWith($(self.elements)); - }); - }); - - var $last_cell = this.$row.children().last(); - $last_cell.addClass('o_group_pager'); - // Prevent group to fold when clicking on the pager - $last_cell.click(function(e) { - e.stopPropagation(); - }); - this.pager.appendTo($last_cell); - } - }, - open: function (point_insertion) { - this.render(); - $(this.elements).insertAfter(point_insertion); - - var no_subgroups = _(this.datagroup.group_by).isEmpty(), - records_terminated = !this.datagroup.context['group_by_no_leaf']; - if (no_subgroups && records_terminated) { - this.render_group_pager(); - } - }, - close: function () { - if (this.pager) { - this.pager.destroy(); - } - this.records.reset(); - this.$to_be_removed.remove(); - }, - /** - * Prefixes ``$node`` with floated spaces in order to indent it relative - * to its own left margin/baseline - * - * @param {jQuery} $node jQuery object to indent - * @param {Number} level current nesting level, >= 1 - * @returns {jQuery} the indentation node created - */ - indent: function ($node, level) { - return $('<span>') - .css({'float': 'left', 'white-space': 'pre'}) - .text(new Array(level).join(' ')) - .prependTo($node); - }, - render_groups: function (datagroups) { - var self = this; - var placeholder = this.make_fragment(); - _(datagroups).each(function (group) { - if (self.children[group.value]) { - self.records.proxy(group.value).reset(); - delete self.children[group.value]; - } - var child = self.children[group.value] = new (self.view.options.GroupsType)(self.view, { - records: self.records.proxy(group.value), - options: self.options, - columns: self.columns - }); - self.bind_child_events(child); - child.datagroup = group; - - var $row = child.$row = $('<tr class="o_group_header">'); - if (group.openable && group.length) { - $row.click(function (e) { - if (!$row.data('open')) { - $row.data('open', true) - .find('span.fa') - .removeClass('fa-caret-right') - .addClass('fa-caret-down'); - child.open(self.point_insertion(e.currentTarget)); - } else { - $row.removeData('open') - .find('span.fa') - .removeClass('fa-caret-down') - .addClass('fa-caret-right'); - child.close(); - // force recompute the selection as closing group reset properties - var selection = self.get_selection(); - $(self).trigger('selected', [selection.ids, this.records]); - } - }); - } - placeholder.appendChild($row[0]); - - var $group_column = $('<th class="o_group_name">').appendTo($row); - // Don't fill this if group_by_no_leaf but no group_by - if (group.grouped_on) { - var row_data = {}; - row_data[group.grouped_on] = group; - var group_label = _t("Undefined"); - var group_column = _(self.columns).detect(function (column) { - return column.id === group.grouped_on; }); - if (group_column) { - try { - group_label = group_column.format(row_data, { - value_if_empty: _t("Undefined"), - process_modifiers: false - }); - } catch (e) { - group_label = _.str.escapeHTML(row_data[group_column.id].value); - } - } else { - group_label = group.value; - var grouped_on_field = self.view.fields_get[group.grouped_on]; - if (grouped_on_field && grouped_on_field.type === 'selection') { - group_label = _.find(grouped_on_field.selection, function(selection) { - return selection[0] === group.value; - }); - } - if (group_label instanceof Array) { - group_label = group_label[1]; - } - if (group_label === false) { - group_label = _t('Undefined'); - } - group_label = _.str.escapeHTML(group_label); - } - - // group_label is html-clean (through format or explicit - // escaping if format failed), can inject straight into HTML - $group_column.html(_.str.sprintf("%s (%d)", - group_label, group.length)); - - if (group.length && group.openable) { - // Make openable if not terminal group & group_by_no_leaf - $group_column.prepend('<span class="fa fa-caret-right" style="padding-right: 5px;">'); - } else { - $group_column.prepend('<span class="fa">'); - } - } - self.indent($group_column, group.level); - - if (self.options.selectable) { - $row.append('<td>'); - } - _(self.columns).chain() - .filter(function (column) { return column.invisible !== '1'; }) - .each(function (column) { - if (column.meta) { - // do not do anything - } else if (column.id in group.aggregates) { - var r = {}; - r[column.id] = {value: group.aggregates[column.id]}; - $('<td class="oe_number">') - .html(column.format(r, {process_modifiers: false})) - .appendTo($row); - } else { - $row.append('<td>'); - } - }); - if (self.options.deletable) { - $row.append('<td class="oe_list_group_pagination">'); - } - }); - return placeholder; - }, - bind_child_events: function (child) { - var $this = $(this), - self = this; - $(child).bind('selected', function (e, _0, _1, deselected) { - // can have selections spanning multiple links - var selection = self.get_selection(); - $this.trigger(e, [selection.ids, selection.records, deselected]); - }).bind(this.passthrough_events, function (e) { - // additional positional parameters are provided to trigger as an - // Array, following the event type or event object, but are - // provided to the .bind event handler as *args. - // Convert our *args back into an Array in order to trigger them - // on the group itself, so it can ultimately be forwarded wherever - // it's supposed to go. - var args = Array.prototype.slice.call(arguments, 1); - $this.trigger.call($this, e, args); - }); - }, - render_dataset: function (dataset) { - var self = this, - list = new (this.view.options.ListType)(this, { - options: this.options, - columns: this.columns, - dataset: dataset, - records: this.records - }); - this.bind_child_events(list); - - var view = this.view; - var current_min = this.datagroup.openable ? this.current_min : view.current_min; - - var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name'); - var options = { offset: current_min - 1, limit: view._limit, context: {bin_size: true} }; - return utils.async_when().then(function() { - return dataset.read_slice(fields, options).then(function (records) { - // FIXME: ignominious hacks, parents (aka form view) should not send two ListView#reload_content concurrently - if (self.records.length) { - self.records.reset(null, {silent: true}); - } - if (!self.datagroup.openable) { - // Update the main list view pager - view.update_pager(dataset, current_min); - } - - self.records.add(records, {silent: true}); - list.render(); - return list; - }); - }); - }, - setup_resequence_rows: function (list, dataset) { - var sequence_field = _(this.columns).findWhere({'widget': 'handle'}); - var seqname = sequence_field ? sequence_field.name : 'sequence'; - - // drag and drop enabled if list is not sorted (unless it is sorted by - // its sequence field (ASC)), and there is a visible column with - // @widget=handle or "sequence" column in the view. - if ((dataset.sort && [seqname, seqname + ' ASC', ''].indexOf(dataset.sort()) === -1) - || !_(this.columns).findWhere({'name': seqname})) { - return; - } - - // ondrop, move relevant record & fix sequences - list.$current.sortable({ - axis: 'y', - items: '> tr[data-id]', - helper: 'clone' - }); - if (sequence_field) { - list.$current.sortable('option', 'handle', '.o_row_handle'); - } - list.$current.sortable('option', { - start: function (e, ui) { - ui.placeholder.height(ui.item.height()); - }, - stop: function (event, ui) { - var to_move = list.records.get(ui.item.data('id')), - target_id = ui.item.prev().data('id'), - from_index = list.records.indexOf(to_move), - target = list.records.get(target_id); - if (list.records.at(from_index - 1) == target) { - return; - } - - list.records.remove(to_move, {silent: true}); - var to = target_id ? list.records.indexOf(target) + 1 : 0; - list.records.add(to_move, { at: to, silent: true }); - - // resequencing time! - var record, index = to, - // if drag to 1st row (to = 0), start sequencing from 0 - // (exclusive lower bound) - seq = to ? list.records.at(to - 1).get(seqname) : 0; - var defs = []; - var fct = function (dataset, id, seq) { - defs.push(utils.async_when().then(function () { - var attrs = {}; - attrs[seqname] = seq; - return dataset.write(id, attrs, {internal_dataset_changed: true}); - })); - }; - while (++seq, (record = list.records.at(index++))) { - // write are independent from one another, so we can just - // launch them all at the same time and we don't really - // give a fig about when they're done - fct(dataset, record.get('id'), seq); - record.set(seqname, seq); - } - $.when.apply($, defs).then(function () { - // use internal_dataset_changed and trigger one onchange after all writes - dataset.trigger("dataset_changed"); - }); - } - }); - }, - render: function (post_render) { - var self = this; - var $el = $('<tbody>'); - this.elements = [$el[0]]; - - return this.datagroup.list( - _(this.view.visible_columns).chain() - .filter(function (column) { return column.tag === 'field';}) - .pluck('name').value(), - function (groups) { - $el[0].appendChild( - self.render_groups(groups)); - if (post_render) { post_render(); } - }, function (dataset) { - return self.render_dataset(dataset).then(function (list) { - self.children[null] = list; - self.elements = - [list.$current.replaceAll($el)[0]]; - self.setup_resequence_rows(list, dataset); - }).always(function() { - if (post_render) { post_render(); } - self.view.trigger('view_list_rendered'); - }); - }); - }, - /** - * Returns the ids of all selected records for this group, and the records - * themselves - */ - get_selection: function () { - var ids = [], records = []; - - _(this.children) - .each(function (child) { - var selection = child.get_selection(); - ids.push.apply(ids, selection.ids); - records.push.apply(records, selection.records); - }); - - return {ids: ids, records: records}; - }, - on_records_reset: function () { - this.$to_be_removed = $(this.elements); - _.each(this.children, function(child){ - this.$to_be_removed = this.$to_be_removed.add(child.$to_be_removed); - }.bind(this)); - this.children = {}; - }, - get_records: function () { - if (_(this.children).isEmpty()) { - if (!this.datagroup.length) { - return; - } - return { - count: this.datagroup.length, - values: this.datagroup.aggregates - }; - } - return _(this.children).chain() - .map(function (child) { - return child.get_records(); - }).flatten().value(); - } -}); - -/** - * Serializes concurrent calls to this asynchronous method. The method must - * return a deferred or promise. - * - * Current-implementation is class-serialized (the mutex is common to all - * instances of the list view). Can be switched to instance-serialized if - * having concurrent list views becomes possible and common. - */ -function synchronized(fn) { - 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); - }); - }; -} - -var DataGroup = Class.extend({ - init: function(parent, model, domain, context, group_by, level) { - this.model = new Model(model, context, domain); - this.group_by = group_by; - this.context = context; - this.domain = domain; - - this.level = level || 0; - }, - list: function (fields, ifGroups, ifRecords) { - var self = this; - if (!_.isEmpty(this.group_by)) { - // ensure group_by fields are read. - fields = _.unique((fields || []).concat(this.group_by)); - } - var query = this.model.query(fields).order_by(this.sort).group_by(this.group_by); - return $.when(query).then(function (querygroups) { - // leaf node - if (!querygroups) { - var ds = new data.DataSetSearch(self, self.model.name, self.model.context(), self.model.domain()); - ds._sort = self.sort; - return ifRecords(ds); - } - // internal node - var child_datagroups = _(querygroups).map(function (group) { - var child_context = _.extend( - {}, self.model.context(), group.model.context()); - var child_dg = new DataGroup( - self, self.model.name, group.model.domain(), - child_context, group.model._context.group_by, - self.level + 1); - child_dg.sort = self.sort; - // copy querygroup properties - child_dg.__context = child_context; - child_dg.__domain = group.model.domain(); - child_dg.folded = group.get('folded'); - child_dg.grouped_on = group.get('grouped_on'); - child_dg.length = group.get('length'); - child_dg.value = group.get('value'); - child_dg.openable = group.get('has_children'); - child_dg.aggregates = group.get('aggregates'); - return child_dg; - }); - ifGroups(child_datagroups); - }); - } -}); - -var StaticDataGroup = DataGroup.extend({ - init: function (dataset) { - this.dataset = dataset; - }, - list: function (fields, ifGroups, ifRecords) { - return ifRecords(this.dataset); - } -}); - -var Column = Class.extend({ - init: function (id, tag, attrs) { - _.extend(attrs, { - id: id, - tag: tag - }); - this.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {}; - delete attrs.modifiers; - _.extend(this, attrs); - - if (this.modifiers['tree_invisible']) { - this.invisible = '1'; - } else { delete this.invisible; } - }, - modifiers_for: function (fields) { - var out = {}; - var domain_computer = data.compute_domain; - - for (var attr in this.modifiers) { - if (!this.modifiers.hasOwnProperty(attr)) { continue; } - var modifier = this.modifiers[attr]; - out[attr] = _.isBoolean(modifier) - ? modifier - : domain_computer(modifier, fields); - } - - return out; - }, - to_aggregate: function () { - if (this.type !== 'integer' && this.type !== 'float' && this.type !== 'monetary') { - return {}; - } - - var aggregation_func = (this.sum && 'sum') || (this.avg && 'avg') || - (this.max && 'max') || (this.min && 'min'); - - if (!aggregation_func) { - return {}; - } - - var C = function (fn, label) { - this['function'] = fn; - this.label = label; - }; - C.prototype = this; - return new C(aggregation_func, this[aggregation_func]); - }, - heading: function () { - return _.escape(this.string); - }, - width: function () {}, - /** - * - * @param row_data record whose values should be displayed in the cell - * @param {Object} [options] - * @param {String} [options.value_if_empty=''] what to display if the field's value is ``false`` - * @param {Boolean} [options.process_modifiers=true] should the modifiers be computed ? - * @param {String} [options.model] current record's model - * @param {Number} [options.id] current record's id - * @return {String} - */ - format: function (row_data, options) { - options = options || {}; - var attrs = {}; - if (options.process_modifiers !== false) { - attrs = this.modifiers_for(row_data); - } - if (attrs.invisible) { return ''; } - - if (!row_data[this.id]) { - return options.value_if_empty === undefined - ? '' - : options.value_if_empty; - } - var f = this._format(row_data, options); - return (f !== '')? f : ' '; - }, - /** - * Method to override in order to provide alternative HTML content for the - * cell. Column._format will simply call ``instance.web.format_value`` and - * escape the output. - * - * The output of ``_format`` will *not* be escaped by ``format``, any - * escaping *must be done* by ``format``. - * - * @private - */ - _format: function (row_data, options) { - return _.escape(formats.format_value( - row_data[this.id].value, this, options.value_if_empty)); - } -}); -ListView.Column = Column; - -var MetaColumn = Column.extend({ - meta: true, - init: function (id, string) { - this._super(id, '', {string: string}); - } -}); -// 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 - */ - format: function (row_data, options) { - options = options || {}; - var attrs = {}; - if (options.process_modifiers !== false) { - attrs = this.modifiers_for(row_data); - } - if (attrs.invisible) { return ''; } - var template = this.icon && 'ListView.row.button' || 'ListView.row.text_button'; - return QWeb.render(template, { - widget: this, - prefix: session.prefix, - disabled: attrs.readonly - || isNaN(row_data.id.value) - || data.BufferedDataSet.virtual_id_regex.test(row_data.id.value) - }); - } -}); - -var ColumnBoolean = Column.extend({ - /** - * Return a potentially disabled checkbox input - * - * @private - */ - _format: function (row_data, options) { - return _.str.sprintf('<div class="o_checkbox"><input type="checkbox" %s disabled="disabled"/><span/></div>', - row_data[this.id].value ? 'checked="checked"' : ''); - } -}); - -var ColumnBinary = Column.extend({ - /** - * Return a link to the binary data as a file - * - * @private - */ - _format: function (row_data, options) { - var text = _t("Download"), filename=_t('Binary file'); - var value = row_data[this.id].value; - if (!value) { - return options.value_if_empty || ''; - } - - var download_url; - if (value.substr(0, 10).indexOf(' ') == -1) { - download_url = "data:application/octet-stream;base64," + value; - } else { - download_url = session.url('/web/content', {model: options.model, field: this.id, id: options.id, download: true}); - if (this.filename) { - download_url += '&filename_field=' + this.filename; - } - } - if (this.filename && row_data[this.filename]) { - text = _.str.sprintf(_t("Download \"%s\""), formats.format_value( - row_data[this.filename].value, {type: 'char'})); - filename = row_data[this.filename].value; - } - return _.template('<a download="<%-download%>" href="<%-href%>"><%-text%></a> (<%-size%>)')({ - text: text, - href: download_url, - size: utils.binary_to_binsize(value), - download: filename, - }); - } -}); - -var ColumnChar = Column.extend({ - replacement: '*', - /** - * If password field, only display replacement characters (if value is - * non-empty) - */ - _format: function (row_data, options) { - var value = row_data[this.id].value; - if (value && this.password === 'True') { - return value.replace(/[\s\S]/g, _.escape(this.replacement)); - } - return this._super(row_data, options); - } -}); - -var ColumnProgressBar = Column.extend({ - /** - * Return a formatted progress bar display - * - * @private - */ - _format: function (row_data, options) { - return _.template( - '<progress value="<%-value%>" max="100"><%-value%>%</progress>')({ - value: _.str.sprintf("%.0f", row_data[this.id].value || 0) - }); - } -}); - -var ColumnHandle = Column.extend({ - init: function () { - this._super.apply(this, arguments); - // Handle overrides the field to not be form-editable. - this.modifiers.readonly = true; - this.string = ""; // Don't display the column header - }, - heading: function () { - return '<span class="o_row_handle fa fa-arrows invisible"></span>'; - }, - width: function () { return 1; }, - /** - * Return styling hooks for a drag handle - * - * @private - */ - _format: function (row_data, options) { - return '<span class="o_row_handle fa fa-arrows"/>'; - } -}); - -var ColumnMany2OneButton = Column.extend({ - _format: function (row_data, options) { - this.has_value = !!row_data[this.id].value; - this.icon = this.has_value ? 'fa-circle o_toggle_button_success' : 'fa-circle text-danger'; - this.string = this.has_value ? _t('View') : _t('Create'); - return QWeb.render('Many2OneButton.cell', { - 'widget': this, - 'prefix': session.prefix, - }); - }, -}); - -var ColumnMany2Many = Column.extend({ - _format: function (row_data, options) { - if (!_.isEmpty(row_data[this.id].value)) { - // If value, use __display version for printing - row_data[this.id] = row_data[this.id + '__display']; - } - return this._super(row_data, options); - } -}); - -var ColumnReference = Column.extend({ - _format: function (row_data, options) { - if (!_.isEmpty(row_data[this.id].value)) { - // If value, use __display version for printing - if (!!row_data[this.id + '__display']) { - row_data[this.id] = row_data[this.id + '__display']; - } else { - row_data[this.id] = {'value': ''}; - } - } - return this._super(row_data, options); - } -}); - -var ColumnUrl = Column.extend({ - /** - * Regex checking if a URL has a scheme - */ - PROTOCOL_REGEX: /^(?!\w+:?\/\/)/, - - /** - * Format a column as a URL if the column has content. - * Add "//" (inherit current protocol) specified in - * RFC 1808, 2396, and 3986 if no other protocol is included. - * - * @param row_data record whose values should be displayed in the cell - * @param options - */ - _format: function(row_data, options) { - var value = row_data[this.id].value; - - if (value) { - return _.template("<a href='<%-href%>' target='_blank'><%-text%></a>")({ - href: value.trim().replace(this.PROTOCOL_REGEX, '//'), - text: value - }); - } - return this._super(row_data, options); - } -}); - -var ColumnMonetary = Column.extend({ - - _format: function (row_data, options) { - var options = pyeval.py_eval(this.options || '{}'); - //name of currency field is defined either by field attribute, in view options or we assume it is named currency_id - var currency_field = (_.isEmpty(options) === false && options.currency_field) || this.currency_field || 'currency_id'; - var currency_id = row_data[currency_field] && row_data[currency_field].value[0]; - var currency = session.get_currency(currency_id); - var digits_precision = this.digits || (currency && currency.digits); - var value = formats.format_value(row_data[this.id].value || 0, {type: this.type, digits: digits_precision}, options.value_if_empty); - if (currency) { - if (currency.position === "after") { - value += ' ' + currency.symbol; - } else { - value = currency.symbol + ' ' + value; - } - } - return value; - }, -}); - -var ColumnToggleButton = Column.extend({ - format: function (row_data, options) { - this._super(row_data, options); - var button_tips = JSON.parse(this.options); - var fieldname = this.field_name; - var has_value = row_data[fieldname] && !!row_data[fieldname].value; - this.icon = has_value ? 'fa-circle o_toggle_button_success' : 'fa-circle text-muted'; - this.string = has_value ? (button_tips ? button_tips['active']: ''): (button_tips ? button_tips['inactive']: ''); - return QWeb.render('toggle_button', { - widget: this, - 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) - .add('field.monetary', ColumnMonetary); - -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 deleted file mode 100644 index 6beb6a7a044..00000000000 --- a/addons/web/static/src/js/views/list_view_editable.js +++ /dev/null @@ -1,866 +0,0 @@ -odoo.define('web.ListEditor', 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 data = require('web.data'); -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; - }, - start: function () { - var self = this; - var form_fields_view = this._validate_view(this.delegate.edition_view(this)); - this.form = new (this.options.formView)(this, this.delegate.dataset, form_fields_view, { - initial_mode: 'edit', - is_list_editable: true, - disable_autofocus: true, - $buttons: $(), - $pager: $(), - }); - return $.when(this._super(), this.form.appendTo($('<div/>')).then(function() { - self.form.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.form.$el); - }).done(this.proxy('do_hide'))); - }, - _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 (!/\bo_list_editable_form\b/.test(arch.attrs['class'])) { - throw new Error("Editor delegate's #edition_view must have the class " + - "'o_list_editable_form' on its root element"); - } - return edition_view; - }, - is_editing: function () { - return !!this.record; - }, - is_creating: function () { - return (this.is_editing() && !this.record.id); - }, - edit: function (record, configureField) { - var self = this; - // TODO: specify sequence of edit calls - var loaded; - if(record) { - this.form.trigger('load_record', _.extend({}, record)); - loaded = this.form.record_loaded; - } else { - loaded = this.form.load_defaults().then(function() { - return self.form.record_loaded; - }); - } - - return loaded.then(function () { - return self.do_show({reload: false}); - }).then(function () { - self.record = self.form.datarecord; - _(self.form.fields).each(function (field, name) { - configureField(name, field); - }); - return self.form; - }); - }, - save: function () { - var self = this; - return this.form - .save(this.delegate.prepends_on_create()) - .then(function (result) { - if(result.created && !self.record.id) { - self.record.id = result.result; - } - return self.record; - }); - }, - cancel: function (force) { - var self = this; - if(force) { - return do_cancel(); - } - var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?"); - return this.form.can_be_discarded(message).then(do_cancel); - - function do_cancel() { - var record = self.record; - self.record = null; - self.do_hide(); - return $.when(record); - } - }, - do_hide: function() { - this.form.do_hide.apply(this.form, arguments); - this.form.set({display_invalid_fields: false}); - }, - do_show: function() { - this.form.do_show.apply(this.form, arguments); - }, -}); - -// 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 = new Editor(this); - // 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('o-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('o-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; - var _super = this._super; - var 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 () { - if (this.editable()) { - this.$('table:first').show(); - this.$('.oe_view_nocontent').remove(); - this.start_edition(); - } else { - this._super.apply(this, arguments); - } - }, - load_list: function (data, grouped) { - // tree/@editable takes priority on everything else if present. - var result = this._super.apply(this, arguments); - - // 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(); - this.editor = new Editor(this); // Editor is not restartable due to formview not being restartable - - if(this.editable()) { - this.$el.addClass('o_list_editable'); - return $.when(result, this.editor.prependTo(this.$el).done(this.proxy('setup_events'))); - } else { - this.$el.removeClass('o_list_editable'); - } - return result; - }, - /** - * Extend the render_buttons function of ListView by adding event listeners - * in the case of an editable list. - * @return {jQuery} the rendered buttons - */ - render_buttons: function() { - var add_button = !this.$buttons; // Ensures that this is only done once - var result = this._super.apply(this, arguments); // Sets this.$buttons - - if (add_button && (this.editable() || this.grouped)) { - var self = this; - this.$buttons - .off('click', '.o_list_button_save') - .on('click', '.o_list_button_save', this.proxy('save_edition')) - .off('click', '.o_list_button_discard') - .on('click', '.o_list_button_discard', function (e) { - e.preventDefault(); - self.cancel_edition(); - }); - } - return result; - }, - do_button_action: function (name, id, callback) { - var self = this; - this.save_edition().done(function (data) { - if(!id && data.created) { - id = data.record.get('id'); - } - self.handle_button(name, id, callback); - }); - }, - /** - * 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.save_edition().then(function() { - return $.when.apply($, 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); // Empty array - 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('o_row_edition'); - self.resize_fields(); - // Local function that returns true if field is visible and editable - var is_focusable = function(field) { - return field && field.$el.is(':visible:not(.o_readonly)'); - }; - var focus_field = options && options.focus_field ? options.focus_field : undefined; - if (!is_focusable(fields[focus_field])) { - focus_field = _.find(self.editor.form.fields_order, function(field) { - return is_focusable(fields[field]); - }); - } - if (fields[focus_field]) { - fields[focus_field].$el.find('input, textarea').andSelf().filter('input, textarea').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); - } - }); - }, function() { - return $.Deferred().resolve(); // Here the save/cancel edition failed so the start_edition is considered as done and succeeded - }); - }, - 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()); - position_element(field.$el, 'left top'); - position_element(field.$translate, 'right top'); - if(field.get('effective_readonly')) { - field.$el.addClass('o_readonly'); - } - if(field.widget == "handle") { - field.$el.addClass('o_row_handle'); - } - - // Workaround a bug in safari mobile where the inputs are not displayed correctly. - if (window.getComputedStyle(field.el.parentElement).webkitOverflowScrolling) { - field.el.style.zIndex = 1; - } - - function position_element($el, pos) { - $el.addClass('o_temp_visible').css({top: 0, left: 0}).position({ - my: pos, - at: pos, - of: $cell, - }).removeClass('o_temp_visible'); - } - }, - /** - * @return {jQuery.Deferred} - */ - save_edition: function (cancel_onfail) { - 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.cancel_edition(true) - .then(function() { - return self.handle_onwrite(record); - }) - .then(function () { - return self.reload_record(record); - }) - .then(function () { - return {created: created, record: record}; - }); - }, function() { - if (cancel_onfail) { - return self.cancel_edition(); - } - }); - }); - }); - }, - /** - * @param {Boolean} [force] force the line to be discarded (even if there was changes) - * @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) { - return; // Record removed by third party during edition - } - 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': 'o_list_editable_form', - 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(); - } - var context = new data.CompoundContext(self.dataset.get_context(), {'on_write_domain': self.dataset.domain}).eval(); - return this.dataset.call(on_write_callback, [source_record.get('id'), context]) - .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(); - } - if (!options) { - options = {}; - } - options.wraparound = !self.is_action_enabled('create'); - var record = self.records[next_record](saveInfo.record, options); - if (record === undefined) { - return self.start_edition(); - } - return self.start_edition(record, options); - }); - }, - keyup_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() || this.editor.is_creating()) { 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}); - - }, - keyup_UP: function (e) { - var self = this; - return this._key_move_record(e, 'pred', function (el, cursor) { - return self._at_start(cursor, el) && !$(el).is('select,.ui-autocomplete-input'); - }); - }, - keyup_DOWN: function (e) { - var self = this; - return this._key_move_record(e, 'succ', function (el, cursor) { - return self._at_end(cursor, el) && !$(el).is('select,.ui-autocomplete-input'); - }); - }, - - 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) { // Keydown and not keyup because this handler must be called before the browser has focused the next field - 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 && $(e.target).closest(last_field.el).length) { - e.preventDefault(); - return this._next(); - } - this.editor.form.__clicked_inside = true; - 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); - } - if (this.__is_starting_edition) { - return; - } - this.__is_starting_edition = true; - - var self = this; - var args = arguments; - var _super = self._super; - - 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(".o_readonly").data('field'), - }).fail(function() { - return _super.apply(self, args); // The record can't be edited so open it in a modal (use-case: readonly mode) - }).always(function () { - self.__is_starting_edition = false; - }); - }, - /** - * 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') + ']'); - return (($row.length)? $row : null); - }, -}); - -return Editor; - -}); diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js new file mode 100644 index 00000000000..9b154214dfc --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -0,0 +1,368 @@ +odoo.define('web.PivotController', function (require) { +"use strict"; +/** + * Odoo Pivot Table Controller + * + * This class is the Controller for the pivot table view. It has to coordinate + * the actions coming from the search view (through the update method), from + * the renderer, from the model, and from the control panel. + * + * It can display action buttons in the control panel, to select a different + * measure, or to perform some other actions such as download/expand/flip the + * view. + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var crash_manager = require('web.crash_manager'); +var framework = require('web.framework'); +var session = require('web.session'); + +var _t = core._t; +var QWeb = core.qweb; + +var PivotController = AbstractController.extend({ + template: 'PivotView', + events: { + 'click .o_pivot_header_cell_opened': '_onOpenHeaderClick', + 'click .o_pivot_header_cell_closed': '_onClosedHeaderClick', + 'click .o_pivot_field_menu a': '_onFieldMenuSelection', + 'click td': '_onCellClick', + 'click .o_pivot_measure_row': '_onMeasureRowClick', + }, + /** + * @override + * @param {Object} params + * @param {Object} params.groupableFields a map from field name to field + * props + * @param {boolean} params.enableLinking configure the pivot view to allow + * opening a list view by clicking on a cell with some data. + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.measures = params.measures; + this.groupableFields = params.groupableFields; + this.title = params.title; + this.enableLinking = params.enableLinking; + this.lastHeaderSelected = null; + }, + /** + * @override + */ + start: function () { + this.$el.toggleClass('o_enableLinking', this.enableLinking); + this.$fieldSelection = this.$('.o_field_selection'); + core.bus.on('click', this, function () { + this.$fieldSelection.empty(); + }); + return this._super(); + }, + /** + * @override + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * We save the current measure and group bys, so we can restore the view + * when we save the current state in the search view. + * + * @override method from AbstractController + * @returns {Object} + */ + getContext: function () { + if (this.handle) { + var state = this.model.get(this.handle); + return { + pivot_measures: state.measures, + pivot_column_groupby: state.data.main_col.groupbys, + pivot_row_groupby: state.data.main_row.groupbys, + }; + } + return {}; + }, + /** + * Render the buttons according to the PivotView.buttons template and + * add listeners on it. + * Set this.$buttons with the produced jQuery element + * + * @param {jQuery} [$node] a jQuery node where the rendered buttons should + * be inserted. $node may be undefined, in which case the PivotView + * does nothing + */ + renderButtons: function ($node) { + if ($node) { + var context = {measures: _.pairs(_.omit(this.measures, '__count'))}; + this.$buttons = $(QWeb.render('PivotView.buttons', context)); + this.$buttons.click(this._onButtonClick.bind(this)); + this.$buttons.find('button').tooltip(); + + this.$buttons.appendTo($node); + this._updateButtons(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Export the current pivot table data in a xls file. For this, we have to + * serialize the current state, then call the server /web/pivot/export_xls. + * + * @private + */ + _downloadTable: function () { + var table = this.model.exportData(); + if(table.measure_row.length + 1 > 256) { + crash_manager.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + return; + } + framework.blockUI(); + session.get_file({ + url: '/web/pivot/export_xls', + data: {data: JSON.stringify(table)}, + complete: framework.unblockUI, + error: crash_manager.rpc_error.bind(crash_manager) + }); + }, + /** + * Render the field selection menu, to select a groupable field. We disable + * already selected groupbys. + * + * @private + * @param {number} top top coordinate where we have to render the menu + * @param {number} left left coordinate for the menu + */ + _renderFieldSelection: function (top, left) { + var state = this.model.get({raw: true}); + var groupedFields = state.rowGroupBys + .concat(state.colGroupBys) + .map(function (f) { return f.split(':')[0];}); + + var fields = _.chain(this.groupableFields) + .pairs() + .sortBy(function (f) { return f[1].string; }) + .map(function (f) { + return [f[0], f[1], _.contains(groupedFields, f[0])]; + }) + .value(); + + this.$fieldSelection.html(QWeb.render('PivotView.FieldSelection', { + fields: fields + })); + + this.$fieldSelection.find('ul').first() + .css({top: top, left: left}) + .show(); + }, + /** + * @private + */ + _update: function (state) { + this._updateButtons(); + var hasNoContent = !state.measures.length || !state.has_data; + + if (hasNoContent) { + this.renderer.$el.detach(); + this.$el.append(QWeb.render('PivotView.nodata')); + } else { + if (!document.contains(this.renderer.el)) { + this.$('div.oe_view_nocontent').remove(); + this.$el.append(this.renderer.$el); + } + } + return this._super.apply(this, arguments); + }, + /** + * @private + */ + _updateButtons: function () { + if (!this.$buttons) { + return; + } + var self = this; + var state = this.model.get({raw: true}); + _.each(this.measures, function (measure, name) { + var isSelected = _.contains(state.measures, name); + self.$buttons.find('li[data-field="' + name + '"]') + .toggleClass('selected', isSelected); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This handler is called when the user clicked on a button in the control + * panel. We then have to react properly: it can either be a change in the + * current measures, or a request to flip/expand/download data. + * + * @private + * @param {MouseEvent} event + */ + _onButtonClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_pivot_flip_button')) { + this.model.flip(this.handle); + this.update(null, {reload: false}); + } + if ($target.hasClass('o_pivot_expand_button')) { + this.model + .expandAll(this.handle) + .then(this.update_state.bind(this)); + } + if ($target.parents('.o_pivot_measures_list').length) { + var parent = $target.parent(); + var field = parent.data('field'); + event.preventDefault(); + event.stopPropagation(); + this.model + .toggleMeasure(field) + .then(this.update.bind(this, {}, {reload: false})); + } + if ($target.hasClass('o_pivot_download')) { + this._downloadTable(); + } + }, + /** + * When the user clicks on a cell, and the view is configured to allow + * 'linking' (with enableLinking), we want to open a list view with the + * corresponding record. + * + * @private + * @param {MouseEvent} event + */ + _onCellClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_pivot_header_cell_closed') || + $target.hasClass('o_pivot_header_cell_opened') || + $target.hasClass('o_empty') || + !this.enableLinking) { + return; + } + var row_id = $target.data('id'); + var col_id = $target.data('col_id'); + var state = this.model.get(this.handle); + var row_domain = state.data.headers[row_id].domain; + var col_domain = state.data.headers[col_id].domain; + var context = _.omit(_.clone(state.context), 'group_by'); + + function _find_view_info (views, view_type) { + return _.find(views, function (view) { + return view[1] === view_type; + }) || [false, view_type]; + } + var views = [ + _find_view_info(this.options.action.views, "list"), + _find_view_info(this.options.action.views, "form"), + ]; + + this.do_action({ + type: 'ir.actions.act_window', + name: this.title, + res_model: this.model, + views: views, + view_type : "list", + view_mode : "list", + target: 'current', + context: context, + domain: state.domain.concat(row_domain, col_domain), + }); + }, + /** + * When we click on a closed header cell, we either want to open the + * dropdown menu to select a new groupby level, or we want to open the + * clicked header, if a corresponging groupby has already been selected. + * + * @private + * @param {MouseEvent} event + */ + _onClosedHeaderClick: function (event) { + var $target = $(event.target); + var id = $target.data('id'); + var header = this.model.getHeader(id); + var groupbys = header.root.groupbys; + + if (header.path.length - 1 < groupbys.length) { + this.model + .expandHeader(header, groupbys[header.path.length - 1]) + .then(this.update.bind(this, {}, {reload: false})); + } else { + this.lastHeaderSelected = id; + var position = $target.position(); + var top = position.top + $target.height(); + var left = position.left + event.offsetX; + this._renderFieldSelection(top, left); + event.stopPropagation(); + } + }, + /** + * This handler is called when the user selects a field in the dropdown menu + * + * @private + * @param {MouseEvent} event + */ + _onFieldMenuSelection: function (event) { + event.preventDefault(); + var $target = $(event.target); + if ($target.parent().hasClass('disabled')) { + event.stopPropagation(); + return; + } + var field = $target.parent().data('field'); + var interval = $target.data('interval'); + var header = this.model.getHeader(this.lastHeaderSelected); + if (interval) { + field = field + ':' + interval; + } + this.model + .expandHeader(header, field) + .then(this.update.bind(this, {}, {reload: false})); + }, + /** + * If the user clicks on a measure row, we can perform an in-memory sort + * + * @private + * @param {MouseEvent} event + */ + _onMeasureRowClick: function (event) { + var $target = $(event.target); + var col_id = $target.data('id'); + var measure = $target.data('measure'); + var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc'); + this.model + .sortRows(this.handle, col_id, measure, isAscending) + .then(this.update_state.bind(this)); + }, + /** + * This method is called when someone clicks on an open header. When that + * happens, we want to close the header, then redisplay the view. + * + * @private + * @param {MouseEvent} event + */ + _onOpenHeaderClick: function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + var headerID = $(event.target).data('id'); + this.model.closeHeader(headerID) + this.update({}, {reload: false}); + }, + +}); + +return PivotController; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js new file mode 100644 index 00000000000..cd203c22d35 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -0,0 +1,704 @@ +odoo.define('web.PivotModel', function (require) { +"use strict"; + +/** + * Pivot Model + * + * The pivot model keeps an in-memory representation of the pivot table that is + * displayed on the screen. The exact layout of this representation is not so + * simple, because a pivot table is at its core a 2-dimensional object, but + * with a 'tree' component: some rows/cols can be expanded so we zoom into the + * structure. + * + * However, we need to be able to manipulate the data in a somewhat efficient + * way, and to transform it into a list of lines to be displayed by the renderer + * + * @todo add a full description/specification of the data layout + */ + +var core = require('web.core'); +var utils = require('web.utils'); +var AbstractModel = require('web.AbstractModel'); + +var _t = core._t; + +var PivotModel = AbstractModel.extend({ + /** + * @override + * @param {Object} params + */ + init: function () { + this._super.apply(this, arguments); + this.numbering = {}; + this.data = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Close a header. This method is actually synchronous, but returns a + * deferred. + * + * @param {any} headerID + * @returns {Deferred} + */ + closeHeader: function (headerID) { + var header = this.data.headers[headerID]; + header.expanded = false; + header.children = []; + var newGroupbyLength = this._getHeaderDepth(header.root) - 1; + header.root.groupbys.splice(newGroupbyLength); + }, + /** + * @param {string} id + * @returns {Deferred} + */ + expandAll: function (id) { + var record = this.localData[id]; + return this._loadData(record); + }, + /** + * Expand (open up) a given header, be it a row or a column. + * + * @todo: add discussion on the number of read_group that it will generate, + * which is (r+1) or (c+1) I think + * + * @param {any} header + * @param {any} field + * @returns + */ + expandHeader: function (header, field) { + var self = this; + + var other_root = header.root.other_root; + var other_groupbys = header.root.other_root.groupbys; + var fields = [].concat(field, other_groupbys, this.data.measures); + var groupbys = []; + + for (var i = 0; i <= other_groupbys.length; i++) { + groupbys.push([field].concat(other_groupbys.slice(0,i))); + } + + return $.when.apply(null, groupbys.map(function (groupBy) { + return self.performModelRPC(self.modelName, 'read_group', [], { + context: self.data.context, + domain: header.domain.length ? header.domain : self.data.domain, + fields: _.map(fields, function (field) { return field.split(':')[0]; }), + groupby: groupBy, + lazy: false, + }); + })).then(function () { + var data = Array.prototype.slice.call(arguments); + var datapt, attrs, j, l, row, col, cell_value, groupBys; + for (i = 0; i < data.length; i++) { + for (j = 0; j < data[i].length; j++){ + datapt = data[i][j]; + groupBys = [field].concat(other_groupbys.slice(0,i)); + attrs = { + value: self._getValue(datapt, groupBys), + domain: datapt.__domain || [], + length: datapt.__count, + }; + + if (i === 0) { + row = self._makeHeader(attrs.value, attrs.domain, header.root, 0, 1, header); + } else { + row = self._getHeader(attrs.value, header.root, 0, 1, header); + } + col = self._getHeader(attrs.value, other_root, 1, i + 1); + if (!col) { + continue; + } + for (cell_value = {}, l=0; l < self.data.measures.length; l++) { + cell_value[self.data.measures[l]] = datapt[self.data.measures[l]]; + } + // cell_value.__count = attrs.length; + if (!self.data.cells[row.id]) { + self.data.cells[row.id] = []; + } + self.data.cells[row.id][col.id] = cell_value; + } + } + if (!_.contains(header.root.groupbys, field)) { + header.root.groupbys.push(field); + } + }); + }, + /** + * @returns {Object} + */ + exportData: function () { + var record = this.model.get(this.handle); + var measureNbr = record.measures.length; + var headers = this.renderer._computeHeaders(); + var measureRow = measureNbr > 1 ? _.last(headers) : []; + var rows = this.renderer._computeRows(); + var i, j, value; + headers[0].splice(0,1); + + // process measureRow + for (i = 0; i < measureRow.length; i++) { + measureRow[i].measure = this.measures[measureRow[i].measure].string; + } + // process all rows + for (i =0, j, value; i < rows.length; i++) { + for (j = 0; j < rows[i].values.length; j++) { + value = rows[i].values[j]; + rows[i].values[j] = { + is_bold: (i === 0) || + ((record.data.main_col.width > 1) && + (j >= rows[i].values.length - measureNbr)), + value: (value === undefined) ? "" : value, + }; + } + } + return { + headers: _.initial(headers), + measure_row: measureRow, + rows: rows, + nbr_measures: measureNbr, + title: this.title, + }; + }, + /** + * Swap the columns and the rows. It is a synchronous operation. + */ + flip: function () { + // swap the data: the main column and the main row + var temp = this.data.main_col; + this.data.main_col = this.data.main_row; + this.data.main_row = temp; + + // we need to update the record metadata: row and col groupBys + temp = this.data.groupedBy; + this.data.groupedBy = this.data.colGroupBys; + this.data.colGroupBys = temp; + }, + /** + * @override + * @param {Object} [options] + * @param {boolean} [options.raw=false] + * @returns {Object} + */ + get: function (options) { + var isRaw = options && options.raw; + return { + colGroupBys: this.data.main_col.groupbys, + fields: this.fields, + has_data: this.data.has_data, + headers: !isRaw && this._computeHeaders(), + mainColWidth: this.data.main_col.width, + measures: this.data.measures, + rows: !isRaw && this._computeRows(), + rowGroupBys: this.data.main_row.groupbys, + sortedColumn: this.data.sorted_column, + }; + }, + getHeader: function (id) { + return this.data.headers[id]; + }, + /** + * @override + * @param {Object} params + * @param {string[]} [params.groupedBy] + * @param {string[]} [params.colGroupBys] + * @param {string[]} params.domain + * @param {string[]} params.rowGroupBys + * @param {string[]} params.colGroupBys + * @param {string[]} params.measures + * @param {Object} params.fields + * @returns {Deferred} + */ + load: function (params) { + this.initialDomain = params.domain; + this.initialRowGroupBys = params.rowGroupBys; + this.initialColGroupBys = params.colGroupBys; + this.initialMeasures = params.measures; + this.fields = params.fields; + this.modelName = params.modelName; + var groupedBy = params.groupedBy.length ? params.groupedBy : this.initialRowGroupBys; + this.data = { + domain: params.domain, + context: params.context, + groupedBy: groupedBy, + colGroupBys: params.colGroupBys || this.initialColGroupBys, + measures: this.initialMeasures, + sorted_column: {}, + }; + return this._loadData(params); + }, + /** + * @override + * @param {any} handle this parameter is ignored + * @param {Object} params + * @returns {Deferred} + */ + reload: function (handle, params) { + var self = this; + var old_row_root = this.data.main_row.root; + var old_col_root = this.data.main_col.root; + if ('domain' in params) { + this.data.domain = params.domain; + } else { + this.data.domain = this.initialDomain; + } + + return this._loadData(params).then(function () { + self._updateTree(old_row_root, self.data.main_row.root); + var new_groupby_length = self._getHeaderDepth(self.data.main_row.root) - 1; + self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length); + + self._updateTree(old_col_root, self.data.main_col.root); + new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1; + self.data.main_row.groupbys = old_col_root.groupbys.slice(0, new_groupby_length); + }); + }, + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * Note that it returns a deferred. @todo: makes sure this does not return a + * deferred. + * + * @param {any} id + * @param {any} col_id + * @param {any} measure + * @param {any} descending + * @returns {Deferred} + */ + sortRows: function (id, col_id, measure, descending) { + var record = this.localData[id]; + + _traverseTree(record.data.main_row.root, function (header) { + header.children.sort(compare); + }); + record.sorted_column = { + id: col_id, + measure: measure, + order: descending ? 'desc' : 'asc', + }; + function _getValue (id1, id2) { + if ((id1 in record.data.cells) && (id2 in record.data.cells[id1])) { + return record.data.cells[id1][id2]; + } + if (id2 in record.data.cells) return record.data.cells[id2][id1]; + } + + function compare (row1, row2) { + var values1 = _getValue(row1.id, col_id), + values2 = _getValue(row2.id, col_id), + value1 = values1 ? values1[measure] : 0, + value2 = values2 ? values2[measure] : 0; + return descending ? value1 - value2 : value2 - value1; + } + return $.when(id); + }, + /** + * Toggle the active state for a given measure, then reload the data. + * + * @param {string} field + * @returns {Deferred} + */ + toggleMeasure: function (field) { + if (_.contains(this.data.measures, field)) { + this.data.measures = _.without(this.data.measures, field); + // in this case, we already have all data in memory, no need to + // actually reload a lesser amount of information + return $.when(); + } else { + this.data.measures.push(field); + } + return this._loadData(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _computeHeaders: function () { + var self = this; + var main_col_dims = this._getHeaderDim(this.data.main_col.root); + var depth = main_col_dims.depth; + var width = main_col_dims.width; + var nbr_measures = this.data.measures.length; + var result = [[{width:1, height: depth + 1}]]; + var col_ids = []; + this.data.main_col.width = width; + this._traverseTree(this.data.main_col.root, function (header) { + var index = header.path.length - 1; + var cell = { + width: self._getHeaderWidth(header) * nbr_measures, + height: header.expanded ? 1 : depth - index, + title: header.path[header.path.length-1], + id: header.id, + expanded: header.expanded, + }; + if (!header.expanded) col_ids.push(header.id); + if (result[index]) result[index].push(cell); + else result[index] = [cell]; + }); + col_ids.push(this.data.main_col.root.id); + this.data.main_col.width = width; + if (width > 1) { + var total_cell = {width:nbr_measures, height: depth, title:""}; + if (nbr_measures === 1) { + total_cell.total = true; + } + result[0].push(total_cell); + } + var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures; + for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) { + measure = this.data.measures[i % nbr_measures]; + measure_row.push({ + measure: measure, + is_bold: (width > 1) && (i >= nbr_measures*width), + id: col_ids[Math.floor(i / nbr_measures)], + }); + } + result.push(measure_row); + return result; + }, + _computeRows: function () { + var self = this; + var aggregates, i; + var result = []; + this._traverseTree(this.data.main_row.root, function (header) { + var values = [], + col_ids = []; + result.push({ + id: header.id, + col_ids: col_ids, + indent: header.path.length - 1, + title: header.path[header.path.length-1], + expanded: header.expanded, + values: values, + }); + self._traverseTree(self.data.main_col.root, add_cells, header.id, values, col_ids); + if (self.data.main_col.width > 1) { + aggregates = self._getCellValue(header.id, self.data.main_col.root.id); + for (i = 0; i < self.data.measures.length; i++) { + values.push(aggregates && aggregates[self.data.measures[i]]); + } + col_ids.push( self.data.main_col.root.id); + } + }); + return result; + function add_cells (col_hdr, row_id, values, col_ids) { + if (col_hdr.expanded) return; + col_ids.push(col_hdr.id); + aggregates = self._getCellValue(row_id, col_hdr.id); + for (i = 0; i < self.data.measures.length; i++) { + values.push(aggregates && aggregates[self.data.measures[i]]); + } + } + }, + /** + * Static helper method + * + * @private @static + * @param {any} root + * @param {any} path + * @returns + */ + _findPathInTree: function (root, path) { + var i, + l = root.path.length; + if (l === path.length) { + return (root.path[l-1] === path[l - 1]) ? root : null; + } + for (i = 0; i < root.children.length; i++) { + if (root.children[i].path[l] === path[l]) { + return this._findPathInTree(root.children[i], path); + } + } + return null; + }, + _getCellValue: function (id1, id2) { + if ((id1 in this.data.cells) && (id2 in this.data.cells[id1])) { + return this.data.cells[id1][id2]; + } + if (id2 in this.data.cells) return this.data.cells[id2][id1]; + }, + /** + * @param {any} value + * @param {any} root + * @param {any} i + * @param {any} j + * @param {any} parent + * @returns {Object} + */ + _getHeader: function (value, root, i, j, parent) { + var path; + var total = _t("Total"); + if (parent) { + path = parent.path.concat(value.slice(i,j)); + } else { + path = [total].concat(value.slice(i,j)); + } + return this._findPathInTree(root, path); + }, + /** + * @private @static + * @param {any} header + * @returns {integer} + */ + _getHeaderDepth: function (header) { + var depth = 1; + this._traverseTree(header, function (hdr) { + depth = Math.max(depth, hdr.path.length); + }); + return depth; + }, + _getHeaderDim: function (header) { + var depth = 1; + var width = 0; + this._traverseTree(header, function (hdr) { + depth = Math.max(depth, hdr.path.length); + if (!hdr.expanded) width++; + }); + return {width: width, depth: depth}; + }, + _getHeaderWidth: function (header) { + var self = this; + if (!header.children.length) return 1; + if (!header.expanded) return 1; + return header.children.reduce(function (s, c) { + return s + self._getHeaderWidth(c); + }, 0); + }, + /** + * @param {any} value + * @param {any} field + * @returns {string} + */ + _getNumberedValue: function (value, field) { + var id= value[0]; + var name= value[1]; + this.numbering[field] = this.numbering[field] || {}; + this.numbering[field][name] = this.numbering[field][name] || {}; + var numbers = this.numbering[field][name]; + numbers[id] = numbers[id] || _.size(numbers) + 1; + return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); + }, + /** + * @param {any} datapt + * @param {any} fields + * @returns {string[]} + */ + _getValue: function (datapt, fields) { + var result = []; + var value; + for (var i = 0; i < fields.length; i++) { + value = this._sanitizeValue(datapt[fields[i]],fields[i]); + result.push(value); + } + return result; + }, + /** + * @returns {Deferred} + */ + _loadData: function () { + var self = this; + var groupBys = []; + var rowGroupBys = this.data.groupedBy; + var colGroupBys = this.data.colGroupBys; + var fields = [].concat(rowGroupBys, colGroupBys, this.data.measures); + + for (var i = 0; i < rowGroupBys.length + 1; i++) { + for (var j = 0; j < colGroupBys.length + 1; j++) { + groupBys.push(rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j))); + } + } + + return $.when.apply(null, groupBys.map(function (groupBy) { + return self.performModelRPC(self.modelName, 'read_group', [], { + context: self.data.context, + domain: self.data.domain, + fields: _.map(fields, function (field) { return field.split(':')[0]; }), + groupby: groupBy, + lazy: false, + }); + })).then(function () { + var data = Array.prototype.slice.call(arguments); + if (data[0][0].__count === 0) { + self.data.has_data = false; + return; + } + self._prepareData(data); + }); + }, + /** + * @param {any} value + * @param {any} domain + * @param {any} root + * @param {any} i + * @param {any} j + * @param {any} parent_header + * @returns {Object} + */ + _makeHeader: function (value, domain, root, i, j, parent_header) { + var total = _t("Total"); + var title = value.length ? value[value.length - 1] : total; + var path, parent; + if (parent_header) { + path = parent_header.path.concat(title); + parent = parent_header; + } else { + path = [total].concat(value.slice(i,j-1)); + parent = value.length ? this._findPathInTree(root, path) : null; + } + var header = { + id: utils.generateID(), + expanded: false, + domain: domain || [], + children: [], + path: value.length ? parent.path.concat(title) : [title] + }; + this.data.headers[header.id] = header; + header.root = root || header; + if (parent) { + parent.children.push(header); + parent.expanded = true; + } + return header; + }, + /** + * @param {Object} data + */ + _prepareData: function (data) { + var self = this; + _.extend(self.data, { + main_row: {}, + main_col: {}, + headers: {}, + cells: [], + }); + + var index = 0; + var rowGroupBys = this.data.groupedBy; + var colGroupBys = this.data.colGroupBys; + var datapt, row, col, attrs, cell_value; + var main_row_header, main_col_header; + var groupBys; + var m; + + + for (var i = 0; i < rowGroupBys.length + 1; i++) { + for (var j = 0; j < colGroupBys.length + 1; j++) { + for (var k = 0; k < data[index].length; k++) { + datapt = data[index][k]; + groupBys = rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j)); + attrs = { + value: self._getValue(datapt, groupBys), + domain: datapt.__domain || [], + length: datapt.__count, + }; + + if (j === 0) { + row = this._makeHeader(attrs.value, attrs.domain, main_row_header, 0, i); + } else { + row = this._getHeader(attrs.value, main_row_header, 0, i); + } + if (i === 0) { + col = this._makeHeader(attrs.value, attrs.domain, main_col_header, i, i+j); + } else { + col = this._getHeader(attrs.value, main_col_header, i, i+j); + } + if (i + j === 0) { + this.data.has_data = attrs.length > 0; + main_row_header = row; + main_col_header = col; + } + if (!this.data.cells[row.id]) this.data.cells[row.id] = []; + for (cell_value = {}, m=0; m < this.data.measures.length; m++) { + cell_value[this.data.measures[m]] = datapt[this.data.measures[m]]; + } + this.data.cells[row.id][col.id] = cell_value; + } + index++; + } + } + + this.data.main_row.groupbys = rowGroupBys; + this.data.main_col.groupbys = colGroupBys; + + main_row_header.other_root = main_col_header; + main_col_header.other_root = main_row_header; + + main_row_header.groupbys = rowGroupBys; + main_col_header.groupbys = colGroupBys; + + this.data.main_row.root = main_row_header; + this.data.main_col.root = main_col_header; + }, + /** + * Format a value to a usable string, for the renderer to display. + * + * @param {any} value + * @param {any} field + * @returns {string} + */ + _sanitizeValue: function (value, field) { + if (value === false) { + return _t("Undefined"); + } + if (value instanceof Array) { + return this._getNumberedValue(value, field); + } + if (field && this.fields[field] && (this.fields[field].type === 'selection')) { + var selected = _.where(this.fields[field].selection, {0: value})[0]; + return selected ? selected[1] : value; + } + return value; + }, + /** + * @private @static + * @param {any} root + * @param {any} f + * @param {any} arg1 + * @param {any} arg2 + * @param {any} arg3 + * @returns + */ + _traverseTree: function (root, f, arg1, arg2, arg3) { + f(root, arg1, arg2, arg3); + if (!root.expanded) return; + for (var i = 0; i < root.children.length; i++) { + this._traverseTree(root.children[i], f, arg1, arg2, arg3); + } + }, + /** + * @param {Object} old_tree + * @param {Object} new_tree + */ + _updateTree: function (old_tree, new_tree) { + if (!old_tree.expanded) { + new_tree.expanded = false; + new_tree.children = []; + return; + } + var tree, j, old_title, new_title; + for (var i = 0; i < new_tree.children.length; i++) { + tree = undefined; + new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1]; + for (j = 0; j < old_tree.children.length; j++) { + old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1]; + if (old_title === new_title) { + tree = old_tree.children[j]; + break; + } + } + if (tree) this._updateTree(tree, new_tree.children[i]); + else { + new_tree.children[i].expanded = false; + new_tree.children[i].children = []; + } + } + }, +}); + +return PivotModel; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js new file mode 100644 index 00000000000..0323dfd8bed --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -0,0 +1,142 @@ +odoo.define('web.PivotRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var field_utils = require('web.field_utils'); + +var PivotRenderer = AbstractRenderer.extend({ + tagName: 'table', + className: 'table-hover table-condensed table-bordered', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {Deferred} + */ + _render: function () { + var $fragment = $(document.createDocumentFragment()); + var $table = $('<table>').appendTo($fragment); + var $thead = $('<thead>').appendTo($table); + var $tbody = $('<tbody>').appendTo($table); + var nbr_measures = this.state.measures.length; + var nbrCols = (this.state.mainColWidth === 1) ? + nbr_measures : + (this.state.mainColWidth + 1) * nbr_measures; + for (var i=0; i < nbrCols + 1; i++) { + $table.prepend($('<col>')); + } + this._renderHeaders($thead, this.state.headers); + this._renderRows($tbody, this.state.rows); + $table.on('hover', 'td', function () { + $table.find('col:eq(' + $(this).index()+')').toggleClass('hover'); + }); + // todo: make sure the next line does something + $table.find('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip(); + this.$el.html($table.contents()); + return this._super.apply(this, arguments); + }, + /** + * @private + * @param {jQueryElement} $thead + * @param {jQueryElement} headers + */ + _renderHeaders: function ($thead, headers) { + var self = this; + var i, j, cell, $row, $cell; + + var groupbyLabels = _.map(this.state.colGroupBys, function (gb) { + return self.state.fields[gb.split(':')[0]].string; + }); + + for (i = 0; i < headers.length; i++) { + $row = $('<tr>'); + for (j = 0; j < headers[i].length; j++) { + cell = headers[i][j]; + $cell = $('<th>') + .text(cell.title) + .attr('rowspan', cell.height) + .attr('colspan', cell.width); + if (i > 0) { + $cell.attr('title', groupbyLabels[i-1]); + } + if (cell.expanded !== undefined) { + $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); + $cell.data('id', cell.id); + } + if (cell.measure) { + $cell.addClass('o_pivot_measure_row text-muted') + .text(this.state.fields[cell.measure].string); + $cell.data('id', cell.id).data('measure', cell.measure); + if (cell.id === this.state.sortedColumn.id && cell.measure === this.state.sortedColumn.measure) { + $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order); + } + } + $row.append($cell); + + $cell.toggleClass('hidden-xs', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.state.measures.length)); + if (cell.height > 1) { + $cell.css('padding', 0); + } + } + $thead.append($row); + } + }, + /** + * @private + * @param {jQueryElement} $tbody + * @param {jQueryElement} rows + */ + _renderRows: function ($tbody, rows) { + var self = this; + var i, j, value, measure, name, $row, $cell, $header; + var nbrMeasures = this.state.measures.length; + var length = rows[0].values.length; + var shouldDisplayTotal = this.state.mainColWidth > 1; + + var groupbyLabels = _.map(this.state.rowGroupBys, function (gb) { + return self.state.fields[gb.split(':')[0]].string; + }); + var measureTypes = this.state.measures.map(function (name) { + return self.state.fields[name].type; + }); + for (i = 0; i < rows.length; i++) { + $row = $('<tr>'); + $header = $('<td>') + .text(rows[i].title) + .data('id', rows[i].id) + .css('padding-left', (5 + rows[i].indent * 30) + 'px') + .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); + if (rows[i].indent > 0) $header.attr('title', groupbyLabels[rows[i].indent - 1]); + $header.appendTo($row); + for (j = 0; j < length; j++) { + value = rows[i].values[j]; + if (value !== undefined) { + name = this.state.measures[j % nbrMeasures]; + measure = this.state.fields[name]; + value = field_utils['format_' + measureTypes[j % nbrMeasures]](value, measure); + } + $cell = $('<td>') + .data('id', rows[i].id) + .data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)]) + .toggleClass('o_empty', !value) + .text(value) + .addClass('o_pivot_cell_value text-right'); + if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){ + $cell.css('font-weight', 'bold'); + } + $row.append($cell); + + $cell.toggleClass('hidden-xs', j < length - nbrMeasures); + } + $tbody.append($row); + } + }, + +}); + +return PivotRenderer; +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js new file mode 100644 index 00000000000..777d9bcb5a7 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -0,0 +1,103 @@ +odoo.define('web.PivotView', function (require) { +"use strict"; + +/** + * The Pivot View is a view that represents data in a 'pivot grid' form. It + * aggregates data on 2 dimensions and displays the result, allows the user to + * 'zoom in' data. + */ + +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var PivotModel = require('web.PivotModel'); +var PivotController = require('web.PivotController'); +var PivotRenderer = require('web.PivotRenderer'); + +var _t = core._t; +var _lt = core._lt; + +var GROUPABLE_TYPES = + ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; + +var PivotView = AbstractView.extend({ + display_name: _lt('Pivot'), + icon: 'fa-table', + config: { + Model: PivotModel, + Controller: PivotController, + Renderer: PivotRenderer, + }, + /** + * @override + * @param {Object} params + */ + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var activeMeasures = []; + var colGroupBys = []; + var rowGroupBys = []; + + var measures = {}; + var groupableFields = {}; + + _.each(fields, function (field, name) { + if ((name !== 'id') && (field.store === true)) { + if (_.contains(['integer', 'float', 'monetary'], field.type)) { + measures[name] = field; + } + if (_.contains(GROUPABLE_TYPES, field.type)) { + groupableFields[name] = field; + } + } + }); + measures.__count = {string: _t("Count"), type: "integer"}; + + arch.children.forEach(function (field) { + var name = field.attrs.name; + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + + // add active measures to the measure list. This is very rarely + // necessary, but it can be useful if one is working with a + // functional field non stored, but in a model with an overrided + // read_group method. In this case, the pivot view could work, and + // the measure should be allowed. However, be careful if you define + // a measure in your pivot view: non stored functional fields will + // probably not work (their aggregate will always be 0). + if (field.type === 'measure' && !(field.name in measures)) { + measures[field.name] = field; + } + if (field.attrs.type === 'measure' || 'operator' in field.attrs) { + activeMeasures.push(name); + } + if (field.attrs.type === 'col') { + colGroupBys.push(name); + } + if (field.attrs.type === 'row') { + rowGroupBys.push(name); + } + }); + if ((!activeMeasures.length) || arch.attrs.display_quantity) { + activeMeasures.push('__count'); + } + fields.__count = {string: _t("Count"), type: "integer"}; + + this.loadParams.measures = activeMeasures; + this.loadParams.colGroupBys = colGroupBys; + this.loadParams.rowGroupBys = rowGroupBys; + this.loadParams.fields = fields; + + this.controllerParams.title = params.title || arch.attrs.string || _t("Untitled"); + this.controllerParams.enableLinking = !arch.attrs.disable_linking; + this.controllerParams.measures = measures; + this.controllerParams.groupableFields = groupableFields; + }, +}); + +return PivotView; + +}); + + diff --git a/addons/web/static/src/js/views/pivot_view.js b/addons/web/static/src/js/views/pivot_view.js deleted file mode 100644 index 95e8d13e5ff..00000000000 --- a/addons/web/static/src/js/views/pivot_view.js +++ /dev/null @@ -1,892 +0,0 @@ -odoo.define('web.PivotView', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Pivot Table view - *---------------------------------------------------------*/ - -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var data_manager = require('web.data_manager'); -var formats = require('web.formats'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); -var session = require('web.session'); -var Sidebar = require('web.Sidebar'); -var utils = require('web.utils'); -var View = require('web.View'); - -var _lt = core._lt; -var _t = core._t; -var QWeb = core.qweb; - -var PivotView = View.extend({ - events: { - 'click .o_pivot_header_cell_opened': 'on_open_header_click', - 'click .o_pivot_header_cell_closed': 'on_closed_header_click', - 'click .o_pivot_field_menu a': 'on_field_menu_selection', - 'click td': 'on_cell_click', - 'click .o_pivot_measure_row': 'on_measure_row_click', - }, - display_name: _lt('Pivot'), - icon: 'fa-table', - require_fields: true, - template: 'PivotView', - - init: function() { - this._super.apply(this, arguments); - this._model = new Model(this.model, {group_by_no_leaf: true}); - - this.measures = {}; - this.groupable_fields = {}; - this.ready = false; // will be ready after the first do_search - this.data_loaded = $.Deferred(); - this.title = this.options.title || this.fields_view.arch.attrs.string; - - this.main_row = { - root: undefined, - groupbys: [], - }; - this.main_col = { - root: undefined, - groupbys: [], - }; - this.initial_col_groupby = []; - this.initial_row_groupby = []; - - this.active_measures = []; - this.headers = {}; - this.cells = {}; - this.has_data = false; - this.$buttons = $(); - - this.last_header_selected = null; - this.sorted_column = {}; - - this.numbering = {}; - this.widgets = []; - - this.enable_linking = !this.fields_view.arch.attrs.disable_linking; - }, - willStart: function () { - var self = this; - - var fields_def = data_manager.load_fields(this.dataset); - var xlwt_def = session.rpc('/web/pivot/check_xlwt').then(function(result) { - self.xlwt_installed = result; - }); - - this.fields_view.arch.children.forEach(function (field) { - var name = field.attrs.name; - if (field.attrs.interval) { - name += ':' + field.attrs.interval; - } - //noinspection FallThroughInSwitchStatementJS - switch (field.attrs.type) { - case 'measure': - self.widgets.push(field.attrs.widget || ""); - self.active_measures.push(name); - break; - case 'col': - self.initial_col_groupby.push(name); - break; - default: - if ('operator' in field.attrs) { - self.active_measures.push(name); - break; - } - case 'row': - self.initial_row_groupby.push(name); - } - }); - if ((!this.active_measures.length) || this.fields_view.arch.attrs.display_quantity) { - this.active_measures.push('__count__'); - } - - return $.when(fields_def, xlwt_def, this._super()).then(function (fields) { - self.prepare_fields(fields); - // add active measures to the measure list. This is very rarely necessary, but it - // can be useful if one is working with a functional field non stored, but in a - // model with an overrided read_group method. In this case, the pivot view could - // work, and the measure should be allowed. However, be careful if you define a - // measure in your pivot view: non stored functional fields will probably not work - // (their aggregate will always be 0). - _.each(self.active_measures, function (m) { - if (!(m in self.measures)) { - self.measures[m] = self.fields[m]; - } - }); - }); - }, - start: function () { - this.$el.toggleClass('o_enable_linking', this.enable_linking); - this.$field_selection = this.$('.o_field_selection'); - core.bus.on('click', this, function () { - this.$field_selection.empty(); - }); - return this._super(); - }, - render_field_selection: function (top, left) { - var grouped_fields = this.main_row.groupbys - .concat(this.main_col.groupbys) - .map(function(f) { return f.split(':')[0];}); - - var fields = _.chain(this.groupable_fields) - .pairs() - .sortBy(function(f) { return f[1].string; }) - .map(function (f) { return [f[0], f[1], _.contains(grouped_fields, f[0])]; }) - .value(); - - this.$field_selection.html(QWeb.render('PivotView.FieldSelection', { - fields: fields - })); - - this.$field_selection.find('ul').first() - .css({top: top, left: left}) - .show(); - }, - /** - * Render the buttons according to the PivotView.buttons template and - * add listeners on it. - * Set this.$buttons with the produced jQuery element - * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted - * $node may be undefined, in which case the PivotView does nothing - */ - render_buttons: function ($node) { - if ($node) { - var self = this; - - var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; - this.$buttons = $(QWeb.render('PivotView.buttons', context)); - this.$buttons.click(this.on_button_click.bind(this)); - this.active_measures.forEach(function (measure) { - self.$buttons.find('li[data-field="' + measure + '"]').addClass('selected'); - }); - this.$buttons.find('button').tooltip(); - - this.$buttons.appendTo($node); - } - }, - /** - * Instantiate and render the sidebar. - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - * $node may be undefined, in which case the PivotView does nothing - **/ - render_sidebar: function($node) { - if (this.xlwt_installed && $node && this.options.sidebar) { - this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); - this.sidebar.appendTo($node); - } - }, - prepare_fields: function (fields) { - var self = this, - groupable_types = ['many2one', 'char', 'boolean', - 'selection', 'date', 'datetime']; - this.fields = fields; - _.each(fields, function (field, name) { - if ((name !== 'id') && (field.store === true)) { - if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') { - self.measures[name] = field; - } - if (_.contains(groupable_types, field.type)) { - self.groupable_fields[name] = field; - } - } - }); - this.measures.__count__ = {string: _t("Count"), type: "integer"}; - }, - do_search: function (domain, context, group_by) { - if (!this.ready) { - this.initial_row_groupby = context.pivot_row_groupby || this.initial_row_groupby; - this.initial_col_groupby = context.pivot_col_groupby || this.initial_col_groupby; - } - this.main_row.groupbys = group_by.length ? group_by : (context.pivot_row_groupby || this.initial_row_groupby.slice(0)); - this.main_col.groupbys = context.pivot_column_groupby || this.initial_col_groupby.slice(0); - this.active_measures = context.pivot_measures || this.active_measures; - - this.domain = domain; - this.context = context; - if (!this.ready) { - this.data_loaded = this.load_data(true); - this.ready = true; - return; - } - this.data_loaded = this.load_data(false); - return this.do_show(); - }, - do_show: function () { - var self = this; - var _super = this._super.bind(this); - this.do_push_state({}); - return this.data_loaded.done(function () { - self.display_table(); - self.$buttons.find('.o_pivot_measures_list li').removeClass('selected'); - self.active_measures.forEach(function (measure) { - self.$buttons.find('li[data-field="' + measure + '"]').addClass('selected'); - }); - _super(); - }); - }, - get_context: function () { - return !this.ready ? {} : { - pivot_measures: this.active_measures, - pivot_column_groupby: this.main_col.groupbys, - pivot_row_groupby: this.main_row.groupbys, - }; - }, - on_button_click: function (event) { - var $target = $(event.target); - if ($target.hasClass('o_pivot_flip_button')) { return this.flip(); } - if ($target.hasClass('o_pivot_expand_button')) { return this.expand_all(); } - if ($target.parents('.o_pivot_measures_list').length) { - var parent = $target.parent(); - var field = parent.data('field'); - parent.toggleClass('selected'); - event.preventDefault(); - event.stopPropagation(); - return this.toggle_measure(field); - } - if ($target.hasClass('o_pivot_download')) { - return this.download_table(); - } - }, - on_open_header_click: function (event) { - var id = $(event.target).data('id'), - header = this.headers[id]; - header.expanded = false; - header.children = []; - var new_groupby_length = this.get_header_depth(header.root) - 1; - header.root.groupbys.splice(new_groupby_length); - this.display_table(); - }, - on_closed_header_click: function (event) { - var $target = $(event.target); - var id = $target.data('id'); - var header = this.headers[id]; - var groupbys = header.root.groupbys; - - if (header.path.length - 1 < groupbys.length) { - this.expand_header(header, groupbys[header.path.length - 1]) - .then(this.proxy('display_table')); - } else { - this.last_header_selected = id; - var position = $target.position(); - var top = position.top + $target.height(); - var left = position.left + event.offsetX; - this.render_field_selection(top, left); - event.stopPropagation(); - } - }, - on_cell_click: function (event) { - var $target = $(event.target); - if ($target.hasClass('o_pivot_header_cell_closed') - || $target.hasClass('o_pivot_header_cell_opened') - || $target.hasClass('o_empty') - || !this.enable_linking) { - return; - } - var row_id = $target.data('id'), - col_id = $target.data('col_id'), - row_domain = this.headers[row_id].domain, - col_domain = this.headers[col_id].domain, - context = _.omit(_.clone(this.context), 'group_by'); - - var views = [ - _find_view_info.call(this, "list"), - _find_view_info.call(this, "form"), - ]; - - return this.do_action({ - type: 'ir.actions.act_window', - name: this.title, - res_model: this.model, - views: views, - view_type : "list", - view_mode : "list", - target: 'current', - context: context, - domain: this.domain.concat(row_domain, col_domain), - }); - - function _find_view_info(view_type) { - return _.find(this.options.action.views, function (view) { - return view[1] === view_type; - }) || [false, view_type]; - } - }, - on_measure_row_click: function (event) { - var $target = $(event.target), - col_id = $target.data('id'), - measure = $target.data('measure'); - - this.sort_rows(col_id, measure, $target.hasClass('o_pivot_measure_row_sorted_asc')); - this.display_table(); - }, - sort_rows: function (col_id, measure, descending) { - var self = this; - traverse_tree(this.main_row.root, function (header) { - header.children.sort(compare); - }); - this.sorted_column = { - id: col_id, - measure: measure, - order: descending ? 'desc' : 'asc', - }; - - function compare (row1, row2) { - var values1 = self.get_value(row1.id, col_id), - values2 = self.get_value(row2.id, col_id), - value1 = values1 ? values1[measure] : 0, - value2 = values2 ? values2[measure] : 0; - return descending ? value1 - value2 : value2 - value1; - } - }, - on_field_menu_selection: function (event) { - event.preventDefault(); - var $target = $(event.target); - if ($target.parent().hasClass('disabled')) { - event.stopPropagation(); - return; - } - var field = $target.parent().data('field'), - interval = $target.data('interval'), - header = this.headers[this.last_header_selected]; - if (interval) field = field + ':' + interval; - this.expand_header(header, field) - .then(function () { - header.root.groupbys.push(field); - }) - .then(this.proxy('display_table')); - }, - expand_header: function (header, field) { - var self = this; - - var other_root = header.root.other_root, - other_groupbys = header.root.other_root.groupbys, - fields = [].concat(field, other_groupbys, this.active_measures), - groupbys = []; - - for (var i = 0; i <= other_groupbys.length; i++) { - groupbys.push([field].concat(other_groupbys.slice(0,i))); - } - return $.when.apply(null, groupbys.map(function (groupby) { - return self._model.query(fields) - .filter(header.domain.length ? header.domain : self.domain) - .context(self.context) - .lazy(false) - .group_by(groupby); - })).then(function () { - var data = Array.prototype.slice.call(arguments), - datapt, attrs, j, k, l, row, col, cell_value, field_name; - for (i = 0; i < data.length; i++) { - for (j = 0; j < data[i].length; j++){ - datapt = data[i][j]; - attrs = datapt.attributes; - if (i === 0) attrs.value = [attrs.value]; - for (k = 0; k < attrs.value.length; k++) { - if (k < 1) field_name = field; - else field_name = other_groupbys[k - 1]; - attrs.value[k] = self.sanitize_value(attrs.value[k], field_name); - } - if (i === 0) { - row = self.make_header(datapt, header.root, 0, 1, header); - } else { - row = self.get_header(datapt, header.root, 0, 1, header); - } - col = self.get_header(datapt, other_root, 1, i + 1); - if (!col) continue; - for (cell_value = {}, l=0; l < self.active_measures.length; l++) { - cell_value[self.active_measures[l]] = attrs.aggregates[self.active_measures[l]]; - } - cell_value.__count__ = attrs.length; - if (!self.cells[row.id]) self.cells[row.id] = []; - self.cells[row.id][col.id] = cell_value; - } - } - }); - }, - expand_all: function () { - this.load_data(false).then(this.proxy('display_table')); - }, - // returns a deferred that resolve when the data is loaded. - load_data: function (update) { - var should_update = (update && this.main_row.root && this.main_col.root); - var self = this, - i, j, - groupbys = [], - row_gbs = this.main_row.groupbys, - col_gbs = this.main_col.groupbys, - fields = [].concat(row_gbs, col_gbs, this.active_measures); - for (i = 0; i < row_gbs.length + 1; i++) { - for (j = 0; j < col_gbs.length + 1; j++) { - groupbys.push(row_gbs.slice(0,i).concat(col_gbs.slice(0,j))); - } - } - return $.when.apply(null, groupbys.map(function (groupby) { - return self._model.query(fields) - .filter(self.domain) - .context(self.context) - .lazy(false) - // .order_by(['probable_revenue']) - .group_by(groupby); - })).then(function () { - var data = Array.prototype.slice.call(arguments); - self.prepare_data(data, should_update); - }); - }, - prepare_data: function (data, should_update) { - var i, j, k, l, m, - index = 0, - row_gbs = this.main_row.groupbys, - col_gbs = this.main_col.groupbys, - main_row_header, main_col_header, - row, col, attrs, datapt, cell_value, - field; - - for (i = 0; i < row_gbs.length + 1; i++) { - for (j = 0; j < col_gbs.length + 1; j++) { - for (k = 0; k < data[index].length; k++) { - datapt = data[index][k]; - attrs = datapt.attributes; - if (i + j === 1) { - attrs.value = [attrs.value]; - } - for (l = 0; l < attrs.value.length; l++) { - if (l < i) field = row_gbs[l]; - else field = col_gbs[l - i]; - attrs.value[l] = this.sanitize_value(attrs.value[l], field); - } - if (j === 0) { - row = this.make_header(datapt, main_row_header, 0, i); - } else { - row = this.get_header(datapt, main_row_header, 0, i); - } - if (i === 0) { - col = this.make_header(datapt, main_col_header, i, i+j); - } else { - col = this.get_header(datapt, main_col_header, i, i+j); - } - if (i + j === 0) { - this.has_data = attrs.length > 0; - main_row_header = row; - main_col_header = col; - } - if (!this.cells[row.id]) this.cells[row.id] = []; - for (cell_value = {}, m=0; m < this.active_measures.length; m++) { - cell_value[this.active_measures[m]] = attrs.aggregates[this.active_measures[m]]; - } - cell_value.__count__ = attrs.length; - this.cells[row.id][col.id] = cell_value; - } - index++; - } - } - if (should_update) { - this.update_tree(this.main_row.root, main_row_header); - var new_groupby_length = this.get_header_depth(main_row_header) - 1; - main_row_header.groupbys = this.main_row.root.groupbys.slice(0, new_groupby_length); - this.update_tree(this.main_col.root, main_col_header); - new_groupby_length = this.get_header_depth(main_col_header) - 1; - main_col_header.groupbys = this.main_col.root.groupbys.slice(0, new_groupby_length); - } else { - main_row_header.groupbys = this.main_row.groupbys; - main_col_header.groupbys = this.main_col.groupbys; - } - main_row_header.other_root = main_col_header; - main_col_header.other_root = main_row_header; - this.main_row.root = main_row_header; - this.main_col.root = main_col_header; - }, - update_tree: function (old_tree, new_tree) { - if (!old_tree.expanded) { - new_tree.expanded = false; - new_tree.children = []; - return; - } - var tree, j, old_title, new_title; - for (var i = 0; i < new_tree.children.length; i++) { - tree = undefined; - new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1]; - for (j = 0; j < old_tree.children.length; j++) { - old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1]; - if (old_title === new_title) { - tree = old_tree.children[j]; - break; - } - } - if (tree) this.update_tree(tree, new_tree.children[i]); - else { - new_tree.children[i].expanded = false; - new_tree.children[i].children = []; - } - } - }, - sanitize_value: function (value, field) { - if (value === false) return _t("Undefined"); - if (value instanceof Array) return this.get_numbered_value(value, field); - if (field && this.fields[field] && (this.fields[field].type === 'selection')) { - var selected = _.where(this.fields[field].selection, {0: value})[0]; - return selected ? selected[1] : value; - } - return value; - }, - get_numbered_value: function(value, field) { - var id= value[0]; - var name= value[1] - this.numbering[field] = this.numbering[field] || {}; - this.numbering[field][name] = this.numbering[field][name] || {}; - var numbers = this.numbering[field][name]; - numbers[id] = numbers[id] || _.size(numbers) + 1; - return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); - }, - make_header: function (data_pt, root, i, j, parent_header) { - var total = _t("Total"); - var attrs = data_pt.attributes, - value = attrs.value, - title = value.length ? value[value.length - 1] : total; - var path, parent; - if (parent_header) { - path = parent_header.path.concat(title); - parent = parent_header; - } else { - path = [total].concat(value.slice(i,j-1)); - parent = value.length ? find_path_in_tree(root, path) : null; - } - var header = { - id: utils.generate_id(), - expanded: false, - domain: data_pt.model._domain, - children: [], - path: value.length ? parent.path.concat(title) : [title] - }; - this.headers[header.id] = header; - header.root = root || header; - if (parent) { - parent.children.push(header); - parent.expanded = true; - } - return header; - }, - get_header: function (data_pt, root, i, j, parent) { - var path; - var total = _t("Total"); - if (parent) { - path = parent.path.concat(data_pt.attributes.value.slice(i,j)); - } else { - path = [total].concat(data_pt.attributes.value.slice(i,j)); - } - return find_path_in_tree(root, path); - }, - _clean_table: function () { - return this.$el - .find('table') - .remove() - .end() - .find('.oe_view_nocontent') - .remove() - .end(); - }, - display_table: function () { - if (!this.active_measures.length || !this.has_data) { - return this._clean_table().append(QWeb.render('PivotView.nodata')); - } - var $fragment = $(document.createDocumentFragment()), - $table = $('<table>') - .addClass('table table-hover table-condensed table-bordered') - .appendTo($fragment), - $thead = $('<thead>').appendTo($table), - $tbody = $('<tbody>').appendTo($table), - headers = this.compute_headers(), - rows = this.compute_rows(), - nbr_measures = this.active_measures.length, - nbr_cols = (this.main_col.width === 1) ? nbr_measures : (this.main_col.width + 1)*nbr_measures; - for (var i=0; i < nbr_cols + 1; i++) { - $table.prepend($('<col>')); - } - this.draw_headers($thead, headers); - this.draw_rows($tbody, rows); - $table.on('hover', 'td', function () { - $table.find('col:eq(' + $(this).index()+')').toggleClass('hover'); - }); - this._clean_table().append($fragment); - this.$('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip(); - }, - draw_headers: function ($thead, headers) { - var self = this, - i, j, cell, $row, $cell; - - var groupby_labels = _.map(this.main_col.groupbys, function (gb) { - return self.fields[gb.split(':')[0]].string; - }); - - for (i = 0; i < headers.length; i++) { - $row = $('<tr>'); - for (j = 0; j < headers[i].length; j++) { - cell = headers[i][j]; - $cell = $('<th>') - .text(cell.title) - .attr('rowspan', cell.height) - .attr('colspan', cell.width); - if (i > 0) { - $cell.attr('title', groupby_labels[i-1]); - } - if (cell.expanded !== undefined) { - $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); - $cell.data('id', cell.id); - } - if (cell.measure) { - $cell.addClass('o_pivot_measure_row text-muted') - .text(this.measures[cell.measure].string); - $cell.data('id', cell.id).data('measure', cell.measure); - if (cell.id === this.sorted_column.id && cell.measure === this.sorted_column.measure) { - $cell.addClass('o_pivot_measure_row_sorted_' + this.sorted_column.order); - } - } - $row.append($cell); - - $cell.toggleClass('hidden-xs', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.active_measures.length)); - if (cell.height > 1) { - $cell.css('padding', 0); - } - } - $thead.append($row); - } - }, - draw_rows: function ($tbody, rows) { - var self = this, - i, j, value, $row, $cell, $header, - nbr_measures = this.active_measures.length, - length = rows[0].values.length, - display_total = this.main_col.width > 1; - - var groupby_labels = _.map(this.main_row.groupbys, function (gb) { - return self.fields[gb.split(':')[0]].string; - }); - var measure_types = this.active_measures.map(function (name) { - return self.measures[name].type; - }); - var widgets = this.widgets; - for (i = 0; i < rows.length; i++) { - $row = $('<tr>'); - $header = $('<td>') - .text(rows[i].title) - .data('id', rows[i].id) - .css('padding-left', (5 + rows[i].indent * 30) + 'px') - .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); - if (rows[i].indent > 0) $header.attr('title', groupby_labels[rows[i].indent - 1]); - $header.appendTo($row); - for (j = 0; j < length; j++) { - value = formats.format_value(rows[i].values[j], {type: measure_types[j % nbr_measures], widget: widgets[j % nbr_measures]}); - $cell = $('<td>') - .data('id', rows[i].id) - .data('col_id', rows[i].col_ids[Math.floor(j / nbr_measures)]) - .toggleClass('o_empty', !value) - .text(value) - .addClass('o_pivot_cell_value text-right'); - if (((j >= length - this.active_measures.length) && display_total) || i === 0){ - $cell.css('font-weight', 'bold'); - } - $row.append($cell); - - $cell.toggleClass('hidden-xs', j < length - this.active_measures.length); - } - $tbody.append($row); - } - }, - compute_headers: function () { - var self = this, - main_col_dims = this.get_header_width_depth(this.main_col.root), - depth = main_col_dims.depth, - width = main_col_dims.width, - nbr_measures = this.active_measures.length, - result = [[{width:1, height: depth + 1}]], - col_ids = []; - this.main_col.width = width; - traverse_tree(this.main_col.root, function (header) { - var index = header.path.length - 1, - cell = { - width: self.get_header_width(header) * nbr_measures, - height: header.expanded ? 1 : depth - index, - title: header.path[header.path.length-1], - id: header.id, - expanded: header.expanded, - }; - if (!header.expanded) col_ids.push(header.id); - if (result[index]) result[index].push(cell); - else result[index] = [cell]; - }); - col_ids.push(this.main_col.root.id); - this.main_col.width = width; - if (width > 1) { - var total_cell = {width:nbr_measures, height: depth, title:""}; - if (nbr_measures === 1) { - total_cell.total = true; - } - result[0].push(total_cell); - } - var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures; - for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) { - measure = this.active_measures[i % nbr_measures]; - measure_row.push({ - measure: measure, - is_bold: (width > 1) && (i >= nbr_measures*width), - id: col_ids[Math.floor(i / nbr_measures)], - }); - } - result.push(measure_row); - return result; - }, - get_header_width: function (header) { - var self = this; - if (!header.children.length) return 1; - if (!header.expanded) return 1; - return header.children.reduce(function (s, c) { - return s + self.get_header_width(c); - }, 0); - }, - get_header_width_depth: function (header) { - var depth = 1, - width = 0; - traverse_tree (header, function (hdr) { - depth = Math.max(depth, hdr.path.length); - if (!hdr.expanded) width++; - }); - return {width: width, depth: depth}; - }, - get_header_depth: function (header) { - var depth = 1; - traverse_tree(header, function (hdr) { - depth = Math.max(depth, hdr.path.length); - }); - return depth; - }, - compute_rows: function () { - var self = this, - aggregates, i, - result = []; - traverse_tree(this.main_row.root, function (header) { - var values = [], - col_ids = []; - result.push({ - id: header.id, - col_ids: col_ids, - indent: header.path.length - 1, - title: header.path[header.path.length-1], - expanded: header.expanded, - values: values, - }); - traverse_tree(self.main_col.root, add_cells, header.id, values, col_ids); - if (self.main_col.width > 1) { - aggregates = self.get_value(header.id, self.main_col.root.id); - for (i = 0; i < self.active_measures.length; i++) { - values.push(aggregates && aggregates[self.active_measures[i]]); - } - col_ids.push( self.main_col.root.id); - } - }); - return result; - function add_cells (col_hdr, row_id, values, col_ids) { - if (col_hdr.expanded) return; - col_ids.push(col_hdr.id); - aggregates = self.get_value(row_id, col_hdr.id); - for (i = 0; i < self.active_measures.length; i++) { - values.push(aggregates && aggregates[self.active_measures[i]]); - } - } - }, - get_value: function (id1, id2) { - if ((id1 in this.cells) && (id2 in this.cells[id1])) { - return this.cells[id1][id2]; - } - if (id2 in this.cells) return this.cells[id2][id1]; - }, - flip: function () { - var temp = this.main_col; - this.main_col = this.main_row; - this.main_row = temp; - this.display_table(); - }, - toggle_measure: function (field) { - if (_.contains(this.active_measures, field)) { - this.active_measures = _.without(this.active_measures, field); - this.display_table(); - } else { - this.active_measures.push(field); - this.load_data().then(this.display_table.bind(this)); - } - }, - download_table: function () { - framework.blockUI(); - var nbr_measures = this.active_measures.length, - headers = this.compute_headers(), - measure_row = nbr_measures > 1 ? _.last(headers) : [], - rows = this.compute_rows(), - i, j, value; - headers[0].splice(0,1); - // process measure_row - for (i = 0; i < measure_row.length; i++) { - measure_row[i].measure = this.measures[measure_row[i].measure].string; - } - // process all rows - for (i =0, j, value; i < rows.length; i++) { - for (j = 0; j < rows[i].values.length; j++) { - value = rows[i].values[j]; - rows[i].values[j] = { - is_bold: (i === 0) || ((this.main_col.width > 1) && (j >= rows[i].values.length - nbr_measures)), - value: (value === undefined) ? "" : value, - }; - } - } - var table = { - headers: _.initial(headers), - measure_row: measure_row, - rows: rows, - nbr_measures: nbr_measures, - title: this.title, - }; - if(table.measure_row.length + 1 > 256) { - c.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); - return; - } - session.get_file({ - url: '/web/pivot/export_xls', - data: {data: JSON.stringify(table)}, - complete: framework.unblockUI, - error: crash_manager.rpc_error.bind(crash_manager) - }); - }, - destroy: function () { - if (this.$buttons) { - this.$buttons.find('button').off(); // remove jquery's tooltip() handlers - } - return this._super.apply(this, arguments); - }, -}); - -function traverse_tree(root, f, arg1, arg2, arg3) { - f(root, arg1, arg2, arg3); - if (!root.expanded) return; - for (var i = 0; i < root.children.length; i++) { - traverse_tree(root.children[i], f, arg1, arg2, arg3); - } -} - -function find_path_in_tree(root, path) { - var i, - l = root.path.length; - if (l === path.length) { - return (root.path[l-1] === path[l - 1]) ? root : null; - } - for (i = 0; i < root.children.length; i++) { - if (root.children[i].path[l] === path[l]) { - return find_path_in_tree(root.children[i], path); - } - } - return null; -} - -core.view_registry.add('pivot', PivotView); - -return PivotView; - -}); diff --git a/addons/web/static/src/js/views/tree_view.js b/addons/web/static/src/js/views/tree_view.js deleted file mode 100644 index 604c9204b42..00000000000 --- a/addons/web/static/src/js/views/tree_view.js +++ /dev/null @@ -1,249 +0,0 @@ -odoo.define('web.TreeView', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Tree view - *---------------------------------------------------------*/ - -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 _lt = core._lt; -var QWeb = core.qweb; - -var TreeView = View.extend({ - display_name: _lt('Tree'), - icon: 'fa-align-left', - // Indicates that this view is not searchable, and thus that no search view should be displayed. - searchable: false, - - /** - * Genuine tree view (the one displayed as a tree, not the list) - */ - init: function() { - this._super.apply(this, arguments); - this.records = {}; - _.bindAll(this, 'color_for'); - this.fields = this.fields_view.fields; - this.children_field = this.fields_view.field_parent; - }, - - /** - * Returns the list of fields needed to correctly read objects. - * - * Gathers the names of all fields in fields_view_get, and adds the - * field_parent (children_field in the tree view) if it's not already one - * of the fields to fetch - * - * @returns {Array} an array of fields which can be provided to DataSet.read_slice and others - */ - fields_list: function () { - var fields = _.keys(this.fields); - if (!_(fields).contains(this.children_field)) { - fields.push(this.children_field); - } - return fields; - }, - willStart: function () { - _(this.fields_view.arch.children).each(function (field) { - if (field.attrs.modifiers && typeof field.attrs.modifiers === "string") { - field.attrs.modifiers = JSON.parse(field.attrs.modifiers); - } - }); - - if (this.fields_view.arch.attrs.colors) { - this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain() - .compact() - .map(function(color_pair) { - var pair = color_pair.split(':'), - color = pair[0], - expr = pair[1]; - return [color, py.parse(py.tokenize(expr)), expr]; - }).value(); - } - - return this._super(); - }, - start: function () { - var self = this; - var has_toolbar = !!this.fields_view.arch.attrs.toolbar; - this.hook_row_click(); - this.$el.html(QWeb.render('TreeView', { - 'title': this.fields_view.arch.attrs.string, - 'fields_view': this.fields_view.arch.children, - 'fields': this.fields, - 'toolbar': has_toolbar - })); - this.$el.addClass(this.fields_view.arch.attrs['class']); - - this.dataset.read_slice(this.fields_list()).done(function(records) { - if (!has_toolbar) { - // WARNING: will do a second read on the same ids, but only on - // first load so not very important - self.getdata(null, _(records).pluck('id')); - return; - } - - var $select = self.$('select') - .change(function () { - var $option = $(this).find(':selected'); - self.getdata($option.val(), $option.data('children')); - }); - _(records).each(function (record) { - self.records[record.id] = record; - $('<option>') - .val(record.id) - .text(record.name) - .data('children', record[self.children_field]) - .appendTo($select); - }); - - if (!_.isEmpty(records)) { - $select.change(); - } - }); - - // TODO store open nodes in url ?... - this.do_push_state({}); - - return this._super(); - }, - /** - * Returns the color for the provided record in the current view (from the - * ``@colors`` attribute) - * - * @param {Object} record record for the current row - * @returns {String} CSS color declaration - */ - color_for: function (record) { - if (!this.colors) { return ''; } - var context = _.extend({}, record, { - uid: session.uid, - current_date: moment().format('YYYY-MM-DD') - // TODO: time, datetime, relativedelta - }); - for(var i=0, len=this.colors.length; i<len; ++i) { - var pair = this.colors[i], - color = pair[0], - expression = pair[1]; - if (py.PY_isTrue(py.evaluate(expression, context))) { - return 'color: ' + color + ';'; - } - // TODO: handle evaluation errors - } - return ''; - }, - /** - * Sets up opening a row - */ - hook_row_click: function () { - var self = this; - this.$el.delegate('.treeview-td span, .treeview-tr span', 'click', function (e) { - e.stopImmediatePropagation(); - self.activate($(this).closest('tr').data('id')); - }); - - this.$el.delegate('.treeview-tr', 'click', function () { - var is_loaded = 0, - $this = $(this), - record_id = $this.data('id'), - row_parent_id = $this.data('row-parent-id'), - record = self.records[record_id], - children_ids = record[self.children_field]; - - _(children_ids).each(function(childid) { - if (self.$('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').length ) { - if (self.$('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').is(':hidden')) { - is_loaded = -1; - } else { - is_loaded++; - } - } - }); - if (is_loaded === 0) { - if (!$this.parent().hasClass('o_open')) { - self.getdata(record_id, children_ids); - } - } else { - self.showcontent($this, record_id, is_loaded < 0); - } - }); - }, - // get child data of selected value - getdata: function (id, children_ids) { - var self = this; - - self.dataset.read_ids(children_ids, this.fields_list()).done(function(records) { - _(records).each(function (record) { - self.records[record.id] = record; - }); - var $curr_node = self.$('#treerow_' + id); - var children_rows = QWeb.render('TreeView.rows', { - 'records': records, - 'children_field': self.children_field, - 'fields_view': self.fields_view.arch.children, - 'fields': self.fields, - 'level': $curr_node.data('level') || 0, - 'render': formats.format_value, - 'color_for': self.color_for, - 'row_parent_id': id - }); - if ($curr_node.length) { - $curr_node.addClass('o_open'); - $curr_node.after(children_rows); - } else { - self.$('tbody').html(children_rows); - } - }); - }, - - // Get details in listview - activate: function(id) { - var self = this; - var local_context = { - active_model: self.model, - active_id: id, - active_ids: [id] - }; - var ctx = pyeval.eval( - 'context', new data.CompoundContext( - this.dataset.get_context(), local_context)); - return this.rpc('/web/treeview/action', { - id: id, - model: this.model, - context: ctx - }).then(function (actions) { - if (!actions.length) { return; } - var action = actions[0][2]; - var c = new data.CompoundContext(local_context).set_eval_context(ctx); - if (action.context) { - c.add(action.context); - } - action.context = c; - return self.do_action(action); - }); - }, - - // show & hide the contents - showcontent: function (curnode,record_id, show) { - curnode.parent('tr').toggleClass('o_open', show); - _(this.records[record_id][this.children_field]).each(function (child_id) { - var $child_row = this.$('[id=treerow_' + child_id + '][data-row-parent-id='+ curnode.data('id') +']'); - if ($child_row.hasClass('o_open')) { - $child_row.toggleClass('o_open',show); - this.showcontent($child_row, child_id, false); - } - $child_row.toggle(show); - }, this); - }, -}); - -core.view_registry.add('tree', TreeView); - -return TreeView; - -}); - diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js new file mode 100644 index 00000000000..0310656cd37 --- /dev/null +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -0,0 +1,376 @@ +odoo.define('web.view_dialogs', function (require) { +"use strict"; + +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var ListController = require('web.ListController'); +var ListView = require('web.ListView'); +var pyeval = require('web.pyeval'); +var SearchView = require('web.SearchView'); +var view_registry = require('web.view_registry'); + +var _t = core._t; + +/** + * Class with everything which is common between FormViewDialog and + * SelectCreateDialog. + */ +var ViewDialog = Dialog.extend({ + custom_events: _.extend({}, Dialog.prototype.custom_events, { + push_state: '_onPushState', + }), + /** + * @constructor + * @param {Widget} parent + * @param {options} [options] + * @param {string} [options.dialogClass=o_act_window] + * @param {string} [options.res_model] the model of the record(s) to open + * @param {any[]} [options.domain] + * @param {Object} [options.context] + */ + init: function (parent, options) { + options = options || {}; + options.dialogClass = options.dialogClass || '' + ' o_act_window'; + + this._super(parent, $.extend(true, {}, options)); + + this.res_model = options.res_model || null; + this.domain = options.domain || []; + this.context = options.context || {}; + this.options = _.extend(this.options || {}, options || {}); + + // FIXME: remove this once a dataset won't be necessary anymore to interact + // with data_manager and instantiate views + this.dataset = new data.DataSet(this, this.res_model, this.context); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * We stop all push_state events from bubbling up. It would be weird to + * change the url because a dialog opened. + * + * @param {OdooEvent} event + */ + _onPushState: function (event) { + event.stopPropagation(); + }, +}); + +/** + * Create and edit dialog (displays a form view record and leave once saved) + */ +var FormViewDialog = ViewDialog.extend({ + /** + * @param {Widget} parent + * @param {Object} [options] + * @param {integer} [options.res_id] the id of the record to open + * @param {Object} [options.form_view_options] dict of options to pass to + * the Form View @todo: make it work + * @param {Object} [options.fields_view] optional form fields_view + * @param {boolean} [options.readonly=false] only applicable when not in + * creation mode + * @param {function} [options.on_save] callback to execute when clicking on + * 'Save' (form view's 'saveRecord' by default) + * @param {function} [options.on_saved] callback executed after on_save + * @param {BasicModel} [options.model] if given, it will be used instead of + * a new form view model + */ + init: function (parent, options) { + var self = this; + + this.res_id = options.res_id || null; + this.on_saved = options.on_saved || (function () {}); + this.context = options.context; + this.model = options.model; + + var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection; + var readonly = _.isNumber(options.res_id) && options.readonly; + + if (!options || !options.buttons) { + options = options || {}; + options.buttons = [{ + text: (readonly ? _t("Close") : _t("Discard")), + classes: "btn-default o_form_button_cancel", + close: true, + click: function () { + if (!readonly) { + self.form_view.model.discardChanges(self.form_view.handle); + } + }, + }]; + + if (!readonly) { + options.buttons.unshift({ + text: _t("Save") + ((multi_select)? " " + _t(" & Close") : ""), + classes: "btn-primary", + click: function () { + this._save().then(self.close.bind(self)); + } + }); + + if (multi_select) { + options.buttons.splice(1, 0, { + text: _t("Save & New"), + classes: "btn-primary", + click: function () { + this._save().then(self.form_view.createRecord.bind(self.form_view)); + }, + }); + } + } + } + this._super(parent, options); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the form view dialog. It is necessarily asynchronous, but this + * method returns immediately. + * + * @returns {FormViewDialog} this instance + */ + open: function () { + var self = this; + var _super = this._super.bind(this); + var FormView = view_registry.get('form'); + var fields_view_def; + if (this.options.fields_view) { + fields_view_def = $.when(this.options.fields_view); + } else { + fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form'); + } + + fields_view_def.then(function (fields_view) { + var formview = new FormView(fields_view.arch, fields_view.fields, { + modelName: self.res_model, + context: self.context, + ids: self.res_id ? [self.res_id] : [], + currentId: self.res_id || undefined, + index: 0, + mode: self.res_id && self.options.readonly ? 'readonly' : 'edit', + footer_to_buttons: true, + default_buttons: false, + model: self.model, + }); + return formview.createController(self); + }).then(function (formView) { + self.form_view = formView; + var fragment = document.createDocumentFragment(); + self.form_view.appendTo(fragment) + .then(function () { + var $buttons = $('<div>'); + self.form_view.renderButtons($buttons); + if ($buttons.children().length) { + self.$footer.empty().append($buttons.contents()); + } + _super().$el.append(fragment); + self.form_view.autofocus(); + }); + }); + + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _save: function () { + var self = this; + var def; + if (this.options.on_save) { + def = this.options.on_save(this.form_view.model.get(this.form_view.handle)); + } else { + def = this.form_view.saveRecord({stayInEdit: true, reload: false}); + } + return $.when(def).then(function () { + // record might have been changed by the save (e.g. if this was a new record, it has an + // id now), so don't re-use the copy obtained before the save + self.on_saved(self.form_view.model.get(self.form_view.handle)); + }); + }, +}); + +var SelectCreateListController = ListController.extend({ + // Override the ListView to handle the custom events 'open_record' (triggered when clicking on a + // row of the list) such that it triggers up 'select_record' with its res_id. + custom_events: _.extend({}, ListController.prototype.custom_events, { + open_record: function (event) { + var selected_record = this.model.get(event.data.id); + this.trigger_up('select_record', {id: selected_record.res_id}); + }, + }), +}); + +/** + * Search dialog (displays a list of records and permits to create a new one by switching to a form view) + */ +var SelectCreateDialog = ViewDialog.extend({ + custom_events: _.extend({}, ViewDialog.prototype.custom_events, { + select_record: function (event) { + if (!this.options.readonly) { + this.on_selected([event.data.id]); + this.close(); + } + }, + selection_changed: function (event) { + this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length); + }, + search: function (event) { + event.stopPropagation(); // prevent this event from bubbling up to the view manager + var d = event.data; + var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys); + this.list_controller.reload(searchData); + }, + }), + + /** + * options: + * - initial_ids + * - initial_view: form or search (default search) + * - list_view_options: dict of options to pass to the List View + * - on_selected: optional callback to execute when records are selected + * - disable_multiple_selection: true to allow create/select multiple records + */ + init: function () { + this._super.apply(this, arguments); + _.defaults(this.options, { initial_view: 'search' }); + this.on_selected = this.options.on_selected || (function () {}); + this.initial_ids = this.options.initial_ids; + }, + + open: function () { + if(this.options.initial_view !== "search") { + return this.create_edit_record(); + } + var user_context = this.getSession().user_context; + + var _super = this._super.bind(this); + var context = pyeval.eval_domains_and_contexts({ + domains: [], + contexts: [user_context, 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.loadViews(this.dataset.model, this.dataset.get_context(), [[false, 'list'], [false, 'search']], {}) + .then(this.setup.bind(this, search_defaults)) + .then(function (fragment) { + _super().$el.append(fragment); + }); + return this; + }, + + setup: function (search_defaults, fields_views) { + var self = this; + var fragment = document.createDocumentFragment(); + + var searchDef = $.Deferred(); + + // Set the dialog's header and its search view + var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment); + var $pager = $('<div/>').addClass('o_pager').appendTo($header); + var options = { + $buttons: $('<div/>').addClass('o_search_options').appendTo($header), + search_defaults: search_defaults, + }; + var searchview = new SearchView(this, this.dataset, fields_views.search, options); + searchview.prependTo($header).done(function () { + var d = searchview.build_search_data(); + d.domains = d.domains.concat([self.domain]); + if (self.initial_ids) { + d.domains.push([["id", "in", self.initial_ids]]); + self.initial_ids = undefined; + } + var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys); + searchDef.resolve(searchData); + }); + + return $.when(searchDef).then(function (searchResult) { + // Set the list view + var listView = new ListView(fields_views.list.arch, fields_views.list.fields, _.extend({ + context: searchResult.context, + domain: searchResult.domain, + groupBy: searchResult.groupBy, + modelName: self.dataset.model, + hasSelectors: !self.options.disable_multiple_selection, + }, self.options.list_view_options)); + listView.setController(SelectCreateListController); + return listView.createController(self); + }).then(function (controller) { + self.list_controller = controller; + // Set the dialog's buttons + var buttons = [{ + text: _t("Cancel"), + classes: "btn-default o_form_button_cancel", + close: true, + }]; + if (!self.options.no_create) { + buttons.unshift({ + text: _t("Create"), + classes: "btn-primary", + click: self.create_edit_record.bind(self) + }); + } + if (!self.options.disable_multiple_selection) { + buttons.unshift({ + text: _t("Select"), + classes: "btn-primary o_select_button", + disabled: true, + close: true, + click: function () { + self.on_selected(self.list_controller.getSelectedIds()); + }, + }); + } + self.set_buttons(buttons); + return self.list_controller.appendTo(fragment); + }).then(function () { + searchview.toggle_visibility(true); + self.list_controller.do_show(); + self.list_controller.renderPager($pager); + return fragment; + }); + }, + _process_search_data: function (domains, contexts, groupbys) { + var user_context = this.getSession().user_context; + contexts = [user_context].concat(contexts); + var results = pyeval.eval_domains_and_contexts({ + domains: domains || [], + contexts: contexts || [], + group_by_seq: groupbys || [] + }); + return { + context: results.context, + domain: results.domain, + groupBy: results.group_by, + }; + }, + create_edit_record: function () { + var self = this; + var dialog = new FormViewDialog(this, _.extend({}, this.options, { + on_saved: function (record) { + self.on_selected([record.res_id]); + }, + })).open(); + dialog.on('closed', this, this.close.bind(this)); + }, +}); + +return { + FormViewDialog: FormViewDialog, + SelectCreateDialog: SelectCreateDialog, +}; + +}); diff --git a/addons/web/static/src/js/views/view_registry.js b/addons/web/static/src/js/views/view_registry.js new file mode 100644 index 00000000000..f936787ba1a --- /dev/null +++ b/addons/web/static/src/js/views/view_registry.js @@ -0,0 +1,44 @@ +odoo.define('web.view_registry', function (require) { +"use strict"; + +/** + * This module defines the view_registry. Web views are added to the registry + * in the 'web._view_registry' module to avoid cyclic dependencies. + * Views defined in other addons should be added in this registry as well, + * ideally in another module than the one defining the view, in order to + * separate the declarative part of a module (the view definition) from its + * 'side-effects' part. + */ + +var Registry = require('web.Registry'); + +return new Registry(); + +}); + +odoo.define('web._view_registry', function (require) { +"use strict"; + +/** + * The purpose of this module is to add the web views in the view_registry. + * This can't be done directly in the module defining the view_registry as it + * would produce cyclic dependencies. + */ + +var FormView = require('web.FormView'); +var GraphView = require('web.GraphView'); +var KanbanView = require('web.KanbanView'); +var ListView = require('web.ListView'); +var PivotView = require('web.PivotView'); +var CalendarView = require('web.CalendarView'); +var view_registry = require('web.view_registry'); + +view_registry + .add('form', FormView) + .add('list', ListView) + .add('kanban', KanbanView) + .add('graph', GraphView) + .add('pivot', PivotView) + .add('calendar', CalendarView); + +}); diff --git a/addons/web/static/src/js/widgets/change_password.js b/addons/web/static/src/js/widgets/change_password.js index 367cf42e680..56f9603a310 100644 --- a/addons/web/static/src/js/widgets/change_password.js +++ b/addons/web/static/src/js/widgets/change_password.js @@ -1,6 +1,11 @@ odoo.define('web.ChangePassword', function (require) { "use strict"; +/** + * This file defines a client action that opens in a dialog (target='new') and + * allows the user to change his password. + */ + var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); @@ -8,30 +13,49 @@ var web_client = require('web.web_client'); var _t = core._t; -var ChangePassword = Widget.extend({ // FIXME +var ChangePassword = Widget.extend({ template: "ChangePassword", - start: function() { + + /** + * @fixme: weird interaction with the parent for the $buttons handling + * + * @override + * @returns {Deferred} + */ + start: function () { var self = this; web_client.set_title(_t("Change Password")); var $button = self.$('.oe_form_button'); $button.appendTo(this.getParent().$footer); - $button.eq(1).click(function(){ - self.$el.parents('.modal').modal('hide'); + $button.eq(1).click(function () { + self.$el.parents('.modal').modal('hide'); + }); + $button.eq(0).click(function () { + self.performRPC('/web/session/change_password', { + fields: $('form[name=change_password_form]').serializeArray(), + }).done(function (result) { + if (result.error) { + self._display_error(result); + } else { + self.do_action('logout'); + } + }); }); - $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) { + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Displays the error in a dialog + * + * @private + * @param {Object} error + * @param {string} error.error + * @param {string} error.title + */ + _display_error: function (error) { return new Dialog(this, { size: 'medium', title: error.title, @@ -43,4 +67,5 @@ var ChangePassword = Widget.extend({ // FIXME core.action_registry.add("change_password", ChangePassword); return ChangePassword; + }); diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index d3135f86ba6..b4ae2023684 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -244,9 +244,9 @@ var DataExport = Dialog.extend({ }).eq(0).change(); waitFor.push(got_fields); - waitFor.push(this.getParent().get_active_domain().then(function (domain) { + waitFor.push(this.getParent().getActiveDomain().then(function (domain) { if (domain === undefined) { - self.ids_to_export = self.getParent().get_selected_ids(); + self.ids_to_export = self.getParent().getSelectedIds(); self.domain = self.dataset.domain; } else { self.ids_to_export = false; diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index bd9416a278a..4877a17c3ce 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -54,8 +54,9 @@ var DateWidget = Widget.extend({ }, set_value: function(value) { this.set({'value': value}); - this.$input.val((value)? this.format_client(value) : ''); - this.picker.date(this.format_client(value)); + var formatted_value = value ? this.format_client(value) : null; + this.$input.val(formatted_value); + this.picker.date(formatted_value); }, get_value: function() { return this.get('value'); @@ -103,9 +104,6 @@ var DateWidget = Widget.extend({ this.trigger("datetime_changed"); } }, - commit_value: function() { - this.change_datetime(); - }, destroy: function() { this.picker.destroy(); this._super.apply(this, arguments); diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js index 2b207a9bb52..44845f7b9e6 100644 --- a/addons/web/static/src/js/widgets/debug_manager.js +++ b/addons/web/static/src/js/widgets/debug_manager.js @@ -2,7 +2,7 @@ odoo.define('web.DebugManager', function (require) { "use strict"; var ActionManager = require('web.ActionManager'); -var common = require('web.form_common'); +var dialogs = require('web.view_dialogs'); var core = require('web.core'); var Dialog = require('web.Dialog'); var formats = require('web.formats'); @@ -159,7 +159,7 @@ var DebugManager = Widget.extend({ }, select_view: function () { var self = this; - new common.SelectCreateDialog(this, { + new dialogs.SelectCreateDialog(this, { res_model: 'ir.ui.view', title: _t('Select a view'), disable_multiple_selection: true, @@ -288,6 +288,9 @@ DebugManager.include({ update: function (tag, descriptor, widget) { switch (tag) { case 'action': + if (this._view_manager) { + this._view_manager.off('switch_mode', this); + } if (!(widget instanceof ViewManager)) { this._active_view = null; this._view_manager = null; @@ -314,11 +317,11 @@ DebugManager.include({ get_metadata: function() { var ds = this._view_manager.dataset; - if (!this._active_view.controller.get_selected_ids().length) { + if (!this._active_view.controller.getSelectedIds().length) { console.warn(_t("No metadata available")); return } - ds.call('get_metadata', [this._active_view.controller.get_selected_ids()]).done(function(result) { + ds.call('get_metadata', [this._active_view.controller.getSelectedIds()]).done(function(result) { new Dialog(this, { title: _.str.sprintf(_t("Metadata (%s)"), ds.model), size: 'medium', @@ -335,11 +338,11 @@ DebugManager.include({ fvg: function() { var dialog = new Dialog(this, { title: _t("Fields View Get") }).open(); $('<pre>').text(utils.json_node_to_xml( - this._active_view.controller.fields_view.arch, true) + this._active_view.controller.renderer.arch, true) ).appendTo(dialog.$el); }, print_workflow: function() { - var ids = this._active_view.controller.get_selected_ids(); + var ids = this._active_view.controller.getSelectedIds(); framework.blockUI(); var action = { context: { active_ids: ids }, diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js index eccbd9d7491..81d5047a5bc 100644 --- a/addons/web/static/src/js/widgets/domain_selector.js +++ b/addons/web/static/src/js/widgets/domain_selector.js @@ -3,7 +3,7 @@ odoo.define("web.DomainSelector", function (require) { var core = require("web.core"); var datepicker = require("web.datepicker"); -var domainUtils = require("web.domainUtils"); +var Domain = require("web.Domain"); var formats = require ("web.formats"); var ModelFieldSelector = require("web.ModelFieldSelector"); var Widget = require("web.Widget"); @@ -108,9 +108,9 @@ var DomainTree = DomainNode.extend({ /// The initialization of a DomainTree creates a "children" array attribute which will contain the /// the DomainNode children. It also deduces the operator from the domain (default to "&"). /// @see DomainTree._addFlattenedChildren - init: function (parent, model, domain, options) { + init: function (parent, model, domainStr, options) { this._super.apply(this, arguments); - this._initialize(domainUtils.stringToDomain(domain)); + this._initialize(Domain.prototype.stringToArray(domainStr)); }, /// @see DomainTree.init _initialize: function (domain) { @@ -257,14 +257,14 @@ var DomainSelector = DomainTree.extend({ /// the widget is re-rendered and notifies the parents. If not, a warning is shown to the /// user and the input is ignored. "change .o_domain_debug_input": function (e) { - var domain; + var currentDomain; try { - domain = domainUtils.stringToDomain($(e.currentTarget).val()); + currentDomain = Domain.prototype.stringToArray($(e.currentTarget).val()); } catch (err) { this.do_warn(_t("Syntax error"), _t("The domain you entered is not properly formed")); return; } - this._redraw(domain).then((function () { + this._redraw(currentDomain).then((function () { this.trigger_up("domain_changed", {child: this, alreadyRedrawn: true}); }).bind(this)); }, @@ -307,7 +307,7 @@ var DomainSelector = DomainTree.extend({ // Display technical domain if in debug mode this.$debugInput = this.$(".o_domain_debug_input"); if (this.$debugInput.length) { - this.$debugInput.val(domainUtils.domainToString(this.getDomain())); + this.$debugInput.val(Domain.prototype.arrayToString(this.getDomain())); } }, }); @@ -335,13 +335,13 @@ var DomainLeaf = DomainNode.extend({ }, }, /// @see DomainNode.init - init: function (parent, model, domain, options) { + init: function (parent, model, domainStr, options) { this._super.apply(this, arguments); - domain = domainUtils.stringToDomain(domain); - this.chain = domain[0][0]; - this.operator = domain[0][1]; - this.value = domain[0][2]; + var currentDomain = Domain.prototype.stringToArray(domainStr); + this.chain = currentDomain[0][0]; + this.operator = currentDomain[0][1]; + this.value = currentDomain[0][2]; this.operator_mapping = operator_mapping; }, diff --git a/addons/web/static/src/js/widgets/iframe_widget.js b/addons/web/static/src/js/widgets/iframe_widget.js new file mode 100644 index 00000000000..cf5bc15c950 --- /dev/null +++ b/addons/web/static/src/js/widgets/iframe_widget.js @@ -0,0 +1,64 @@ +odoo.define('web.IFrameWidget', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +/** + * Generic widget to create an iframe that listens for clicks + * + * It should be extended by overwritting the methods: + * init: function(parent) { + * this._super(parent, <url_of_iframe>); + * }, + * _onIFrameClicked: function(e){ + * filter the clicks you want to use and apply + * an action on it + * } + */ +var IFrameWidget = Widget.extend({ + tagName: 'iframe', + /** + * @constructor + * @param {Widget} parent + * @param {string} url + */ + init: function (parent, url) { + this._super(parent); + this.url = url; + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + this.$el.css({height: '100%', width: '100%', border: 0}); + this.$el.attr({src: this.url}); + this.$el.on("load", this._bindEvents.bind(this)); + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when the iframe is ready + */ + _bindEvents: function (){ + this.$el.contents().click(this._onIFrameClicked.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {MouseEvent} event + */ + _onIFrameClicked: function (event){ + } +}); + +return IFrameWidget; + +}); diff --git a/addons/web/static/src/js/widgets/priority.js b/addons/web/static/src/js/widgets/priority.js deleted file mode 100644 index 44ff3b50c1e..00000000000 --- a/addons/web/static/src/js/widgets/priority.js +++ /dev/null @@ -1,84 +0,0 @@ -odoo.define('web.Priority', function (require) { -"use strict"; - -var core = require('web.core'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var Priority = Widget.extend({ - template: "Priority", - events: { - 'mouseover > a': function(e) { - clearTimeout(this.hover_timer); - - this.$stars.removeClass('fa-star-o').addClass('fa-star'); - $(e.target).nextAll().removeClass('fa-star').addClass('fa-star-o'); - }, - 'mouseout > a': function(e) { - clearTimeout(this.hover_timer); - - var self = this; - this.hover_timer = setTimeout(function() { - self._render_value(); - }, 200); - }, - 'click > a': function(e) { - e.preventDefault(); - e.stopPropagation(); - - var old_value = this.value; - this.value = String($(e.target).data('value')); - if(old_value === this.value) { - this.value = this.empty_value; - } - - this._render_value(); - this.trigger('update', {value: this.value}); - }, - }, - init: function(parent, options) { - this._super.apply(this, arguments); - - options = _.defaults(options || {}, { - readonly: true, - value: 0, - values: [ - [0, _t("Normal")], - [1, _t("Low")], - [2, _t("High")], - [3, _t("Very High")] - ], - }); - - this.readonly = options.readonly; - this.value = options.value; - this.values = options.values; - this.empty_value = this.values[0][0]; - }, - start: function() { - this._render_value(); - return this._super(); - }, - set_value: function(value) { - this.value = value; - this._render_value(); - }, - _render_value: function() { - var self = this; - var index_value = this.value ? _.findIndex(this.values, function (v) { - return v[0] === self.value; - }) : 0; - this.$stars = this.$('.o_priority_star'); - this.$stars.each(function(i, el) { - var $star = $(el); - $star.toggleClass('fa-star', (i+1 <= index_value)) - .toggleClass('fa-star-o', (i+1 > index_value)); - }); - }, -}); - -return Priority; - -}); diff --git a/addons/web/static/src/js/widgets/progress_bar.js b/addons/web/static/src/js/widgets/progress_bar.js deleted file mode 100644 index 265388aab7c..00000000000 --- a/addons/web/static/src/js/widgets/progress_bar.js +++ /dev/null @@ -1,141 +0,0 @@ -odoo.define('web.ProgressBar', function (require) { -"use strict"; - -var core = require('web.core'); -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -/** - * options - * - readonly - * - value - * - max_value - * - title: title of the gauge, displayed on the left - */ -var ProgressBar = Widget.extend({ - template: "ProgressBar", - - events: { - 'change input': 'on_change_input', - 'input input': 'on_change_input', - 'keyup input': function(e) { - if(e.which === $.ui.keyCode.ENTER) { - this.on_change_input(e); - } - }, - }, - - init: function (parent, options) { - this._super(parent); - - options = _.defaults(options || {}, { - readonly: true, - edit_on_click: false, - value: 0, - max_value: 100, - title: '', - edit_max_value: false, - }); - - this.readonly = options.readonly; - this.edit_on_click = options.edit_on_click; - this.value = options.value; - this.max_value = options.max_value; - this.title = options.title; - this.edit_max_value = options.edit_max_value; - }, - - start: function() { - this._render_value(); - - if(!this.readonly) { - if(this.edit_on_click) { - var self = this; - this.$el.on('click', '.o_progress', function(e) { - var $target = $(e.currentTarget); - self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); - self._render_value(); - self.trigger('update', {value: self.value, max_value: self.max_value, changed_value: self.value}); - }); - } else { - this.$el.on('blur', 'input', _.bind(this.on_change_input, this)); - } - - this.$('.o_progressbar_value').focus().select() - } - - return this._super(); - }, - - on_change_input: function(e) { - var $input = $(e.target); - if(e.type === 'change' && !$input.is(':focus')) { - return; - } - if(isNaN($input.val())) { - this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid.")); - } else { - if(e.type === 'input') { - this._render_value($input.val()); - if(parseFloat($input.val()) === 0) { - $input.select(); - } - } else { - if(this.edit_max_value) { - this.max_value = $(e.target).val(); - } else { - this.value = $(e.target).val() || 0; - } - - this._render_value(); - this.trigger('update', {value: this.value, max_value: this.max_value, changed_value: (this.edit_max_value)? this.max_value : this.value}); - } - } - }, - - set_value: function(v) { - this.value = v; - this._render_value(); - }, - - _render_value: function(v) { - var value = this.value; - var max_value = this.max_value; - if(!isNaN(v)) { - if(this.edit_max_value) { - max_value = v; - } else { - value = v; - } - } - value = value || 0; - max_value = max_value || 0; - - var widthComplete; - if(value <= max_value) { - widthComplete = value/max_value * 100; - } else { - widthComplete = 100; - } - - this.$('.o_progress').toggleClass('o_progress_overflow', value > max_value); - this.$('.o_progressbar_complete').css('width', widthComplete + '%'); - - if(this.readonly) { - if(max_value !== 100) { - this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value)); - } else { - this.$('.o_progressbar_value').html(utils.human_number(value) + "%"); - } - } else if(isNaN(v)) { - this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value); - } - } -}); - -return ProgressBar; - -}); diff --git a/addons/web/static/src/js/widgets/user_menu.js b/addons/web/static/src/js/widgets/user_menu.js deleted file mode 100644 index d1cd0eb842b..00000000000 --- a/addons/web/static/src/js/widgets/user_menu.js +++ /dev/null @@ -1,81 +0,0 @@ -odoo.define('web.UserMenu', 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 UserMenu = Widget.extend({ - template: "UserMenu", - 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)); - } - }); - return this._super.apply(this, arguments).then(function () { - return self.do_update(); - }); - }, - do_update: function () { - var $avatar = this.$('.oe_topbar_avatar'); - if (!session.uid) { - $avatar.attr('src', $avatar.data('default-src')); - return $.when(); - } - var topbar_name = session.name; - if(session.debug) { - topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db); - } - this.$('.oe_topbar_name').text(topbar_name); - var avatar_src = session.url('/web/image', {model:'res.users', field: 'image_small', id: session.uid}); - $avatar.attr('src', avatar_src); - }, - on_menu_documentation: function () { - window.open('https://www.odoo.com/documentation/user', '_blank'); - }, - on_menu_support: function () { - window.open('https://www.odoo.com/buy', '_blank'); - }, - on_menu_settings: function() { - var self = this; - this.trigger_up('clear_uncommitted_changes', { - callback: function() { - self.rpc("/web/action/load", { action_id: "base.action_res_users_my" }).done(function(result) { - result.res_id = session.uid; - self.do_action(result); - }); - }, - }); - }, - on_menu_account: function() { - var self = this; - this.trigger_up('clear_uncommitted_changes', { - callback: function() { - self.rpc('/web/session/account').then(function(url) { - framework.redirect(url); - }).fail(function(result, ev){ - ev.preventDefault(); - framework.redirect('https://accounts.odoo.com/account'); - }); - }, - }); - }, - on_menu_logout: function() { - this.trigger_up('clear_uncommitted_changes', { - callback: this.do_action.bind(this, 'logout'), - }); - }, -}); - -return UserMenu; -}); |
