[FIX] web: deal with reference fields

The reference fields haven't been correctly implemented with the new views.
This commit restores their behaviour.

Note that an small improvement has been introduced in this commit.
Before saas-16, a `name_get` was done for each record (so 80 if the field
was displayed in a full list view), which is not optimal. The calls are now
batched by model (so one `name_get` by model appearing in the field values).
This commit is contained in:
Martin Geubelle
2017-06-28 16:53:45 +02:00
parent 157732cfb1
commit ec3bc2eeaf
8 changed files with 458 additions and 23 deletions
@@ -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
@@ -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
},
@@ -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,
};
});
@@ -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);
}
+8
View File
@@ -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">
@@ -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();
});
});
});
});
@@ -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();
});
});});
+32 -2
View File
@@ -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();
});
});
});