diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index f8d6daf081d..a16a1df35a4 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -2188,7 +2188,7 @@ var BasicModel = AbstractModel.extend({ * @param {Object} [options] * @param {string[]} [options.fieldNames] the list of fields to fetch. If * not given, fetch all the fields in record.fieldNames (+ display_name) - * @param {string} [optinos.viewType] the type of view for which the record + * @param {string} [options.viewType] the type of view for which the record * is fetched (usefull to load the adequate fields), by defaults, uses * record.viewType * @returns {Deferred} resolves to the record or is rejected in @@ -2257,6 +2257,46 @@ var BasicModel = AbstractModel.extend({ } return $.when(def); }, + /** + * Fetches data for reference fields and assigns these data to newly + * created datapoint. + * Then places datapoint reference into parent record. + * + * @param {Object} datapoints a collection of ids classed by model, + * @see _getDataToFetchByModel + * @param {string} model + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchReferenceData: function (datapoints, model, fieldName) { + var self = this; + var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); }); + // we need one parent for the context (they all have the same) + var parent = datapoints[ids[0]][0]; + var def = self._rpc({ + model: model, + method: 'name_get', + args: [ids], + context: self.localData[parent].getContext({fieldName: fieldName}), + }).then(function (result) { + _.each(result, function (el) { + var parentIDs = datapoints[el[0]]; + _.each(parentIDs, function (parentID) { + var parent = self.localData[parentID]; + var referenceDp = self._makeDataPoint({ + data: { + id: el[0], + display_name: el[1], + }, + modelName: model, + parentID: parent, + }); + parent.data[fieldName] = referenceDp.id; + }); + }); + }); + return def; + }, /** * Fetch the extra data (`name_get`) for the reference fields of the record * model. @@ -2294,59 +2334,13 @@ var BasicModel = AbstractModel.extend({ _fetchReferenceBatched: function (list, fieldName) { var self = this; list = this._applyX2ManyOperations(list); + this._sortList(list); - // collect ids by model - var toFetch = {}; - _.each(list.data, function (dataPoint) { - var record = self.localData[dataPoint]; - var value = record.data[fieldName]; - // if the reference field has already been fetched, the value is a - // datapoint ID, and in this case there's nothing to do - if (value && !self.localData[value]) { - var model = value.split(',')[0]; - var resID = value.split(',')[1]; - if (!(model in toFetch)) { - toFetch[model] = {}; - } - // there could be multiple datapoints with the same model/resID - if (toFetch[model][resID]) { - toFetch[model][resID].push(dataPoint); - } else { - toFetch[model][resID] = [dataPoint]; - } - } - }); - + var toFetch = this._getDataToFetchByModel(list, fieldName); var defs = []; - var def; // one name_get by model _.each(toFetch, function (datapoints, model) { - var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); }); - // we need one parent for the context (they all have the same) - var parent = datapoints[ids[0]][0]; - def = self._rpc({ - model: model, - method: 'name_get', - args: [ids], - context: self.localData[parent].getContext({fieldName: fieldName}), - }).then(function (result) { - _.each(result, function (el) { - var parentIDs = datapoints[el[0]]; - _.each(parentIDs, function (parentID) { - var parent = self.localData[parentID]; - var referenceDp = self._makeDataPoint({ - data: { - id: el[0], - display_name: el[1], - }, - modelName: model, - parentID: parent, - }); - parent.data[fieldName] = referenceDp.id; - }); - }); - }); - defs.push(def); + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); }); return $.when.apply($, defs); @@ -2368,6 +2362,94 @@ var BasicModel = AbstractModel.extend({ } return $.when.apply($, defs); }, + /** + * Batch reference requests for all records in list. + * + * @see _fetchReferencesSingleBatch + * @param {Object} list a valid resource object + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchReferenceSingleBatch: function (list, fieldName) { + var self = this; + + // collect ids by model + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + _.extend(toFetch, self._getDataToFetchByModel(group, fieldName)); + }); + + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return $.when.apply($, defs); + }, + /** + * Batch requests for all reference field in list's children. + * Called by _readGroup to make only one 'name_get' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Deferred} + */ + _fetchReferencesSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var fIndex in fieldNames) { + var field = list.fields[fieldNames[fIndex]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex])); + } + } + return $.when.apply($, defs); + }, + /** + * Fetch model data from server, relationally to fieldName and resulted + * field relation. For example, if fieldName is "tag_ids" and referred to + * project.tags, it will fetch project.tags' related fields where its id is + * contained in toFetch.ids array. + * + * @param {Object} list a valid resource object + * @param {Object} toFetch a list of records and res_ids, + * @see _getDataToFetch + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchRelatedData: function (list, toFetch, fieldName) { + var self = this; + var ids = _.keys(toFetch); + for (var i = 0; i < ids.length; i++) { + ids[i] = Number(ids[i]); + } + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + + if (!ids.length || fieldInfo.__no_fetch) { + return $.when(); + } + + var def; + var fieldNames = _.keys(fieldInfo.relatedFields); + if (fieldNames.length) { + var field = list.fields[fieldName]; + def = this._rpc({ + model: field.relation, + method: 'read', + args: [ids, fieldNames], + context: list.getContext() || {}, + }); + } else { + def = $.when(_.map(ids, function (id) { + return {id:id}; + })); + } + return def.then(function (result) { + var records = _.uniq(_.flatten(_.values(toFetch))); + self._updateRecordsData(records, fieldName, result); + }); + }, /** * This method is incorrectly named. It should be named something like * _fetchMany2OneData. @@ -2658,9 +2740,14 @@ var BasicModel = AbstractModel.extend({ * Fetch all data in a ungrouped list * * @param {Object} list a valid resource object - * @returns {Deferred} resolves to the fecthed list + * @param {Object} [options] + * @param {boolean} [options.enableRelationalFetch=true] if false, will not + * fetch x2m and relational data (that will be done by _readGroup in this + * case). + * @returns {Deferred} resolves to the fetched list */ - _fetchUngroupedList: function (list) { + _fetchUngroupedList: function (list, options) { + options = _.defaults(options || {}, {enableRelationalFetch: true}); var self = this; var def; if (list.static) { @@ -2678,13 +2765,31 @@ var BasicModel = AbstractModel.extend({ def = this._searchReadUngroupedList(list); } return def.then(function () { - return $.when( - self._fetchX2ManysBatched(list), - self._fetchReferencesBatched(list)); + if (options.enableRelationalFetch) { + return $.when( + self._fetchX2ManysBatched(list, options), + self._fetchReferencesBatched(list, options) + ); + } }).then(function () { return list; }); }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchX2ManyBatched: function (list, fieldName) { + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetch(list, fieldName); + return this._fetchRelatedData(list, toFetch, fieldName); + }, /** * 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, @@ -2752,93 +2857,6 @@ var BasicModel = AbstractModel.extend({ }); 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]; - var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; - var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; - var fields = view ? view.fields : fieldInfo.relatedFields; - var viewType = view ? view.type : fieldInfo.viewType; - list = this._applyX2ManyOperations(list); - this._sortList(list); - var x2mRecords = []; - - // step 1: collect ids - var ids = []; - _.each(list.data, function (dataPoint) { - var record = self.localData[dataPoint]; - if (typeof record.data[fieldName] === 'string') { - // in this case, the value is a local ID, which means that the - // record has already been processed. It can happen for example - // when a user adds a record in a m2m relation, or loads more - // records in a kanban column - return; - } - x2mRecords.push(record); - ids = _.unique(ids.concat(record.data[fieldName] || [])); - var m2mList = self._makeDataPoint({ - fieldsInfo: fieldsInfo, - fields: fields, - modelName: field.relation, - parentID: record.id, - res_ids: record.data[fieldName], - static: true, - type: 'list', - viewType: viewType, - }); - record.data[fieldName] = m2mList.id; - }); - - if (!ids.length || fieldInfo.__no_fetch) { - return $.when(); - } - var def; - var fieldNames = _.keys(fieldInfo.relatedFields); - // step 2: fetch data from server - // if we want specific fields - // if not we return an array of objects with the id - // to avoid fetching all the relation fields and an useless rpc - if (fieldNames.length) { - def = this._rpc({ - model: field.relation, - method: 'read', - args: [ids, fieldNames], - context: list.getContext() || {}, - }); - } else { - def = $.when(_.map(ids, function (id) { - return {id:id}; - })); - } - return def.then(function (results) { - // step 3: assign values to correct datapoints - _.each(x2mRecords, function (record) { - var m2mList = self.localData[record.data[fieldName]]; - m2mList.data = []; - _.each(m2mList.res_ids, function (res_id) { - var dataPoint = self._makeDataPoint({ - modelName: field.relation, - data: _.findWhere(results, {id: res_id}), - fields: fields, - fieldsInfo: fieldsInfo, - parentID: m2mList.id, - viewType: viewType, - }); - m2mList.data.push(dataPoint.id); - m2mList._cache[res_id] = dataPoint.id; - }); - }); - }); - }, /** * batch request for x2ms for datapoint of type list * @@ -2856,6 +2874,50 @@ var BasicModel = AbstractModel.extend({ } return $.when.apply($, defs); }, + /** + * For a non-static list, batches requests for all its sublists' records. + * Make only one rpc for all records on the concerned field. + * + * @see _fetchX2ManysSingleBatch + * @param {Object} list a valid resource object, its data must be another + * list containing records + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchX2ManySingleBatch: function (list, fieldName) { + var self = this; + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + var nextDataToFetch = self._getDataToFetch(group, fieldName); + _.each(_.keys(nextDataToFetch), function (id) { + if (toFetch[id]) { + toFetch[id] = toFetch[id].concat(nextDataToFetch[id]); + } else { + toFetch[id] = nextDataToFetch[id]; + } + }); + }); + return self._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * Batch requests for all x2m in list's children. + * Called by _readGroup to make only one 'read' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Deferred} + */ + _fetchX2ManysSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many'){ + defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i])); + } + } + return $.when.apply($, defs); + }, /** * Generates an object mapping field names to their changed value in a given * record (i.e. maps to the new value for basic fields, to the res_id for @@ -3154,6 +3216,90 @@ var BasicModel = AbstractModel.extend({ return context.eval(); }, + /** + * Collects from a record a list of ids to fetch, according to fieldName, + * and a list of records where to set the result of the fetch. + * + * @param {Object} list a list containing records we want to get the ids, + * it assumes _applyX2ManyOperations and _sort have been already called on + * this list + * @param {string} fieldName + * @return {Object} a list of records and res_ids + */ + _getDataToFetch: function (list, fieldName) { + var self = this; + var field = list.fields[fieldName]; + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + var toFetch = {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + if (typeof record.data[fieldName] === 'string'){ + // in this case, the value is a local ID, which means that the + // record has already been processed. It can happen for example + // when a user adds a record in a m2m relation, or loads more + // records in a kanban column + return; + } + + _.each(record.data[fieldName], function (id) { + toFetch[id] = toFetch[id] || []; + toFetch[id].push(record); + }); + + var m2mList = self._makeDataPoint({ + fieldsInfo: fieldsInfo, + fields: fields, + modelName: field.relation, + parentID: record.id, + res_ids: record.data[fieldName], + static: true, + type: 'list', + viewType: viewType, + }); + record.data[fieldName] = m2mList.id; + }); + + return toFetch; + }, + /** + * Determines and returns from a list a collection of ids classed by + * their model. + * + * @param {Object} list a valid resource object + * @param {string} fieldName + * @returns {Object} each key represent a model and contain a sub-object + * where each key represent an id (res_id) containing an array of + * webclient id (referred to a datapoint, so not a res_id). + */ + _getDataToFetchByModel: function (list, fieldName) { + var self = this; + var toFetch = {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var value = record.data[fieldName]; + // if the reference field has already been fetched, the value is a + // datapoint ID, and in this case there's nothing to do + if (value && !self.localData[value]) { + var model = value.split(',')[0]; + var resID = value.split(',')[1]; + if (!(model in toFetch)) { + toFetch[model] = {}; + } + // there could be multiple datapoints with the same model/resID + if (toFetch[model][resID]) { + toFetch[model][resID].push(dataPoint); + } else { + toFetch[model][resID] = [dataPoint]; + } + } + }); + return toFetch; + }, /** * Some records are associated to a/some domain(s). This method allows to * retrieve them, evaluated. @@ -3448,7 +3594,7 @@ var BasicModel = AbstractModel.extend({ return this._readGroup(dataPoint, options); } if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { - return this._fetchUngroupedList(dataPoint); + return this._fetchUngroupedList(dataPoint, options); } }, /** @@ -4121,6 +4267,7 @@ var BasicModel = AbstractModel.extend({ list.count += newGroup.count; if (newGroup.isOpen && newGroup.count > 0) { openGroupCount++; + options = _.defaults({enableRelationalFetch: false}, options); defs.push(self._load(newGroup, options)); } }); @@ -4138,6 +4285,7 @@ var BasicModel = AbstractModel.extend({ emptyGroup.aggregateValues = {}; }); } + return $.when.apply($, defs).then(function () { if (!options || !options.onlyGroups) { // generate the res_ids of the main list, being the concatenation @@ -4147,6 +4295,10 @@ var BasicModel = AbstractModel.extend({ })); } return list; + }).then(function () { + self._fetchX2ManysSingleBatch(list); + self._fetchReferencesSingleBatch(list); + return list; }); }); }, @@ -4487,6 +4639,45 @@ var BasicModel = AbstractModel.extend({ this._updateParentResIDs(parent); } }, + /** + * Helper method to create datapoints and assign them values, then link + * those datapoints into records' data. + * + * @param {Object[]} records a list of record where datapoints will be + * assigned, it assumes _applyX2ManyOperations and _sort have been + * already called on this list + * @param {string} fieldName concerned field in records + * @param {Object[]} values typically a list of values got from a rpc + */ + _updateRecordsData: function (records, fieldName, values) { + if (!records.length || !values) { + return; + } + var self = this; + var field = records[0].fields[fieldName]; + var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + _.each(records, function (record) { + var x2mList = self.localData[record.data[fieldName]]; + x2mList.data = []; + _.each(x2mList.res_ids, function (res_id) { + var dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: _.findWhere(values, {id: res_id}), + fields: fields, + fieldsInfo: fieldsInfo, + parentID: x2mList.id, + viewType: viewType, + }); + x2mList.data.push(dataPoint.id); + x2mList._cache[res_id] = dataPoint.id; + }); + }); + }, /** * Helper method. Recursively traverses the data, starting from the element * record (or list), then following all relations. This is useful when one diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 17c0c022e85..7aca5cba694 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -2297,6 +2297,138 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('o2m loaded in only one batch', function (assert) { + assert.expect(9); + + this.data.subtask = { + fields: { + name: {string: 'Name', type: 'char'} + }, + records: [ + {id: 1, name: "subtask #1"}, + {id: 2, name: "subtask #2"}, + ] + }; + this.data.partner.fields.subtask_ids = { + string: 'Subtasks', + type: 'one2many', + relation: 'subtask' + }; + this.data.partner.records[0].subtask_ids = [1]; + this.data.partner.records[1].subtask_ids = [2]; + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + kanban.reload(); + assert.verifySteps([ + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + ]); + kanban.destroy(); + }); + + QUnit.test('m2m loaded in only one batch', function (assert) { + assert.expect(9); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + kanban.reload(); + assert.verifySteps([ + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'read', + ]); + kanban.destroy(); + }); + + QUnit.test('fetch reference in only one batch', function (assert) { + assert.expect(9); + + this.data.partner.records[0].ref_product = 'product,3'; + this.data.partner.records[1].ref_product = 'product,5'; + this.data.partner.fields.ref_product = { + string: "Reference Field", + type: 'reference', + }; + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
', + groupBy: ['product_id'], + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + + kanban.reload(); + assert.verifySteps([ + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'name_get', + 'read_group', + '/web/dataset/search_read', + '/web/dataset/search_read', + 'name_get', + ]); + kanban.destroy(); + }); + QUnit.test('can drag and drop a record from one column to the next', function (assert) { assert.expect(9);