[FIX] web: handle nested one2manys and onchange
Let's assume the following scenario in a form view:
- have a one2many field, say fieldA, displayed as a list,
containing another one2many, fieldB, (no widget, thus
displaying 'n record(s)')
- the one2many list is not editable, so there is a sub form view,
displaying fieldB as a list, and in this list some random field,
say fieldC, is displayed
- have a random field on the main form view, with an onchange to
populate the one2many
- set that random field s.t. the onchange returns something like
fieldA: [[5], [0, 0, {fieldB: [[0, 0, {fieldC: 'value'}]]}]]
- there is now a record in fieldA's list, displaying '1 record'
as value for fieldB
- click on that record to open it in a form view (dialog)
- in the dialog, in fieldB's list, we expect to have a single row
displaying 'value' as value for fieldC
Before this rev., it crashed when opening the record in the dialog,
whether the form view was inline or not.
The crash occured because fieldB was already in the list view, so
datapoints already existed for it (a list datapoint, and record
datapoints for records in the relation, in our example one record
datapoint). However, those datapoints didn't have the fieldsInfo of
the form view. When opening the form view, we added the fieldsInfo
of the form to the datapoint of the record we opened, but we didn't
recursively apply the fieldsInfo to its children. As a consequence,
when rendering the list view for fieldB in the dialog, we haven't
the information about fieldC, and it crashed.
Note that if fieldB wasn't present in fieldA's list view, it worked
fine because datapoints didn't exist before we opened the record in
the dialog.
Task 2120235
This commit is contained in:
@@ -214,21 +214,63 @@ var BasicModel = AbstractModel.extend({
|
||||
* It is useful for the cases where a record element is shared between
|
||||
* various views, such as a one2many with a tree and a form view.
|
||||
*
|
||||
* @param {string} recordID a valid element ID
|
||||
* @param {string} datapointID a valid element ID (of type 'list' or 'record')
|
||||
* @param {Object} viewInfo
|
||||
* @param {Object} viewInfo.fields
|
||||
* @param {Object} viewInfo.fieldsInfo
|
||||
* @param {Object} viewInfo.fieldInfo
|
||||
* @param {string} viewInfo.viewType
|
||||
* @returns {Promise} resolved when the fieldInfo have been set on the given
|
||||
* datapoint and all its children, and all rawChanges have been applied
|
||||
*/
|
||||
addFieldsInfo: function (recordID, viewInfo) {
|
||||
var record = this.localData[recordID];
|
||||
record.fields = _.extend({}, record.fields, viewInfo.fields);
|
||||
// complete the given fieldsInfo with the fields of the main view, so
|
||||
addFieldsInfo: function (dataPointID, viewInfo) {
|
||||
var dataPoint = this.localData[dataPointID];
|
||||
dataPoint.fields = _.extend({}, dataPoint.fields, viewInfo.fields);
|
||||
// complete the given fieldInfo with the fields of the main view, so
|
||||
// that those field will be reloaded if a reload is triggered by the
|
||||
// secondary view
|
||||
var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) {
|
||||
return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]);
|
||||
dataPoint.fieldsInfo = dataPoint.fieldsInfo || {};
|
||||
const mainFieldInfo = dataPoint.fieldsInfo[dataPoint[viewInfo.viewType]];
|
||||
dataPoint.fieldsInfo[viewInfo.viewType] = _.defaults({}, viewInfo.fieldInfo, mainFieldInfo);
|
||||
|
||||
// Some fields in the new fields info might not be in the previous one,
|
||||
// so we might have stored changes for them (e.g. coming from onchange
|
||||
// RPCs), that we haven't been able to process earlier (because those
|
||||
// fields were unknown at that time). So we now try to process them.
|
||||
return this.applyRawChanges(dataPointID, viewInfo.viewType).then(() => {
|
||||
const proms = [];
|
||||
const fieldInfo = dataPoint.fieldsInfo[viewInfo.viewType];
|
||||
// recursively apply the new field info on sub datapoints
|
||||
if (dataPoint.type === 'list') {
|
||||
// case 'list': on all datapoints in the list
|
||||
Object.values(dataPoint._cache).forEach(subDataPointID => {
|
||||
proms.push(this.addFieldsInfo(subDataPointID, {
|
||||
fields: dataPoint.fields,
|
||||
fieldInfo: dataPoint.fieldsInfo[viewInfo.viewType],
|
||||
viewType: viewInfo.viewType,
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
// case 'record': on datapoints of all x2many fields
|
||||
const values = _.extend({}, dataPoint.data, dataPoint._changes);
|
||||
Object.keys(fieldInfo).forEach(fieldName => {
|
||||
const fieldType = dataPoint.fields[fieldName].type;
|
||||
if (fieldType === 'one2many' || fieldType === 'many2many') {
|
||||
const mode = fieldInfo[fieldName].mode;
|
||||
const views = fieldInfo[fieldName].views;
|
||||
const x2mDataPointID = values[fieldName];
|
||||
if (views[mode] && x2mDataPointID) {
|
||||
proms.push(this.addFieldsInfo(x2mDataPointID, {
|
||||
fields: views[mode].fields,
|
||||
fieldInfo: views[mode].fieldsInfo[mode],
|
||||
viewType: mode,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return Promise.all(proms);
|
||||
});
|
||||
record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo);
|
||||
|
||||
},
|
||||
/**
|
||||
* Add and process default values for a given record. Those values are
|
||||
@@ -3735,8 +3777,8 @@ var BasicModel = AbstractModel.extend({
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isFieldProtected: function (record, fieldName, viewType) {
|
||||
var fieldInfo = record.fieldsInfo &&
|
||||
(record.fieldsInfo[viewType || record.viewType][fieldName]);
|
||||
viewType = viewType || record.viewType;
|
||||
var fieldInfo = viewType && record.fieldsInfo && record.fieldsInfo[viewType][fieldName];
|
||||
if (fieldInfo) {
|
||||
var rawModifiers = fieldInfo.modifiers || {};
|
||||
var modifiers = this._evalModifiers(record, _.pick(rawModifiers, 'readonly'));
|
||||
|
||||
@@ -96,14 +96,15 @@ var BasicView = AbstractView.extend({
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_loadData: function (model) {
|
||||
_loadData: async function (model) {
|
||||
if (this.recordID) {
|
||||
// Add the fieldsInfo of the current view to the given recordID,
|
||||
// as it will be shared between two views, and it must be able to
|
||||
// handle changes on fields that are only on this view.
|
||||
model.addFieldsInfo(this.recordID, {
|
||||
await model.addFieldsInfo(this.recordID, {
|
||||
fields: this.fields,
|
||||
fieldsInfo: this.fieldsInfo,
|
||||
fieldInfo: this.fieldsInfo[this.viewType],
|
||||
viewType: this.viewType,
|
||||
});
|
||||
|
||||
var record = model.get(this.recordID);
|
||||
@@ -119,7 +120,7 @@ var BasicView = AbstractView.extend({
|
||||
// in the form view (e.g. if field is a many2many list in the form
|
||||
// view, or if it is displayed by a widget requiring specialData).
|
||||
// So when this happens, F is added to the list of fieldNames to fetch.
|
||||
_.each(viewFields, function (name) {
|
||||
_.each(viewFields, (name) => {
|
||||
if (!_.contains(fieldNames, name)) {
|
||||
var fieldType = record.fields[name].type;
|
||||
var fieldInfo = fieldsInfo[name];
|
||||
@@ -137,30 +138,28 @@ var BasicView = AbstractView.extend({
|
||||
// X2Many case: field is an x2many displayed as a list or
|
||||
// kanban view, but the related fields haven't been loaded yet.
|
||||
if ((fieldType === 'one2many' || fieldType === 'many2many')) {
|
||||
if (!('fieldsInfo' in record.data[name])) {
|
||||
var x2mFieldInfo = record.fieldsInfo[this.viewType][name];
|
||||
var viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode;
|
||||
var knownFields = Object.keys(record.data[name].fieldsInfo[record.data[name].viewType] || {});
|
||||
var newFields = Object.keys(record.data[name].fieldsInfo[viewType]);
|
||||
if (_.difference(newFields, knownFields).length) {
|
||||
fieldNames.push(name);
|
||||
} else {
|
||||
var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {};
|
||||
var fieldViewTypes = Object.keys(fieldViews);
|
||||
var recordViewTypes = Object.keys(record.data[name].fieldsInfo);
|
||||
if (_.difference(fieldViewTypes, recordViewTypes).length) {
|
||||
fieldNames.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (record.data[name].viewType === 'default') {
|
||||
// Use case: x2many (tags) in x2many list views
|
||||
// When opening the x2many record form view, the
|
||||
// x2many will be reloaded but it may not have
|
||||
// the same fields (ex: tags in list and list in
|
||||
// form) so we need to merge the fieldsInfo to
|
||||
// avoid losing the initial fields (display_name)
|
||||
var defaultFieldInfo = record.data[name].fieldsInfo.default;
|
||||
_.each(fieldViews, function (fieldView) {
|
||||
_.each(fieldView.fieldsInfo, function (x2mFieldInfo) {
|
||||
_.defaults(x2mFieldInfo, defaultFieldInfo);
|
||||
});
|
||||
if (record.data[name].viewType === 'default') {
|
||||
// Use case: x2many (tags) in x2many list views
|
||||
// When opening the x2many record form view, the
|
||||
// x2many will be reloaded but it may not have
|
||||
// the same fields (ex: tags in list and list in
|
||||
// form) so we need to merge the fieldsInfo to
|
||||
// avoid losing the initial fields (display_name)
|
||||
var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {};
|
||||
var defaultFieldInfo = record.data[name].fieldsInfo.default;
|
||||
_.each(fieldViews, function (fieldView) {
|
||||
_.each(fieldView.fieldsInfo, function (x2mFieldInfo) {
|
||||
_.defaults(x2mFieldInfo, defaultFieldInfo);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Many2one: context is not the same between the different views
|
||||
@@ -176,25 +175,18 @@ var BasicView = AbstractView.extend({
|
||||
|
||||
var def;
|
||||
if (fieldNames.length) {
|
||||
// Some fields in the new view weren't in the previous one, so
|
||||
// we might have stored changes for them (e.g. coming from
|
||||
// onchange RPCs), that we haven't been able to process earlier
|
||||
// (because those fields were unknow at that time). So we ask
|
||||
// the model to process them.
|
||||
def = model.applyRawChanges(record.id, viewType).then(function () {
|
||||
if (model.isNew(record.id)) {
|
||||
return model.applyDefaultValues(record.id, {}, {
|
||||
fieldNames: fieldNames,
|
||||
viewType: viewType,
|
||||
});
|
||||
} else {
|
||||
return model.reload(record.id, {
|
||||
fieldNames: fieldNames,
|
||||
keepChanges: true,
|
||||
viewType: viewType,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (model.isNew(record.id)) {
|
||||
def = model.applyDefaultValues(record.id, {}, {
|
||||
fieldNames: fieldNames,
|
||||
viewType: viewType,
|
||||
});
|
||||
} else {
|
||||
def = model.reload(record.id, {
|
||||
fieldNames: fieldNames,
|
||||
keepChanges: true,
|
||||
viewType: viewType,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.resolve(def).then(function () {
|
||||
return model.get(record.id);
|
||||
|
||||
@@ -5409,6 +5409,108 @@ QUnit.module('fields', {}, function () {
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('nested x2many (inline form view) and onchanges', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
this.data.partner.onchanges.bar = function (obj) {
|
||||
if (!obj.bar) {
|
||||
obj.p = [[5], [0, 0, {
|
||||
turtles: [[0, 0, {
|
||||
turtle_foo: 'new turtle',
|
||||
}]],
|
||||
}]];
|
||||
}
|
||||
};
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `<form>
|
||||
<field name="bar"/>
|
||||
<field name="p">
|
||||
<tree>
|
||||
<field name="turtles"/>
|
||||
</tree>
|
||||
<form>
|
||||
<field name="turtles">
|
||||
<tree>
|
||||
<field name="turtle_foo"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>
|
||||
</field>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
assert.containsNone(form, '.o_data_row');
|
||||
|
||||
await testUtils.dom.click(form.$('.o_field_widget[name=bar] input'));
|
||||
assert.containsOnce(form, '.o_data_row');
|
||||
assert.strictEqual(form.$('.o_data_row').text(), '1 record');
|
||||
|
||||
await testUtils.dom.click(form.$('.o_data_row:first'));
|
||||
|
||||
assert.containsOnce(document.body, '.modal .o_form_view');
|
||||
assert.containsOnce(document.body, '.modal .o_form_view .o_data_row');
|
||||
assert.strictEqual($('.modal .o_form_view .o_data_row').text(), 'new turtle');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('nested x2many (non inline form view) and onchanges', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
this.data.partner.onchanges.bar = function (obj) {
|
||||
if (!obj.bar) {
|
||||
obj.p = [[5], [0, 0, {
|
||||
turtles: [[0, 0, {
|
||||
turtle_foo: 'new turtle',
|
||||
}]],
|
||||
}]];
|
||||
}
|
||||
};
|
||||
|
||||
const form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="bar"/>
|
||||
<field name="p">
|
||||
<tree>
|
||||
<field name="turtles"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
archs: {
|
||||
'partner,false,form': `
|
||||
<form>
|
||||
<field name="turtles">
|
||||
<tree>
|
||||
<field name="turtle_foo"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>`,
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsNone(form, '.o_data_row');
|
||||
|
||||
await testUtils.dom.click(form.$('.o_field_widget[name=bar] input'));
|
||||
assert.containsOnce(form, '.o_data_row');
|
||||
assert.strictEqual(form.$('.o_data_row').text(), '1 record');
|
||||
|
||||
await testUtils.dom.click(form.$('.o_data_row:first'));
|
||||
|
||||
assert.containsOnce(document.body, '.modal .o_form_view');
|
||||
assert.containsOnce(document.body, '.modal .o_form_view .o_data_row');
|
||||
assert.strictEqual($('.modal .o_form_view .o_data_row').text(), 'new turtle');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('one2many (who contains display_name) with tree view and without form view', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user