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,11 +1559,139 @@ 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 @@
+
+
+
+
+
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: '',
+ archs: {
+ 'product,false,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: '',
+ 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();
+ });
+
});
});