903 lines
33 KiB
JavaScript
903 lines
33 KiB
JavaScript
flectra.define('web.EditableListRenderer', function (require) {
|
|
"use strict";
|
|
|
|
/**
|
|
* Editable List renderer
|
|
*
|
|
* The list renderer is reasonably complex, so we split it in two files. This
|
|
* file simply 'includes' the basic ListRenderer to add all the necessary
|
|
* behaviors to enable editing records.
|
|
*
|
|
* Unlike Flectra v1 and before, this list renderer is independant from the form
|
|
* view. It uses the same widgets, but the code is totally stand alone.
|
|
*/
|
|
var core = require('web.core');
|
|
var dom = require('web.dom');
|
|
var ListRenderer = require('web.ListRenderer');
|
|
var utils = require('web.utils');
|
|
|
|
var _t = core._t;
|
|
|
|
ListRenderer.include({
|
|
custom_events: _.extend({}, ListRenderer.prototype.custom_events, {
|
|
navigation_move: '_onNavigationMove',
|
|
}),
|
|
events: _.extend({}, ListRenderer.prototype.events, {
|
|
'click tbody td.o_data_cell': '_onCellClick',
|
|
'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
|
|
'click tfoot': '_onFooterClick',
|
|
'click tr .o_list_record_delete': '_onTrashIconClick',
|
|
'click .o_field_x2many_list_row_add a': '_onAddRecord',
|
|
}),
|
|
/**
|
|
* @override
|
|
* @param {Object} params
|
|
* @param {boolean} params.addCreateLine
|
|
* @param {boolean} params.addTrashIcon
|
|
*/
|
|
init: function (parent, state, params) {
|
|
this._super.apply(this, arguments);
|
|
|
|
// if addCreateLine is true, the renderer will add a 'Add an item' link
|
|
// at the bottom of the list view
|
|
this.addCreateLine = params.addCreateLine;
|
|
|
|
// if addTrashIcon is true, there will be a small trash icon at the end
|
|
// of each line, so the user can delete a record.
|
|
this.addTrashIcon = params.addTrashIcon;
|
|
|
|
this.currentRow = null;
|
|
this.currentFieldIndex = null;
|
|
},
|
|
/**
|
|
* @override
|
|
* @returns {Deferred}
|
|
*/
|
|
start: function () {
|
|
// deliberately use the 'editable' attribute instead of '_isEditable'
|
|
// function, because the groupBy must not be taken into account to
|
|
// enable the '_onWindowClicked' handler (otherwise, an editable grouped
|
|
// list which is reloaded without groupBy wouldn't have this handler
|
|
// bound, and edited rows couldn't be left by clicking outside the list)
|
|
if (this.editable) {
|
|
this.$el.css({height: '100%'}); // seems useless: to remove in master
|
|
core.bus.on('click', this, this._onWindowClicked.bind(this));
|
|
}
|
|
return this._super();
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* If the given recordID is the list main one (or that no recordID is
|
|
* given), then the whole view can be saved if one of the two following
|
|
* conditions is true:
|
|
* - There is no line in edition (all lines are saved so they are all valid)
|
|
* - The line in edition can be saved
|
|
*
|
|
* @override
|
|
* @param {string} [recordID]
|
|
* @returns {string[]}
|
|
*/
|
|
canBeSaved: function (recordID) {
|
|
if ((recordID || this.state.id) === this.state.id) {
|
|
recordID = this.getEditableRecordID();
|
|
if (recordID === null) {
|
|
return [];
|
|
}
|
|
}
|
|
return this._super(recordID);
|
|
},
|
|
/**
|
|
* We need to override the confirmChange method from BasicRenderer to
|
|
* reevaluate the row decorations. Since they depends on the current value
|
|
* of the row, they might have changed between each edit.
|
|
*
|
|
* @override
|
|
*/
|
|
confirmChange: function (state, id) {
|
|
var self = this;
|
|
return this._super.apply(this, arguments).then(function (widgets) {
|
|
if (widgets.length) {
|
|
var rowIndex = _.findIndex(state.data, function (r) {
|
|
return r.id === id;
|
|
});
|
|
var $row = self.$('.o_data_row:nth(' + rowIndex + ')');
|
|
self._setDecorationClasses(state.data[rowIndex], $row);
|
|
self._updateFooter();
|
|
}
|
|
return widgets;
|
|
});
|
|
},
|
|
/**
|
|
* This is a specialized version of confirmChange, meant to be called when
|
|
* the change may have affected more than one line (so, for example, an
|
|
* onchange which add/remove a few lines in a x2many. This does not occur
|
|
* in a normal list view)
|
|
*
|
|
* The update is more difficult when other rows could have been changed. We
|
|
* need to potentially remove some lines, add some other lines, update some
|
|
* other lines and maybe reorder a few of them. This problem would neatly
|
|
* be solved by using a virtual dom, but we do not have this luxury yet.
|
|
* So, in the meantime, what we do is basically remove every current row
|
|
* except the 'main' one (the row which caused the update), then rerender
|
|
* every new row and add them before/after the main one.
|
|
*
|
|
* @param {Object} state
|
|
* @param {string} id
|
|
* @param {string[]} fields
|
|
* @param {FlectraEvent} ev
|
|
* @returns {Deferred<AbstractField[]>} resolved with the list of widgets
|
|
* that have been reset
|
|
*/
|
|
confirmUpdate: function (state, id, fields, ev) {
|
|
var self = this;
|
|
|
|
// store the cursor position to restore it once potential onchanges have
|
|
// been applied
|
|
var currentRowID, currentWidget, focusedElement, selectionRange;
|
|
if (self.currentRow !== null) {
|
|
currentRowID = this.state.data[this.currentRow].id;
|
|
currentWidget = this.allFieldWidgets[currentRowID][this.currentFieldIndex];
|
|
focusedElement = currentWidget.getFocusableElement().get(0);
|
|
if (currentWidget.formatType !== 'boolean') {
|
|
selectionRange = dom.getSelectionRange(focusedElement);
|
|
}
|
|
}
|
|
|
|
var oldData = this.state.data;
|
|
this.state = state;
|
|
return this.confirmChange(state, id, fields, ev).then(function () {
|
|
// If no record with 'id' can be found in the state, the
|
|
// confirmChange method will have rerendered the whole view already,
|
|
// so no further work is necessary.
|
|
var record = _.findWhere(state.data, {id: id});
|
|
if (!record) {
|
|
return;
|
|
}
|
|
var oldRowIndex = _.findIndex(oldData, {id: id});
|
|
var $row = self.$('.o_data_row:nth(' + oldRowIndex + ')');
|
|
$row.nextAll('.o_data_row').remove();
|
|
$row.prevAll().remove();
|
|
_.each(oldData, function (rec) {
|
|
if (rec.id !== id) {
|
|
self._destroyFieldWidgets(rec.id);
|
|
}
|
|
});
|
|
var newRowIndex = _.findIndex(state.data, {id: id});
|
|
var $lastRow = $row;
|
|
_.each(state.data, function (record, index) {
|
|
if (index === newRowIndex) {
|
|
return;
|
|
}
|
|
var $newRow = self._renderRow(record);
|
|
if (index < newRowIndex) {
|
|
$newRow.insertBefore($row);
|
|
} else {
|
|
$newRow.insertAfter($lastRow);
|
|
$lastRow = $newRow;
|
|
}
|
|
});
|
|
if (self.currentRow !== null) {
|
|
self.currentRow = newRowIndex;
|
|
return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true}).then(function () {
|
|
// restore the cursor position
|
|
currentRowID = self.state.data[newRowIndex].id;
|
|
currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
|
|
focusedElement = currentWidget.getFocusableElement().get(0);
|
|
if (selectionRange) {
|
|
dom.setSelectionRange(focusedElement, selectionRange);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Edit a given record in the list
|
|
*
|
|
* @param {string} recordID
|
|
*/
|
|
editRecord: function (recordID) {
|
|
var rowIndex = _.findIndex(this.state.data, {id: recordID});
|
|
this._selectCell(rowIndex, 0);
|
|
},
|
|
/**
|
|
* Returns the recordID associated to the line which is currently in edition
|
|
* or null if there is no line in edition.
|
|
*
|
|
* @returns {string|null}
|
|
*/
|
|
getEditableRecordID: function () {
|
|
if (this.currentRow !== null) {
|
|
return this.state.data[this.currentRow].id;
|
|
}
|
|
return null;
|
|
},
|
|
/**
|
|
* Removes the line associated to the given recordID (the index of the row
|
|
* is found thanks to the old state), then updates the state.
|
|
*
|
|
* @param {Object} state
|
|
* @param {string} recordID
|
|
*/
|
|
removeLine: function (state, recordID) {
|
|
var self = this;
|
|
var rowIndex = _.findIndex(this.state.data, {id: recordID});
|
|
this.state = state;
|
|
if (rowIndex === -1) {
|
|
return;
|
|
}
|
|
if (rowIndex === this.currentRow) {
|
|
this.currentRow = null;
|
|
}
|
|
|
|
// remove the row
|
|
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
|
|
if (this.state.count >= 4) {
|
|
$row.remove();
|
|
} else {
|
|
$row.replaceWith(this._renderEmptyRow());
|
|
}
|
|
|
|
this._destroyFieldWidgets(recordID);
|
|
},
|
|
/**
|
|
* Updates the already rendered row associated to the given recordID so that
|
|
* it fits the given mode.
|
|
*
|
|
* @param {string} recordID
|
|
* @param {string} mode
|
|
* @returns {Deferred}
|
|
*/
|
|
setRowMode: function (recordID, mode) {
|
|
var self = this;
|
|
|
|
// find the record and its row index (handles ungrouped and grouped cases
|
|
// as even if the grouped list doesn't support edition, it may contain
|
|
// a widget allowing the edition in readonly (e.g. priority), so it
|
|
// should be able to update a record as well)
|
|
var record;
|
|
var rowIndex;
|
|
if (this.state.groupedBy.length) {
|
|
rowIndex = -1;
|
|
var count = 0;
|
|
utils.traverse_records(this.state, function (r) {
|
|
if (r.id === recordID) {
|
|
record = r;
|
|
rowIndex = count;
|
|
}
|
|
count++;
|
|
});
|
|
} else {
|
|
rowIndex = _.findIndex(this.state.data, {id: recordID});
|
|
record = this.state.data[rowIndex];
|
|
}
|
|
|
|
if (rowIndex < 0) {
|
|
return $.when();
|
|
}
|
|
var editMode = (mode === 'edit');
|
|
|
|
this.currentRow = editMode ? rowIndex : null;
|
|
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
|
|
var $tds = $row.children('.o_data_cell');
|
|
var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
|
|
|
|
// When switching to edit mode, force the dimensions of all cells to
|
|
// their current value so that they won't change if their content
|
|
// changes, to prevent the view from flickering.
|
|
if (editMode) {
|
|
$tds.each(function () {
|
|
var $td = $(this);
|
|
$td.css({width: $td.outerWidth()});
|
|
});
|
|
}
|
|
|
|
// Prepare options for cell rendering (this depends on the mode)
|
|
var options = {
|
|
renderInvisible: editMode,
|
|
renderWidgets: editMode,
|
|
};
|
|
options.mode = editMode ? 'edit' : 'readonly';
|
|
|
|
// Switch each cell to the new mode; note: the '_renderBodyCell'
|
|
// function might fill the 'this.defs' variables with multiple deferred
|
|
// so we create the array and delete it after the rendering.
|
|
var defs = [];
|
|
this.defs = defs;
|
|
_.each(this.columns, function (node, colIndex) {
|
|
var $td = $tds.eq(colIndex);
|
|
var $newTd = self._renderBodyCell(record, node, colIndex, options);
|
|
|
|
// Widgets are unregistered of modifiers data when they are
|
|
// destroyed. This is not the case for simple buttons so we have to
|
|
// do it here.
|
|
if ($td.hasClass('o_list_button')) {
|
|
self._unregisterModifiersElement(node, recordID, $td.children());
|
|
}
|
|
|
|
// For edit mode we only replace the content of the cell with its
|
|
// new content (invisible fields, editable fields, ...).
|
|
// For readonly mode, we replace the whole cell so that the
|
|
// dimensions of the cell are not forced anymore.
|
|
if (editMode) {
|
|
$td.empty().append($newTd.contents());
|
|
} else {
|
|
self._unregisterModifiersElement(node, recordID, $td);
|
|
$td.replaceWith($newTd);
|
|
}
|
|
});
|
|
delete this.defs;
|
|
|
|
// Destroy old field widgets
|
|
_.each(oldWidgets, this._destroyFieldWidget.bind(this, recordID));
|
|
|
|
// Toggle selected class here so that style is applied at the end
|
|
$row.toggleClass('o_selected_row', editMode);
|
|
|
|
return $.when.apply($, defs);
|
|
},
|
|
/**
|
|
* This method is called whenever we click/move outside of a row that was
|
|
* in edit mode. This is the moment we save all accumulated changes on that
|
|
* row, if needed (@see BasicController.saveRecord).
|
|
*
|
|
* Note that we have to disable the focusable elements (inputs, ...) to
|
|
* prevent subsequent editions. These edits would be lost, because the list
|
|
* view only saves records when unselecting a row.
|
|
*
|
|
* @returns {Deferred} The deferred resolves if the row was unselected (and
|
|
* possibly removed). If may be rejected, when the row is dirty and the
|
|
* user refuses to discard its changes.
|
|
*/
|
|
unselectRow: function () {
|
|
// Protect against calling this method when no row is selected
|
|
if (this.currentRow === null) {
|
|
return $.when();
|
|
}
|
|
|
|
var record = this.state.data[this.currentRow];
|
|
var recordWidgets = this.allFieldWidgets[record.id];
|
|
toggleWidgets(true);
|
|
|
|
var def = $.Deferred();
|
|
this.trigger_up('save_line', {
|
|
recordID: record.id,
|
|
onSuccess: def.resolve.bind(def),
|
|
onFailure: def.reject.bind(def),
|
|
});
|
|
return def.fail(toggleWidgets.bind(null, false));
|
|
|
|
function toggleWidgets(disabled) {
|
|
_.each(recordWidgets, function (widget) {
|
|
var $el = widget.getFocusableElement();
|
|
$el.prop('disabled', disabled);
|
|
});
|
|
}
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Destroy all field widgets corresponding to a record. Useful when we are
|
|
* removing a useless row.
|
|
*
|
|
* @param {string} recordID
|
|
*/
|
|
_destroyFieldWidgets: function (recordID) {
|
|
if (recordID in this.allFieldWidgets) {
|
|
var widgetsToDestroy = this.allFieldWidgets[recordID].slice();
|
|
_.each(widgetsToDestroy, this._destroyFieldWidget.bind(this, recordID));
|
|
delete this.allFieldWidgets[recordID];
|
|
}
|
|
},
|
|
/**
|
|
* Returns the current number of columns. The editable renderer may add a
|
|
* trash icon on the right of a record, so we need to take this into account
|
|
*
|
|
* @override
|
|
* @returns {number}
|
|
*/
|
|
_getNumberOfCols: function () {
|
|
var n = this._super();
|
|
if (this.addTrashIcon) {
|
|
n++;
|
|
}
|
|
return n;
|
|
},
|
|
/**
|
|
* Returns true iff the list is editable, i.e. if it isn't grouped and if
|
|
* the editable attribute is set on the root node of its arch.
|
|
*
|
|
* @private
|
|
* @returns {boolean}
|
|
*/
|
|
_isEditable: function () {
|
|
return !this.state.groupedBy.length && this.editable;
|
|
},
|
|
/**
|
|
* Move the cursor on the end of the previous line, if possible.
|
|
* If there is no previous line, then we create a new record.
|
|
*
|
|
* @private
|
|
*/
|
|
_moveToPreviousLine: function () {
|
|
if (this.currentRow > 0) {
|
|
this._selectCell(this.currentRow - 1, this.columns.length - 1);
|
|
} else {
|
|
this.unselectRow().then(this.trigger_up.bind(this, 'add_record'));
|
|
}
|
|
},
|
|
/**
|
|
* Move the cursor on the beginning of the next line, if possible.
|
|
* If there is no next line, then we create a new record.
|
|
*
|
|
* @private
|
|
*/
|
|
_moveToNextLine: function () {
|
|
var record = this.state.data[this.currentRow];
|
|
var fieldNames = this.canBeSaved(record.id);
|
|
if (fieldNames.length) {
|
|
return;
|
|
}
|
|
|
|
if (this.currentRow < this.state.data.length - 1) {
|
|
this._selectCell(this.currentRow + 1, 0);
|
|
} else {
|
|
var self = this;
|
|
this.unselectRow().then(function () {
|
|
self.trigger_up('add_record', {
|
|
onFail: self._selectCell.bind(self, 0, 0, {}),
|
|
});
|
|
});
|
|
}
|
|
},
|
|
/**
|
|
* @override
|
|
* @returns {Deferred}
|
|
*/
|
|
_render: function () {
|
|
this.currentRow = null;
|
|
this.currentFieldIndex = null;
|
|
return this._super.apply(this, arguments);
|
|
},
|
|
/**
|
|
* The renderer needs to support reordering lines. This is only active in
|
|
* edit mode. The hasHandle attribute is used when there is a sequence
|
|
* widget.
|
|
*
|
|
* @override
|
|
* @returns {jQueryElement}
|
|
*/
|
|
_renderBody: function () {
|
|
var $body = this._super();
|
|
if (this.hasHandle) {
|
|
$body.sortable({
|
|
axis: 'y',
|
|
items: '> tr.o_data_row',
|
|
helper: 'clone',
|
|
handle: '.o_row_handle',
|
|
stop: this._resequence.bind(this),
|
|
});
|
|
}
|
|
return $body;
|
|
},
|
|
/**
|
|
* Editable rows are possibly extended with a trash icon on their right, to
|
|
* allow deleting the corresponding record.
|
|
*
|
|
* @override
|
|
* @param {any} record
|
|
* @param {any} index
|
|
* @returns {jQueryElement}
|
|
*/
|
|
_renderRow: function (record, index) {
|
|
var $row = this._super.apply(this, arguments);
|
|
if (this.addTrashIcon) {
|
|
var $icon = $('<button>', {class: 'fa fa-trash-o o_list_record_delete_btn', name: 'delete',
|
|
'aria-label': _t('Delete row ') + (index+1)});
|
|
var $td = $('<td>', {class: 'o_list_record_delete'}).append($icon);
|
|
$row.append($td);
|
|
}
|
|
return $row;
|
|
},
|
|
/**
|
|
* If the editable list view has the parameter addCreateLine, we need to
|
|
* add a last row with the necessary control.
|
|
*
|
|
* @override
|
|
* @returns {jQueryElement}
|
|
*/
|
|
_renderRows: function () {
|
|
var $rows = this._super();
|
|
if (this.addCreateLine) {
|
|
var $a = $('<a href="#">').text(_t("Add an item"));
|
|
var $td = $('<td>')
|
|
.attr('colspan', this._getNumberOfCols())
|
|
.addClass('o_field_x2many_list_row_add')
|
|
.append($a);
|
|
var $tr = $('<tr>').append($td);
|
|
$rows.push($tr);
|
|
}
|
|
return $rows;
|
|
},
|
|
/**
|
|
* @override
|
|
* @private
|
|
* @returns {Deferred} this deferred is resolved immediately
|
|
*/
|
|
_renderView: function () {
|
|
var self = this;
|
|
this.currentRow = null;
|
|
return this._super.apply(this, arguments).then(function () {
|
|
if (self._isEditable()) {
|
|
self.$('table').addClass('o_editable_list');
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Force the resequencing of the items in the list.
|
|
*
|
|
* @private
|
|
* @param {jQuery.Event} event
|
|
* @param {Object} ui jqueryui sortable widget
|
|
*/
|
|
_resequence: function (event, ui) {
|
|
var self = this;
|
|
var movedRecordID = ui.item.data('id');
|
|
var rows = this.state.data;
|
|
var row = _.findWhere(rows, {id: movedRecordID});
|
|
var index0 = rows.indexOf(row);
|
|
var index1 = ui.item.index();
|
|
var lower = Math.min(index0, index1);
|
|
var upper = Math.max(index0, index1) + 1;
|
|
|
|
var order = _.findWhere(self.state.orderedBy, {name: self.handleField});
|
|
var asc = !order || order.asc;
|
|
var reorderAll = false;
|
|
var sequence = (asc ? -1 : 1) * Infinity;
|
|
|
|
// determine if we need to reorder all lines
|
|
_.each(rows, function (row, index) {
|
|
if ((index < lower || index >= upper) &&
|
|
((asc && sequence >= row.data[self.handleField]) ||
|
|
(!asc && sequence <= row.data[self.handleField]))) {
|
|
reorderAll = true;
|
|
}
|
|
sequence = row.data[self.handleField];
|
|
});
|
|
|
|
if (reorderAll) {
|
|
rows = _.without(rows, row);
|
|
rows.splice(index1, 0, row);
|
|
} else {
|
|
rows = rows.slice(lower, upper);
|
|
rows = _.without(rows, row);
|
|
if (index0 > index1) {
|
|
rows.unshift(row);
|
|
} else {
|
|
rows.push(row);
|
|
}
|
|
}
|
|
|
|
var sequences = _.pluck(_.pluck(rows, 'data'), self.handleField);
|
|
var rowIDs = _.pluck(rows, 'id');
|
|
|
|
if (!asc) {
|
|
rowIDs.reverse();
|
|
}
|
|
this.unselectRow().then(function () {
|
|
self.trigger_up('resequence', {
|
|
rowIDs: rowIDs,
|
|
offset: _.min(sequences),
|
|
handleField: self.handleField,
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* This is one of the trickiest method in the editable renderer. It has to
|
|
* do a lot of stuff: it has to determine which cell should be selected (if
|
|
* the target cell is readonly, we need to find another suitable cell), then
|
|
* unselect the current row, and activate the line where the selected cell
|
|
* is, if necessary.
|
|
*
|
|
* @param {integer} rowIndex
|
|
* @param {integer} fieldIndex
|
|
* @param {Object} [options]
|
|
* @param {Event} [options.event] original target of the event which
|
|
* @param {boolean} [options.wrap=true] if true and no widget could be
|
|
* triggered the cell selection
|
|
* selected from the fieldIndex to the last column, then we wrap around and
|
|
* try to select a widget starting from the beginning
|
|
* @param {boolean} [options.force=false] if true, force selecting the cell
|
|
* even if seems to be already the selected one (useful after a re-
|
|
* rendering, to reset the focus on the correct field)
|
|
* @return {Deferred} fails if no cell could be selected
|
|
*/
|
|
_selectCell: function (rowIndex, fieldIndex, options) {
|
|
options = options || {};
|
|
// Do nothing if the user tries to select current cell
|
|
if (!options.force && rowIndex === this.currentRow && fieldIndex === this.currentFieldIndex) {
|
|
return $.when();
|
|
}
|
|
var wrap = options.wrap === undefined ? true : options.wrap;
|
|
|
|
// Select the row then activate the widget in the correct cell
|
|
var self = this;
|
|
return this._selectRow(rowIndex).then(function () {
|
|
var record = self.state.data[rowIndex];
|
|
if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) {
|
|
return $.Deferred().reject();
|
|
}
|
|
// _activateFieldWidget might trigger an onchange,
|
|
// which requires currentFieldIndex to be set
|
|
// so that the cursor can be restored
|
|
var oldFieldIndex = self.currentFieldIndex;
|
|
self.currentFieldIndex = fieldIndex;
|
|
fieldIndex = self._activateFieldWidget(record, fieldIndex, {
|
|
inc: 1,
|
|
wrap: wrap,
|
|
event: options && options.event,
|
|
});
|
|
if (fieldIndex < 0) {
|
|
self.currentFieldIndex = oldFieldIndex;
|
|
return $.Deferred().reject();
|
|
}
|
|
self.currentFieldIndex = fieldIndex;
|
|
});
|
|
},
|
|
/**
|
|
* Activates the row at the given row index.
|
|
*
|
|
* @param {integer} rowIndex
|
|
* @returns {Deferred}
|
|
*/
|
|
_selectRow: function (rowIndex) {
|
|
// Do nothing if already selected
|
|
if (rowIndex === this.currentRow) {
|
|
return $.when();
|
|
}
|
|
|
|
// To select a row, the currently selected one must be unselected first
|
|
var self = this;
|
|
return this.unselectRow().then(function () {
|
|
if (self.state.data.length <= rowIndex) {
|
|
// The row to selected doesn't exist anymore (probably because
|
|
// an onchange triggered when unselecting the previous one
|
|
// removes rows)
|
|
return $.Deferred().reject();
|
|
}
|
|
// Notify the controller we want to make a record editable
|
|
var def = $.Deferred();
|
|
self.trigger_up('edit_line', {
|
|
index: rowIndex,
|
|
onSuccess: def.resolve.bind(def),
|
|
});
|
|
return def;
|
|
});
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* This method is called when we click on the 'Add an Item' button in a sub
|
|
* list such as a one2many in a form view.
|
|
*
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onAddRecord: function (event) {
|
|
// we don't want the browser to navigate to a the # url
|
|
event.preventDefault();
|
|
|
|
// we don't want the click to cause other effects, such as unselecting
|
|
// the row that we are creating, because it counts as a click on a tr
|
|
event.stopPropagation();
|
|
|
|
// but we do want to unselect current row
|
|
var self = this;
|
|
this.unselectRow().then(function () {
|
|
self.trigger_up('add_record'); // TODO write a test, the deferred was not considered
|
|
});
|
|
},
|
|
/**
|
|
* When the user clicks on a cell, we simply select it.
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onCellClick: function (event) {
|
|
// The special_click property explicitely allow events to bubble all
|
|
// the way up to bootstrap's level rather than being stopped earlier.
|
|
if (!this._isEditable() || $(event.target).prop('special_click')) {
|
|
return;
|
|
}
|
|
var $td = $(event.currentTarget);
|
|
var $tr = $td.parent();
|
|
var rowIndex = this.$('.o_data_row').index($tr);
|
|
var fieldIndex = Math.max($tr.find('.o_data_cell').not('.o_list_button').index($td), 0);
|
|
this._selectCell(rowIndex, fieldIndex, {event: event});
|
|
},
|
|
/**
|
|
* We need to manually unselect row, because noone else would do it
|
|
*/
|
|
_onEmptyRowClick: function () {
|
|
this.unselectRow();
|
|
},
|
|
/**
|
|
* Clicking on a footer should unselect (and save) the currently selected
|
|
* row. It has to be done this way, because this is a click inside this.el,
|
|
* and _onWindowClicked ignore those clicks.
|
|
*/
|
|
_onFooterClick: function () {
|
|
this.unselectRow();
|
|
},
|
|
/**
|
|
* Handles the keyboard navigation according to events triggered by field
|
|
* widgets.
|
|
* - up/down: move to the cell above/below if any, or the first activable
|
|
* one on the row above/below if any on the right of this cell
|
|
* above/below (if none on the right, wrap to the beginning of the
|
|
* line).
|
|
* - left/right: move to the first activable cell on the left/right if any
|
|
* (wrap to the end/beginning of the line if necessary).
|
|
* - previous: move to the first activable cell on the left if any, if not
|
|
* move to the rightmost activable cell on the row above.
|
|
* - next: move to the first activable cell on the right if any, if not move
|
|
* to the leftmost activable cell on the row below.
|
|
* - next_line: move to leftmost activable cell on the row below.
|
|
*
|
|
* Note: moving to a line below if on the last line or moving to a line
|
|
* above if on the first line automatically creates a new line.
|
|
*
|
|
* @private
|
|
* @param {FlectraEvent} ev
|
|
*/
|
|
_onNavigationMove: function (ev) {
|
|
ev.stopPropagation(); // stop the event, the action is done by this renderer
|
|
switch (ev.data.direction) {
|
|
case 'up':
|
|
if (this.currentRow > 0) {
|
|
this._selectCell(this.currentRow - 1, this.currentFieldIndex);
|
|
}
|
|
break;
|
|
case 'right':
|
|
if (this.currentFieldIndex + 1 < this.columns.length) {
|
|
this._selectCell(this.currentRow, this.currentFieldIndex + 1);
|
|
}
|
|
break;
|
|
case 'down':
|
|
if (this.currentRow < this.state.data.length - 1) {
|
|
this._selectCell(this.currentRow + 1, this.currentFieldIndex);
|
|
}
|
|
break;
|
|
case 'left':
|
|
if (this.currentFieldIndex > 0) {
|
|
this._selectCell(this.currentRow, this.currentFieldIndex - 1);
|
|
}
|
|
break;
|
|
case 'previous':
|
|
if (this.currentFieldIndex > 0) {
|
|
this._selectCell(this.currentRow, this.currentFieldIndex - 1, {wrap: false})
|
|
.fail(this._moveToPreviousLine.bind(this));
|
|
} else {
|
|
this._moveToPreviousLine();
|
|
}
|
|
break;
|
|
case 'next':
|
|
if (this.currentFieldIndex + 1 < this.columns.length) {
|
|
this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false})
|
|
.fail(this._moveToNextLine.bind(this));
|
|
} else {
|
|
this._moveToNextLine();
|
|
}
|
|
break;
|
|
case 'next_line':
|
|
this._moveToNextLine();
|
|
break;
|
|
case 'cancel':
|
|
// stop the original event (typically an ESCAPE keydown), to
|
|
// prevent from closing the potential dialog containing this list
|
|
ev.data.originalEvent.stopPropagation();
|
|
this.trigger_up('discard_changes', {
|
|
recordID: ev.target.dataPointID,
|
|
});
|
|
break;
|
|
}
|
|
},
|
|
/**
|
|
* If the list view editable, just let the event bubble. We don't want to
|
|
* open the record in this case anyway.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_onRowClicked: function () {
|
|
if (!this._isEditable()) {
|
|
this._super.apply(this, arguments);
|
|
}
|
|
},
|
|
/**
|
|
* Overrides to prevent from sorting if we are currently editing a record.
|
|
*
|
|
* @override
|
|
* @private
|
|
*/
|
|
_onSortColumn: function () {
|
|
if (this.currentRow === null) {
|
|
this._super.apply(this, arguments);
|
|
}
|
|
},
|
|
/**
|
|
* Triggers a delete event. I don't know why we stop the propagation of the
|
|
* event.
|
|
*
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onTrashIconClick: function (event) {
|
|
event.stopPropagation();
|
|
var id = $(event.target).closest('tr').data('id');
|
|
this.trigger_up('list_record_delete', {id: id});
|
|
},
|
|
/**
|
|
* When a click happens outside the list view, or outside a currently
|
|
* selected row, we want to unselect it.
|
|
*
|
|
* This is quite tricky, because in many cases, such as an autocomplete
|
|
* dropdown opened by a many2one in a list editable row, we actually don't
|
|
* want to unselect (and save) the current row.
|
|
*
|
|
* So, we try to ignore clicks on subelements of the renderer that are
|
|
* appended in the body, outside the table)
|
|
*
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onWindowClicked: function (event) {
|
|
// ignore clicks if this renderer is not in the dom.
|
|
if (!document.contains(this.el)) {
|
|
return;
|
|
}
|
|
|
|
// there is currently no selected row
|
|
if (this.currentRow === null) {
|
|
return;
|
|
}
|
|
|
|
// ignore clicks in autocomplete dropdowns
|
|
if ($(event.target).parents('.ui-autocomplete').length) {
|
|
return;
|
|
}
|
|
|
|
// ignore clicks in modals, except if the list is in a modal, and the
|
|
// click is performed in that modal
|
|
var $clickModal = $(event.target).closest('.modal');
|
|
if ($clickModal.length) {
|
|
var $listModal = this.$el.closest('.modal');
|
|
if ($clickModal.prop('id') !== $listModal.prop('id')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ignore clicks if target is no longer in dom. For example, a click on
|
|
// the 'delete' trash icon of a m2m tag.
|
|
if (!document.contains(event.target)) {
|
|
return;
|
|
}
|
|
|
|
// ignore clicks if target is inside the list. In that case, they are
|
|
// handled directly by the renderer.
|
|
if (this.el.contains(event.target) && this.el !== event.target) {
|
|
return;
|
|
}
|
|
|
|
this.unselectRow();
|
|
},
|
|
});
|
|
|
|
});
|