diff options
| -rw-r--r-- | addons/web/static/src/js/fields/field_registry.js | 1 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/field_utils.js | 4 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/relational_fields.js | 102 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_model.js | 195 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 8 | ||||
| -rw-r--r-- | addons/web/static/tests/fields/relational_fields_tests.js | 100 | ||||
| -rw-r--r-- | addons/web/static/tests/views/basic_model_tests.js | 37 | ||||
| -rw-r--r-- | addons/web/static/tests/views/list_tests.js | 34 |
8 files changed, 458 insertions, 23 deletions
diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index dcd8f7acd48..efafec6ba6a 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -64,6 +64,7 @@ registry .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes) .add('one2many', relational_fields.FieldOne2Many) .add('statusbar', relational_fields.FieldStatus) + .add('reference', relational_fields.FieldReference) .add('one2many_list', relational_fields.FieldOne2Many); // Special fields diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js index 9a6734b7baf..becb1e124ef 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -494,7 +494,7 @@ return { many2one: formatMany2one, monetary: formatMonetary, one2many: formatX2Many, - reference: _.identity, // todo + reference: formatMany2one, selection: formatSelection, text: formatChar, }, @@ -512,7 +512,7 @@ return { many2one: parseMany2one, monetary: parseFloat, one2many: _.identity, - reference: _.identity, // todo + reference: parseMany2one, selection: _.identity, // todo text: _.identity, // todo }, diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 5b9114b6c82..44f884f130c 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1943,6 +1943,106 @@ var FieldRadio = FieldSelection.extend({ }, }); +/** + * The FieldReference is a combination of a select (for the model) and + * a FieldMany2one for its value. + * Its intern representation is similar to the many2one (a datapoint with a + * `name_get` as data). + */ +var FieldReference = FieldMany2One.extend({ + supportedFieldTypes: ['reference'], + template: 'FieldReference', + events: _.extend({}, FieldMany2One.prototype.events, { + 'change select': '_onSelectionChange', + }), + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + // needs to be copied as it is an unmutable object + this.field = _.extend({}, this.field); + if (this.value) { + this._setRelation(this.value.model); + } + }, + /** + * @override + */ + start: function () { + this.$('select').val(this.field.relation); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a select in edit mode (for the model). + * + * @override + */ + _renderEdit: function () { + this._super.apply(this, arguments); + + if (this.$('select').val()) { + this.$('.o_input_dropdown').show(); + this.$el.addClass('o_row'); // this class is used to display the two + // components (select & input) on the same line + } else { + // hide the many2one if the selection is empty + this.$('.o_input_dropdown').hide(); + } + + }, + /** + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this._setRelation(this.value && this.value.model); + }, + /** + * Set `relation` key in field properties. + * + * @private + * @param {string} model + */ + _setRelation: function (model) { + // used to generate the search in many2one + this.field.relation = model; + }, + /** + * @override + * @private + */ + _setValue: function (value, options) { + value = value || {}; + // we need to specify the model for the change in basic_model + // the value is then now a dict with id, display_name and model + value.model = this.$('select').val(); + return this._super(value, options); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When the selection (model) changes, the many2one is reset. + * + * @private + */ + _onSelectionChange: function () { + var value = this.$('select').val(); + this.reinitialize(false); + this._setRelation(value); + }, +}); + return { FieldMany2One: FieldMany2One, KanbanFieldMany2One: KanbanFieldMany2One, @@ -1960,6 +2060,8 @@ return { FieldRadio: FieldRadio, FieldSelection: FieldSelection, FieldStatus: FieldStatus, + + FieldReference: FieldReference, }; }); 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 811e3632975..90fed121f15 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -332,8 +332,16 @@ var BasicModel = AbstractModel.extend({ } else { data[fieldName] = this.get(data[fieldName]) || false; } - } - if (field.type === 'one2many' || field.type === 'many2many') { + } else if (field.type === 'reference') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? + relDataPoint.model + ',' + relDataPoint.res_id : + false; + } else { + data[fieldName] = this.get(data[fieldName]) || false; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { if (options.raw) { relDataPoint = this.localData[data[fieldName]]; relDataPoint = this._applyX2ManyOperations(relDataPoint); @@ -946,8 +954,8 @@ var BasicModel = AbstractModel.extend({ field = record.fields[fieldName]; if (field.type === 'one2many' || field.type === 'many2many') { defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], viewType)); - } else if (field.type === 'many2one') { - defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName])); + } else if (field.type === 'many2one' || field.type === 'reference') { + defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName])); } else { record._changes[fieldName] = changes[fieldName]; } @@ -989,19 +997,19 @@ var BasicModel = AbstractModel.extend({ }); }, /** - * 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. + * Apply an x2one (either a many2one or a reference field) change. 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) { + _applyX2OneChange: function (record, fieldName, data) { var self = this; - if (!data || data.id === false) { + if (!data || !data.id) { record._changes[fieldName] = false; return $.when(); } @@ -1022,12 +1030,16 @@ var BasicModel = AbstractModel.extend({ return $.when(); } var rel_data = _.pick(data, 'id', 'display_name'); + var field = record.fields[fieldName]; + + // the reference field doesn't store its co-model in its field metadata + // but directly in the data (as the co-model isn't fixed) + var coModel = field.type === 'reference' ? data.model : field.relation; var def; if (rel_data.display_name === undefined) { - var field = record.fields[fieldName]; // TODO: refactor this to use _fetchNameGet def = this._rpc({ - model: field.relation, + model: coModel, method: 'name_get', args: [data.id], context: record.context, @@ -1042,7 +1054,7 @@ var BasicModel = AbstractModel.extend({ data: rel_data, fields: {}, fieldsInfo: {}, - modelName: record.fields[fieldName].relation, + modelName: coModel, parentID: record.id, }); record._changes[fieldName] = rec.id; @@ -1239,7 +1251,10 @@ var BasicModel = AbstractModel.extend({ list_records[record.id].data = record; self._parseServerData(fieldNames, list, record); }); - return self._fetchX2ManysBatched(list); + return $.when( + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ); }); defs.push(def); } @@ -1544,12 +1559,140 @@ var BasicModel = AbstractModel.extend({ self._parseServerData(fieldNames, record, record.data); }) .then(function () { - return self._fetchX2Manys(record, options).then(function () { + return $.when( + self._fetchX2Manys(record, options), + self._fetchReferences(record) + ).then(function () { return self._postprocess(record, options); }); }); }, /** + * Fetch the extra data (`name_get`) for the reference fields of the record + * model. + * + * @private + * @param {Object} record + * @returns {Deferred} + */ + _fetchReferences: function (record) { + var self = this; + var defs = []; + var fieldNames = record.getFieldNames(); + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'reference') { + var value = record.data[fieldName]; + if (value) { + var model = value.split(',')[0]; + var resID = parseInt(value.split(',')[1]); + var def = self._rpc({ + model: model, + method: 'name_get', + args: [resID], + context: record.getContext({fieldName: fieldName}), + }).then(function (result) { + var referenceDp = self._makeDataPoint({ + data: { + id: result[0][0], + display_name: result[0][1], + }, + modelName: model, + parentID: record.id, + }); + record.data[fieldName] = referenceDp.id; + }); + defs.push(def); + } + } + }); + return $.when.apply($, defs); + }, + /** + * Batch requests for one reference field in list (one request by different + * model in the field values). + * + * @see _fetchReferencesBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchReferenceBatched: function (list, fieldName) { + var self = this; + list = this._applyX2ManyOperations(list); + + // collect ids by model + var toFetch = {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var value = record.data[fieldName]; + if (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 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); + }); + + return $.when.apply($, defs); + }, + /** + * Batch requests for references for datapoint of type list. + * + * @param {Object} list + * @returns {Deferred} + */ + _fetchReferencesBatched: 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 === 'reference') { + defs.push(this._fetchReferenceBatched(list, fieldNames[i])); + } + } + return $.when.apply($, defs); + }, + /** * This method is incorrectly named. It should be named something like * _fetchMany2OneData. * @@ -1826,7 +1969,9 @@ var BasicModel = AbstractModel.extend({ def = this._searchReadUngroupedList(list); } return def.then(function () { - return self._fetchX2ManysBatched(list); + return $.when( + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list)); }).then(function () { return list; }); @@ -1880,7 +2025,10 @@ var BasicModel = AbstractModel.extend({ record.data[fieldName] = list.id; if (!fieldInfo.__no_fetch) { var def = self._readUngroupedList(list).then(function () { - return self._fetchX2ManysBatched(list); + return $.when( + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ); }); defs.push(def); } @@ -2029,6 +2177,7 @@ var BasicModel = AbstractModel.extend({ // process relational fields and handle the null case var type = record.fields[fieldName].type; + var value; if (type === 'one2many' || type === 'many2many') { if (commands[fieldName].length) { // replace localId by commands changes[fieldName] = commands[fieldName]; @@ -2036,8 +2185,13 @@ var BasicModel = AbstractModel.extend({ delete changes[fieldName]; } } else if (type === 'many2one' && fieldName in changes) { - var value = changes[fieldName]; + value = changes[fieldName]; changes[fieldName] = value ? this.localData[value].res_id : false; + } else if (type === 'reference' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? + this.localData[value].model + ',' + this.localData[value].res_id : + false; } else if (changes[fieldName] === null) { changes[fieldName] = false; } @@ -2683,7 +2837,10 @@ var BasicModel = AbstractModel.extend({ x2manyList.res_ids = value[2]; x2manyList.count = x2manyList.res_ids.length; var def = self._readUngroupedList(x2manyList).then(function () { - return self._fetchX2ManysBatched(x2manyList); + return $.when( + self._fetchX2ManysBatched(x2manyList), + self._fetchReferencesBatched(x2manyList) + ); }); defs.push(def); } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 774c3b9b09c..bcc832c822c 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -753,6 +753,14 @@ <button type="button" t-if="!widget.nodeOptions.no_open" class="fa fa-external-link btn btn-default o_external_button" tabindex="-1" draggable="false"/> </div> </t> +<t t-name="FieldReference" t-extend="FieldMany2One"> + <t t-jquery=".o_input_dropdown" t-operation="before"> + <select class="o_input o_field_widget"> + <option/> + <option t-foreach="widget.field.selection" t-as="model" t-att-value="model[0]"><t t-esc="model[1]"/></option> + </select> + </t> +</t> <t t-name="FieldMany2ManyTag"> <t t-foreach="elements" t-as="el"> <span t-attf-class="badge dropdown o_tag_color_#{el.color || 10}" t-att-data-color="el.color || 10" t-att-data-index="el_index" t-att-data-id="el.id"> diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index e67faabe220..5a2844f620d 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -34,6 +34,8 @@ QUnit.module('relational_fields', { date: {string: "Some Date", type: "date"}, datetime: {string: "Datetime Field", type: 'datetime'}, user_id: {string: "User", type: 'many2one', relation: 'user'}, + reference: {string: "Reference Field", type: 'reference', selection: [ + ["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]}, }, records: [{ id: 1, @@ -47,6 +49,7 @@ QUnit.module('relational_fields', { timmy: [], trululu: 4, user_id: 17, + reference: 'product,37', }, { id: 2, display_name: "second record", @@ -6419,6 +6422,103 @@ QUnit.module('relational_fields', { }); }); + QUnit.module('FieldReference'); + + QUnit.test('reference in form view', function (assert) { + assert.expect(15); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="reference" string="custom label"/>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': '<form string="Product"><field name="display_name"/></form>', + }, + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'get_formview_action') { + assert.deepEqual(args.args[0], [37], "should call get_formview_action with correct id"); + return $.when({ + res_id: 17, + type: 'ir.actions.act_window', + target: 'current', + res_model: 'res.partner' + }); + } + if (args.method === 'get_formview_id') { + assert.deepEqual(args.args[0], [37], "should call get_formview_id with correct id"); + return $.when(false); + } + if (args.method === 'name_search') { + assert.strictEqual(args.model, 'partner_type', + "the name_search should be done on the newly set model"); + } + if (args.method === 'write') { + assert.strictEqual(args.model, 'partner', + "should write on the current model"); + assert.deepEqual(args.args, [[1], {reference: 'partner_type,12'}], + "should write the correct value"); + } + return this._super(route, args); + }, + }); + + testUtils.intercept(form, 'do_action', function (event) { + assert.strictEqual(event.data.action.res_id, 17, + "should do a do_action with correct parameters"); + }); + + assert.strictEqual(form.$('a.o_form_uri:contains(xphone)').length, 1, + "should contain a link"); + form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action) + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_field_widget').length, 2, + "should contain two field widgets (selection and many2one)"); + assert.strictEqual(form.$('.o_field_many2one').length, 1, + "should contain one many2one"); + assert.strictEqual(form.$('.o_field_widget select').val(), "product", + "widget should contain one select with the model"); + assert.strictEqual(form.$('.o_field_widget input').val(), "xphone", + "widget should contain one input with the record"); + + var options = _.map(form.$('.o_field_widget select > option'), function (el) { + return $(el).val(); + }); + assert.deepEqual(options, ['', 'product', 'partner_type', 'partner'], + "the options should be correctly set"); + + + form.$('.o_external_button').click(); // click on the external button (should do an RPC) + + assert.strictEqual($('.modal .modal-title').text().trim(), 'Open: custom label', + "dialog title should display the custom string label"); + $('.modal .o_form_button_cancel').click(); + + form.$('.o_field_widget select').val('partner_type').trigger('change'); + assert.strictEqual(form.$('.o_field_widget input').val(), "", + "many2one value should be reset after model change"); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + // change the value of the m2o with a suggestion of the dropdown + form.$('.o_field_many2one input').click(); // will trigger a name_search on partner_type model + $dropdown.find('li:first()').click(); + + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('a.o_form_uri:contains(gold)').length, 1, + "should contain a link with the new value"); + + form.destroy(); + }); + }); }); }); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 97047365436..715f6099e0a 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -20,6 +20,7 @@ QUnit.module('Views', { product_ids: {string: "Favorite products", type: 'one2many', relation: 'product'}, category: {string: "Category M2M", type: 'many2many', relation: 'partner_type'}, date: {string: "Date Field", type: 'date'}, + reference: {string: "Reference Field", type: 'reference', selection: [["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]}, }, records: [ {id: 1, foo: 'blip', bar: 1, product_id: 37, category: [12], display_name: "first partner", date: "2017-01-25"}, @@ -1777,6 +1778,7 @@ QUnit.module('Views', { product_id: 37, product_ids: [], qux: false, + reference: false, total: 0 }, "should use the proper eval context"); }); @@ -1820,4 +1822,39 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('fetch references in list, with not too many rpcs', function (assert) { + assert.expect(5); + + this.data.partner.records[0].reference = 'product,37'; + this.data.partner.records[1].reference = 'product,41'; + + this.params.fieldNames = ['reference']; + this.params.domain = []; + this.params.groupedBy = []; + this.params.res_id = undefined; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(route); + if (route === "/web/dataset/call_kw/product/name_get") { + assert.deepEqual(args.args, [[37, 41]], + "the name_get should contain the product ids"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + + assert.strictEqual(record.data[0].data.reference.data.display_name, "xphone", + "name_get should have been correctly fetched"); + assert.verifySteps(["/web/dataset/search_read", "/web/dataset/call_kw/product/name_get"], + "should have done 2 rpc (searchread and name_get for product)"); + }); + model.destroy(); + }); + });}); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index c39e6d2b1b0..e451c5a9c69 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -23,6 +23,8 @@ QUnit.module('Views', { currency_id: {string: "Currency", type: "many2one", relation: "res_currency", default: 1}, datetime: {string: "Datetime Field", type: 'datetime'}, + reference: {string: "Reference Field", type: 'reference', selection: [ + ["bar", "Bar"], ["res_currency", "Currency"], ["event", "Event"]]}, }, records: [ { @@ -37,11 +39,12 @@ QUnit.module('Views', { currency_id: 2, date: "2017-01-25", datetime: "2016-12-12 10:55:05", + reference: 'bar,1', }, {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, - m2o: 2, m2m: [1, 2, 3], amount: 500}, + m2o: 2, m2m: [1, 2, 3], amount: 500, reference: 'res_currency,1'}, {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, - m2o: 1, m2m: [], amount: 300}, + m2o: 1, m2m: [], amount: 300, reference: 'res_currency,2'}, {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, m2o: 1, m2m: [1], amount: 0}, ] @@ -2400,6 +2403,33 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('reference field rendering', function (assert) { + assert.expect(4); + + this.data.foo.records.push({ + id: 5, + reference: 'res_currency,2', + }); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="reference"/></tree>', + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.step(args.model); + } + return this._super.apply(this, arguments); + }, + }); + + assert.verifySteps(['bar', 'res_currency'], "should have done 1 name_get by model in reference values"); + assert.strictEqual(list.$('tbody td').text(), "Value 1USDEUREUR", + "should have the display_name of the reference"); + list.destroy(); + }); + }); }); |
