From e8a00bc50da435d3859118ca127d20a2eb9a0cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Mon, 9 Mar 2015 16:34:27 +0100 Subject: [REF] web: add module system to the web client This commit introduces a new module system for the javascript code. Instead of using global (namespaced) variables everywhere (like openerp.web.whatever, ...), each file declare one (or more) module with its dependencies. Each module will be loaded after its dependencies, and will be given a reference to the return values. This change is a massive departure from the way the client worked previously. It will sadly break some/most history, but I am sure that the future git ecosystem will allow you to easily browse the history past this revision (one can dream). This commit feature: * main namespace is now odoo instead of openerp * a way to define modules and dependencies (inspired by requirejs) * many files have been splitted in simpler subfiles * basic support for debugging in odoo.__DEBUG__ * no global variable (i.e. nothing in window.openerp) * many bug fixes, many new bugs Future vision: this modularization should simplify future code development, simplify debugging and will allow a serious testing effort --- addons/web/static/src/js/framework/data.js | 1117 ++++++++++++++++++++++++++++ 1 file changed, 1117 insertions(+) create mode 100644 addons/web/static/src/js/framework/data.js (limited to 'addons/web/static/src/js/framework/data.js') diff --git a/addons/web/static/src/js/framework/data.js b/addons/web/static/src/js/framework/data.js new file mode 100644 index 00000000000..f8f091be80d --- /dev/null +++ b/addons/web/static/src/js/framework/data.js @@ -0,0 +1,1117 @@ +odoo.define('web.data', ['web.core', 'web.Model', 'web.session', 'web.pyeval'], function (require) { +"use strict"; + +var core = require('web.core'); +var Model = require('web.Model'); +var session = require('web.session'); +var pyeval = require('web.pyeval'); + +var Class = core.Class; +var mixins = core.mixins; +var _t = core._t; + +/** + * Serializes the sort criterion array of a dataset into a form which can be + * consumed by OpenERP's RPC APIs. + * + * @param {Array} criterion array of fields, from first to last criteria, prefixed with '-' for reverse sorting + * @returns {String} SQL-like sorting string (``ORDER BY``) clause + */ +function serialize_sort(criterion) { + return _.map(criterion, + function (criteria) { + if (criteria[0] === '-') { + return criteria.slice(1) + ' DESC'; + } + return criteria + ' ASC'; + }).join(', '); +} + +/** + * Reverse of the serialize_sort function: convert an array of SQL-like sort + * descriptors into a list of fields prefixed with '-' if necessary. + */ +function deserialize_sort(criterion) { + return _.map(criterion, function (criteria) { + var split = _.without(criteria.split(' '), ''); + return (split[1] && split[1].toLowerCase() === 'desc' ? '-' : '') + split[0]; + }); +} + +var Query = Class.extend({ + init: function (model, fields) { + this._model = model; + this._fields = fields; + this._filter = []; + this._context = {}; + this._lazy = true; + this._limit = false; + this._offset = 0; + this._order_by = []; + }, + clone: function (to_set) { + to_set = to_set || {}; + var q = new Query(this._model, this._fields); + q._context = this._context; + q._filter = this._filter; + q._lazy = this._lazy; + q._limit = this._limit; + q._offset = this._offset; + q._order_by = this._order_by; + + for(var key in to_set) { + if (!to_set.hasOwnProperty(key)) { continue; } + switch(key) { + case 'filter': + q._filter = new CompoundDomain( + q._filter, to_set.filter); + break; + case 'context': + q._context = new CompoundContext( + q._context, to_set.context); + break; + case 'lazy': + case 'limit': + case 'offset': + case 'order_by': + q['_' + key] = to_set[key]; + } + } + return q; + }, + _execute: function (options) { + var self = this; + options = options || {}; + return session.rpc('/web/dataset/search_read', { + model: this._model.name, + fields: this._fields || false, + domain: pyeval.eval('domains', + [this._model.domain(this._filter)]), + context: pyeval.eval('contexts', + [this._model.context(this._context)]), + offset: this._offset, + limit: this._limit, + sort: serialize_sort(this._order_by) + }, options).then(function (results) { + self._count = results.length; + return results.records; + }, null); + }, + /** + * Fetches the first record matching the query, or null + * + * @param {Object} [options] additional options for the rpc() method + * @returns {jQuery.Deferred} + */ + first: function (options) { + var self = this; + return this.clone({limit: 1})._execute(options).then(function (records) { + delete self._count; + if (records.length) { return records[0]; } + return null; + }); + }, + /** + * Fetches all records matching the query + * + * @param {Object} [options] additional options for the rpc() method + * @returns {jQuery.Deferred>} + */ + all: function (options) { + return this._execute(options); + }, + /** + * Fetches the number of records matching the query in the database + * + * @returns {jQuery.Deferred} + */ + count: function () { + if (this._count !== undefined) { return $.when(this._count); } + return this._model.call( + 'search_count', [this._filter], { + context: this._model.context(this._context)}); + }, + /** + * Performs a groups read according to the provided grouping criterion + * + * @param {String|Array} grouping + * @returns {jQuery.Deferred> | null} + */ + group_by: function (grouping) { + var ctx = pyeval.eval( + 'context', this._model.context(this._context)); + + // undefined passed in explicitly (!) + if (_.isUndefined(grouping)) { + grouping = []; + } + + if (!(grouping instanceof Array)) { + grouping = _.toArray(arguments); + } + if (_.isEmpty(grouping) && !ctx.group_by_no_leaf) { + return null; + } + var raw_fields = _.map(grouping.concat(this._fields || []), function (field) { + return field.split(':')[0]; + }); + + var self = this; + return this._model.call('read_group', { + groupby: grouping, + fields: _.uniq(raw_fields), + domain: this._model.domain(this._filter), + context: ctx, + offset: this._offset, + lazy: this._lazy, + limit: this._limit, + orderby: serialize_sort(this._order_by) || false + }).then(function (results) { + return _(results).map(function (result) { + // FIX: querygroup initialization + result.__context = result.__context || {}; + result.__context.group_by = result.__context.group_by || []; + _.defaults(result.__context, ctx); + var grouping_fields = self._lazy ? [grouping[0]] : grouping; + return new QueryGroup( + self._model.name, grouping_fields, result); + }); + }); + }, + /** + * Creates a new query with the union of the current query's context and + * the new context. + * + * @param context context data to add to the query + * @returns {openerp.web.Query} + */ + context: function (context) { + if (!context) { return this; } + return this.clone({context: context}); + }, + /** + * Creates a new query with the union of the current query's filter and + * the new domain. + * + * @param domain domain data to AND with the current query filter + * @returns {openerp.web.Query} + */ + filter: function (domain) { + if (!domain) { return this; } + return this.clone({filter: domain}); + }, + /** + * Creates a new query with the provided parameter lazy replacing the current + * query's own. + * + * @param {Boolean} lazy indicates if the read_group should return only the + * first level of groupby records, or should return the records grouped by + * all levels at once (so, it makes only 1 db request). + * @returns {openerp.web.Query} + */ + lazy: function (lazy) { + return this.clone({lazy: lazy}); + }, + /** + * Creates a new query with the provided limit replacing the current + * query's own limit + * + * @param {Number} limit maximum number of records the query should retrieve + * @returns {openerp.web.Query} + */ + limit: function (limit) { + return this.clone({limit: limit}); + }, + /** + * Creates a new query with the provided offset replacing the current + * query's own offset + * + * @param {Number} offset number of records the query should skip before starting its retrieval + * @returns {openerp.web.Query} + */ + offset: function (offset) { + return this.clone({offset: offset}); + }, + /** + * Creates a new query with the provided ordering parameters replacing + * those of the current query + * + * @param {String...} fields ordering clauses + * @returns {openerp.web.Query} + */ + order_by: function (fields) { + if (fields === undefined) { return this; } + if (!(fields instanceof Array)) { + fields = _.toArray(arguments); + } + if (_.isEmpty(fields)) { return this; } + return this.clone({order_by: fields}); + } +}); + +var QueryGroup = Class.extend({ + init: function (model, grouping_fields, read_group_group) { + // In cases where group_by_no_leaf and no group_by, the result of + // read_group has aggregate fields but no __context or __domain. + // Create default (empty) values for those so that things don't break + var fixed_group = _.extend( + {__context: {group_by: []}, __domain: []}, + read_group_group); + + var count_key = (grouping_fields[0] && grouping_fields[0].split(':')[0]) + '_count'; + var aggregates = {}; + _(fixed_group).each(function (value, key) { + if (key.indexOf('__') === 0 + || _.contains(grouping_fields, key) + || (key === count_key)) { + return; + } + aggregates[key] = value || 0; + }); + + this.model = new Model( + model, fixed_group.__context, fixed_group.__domain); + + var group_size = fixed_group[count_key] || fixed_group.__count || 0; + var leaf_group = fixed_group.__context.group_by.length === 0; + + var value = (grouping_fields.length === 1) + ? fixed_group[grouping_fields[0]] + : _.map(grouping_fields, function (field) { return fixed_group[field]; }); + var grouped_on = (grouping_fields.length === 1) + ? grouping_fields[0] + : grouping_fields; + this.attributes = { + folded: !!(fixed_group.__fold), + grouped_on: grouped_on, + // if terminal group (or no group) and group_by_no_leaf => use group.__count + length: group_size, + value: value, + // A group is open-able if it's not a leaf in group_by_no_leaf mode + has_children: !(leaf_group && fixed_group.__context.group_by_no_leaf), + + aggregates: aggregates + }; + }, + get: function (key) { + return this.attributes[key]; + }, + subgroups: function () { + return this.model.query().group_by(this.model.context().group_by); + }, + query: function () { + return this.model.query.apply(this.model, arguments); + } +}); + +var DataSet = Class.extend(mixins.PropertiesMixin, { + /** + * Collection of OpenERP records, used to share records and the current selection between views. + * + * @constructs instance.web.DataSet + * + * @param {String} model the OpenERP model this dataset will manage + */ + init: function(parent, model, context) { + mixins.PropertiesMixin.init.call(this); + this.model = model; + this.context = context || {}; + this.index = null; + this._sort = []; + this._model = new Model(model, context); + }, + previous: function () { + this.index -= 1; + if (!this.ids.length) { + this.index = null; + } else if (this.index < 0) { + this.index = this.ids.length - 1; + } + return this; + }, + next: function () { + this.index += 1; + if (!this.ids.length) { + this.index = null; + } else if (this.index >= this.ids.length) { + this.index = 0; + } + return this; + }, + select_id: function(id) { + var idx = this.get_id_index(id); + if (idx === null) { + return false; + } else { + this.index = idx; + return true; + } + }, + get_id_index: function(id) { + for (var i=0, ii=this.ids.length; i= ids.length - 1) { + this.index = ids.length - 1; + } + }, + unlink: function(ids) { + this.set_ids(_.without.apply(null, [this.ids].concat(ids))); + this.trigger('unlink', ids); + return $.Deferred().resolve({result: true}); + }, +}); + +var DataSetSearch = DataSet.extend({ + /** + * @constructs instance.web.DataSetSearch + * @extends instance.web.DataSet + * + * @param {Object} parent + * @param {String} model + * @param {Object} context + * @param {Array} domain + */ + init: function(parent, model, context, domain) { + this._super(parent, model, context); + this.domain = domain || []; + this._length = null; + this.ids = []; + this._model = new Model(model, context, domain); + }, + /** + * Read a slice of the records represented by this DataSet, based on its + * domain and context. + * + * @params {Object} options + * @param {Array} [options.fields] fields to read and return, by default all fields are returned + * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context + * @param {Array} [options.domain] domain data to add to the request payload, ANDed with the dataset's domain + * @param {Number} [options.offset=0] The index from which selected records should be returned + * @param {Number} [options.limit=null] The maximum number of records to return + * @returns {$.Deferred} + */ + read_slice: function (fields, options) { + options = options || {}; + var self = this; + var q = this._model.query(fields || false) + .filter(options.domain) + .context(options.context) + .offset(options.offset || 0) + .limit(options.limit || false); + q = q.order_by.apply(q, this._sort); + + return q.all().done(function (records) { + // FIXME: not sure about that one, *could* have discarded count + q.count().done(function (count) { self._length = count; }); + self.ids = _(records).pluck('id'); + }); + }, + get_domain: function (other_domain) { + return this._model.domain(other_domain); + }, + alter_ids: function (ids) { + this._super(ids); + if (this.index !== null && this.index >= this.ids.length) { + this.index = this.ids.length > 0 ? this.ids.length - 1 : 0; + } + }, + remove_ids: function (ids) { + var before = this.ids.length; + this._super(ids); + if (this._length) { + this._length -= (before - this.ids.length); + } + }, + unlink: function(ids, callback, error_callback) { + var self = this; + return this._super(ids).done(function() { + self.remove_ids( ids); + self.trigger("dataset_changed", ids, callback, error_callback); + }); + }, + size: function () { + if (this._length !== null) { + return this._length; + } + return this._super(); + } +}); + +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 = []; + }, + default_get: function(fields, options) { + var self = this; + return this._super(fields, options).done(function(res) { + self.last_default_get = res; + }); + }, + create: function(data, options) { + var cached = { + id:_.uniqueId(this.virtual_id_prefix), + values: _.extend({}, data, (options || {}).readonly_fields || {}), + defaults: this.last_default_get + }; + this.to_create.push(_.extend(_.clone(cached), {values: _.clone(data)})); + this.cache.push(cached); + return $.Deferred().resolve(cached.id).promise(); + }, + write: function (id, data, options) { + var self = this; + var record = _.detect(this.to_create, function(x) {return x.id === id;}); + record = record || _.detect(this.to_write, function(x) {return x.id === id;}); + var dirty = false; + if (record) { + for (var k in data) { + if (record.values[k] === undefined || record.values[k] !== data[k]) { + dirty = true; + break; + } + } + $.extend(record.values, data); + } else { + dirty = true; + record = {id: id, values: data}; + self.to_write.push(record); + } + var cached = _.detect(this.cache, function(x) {return x.id === id;}); + if (!cached) { + cached = {id: id, values: {}}; + this.cache.push(cached); + } + $.extend(cached.values, _.extend({}, record.values, (options || {}).readonly_fields || {})); + if (dirty) + this.trigger("dataset_changed", id, data, options); + return $.Deferred().resolve(true).promise(); + }, + unlink: function(ids, callback, error_callback) { + var self = this; + _.each(ids, function(id) { + if (! _.detect(self.to_create, function(x) { return x.id === id; })) { + self.to_delete.push({id: id}); + } + }); + this.to_create = _.reject(this.to_create, function(x) { return _.include(ids, x.id);}); + this.to_write = _.reject(this.to_write, function(x) { return _.include(ids, x.id);}); + this.cache = _.reject(this.cache, function(x) { return _.include(ids, x.id);}); + this.set_ids(_.without.apply(_, [this.ids].concat(ids))); + this.trigger("dataset_changed", ids, callback, error_callback); + return $.async_when({result: true}).done(callback); + }, + reset_ids: function(ids) { + this.set_ids(ids); + this.to_delete = []; + this.to_create = []; + this.to_write = []; + this.cache = []; + this.delete_all = false; + _.each(_.clone(this.running_reads), function(el) { + el.reject(); + }); + }, + read_ids: function (ids, fields, options) { + var self = this; + var to_get = []; + _.each(ids, function(id) { + var cached = _.detect(self.cache, function(x) {return x.id === id;}); + var created = _.detect(self.to_create, function(x) {return x.id === id;}); + if (created) { + _.each(fields, function(x) {if (cached.values[x] === undefined) + cached.values[x] = created.defaults[x] || false;}); + } else { + if (!cached || !_.all(fields, function(x) {return cached.values[x] !== undefined;})) + to_get.push(id); + } + }); + var return_records = function() { + var records = _.map(ids, function(id) { + var c = _.find(self.cache, function(c) {return c.id === id;}); + return _.isUndefined(c) ? c : _.extend({}, c.values, {"id": id}); + }); + 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); + }); + this._super(to_get, fields, options).then(function() { + def.resolve.apply(def, arguments); + }, function() { + def.reject.apply(def, arguments); + }); + return def.then(function(records) { + _.each(records, function(record, index) { + var id = to_get[index]; + var cached = _.detect(self.cache, function(x) {return x.id === id;}); + if (!cached) { + self.cache.push({id: id, values: record}); + } else { + // I assume cache value is prioritary + cached.values = _.defaults(_.clone(cached.values), 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 to_write + // or to_create collection) + if (_(this.to_create.concat(this.to_write)).find(function (record) { + return record.id === id; })) { + return; + } + for(var i=0, len=this.cache.length; i= 0; i--) { + var ex = expr[i]; + if (ex.length == 1) { + var top = stack.pop(); + switch (ex) { + case '|': + stack.push(stack.pop() || top); + continue; + case '&': + stack.push(stack.pop() && top); + continue; + case '!': + stack.push(!top); + continue; + default: + throw new Error(_.str.sprintf( + _t("Unknown operator %s in domain %s"), + ex, JSON.stringify(expr))); + } + } + + var field = fields[ex[0]]; + if (!field) { + throw new Error(_.str.sprintf( + _t("Unknown field %s in domain %s"), + ex[0], JSON.stringify(expr))); + } + var field_value = field.get_value ? field.get_value() : field.value; + var op = ex[1]; + var val = ex[2]; + + switch (op.toLowerCase()) { + case '=': + case '==': + stack.push(_.isEqual(field_value, val)); + break; + case '!=': + case '<>': + stack.push(!_.isEqual(field_value, val)); + break; + case '<': + stack.push(field_value < val); + break; + case '>': + stack.push(field_value > val); + break; + case '<=': + stack.push(field_value <= val); + break; + case '>=': + stack.push(field_value >= val); + break; + case 'in': + if (!_.isArray(val)) val = [val]; + stack.push(_(val).contains(field_value)); + break; + case 'not in': + if (!_.isArray(val)) val = [val]; + stack.push(!_(val).contains(field_value)); + break; + default: + console.warn( + _t("Unsupported operator %s in domain %s"), + op, JSON.stringify(expr)); + } + } + return _.all(stack, _.identity); +} + + +return { + Query: Query, + DataSet: DataSet, + DataSetStatic: DataSetStatic, + DataSetSearch: DataSetSearch, + BufferedDataSet: BufferedDataSet, + ProxyDataSet: ProxyDataSet, + CompoundContext: CompoundContext, + CompoundDomain: CompoundDomain, + compute_domain: compute_domain, +}; + +}); \ No newline at end of file -- cgit v1.2.3