summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/list
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/list
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/list')
-rw-r--r--addons/web/static/src/js/views/list/list_confirm_dialog.js104
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js992
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js1851
-rw-r--r--addons/web/static/src/js/views/list/list_model.js175
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js1470
-rw-r--r--addons/web/static/src/js/views/list/list_view.js137
6 files changed, 4729 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/list/list_confirm_dialog.js b/addons/web/static/src/js/views/list/list_confirm_dialog.js
new file mode 100644
index 00000000..7fba13c3
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_confirm_dialog.js
@@ -0,0 +1,104 @@
+odoo.define('web.ListConfirmDialog', function (require) {
+"use strict";
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+const FieldWrapper = require('web.FieldWrapper');
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+const utils = require('web.utils');
+
+const _t = core._t;
+const qweb = core.qweb;
+
+/**
+ * Multi edition confirmation modal for list views.
+ *
+ * Handles the display of the amount of changed records (+ valid ones) and
+ * of the widget representing the new value.
+ *
+ * @class
+ */
+const ListConfirmDialog = Dialog.extend(WidgetAdapterMixin, {
+ /**
+ * @constructor
+ * @override
+ * @param {Widget} parent
+ * @param {Object} record edited record with updated value
+ * @param {Object} changes changes registered by the list controller
+ * @param {Object} changes isDomainSelected true iff the user selected the
+ * whole domain
+ * @param {string} changes.fieldLabel label of the changed field
+ * @param {string} changes.fieldName technical name of the changed field
+ * @param {number} changes.nbRecords number of records (total)
+ * @param {number} changes.nbValidRecords number of valid records
+ * @param {Object} [options]
+ */
+ init: function (parent, record, changes, options) {
+ options = Object.assign({}, options, {
+ $content: $(qweb.render('ListView.confirmModal', { changes })),
+ buttons: options.buttons || [{
+ text: _t("Ok"),
+ classes: 'btn-primary',
+ close: true,
+ click: options.confirm_callback,
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ click: options.cancel_callback,
+ }],
+ onForceClose: options.cancel_callback,
+ size: options.size || 'medium',
+ title: options.title || _t("Confirmation"),
+ });
+
+ this._super(parent, options);
+
+ const Widget = record.fieldsInfo.list[changes.fieldName].Widget;
+ const widgetOptions = {
+ mode: 'readonly',
+ viewType: 'list',
+ noOpen: true,
+ };
+ this.isLegacyWidget = !utils.isComponent(Widget);
+ if (this.isLegacyWidget) {
+ this.fieldWidget = new Widget(this, changes.fieldName, record, widgetOptions);
+ } else {
+ this.fieldWidget = new FieldWrapper(this, Widget, {
+ fieldName: changes.fieldName,
+ record,
+ options: widgetOptions,
+ });
+ }
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ let widgetProm;
+ if (this.isLegacyWidget) {
+ widgetProm = this.fieldWidget._widgetRenderAndInsert(function () {});
+ } else {
+ widgetProm = this.fieldWidget.mount(document.createDocumentFragment());
+ }
+ return Promise.all([widgetProm, this._super.apply(this, arguments)]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$content.find('.o_changes_widget').replaceWith(this.fieldWidget.$el);
+ this.fieldWidget.el.style.pointerEvents = 'none';
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ WidgetAdapterMixin.destroy.call(this);
+ this._super();
+ },
+});
+
+return ListConfirmDialog;
+
+});
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
new file mode 100644
index 00000000..a19afb6a
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -0,0 +1,992 @@
+odoo.define('web.ListController', function (require) {
+"use strict";
+
+/**
+ * The List Controller controls the list renderer and the list model. Its role
+ * is to allow these two components to communicate properly, and also, to render
+ * and bind all extra buttons/pager in the control panel.
+ */
+
+var core = require('web.core');
+var BasicController = require('web.BasicController');
+var DataExport = require('web.DataExport');
+var Dialog = require('web.Dialog');
+var ListConfirmDialog = require('web.ListConfirmDialog');
+var session = require('web.session');
+const viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var ListController = BasicController.extend({
+ /**
+ * This key contains the name of the buttons template to render on top of
+ * the list view. It can be overridden to add buttons in specific child views.
+ */
+ buttons_template: 'ListView.buttons',
+ events: _.extend({}, BasicController.prototype.events, {
+ 'click .o_list_export_xlsx': '_onDirectExportData',
+ 'click .o_list_select_domain': '_onSelectDomain',
+ }),
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ activate_next_widget: '_onActivateNextWidget',
+ add_record: '_onAddRecord',
+ button_clicked: '_onButtonClicked',
+ group_edit_button_clicked: '_onEditGroupClicked',
+ edit_line: '_onEditLine',
+ save_line: '_onSaveLine',
+ selection_changed: '_onSelectionChanged',
+ toggle_column_order: '_onToggleColumnOrder',
+ toggle_group: '_onToggleGroup',
+ }),
+ /**
+ * @constructor
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.editable
+ * @param {boolean} params.hasActionMenus
+ * @param {Object[]} [params.headerButtons=[]]: a list of node descriptors
+ * for controlPanel's action buttons
+ * @param {Object} params.toolbarActions
+ * @param {boolean} params.noLeaf
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.hasActionMenus = params.hasActionMenus;
+ this.headerButtons = params.headerButtons || [];
+ this.toolbarActions = params.toolbarActions || {};
+ this.editable = params.editable;
+ this.noLeaf = params.noLeaf;
+ this.selectedRecords = params.selectedRecords || [];
+ this.multipleRecordsSavingPromise = null;
+ this.fieldChangedPrevented = false;
+ this.lastFieldChangedEvent = null;
+ this.isPageSelected = false; // true iff all records of the page are selected
+ this.isDomainSelected = false; // true iff the user selected all records matching the domain
+ this.isExportEnable = false;
+ },
+
+ willStart() {
+ const sup = this._super(...arguments);
+ const acl = session.user_has_group('base.group_allow_export').then(hasGroup => {
+ this.isExportEnable = hasGroup;
+ });
+ return Promise.all([sup, acl]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /*
+ * @override
+ */
+ getOwnedQueryParams: function () {
+ var state = this._super.apply(this, arguments);
+ var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
+ return _.extend({}, state, {orderedBy: orderedBy});
+ },
+ /**
+ * Returns the list of currently selected res_ids (with the check boxes on
+ * the left)
+ *
+ * @override
+ *
+ * @returns {number[]} list of res_ids
+ */
+ getSelectedIds: function () {
+ return _.map(this.getSelectedRecords(), function (record) {
+ return record.res_id;
+ });
+ },
+ /**
+ * Returns the list of currently selected records (with the check boxes on
+ * the left)
+ *
+ * @returns {Object[]} list of records
+ */
+ getSelectedRecords: function () {
+ var self = this;
+ return _.map(this.selectedRecords, function (db_id) {
+ return self.model.get(db_id, {raw: true});
+ });
+ },
+ /**
+ * Display and bind all buttons in the control panel
+ *
+ * Note: clicking on the "Save" button does nothing special. Indeed, all
+ * editable rows are saved once left and clicking on the "Save" button does
+ * induce the leaving of the current row.
+ *
+ * @override
+ */
+ renderButtons: function ($node) {
+ if (this.noLeaf || !this.hasButtons) {
+ this.hasButtons = false;
+ this.$buttons = $('<div>');
+ } else {
+ this.$buttons = $(qweb.render(this.buttons_template, {widget: this}));
+ this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this));
+ this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add'));
+ this.$buttons.find('.o_list_button_add').tooltip({
+ delay: {show: 200, hide: 0},
+ title: function () {
+ return qweb.render('CreateButton.tooltip');
+ },
+ trigger: 'manual',
+ });
+ this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this));
+ this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this));
+ }
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * Renders (and updates) the buttons that are described inside the `header`
+ * node of the list view arch. Those buttons are visible when selecting some
+ * records. They will be appended to the controlPanel's buttons.
+ *
+ * @private
+ */
+ _renderHeaderButtons() {
+ if (this.$headerButtons) {
+ this.$headerButtons.remove();
+ this.$headerButtons = null;
+ }
+ if (!this.headerButtons.length || !this.selectedRecords.length) {
+ return;
+ }
+ const btnClasses = 'btn-primary btn-secondary btn-link btn-success btn-info btn-warning btn-danger'.split(' ');
+ let $elms = $();
+ this.headerButtons.forEach(node => {
+ const $btn = viewUtils.renderButtonFromNode(node);
+ $btn.addClass('btn');
+ if (!btnClasses.some(cls => $btn.hasClass(cls))) {
+ $btn.addClass('btn-secondary');
+ }
+ $btn.on("click", this._onHeaderButtonClicked.bind(this, node));
+ $elms = $elms.add($btn);
+ });
+ this.$headerButtons = $elms;
+ this.$headerButtons.appendTo(this.$buttons);
+ },
+ /**
+ * Overrides to update the list of selected records
+ *
+ * @override
+ */
+ update: function (params, options) {
+ var self = this;
+ let res_ids;
+ if (options && options.keepSelection) {
+ // filter out removed records from selection
+ res_ids = this.model.get(this.handle).res_ids;
+ this.selectedRecords = _.filter(this.selectedRecords, function (id) {
+ return _.contains(res_ids, self.model.get(id).res_id);
+ });
+ } else {
+ this.selectedRecords = [];
+ }
+ if (this.selectedRecords.length === 0 || this.selectedRecords.length < res_ids.length) {
+ this.isDomainSelected = false;
+ this.isPageSelected = false;
+ }
+
+ params.selectedRecords = this.selectedRecords;
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * This helper simply makes sure that the control panel buttons matches the
+ * current mode.
+ *
+ * @override
+ * @param {string} mode either 'readonly' or 'edit'
+ */
+ updateButtons: function (mode) {
+ if (this.hasButtons) {
+ this.$buttons.toggleClass('o-editing', mode === 'edit');
+ const state = this.model.get(this.handle, {raw: true});
+ if (state.count) {
+ this.$buttons.find('.o_list_export_xlsx').show();
+ } else {
+ this.$buttons.find('.o_list_export_xlsx').hide();
+ }
+ }
+ this._updateSelectionBox();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see BasicController._abandonRecord
+ * If the given abandoned record is not the main one, notifies the renderer
+ * to remove the appropriate subrecord (line).
+ *
+ * @override
+ * @private
+ * @param {string} [recordID] - default to the main recordID
+ */
+ _abandonRecord: function (recordID) {
+ this._super.apply(this, arguments);
+ if ((recordID || this.handle) !== this.handle) {
+ var state = this.model.get(this.handle);
+ this.renderer.removeLine(state, recordID);
+ this._updatePaging(state);
+ }
+ },
+ /**
+ * Adds a new record to the a dataPoint of type 'list'.
+ * Disables the buttons to prevent concurrent record creation or edition.
+ *
+ * @todo make record creation a basic controller feature
+ * @private
+ * @param {string} dataPointId a dataPoint of type 'list' (may be grouped)
+ * @return {Promise}
+ */
+ _addRecord: function (dataPointId) {
+ var self = this;
+ this._disableButtons();
+ return this._removeSampleData(() => {
+ return this.renderer.unselectRow().then(function () {
+ return self.model.addDefaultRecord(dataPointId, {
+ position: self.editable,
+ });
+ }).then(function (recordID) {
+ var state = self.model.get(self.handle);
+ self._updateRendererState(state, { keepWidths: true })
+ .then(function () {
+ self.renderer.editRecord(recordID);
+ })
+ .then(() => {
+ self._updatePaging(state);
+ });
+ }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
+ });
+ },
+ /**
+ * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard
+ *
+ * @param {jQueryElement} $createButton The create button itself
+ */
+ _assignCreateKeyboardBehavior: function($createButton) {
+ var self = this;
+ $createButton.on('keydown', function(e) {
+ $createButton.tooltip('hide');
+ switch(e.which) {
+ case $.ui.keyCode.ENTER:
+ e.preventDefault();
+ self._onCreateRecord.apply(self);
+ break;
+ case $.ui.keyCode.DOWN:
+ e.preventDefault();
+ self._giveFocus();
+ break;
+ case $.ui.keyCode.TAB:
+ if (
+ !e.shiftKey &&
+ e.target.classList.contains("btn-primary") &&
+ !self.model.isInSampleMode()
+ ) {
+ e.preventDefault();
+ $createButton.tooltip('show');
+ }
+ break;
+ }
+ });
+ },
+ /**
+ * This function is the hook called by the field manager mixin to confirm
+ * that a record has been saved.
+ *
+ * @override
+ * @param {string} id a basicmodel valid resource handle. It is supposed to
+ * be a record from the list view.
+ * @returns {Promise}
+ */
+ _confirmSave: function (id) {
+ var state = this.model.get(this.handle);
+ return this._updateRendererState(state, { noRender: true })
+ .then(this._setMode.bind(this, 'readonly', id));
+ },
+ /**
+ * Deletes records matching the current domain. We limit the number of
+ * deleted records to the 'active_ids_limit' config parameter.
+ *
+ * @private
+ */
+ _deleteRecordsInCurrentDomain: function () {
+ const doIt = async () => {
+ const state = this.model.get(this.handle, {raw: true});
+ const resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit);
+ await this._rpc({
+ model: this.modelName,
+ method: 'unlink',
+ args: [resIds],
+ context: state.getContext(),
+ });
+ if (resIds.length === session.active_ids_limit) {
+ const msg = _.str.sprintf(
+ _t("Only the first %d records have been deleted (out of %d selected)"),
+ resIds.length, state.count
+ );
+ this.do_notify(false, msg);
+ }
+ this.reload();
+ };
+ if (this.confirmOnDelete) {
+ Dialog.confirm(this, _t("Are you sure you want to delete these records ?"), {
+ confirm_callback: doIt,
+ });
+ } else {
+ doIt();
+ }
+ },
+ /**
+ * To improve performance, list view must not be rerendered if it is asked
+ * to discard all its changes. Indeed, only the in-edition row needs to be
+ * discarded in that case.
+ *
+ * @override
+ * @private
+ * @param {string} [recordID] - default to main recordID
+ * @returns {Promise}
+ */
+ _discardChanges: function (recordID) {
+ if ((recordID || this.handle) === this.handle) {
+ recordID = this.renderer.getEditableRecordID();
+ if (recordID === null) {
+ return Promise.resolve();
+ }
+ }
+ var self = this;
+ return this._super(recordID).then(function () {
+ self.updateButtons('readonly');
+ });
+ },
+ /**
+ * Returns the ids of records matching the given domain.
+ *
+ * @private
+ * @param {Array[]} domain
+ * @param {integer} [limit]
+ * @returns {integer[]}
+ */
+ _domainToResIds: function (domain, limit) {
+ return this._rpc({
+ model: this.modelName,
+ method: 'search',
+ args: [domain],
+ kwargs: {
+ limit: limit,
+ },
+ });
+ },
+ /**
+ * @returns {DataExport} the export dialog widget
+ * @private
+ */
+ _getExportDialogWidget() {
+ let state = this.model.get(this.handle);
+ let defaultExportFields = this.renderer.columns.filter(field => field.tag === 'field' && state.fields[field.attrs.name].exportable !== false).map(field => field.attrs.name);
+ let groupedBy = this.renderer.state.groupedBy;
+ const domain = this.isDomainSelected && state.getDomain();
+ return new DataExport(this, state, defaultExportFields, groupedBy,
+ domain, this.getSelectedIds());
+ },
+ /**
+ * Only display the pager when there are data to display.
+ *
+ * @override
+ * @private
+ */
+ _getPagingInfo: function (state) {
+ if (!state.count) {
+ return null;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _getActionMenuItems: function (state) {
+ if (!this.hasActionMenus || !this.selectedRecords.length) {
+ return null;
+ }
+ const props = this._super(...arguments);
+ const otherActionItems = [];
+ if (this.isExportEnable) {
+ otherActionItems.push({
+ description: _t("Export"),
+ callback: () => this._onExportData()
+ });
+ }
+ if (this.archiveEnabled) {
+ otherActionItems.push({
+ description: _t("Archive"),
+ callback: () => {
+ Dialog.confirm(this, _t("Are you sure that you want to archive all the selected records?"), {
+ confirm_callback: () => this._toggleArchiveState(true),
+ });
+ }
+ }, {
+ description: _t("Unarchive"),
+ callback: () => this._toggleArchiveState(false)
+ });
+ }
+ if (this.activeActions.delete) {
+ otherActionItems.push({
+ description: _t("Delete"),
+ callback: () => this._onDeleteSelectedRecords()
+ });
+ }
+ return Object.assign(props, {
+ items: Object.assign({}, this.toolbarActions, { other: otherActionItems }),
+ context: state.getContext(),
+ domain: state.getDomain(),
+ isDomainSelected: this.isDomainSelected,
+ });
+ },
+ /**
+ * Saves multiple records at once. This method is called by the _onFieldChanged method
+ * since the record must be confirmed as soon as the focus leaves a dirty cell.
+ * Pseudo-validation is performed with registered modifiers.
+ * Returns a promise that is resolved when confirming and rejected in any other case.
+ *
+ * @private
+ * @param {string} recordId
+ * @param {Object} node
+ * @param {Object} changes
+ * @returns {Promise}
+ */
+ _saveMultipleRecords: function (recordId, node, changes) {
+ var fieldName = Object.keys(changes)[0];
+ var value = Object.values(changes)[0];
+ var recordIds = _.union([recordId], this.selectedRecords);
+ var validRecordIds = recordIds.reduce((result, nextRecordId) => {
+ var record = this.model.get(nextRecordId);
+ var modifiers = this.renderer._registerModifiers(node, record);
+ if (!modifiers.readonly && (!modifiers.required || value)) {
+ result.push(nextRecordId);
+ }
+ return result;
+ }, []);
+ return new Promise((resolve, reject) => {
+ const saveRecords = () => {
+ this.model.saveRecords(this.handle, recordId, validRecordIds, fieldName)
+ .then(async () => {
+ this.updateButtons('readonly');
+ const state = this.model.get(this.handle);
+ // We need to check the current multi-editable state here
+ // in case the selection is changed. If there are changes
+ // and the list was multi-editable, we do not want to select
+ // the next row.
+ this.selectedRecords = [];
+ await this._updateRendererState(state, {
+ keepWidths: true,
+ selectedRecords: [],
+ });
+ this._updateSelectionBox();
+ this.renderer.focusCell(recordId, node);
+ resolve(!Object.keys(changes).length);
+ })
+ .guardedCatch(discardAndReject);
+ };
+ const discardAndReject = () => {
+ this.model.discardChanges(recordId);
+ this._confirmSave(recordId).then(() => {
+ this.renderer.focusCell(recordId, node);
+ reject();
+ });
+ };
+ if (validRecordIds.length > 0) {
+ if (recordIds.length === 1) {
+ // Save without prompt
+ return saveRecords();
+ }
+ const dialogOptions = {
+ confirm_callback: saveRecords,
+ cancel_callback: discardAndReject,
+ };
+ const record = this.model.get(recordId);
+ const dialogChanges = {
+ isDomainSelected: this.isDomainSelected,
+ fieldLabel: node.attrs.string || record.fields[fieldName].string,
+ fieldName: node.attrs.name,
+ nbRecords: recordIds.length,
+ nbValidRecords: validRecordIds.length,
+ };
+ new ListConfirmDialog(this, record, dialogChanges, dialogOptions)
+ .open({ shouldFocusButtons: true });
+ } else {
+ Dialog.alert(this, _t("No valid record to save"), {
+ confirm_callback: discardAndReject,
+ });
+ }
+ });
+ },
+ /**
+ * Overridden to deal with edition of multiple line.
+ *
+ * @override
+ * @param {string} recordId
+ */
+ _saveRecord: function (recordId) {
+ var record = this.model.get(recordId, { raw: true });
+ if (record.isDirty() && this.renderer.isInMultipleRecordEdition(recordId)) {
+ if (!this.multipleRecordsSavingPromise && this.lastFieldChangedEvent) {
+ this._onFieldChanged(this.lastFieldChangedEvent);
+ this.lastFieldChangedEvent = null;
+ }
+ // do not save the record (see _saveMultipleRecords)
+ const prom = this.multipleRecordsSavingPromise || Promise.reject();
+ this.multipleRecordsSavingPromise = null;
+ return prom;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Allows to change the mode of a single row.
+ *
+ * @override
+ * @private
+ * @param {string} mode
+ * @param {string} [recordID] - default to main recordID
+ * @returns {Promise}
+ */
+ _setMode: function (mode, recordID) {
+ if ((recordID || this.handle) !== this.handle) {
+ this.mode = mode;
+ this.updateButtons(mode);
+ return this.renderer.setRowMode(recordID, mode);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * @override
+ */
+ _shouldBounceOnClick() {
+ const state = this.model.get(this.handle, {raw: true});
+ return !state.count || state.isSample;
+ },
+ /**
+ * Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
+ *
+ * @private
+ * @param {boolean} archive
+ * @returns {Promise}
+ */
+ _toggleArchiveState: async function (archive) {
+ let resIds;
+ let displayNotif = false;
+ const state = this.model.get(this.handle, {raw: true});
+ if (this.isDomainSelected) {
+ resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit);
+ displayNotif = (resIds.length === session.active_ids_limit);
+ } else {
+ resIds = this.model.localIdsToResIds(this.selectedRecords);
+ }
+ await this._archive(resIds, archive);
+ if (displayNotif) {
+ const msg = _.str.sprintf(
+ _t("Of the %d records selected, only the first %d have been archived/unarchived."),
+ state.count, resIds.length
+ );
+ this.do_notify(_t('Warning'), msg);
+ }
+ },
+ /**
+ * Hide the create button in non-empty grouped editable list views, as an
+ * 'Add an item' link is available in each group.
+ *
+ * @private
+ */
+ _toggleCreateButton: function () {
+ if (this.hasButtons) {
+ var state = this.model.get(this.handle);
+ var createHidden = this.editable && state.groupedBy.length && state.data.length;
+ this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden);
+ }
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ _update: async function () {
+ await this._super(...arguments);
+ this._toggleCreateButton();
+ this.updateButtons('readonly');
+ },
+ /**
+ * When records are selected, a box is displayed in the control panel (next
+ * to the buttons). It indicates the number of selected records, and allows
+ * the user to select the whole domain instead of the current page (when the
+ * page is selected). This function renders and displays this box when at
+ * least one record is selected.
+ * Since header action buttons' display is dependent on the selection, we
+ * refresh them each time the selection is updated.
+ *
+ * @private
+ */
+ _updateSelectionBox() {
+ if (this.$selectionBox) {
+ this.$selectionBox.remove();
+ this.$selectionBox = null;
+ }
+ if (this.selectedRecords.length) {
+ const state = this.model.get(this.handle, {raw: true});
+ this.$selectionBox = $(qweb.render('ListView.selection', {
+ isDomainSelected: this.isDomainSelected,
+ isPageSelected: this.isPageSelected,
+ nbSelected: this.selectedRecords.length,
+ nbTotal: state.count,
+ }));
+ this.$selectionBox.appendTo(this.$buttons);
+ }
+ this._renderHeaderButtons();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Triggered when navigating with TAB, when the end of the list has been
+ * reached. Go back to the first row in that case.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateNextWidget: function (ev) {
+ ev.stopPropagation();
+ this.renderer.editFirstRecord(ev);
+ },
+ /**
+ * Add a record to the list
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} [ev.data.groupId=this.handle] the id of a dataPoint of
+ * type list to which the record must be added (default: main list)
+ */
+ _onAddRecord: function (ev) {
+ ev.stopPropagation();
+ var dataPointId = ev.data.groupId || this.handle;
+ if (this.activeActions.create) {
+ this._addRecord(dataPointId);
+ } else if (ev.data.onFail) {
+ ev.data.onFail();
+ }
+ },
+ /**
+ * Handles a click on a button by performing its action.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onButtonClicked: function (ev) {
+ ev.stopPropagation();
+ this._callButtonAction(ev.data.attrs, ev.data.record);
+ },
+ /**
+ * When the user clicks on the 'create' button, two things can happen. We
+ * can switch to the form view with no active res_id, so it is in 'create'
+ * mode, or we can edit inline.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCreateRecord: function (ev) {
+ // we prevent the event propagation because we don't want this event to
+ // trigger a click on the main bus, which would be then caught by the
+ // list editable renderer and would unselect the newly created row
+ if (ev) {
+ ev.stopPropagation();
+ }
+ var state = this.model.get(this.handle, {raw: true});
+ if (this.editable && !state.groupedBy.length) {
+ this._addRecord(this.handle);
+ } else {
+ this.trigger_up('switch_view', {view_type: 'form', res_id: undefined});
+ }
+ },
+ /**
+ * Called when the 'delete' action is clicked on in the side bar.
+ *
+ * @private
+ */
+ _onDeleteSelectedRecords: async function () {
+ if (this.isDomainSelected) {
+ this._deleteRecordsInCurrentDomain();
+ } else {
+ this._deleteRecords(this.selectedRecords);
+ }
+ },
+ /**
+ * Handler called when the user clicked on the 'Discard' button.
+ *
+ * @param {Event} ev
+ */
+ _onDiscard: function (ev) {
+ ev.stopPropagation(); // So that it is not considered as a row leaving
+ this._discardChanges().then(() => {
+ this.lastFieldChangedEvent = null;
+ });
+ },
+ /**
+ * Used to detect if the discard button is about to be clicked.
+ * Some focusout events might occur and trigger a save which
+ * is not always wanted when clicking "Discard".
+ *
+ * @param {MouseEvent} ev
+ * @private
+ */
+ _onDiscardMousedown: function (ev) {
+ var self = this;
+ this.fieldChangedPrevented = true;
+ window.addEventListener('mouseup', function (mouseupEvent) {
+ var preventedEvent = self.fieldChangedPrevented;
+ self.fieldChangedPrevented = false;
+ // If the user starts clicking (mousedown) on the button and stops clicking
+ // (mouseup) outside of the button, we want to trigger the original onFieldChanged
+ // Event that was prevented in the meantime.
+ if (ev.target !== mouseupEvent.target && preventedEvent.constructor.name === 'OdooEvent') {
+ self._onFieldChanged(preventedEvent);
+ }
+ }, { capture: true, once: true });
+ },
+ /**
+ * Called when the user asks to edit a row -> Updates the controller buttons
+ *
+ * @param {OdooEvent} ev
+ */
+ _onEditLine: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ this.trigger_up('mutexify', {
+ action: function () {
+ self._setMode('edit', ev.data.recordId)
+ .then(ev.data.onSuccess);
+ },
+ });
+ },
+ /**
+ * Opens the Export Dialog
+ *
+ * @private
+ */
+ _onExportData: function () {
+ this._getExportDialogWidget().open();
+ },
+ /**
+ * Export Records in a xls file
+ *
+ * @private
+ */
+ _onDirectExportData() {
+ // access rights check before exporting data
+ return this._rpc({
+ model: 'ir.exports',
+ method: 'search_read',
+ args: [[], ['id']],
+ limit: 1,
+ }).then(() => this._getExportDialogWidget().export())
+ },
+ /**
+ * Opens the related form view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditGroupClicked: function (ev) {
+ ev.stopPropagation();
+ this.do_action({
+ context: {create: false},
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ res_model: ev.data.record.model,
+ res_id: ev.data.record.res_id,
+ flags: {mode: 'edit'},
+ });
+ },
+ /**
+ * Overridden to deal with the edition of multiple records.
+ *
+ * Note that we don't manage saving multiple records on saveLine
+ * because we don't want the onchanges to be applied.
+ *
+ * @private
+ * @override
+ */
+ _onFieldChanged: function (ev) {
+ ev.stopPropagation();
+ const recordId = ev.data.dataPointID;
+ this.lastFieldChangedEvent = ev;
+
+ if (this.fieldChangedPrevented) {
+ this.fieldChangedPrevented = ev;
+ } else if (this.renderer.isInMultipleRecordEdition(recordId)) {
+ const saveMulti = () => {
+ // if ev.data.__originalComponent is set, it is the field Component
+ // that triggered the event, otherwise ev.target is the legacy field
+ // Widget that triggered the event
+ const target = ev.data.__originalComponent || ev.target;
+ this.multipleRecordsSavingPromise =
+ this._saveMultipleRecords(ev.data.dataPointID, target.__node, ev.data.changes);
+ };
+ // deal with edition of multiple lines
+ ev.data.onSuccess = saveMulti; // will ask confirmation, and save
+ ev.data.onFailure = saveMulti; // will show the appropriate dialog
+ // disable onchanges as we'll save directly
+ ev.data.notifyChange = false;
+ // In multi edit mode, we will be asked if we want to write on the selected
+ // records, so the force_save for readonly is not necessary.
+ ev.data.force_save = false;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @param {Object} node the button's node in the xml
+ * @returns {Promise}
+ */
+ async _onHeaderButtonClicked(node) {
+ this._disableButtons();
+ const state = this.model.get(this.handle);
+ try {
+ let resIds;
+ if (this.isDomainSelected) {
+ const limit = session.active_ids_limit;
+ resIds = await this._domainToResIds(state.getDomain(), limit);
+ } else {
+ resIds = this.getSelectedIds();
+ }
+ // add the context of the button node (in the xml) and our custom one
+ // (active_ids and domain) to the action's execution context
+ const actionData = Object.assign({}, node.attrs, {
+ context: state.getContext({ additionalContext: node.attrs.context }),
+ });
+ Object.assign(actionData.context, {
+ active_domain: state.getDomain(),
+ active_id: resIds[0],
+ active_ids: resIds,
+ active_model: state.model,
+ });
+ // load the action with the correct context and record parameters (resIDs, model etc...)
+ const recordData = {
+ context: state.getContext(),
+ model: state.model,
+ resIDs: resIds,
+ };
+ await this._executeButtonAction(actionData, recordData);
+ } finally {
+ this._enableButtons();
+ }
+ },
+ /**
+ * Called when the renderer displays an editable row and the user tries to
+ * leave it -> Saves the record associated to that line.
+ *
+ * @param {OdooEvent} ev
+ */
+ _onSaveLine: function (ev) {
+ this.saveRecord(ev.data.recordID)
+ .then(ev.data.onSuccess)
+ .guardedCatch(ev.data.onFailure);
+ },
+ /**
+ * @private
+ */
+ _onSelectDomain: function (ev) {
+ ev.preventDefault();
+ this.isDomainSelected = true;
+ this._updateSelectionBox();
+ this._updateControlPanel();
+ },
+ /**
+ * When the current selection changes (by clicking on the checkboxes on the
+ * left), we need to display (or hide) the 'sidebar'.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSelectionChanged: function (ev) {
+ this.selectedRecords = ev.data.selection;
+ this.isPageSelected = ev.data.allChecked;
+ this.isDomainSelected = false;
+ this.$('.o_list_export_xlsx').toggle(!this.selectedRecords.length);
+ this._updateSelectionBox();
+ this._updateControlPanel();
+ },
+ /**
+ * If the record is set as dirty while in multiple record edition,
+ * we want to immediatly discard the change.
+ *
+ * @private
+ * @override
+ * @param {OdooEvent} ev
+ */
+ _onSetDirty: function (ev) {
+ var recordId = ev.data.dataPointID;
+ if (this.renderer.isInMultipleRecordEdition(recordId)) {
+ ev.stopPropagation();
+ Dialog.alert(this, _t("No valid record to save"), {
+ confirm_callback: async () => {
+ this.model.discardChanges(recordId);
+ await this._confirmSave(recordId);
+ this.renderer.focusCell(recordId, ev.target.__node);
+ },
+ });
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * When the user clicks on one of the sortable column headers, we need to
+ * tell the model to sort itself properly, to update the pager and to
+ * rerender the view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleColumnOrder: function (ev) {
+ ev.stopPropagation();
+ var state = this.model.get(this.handle);
+ if (!state.groupedBy) {
+ this._updatePaging(state, { currentMinimum: 1 });
+ }
+ var self = this;
+ this.model.setSort(state.id, ev.data.name).then(function () {
+ self.update({});
+ });
+ },
+ /**
+ * In a grouped list view, each group can be clicked on to open/close them.
+ * This method just transfer the request to the model, then update the
+ * renderer.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleGroup: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ this.model
+ .toggleGroup(ev.data.group.id)
+ .then(function () {
+ self.update({}, {keepSelection: true, reload: false}).then(function () {
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ });
+ });
+ },
+});
+
+return ListController;
+
+});
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
new file mode 100644
index 00000000..7afe0425
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -0,0 +1,1851 @@
+odoo.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 Odoo v10 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');
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+var _t = core._t;
+
+ListRenderer.include({
+ RESIZE_DELAY: 200,
+ custom_events: _.extend({}, ListRenderer.prototype.custom_events, {
+ navigation_move: '_onNavigationMove',
+ }),
+ events: _.extend({}, ListRenderer.prototype.events, {
+ 'click .o_field_x2many_list_row_add a': '_onAddRecord',
+ 'click .o_group_field_row_add a': '_onAddRecordToGroup',
+ 'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord',
+ 'click tbody td.o_data_cell': '_onCellClick',
+ 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
+ 'click tfoot': '_onFooterClick',
+ 'click tr .o_list_record_remove': '_onRemoveIconClick',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.addCreateLine
+ * @param {boolean} params.addCreateLineInGroups
+ * @param {boolean} params.addTrashIcon
+ * @param {boolean} params.isMany2Many
+ * @param {boolean} params.isMultiEditable
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+
+ this.editable = params.editable;
+ this.isMultiEditable = params.isMultiEditable;
+ this.columnWidths = false;
+
+ // if addCreateLine (resp. addCreateLineInGroups) is true, the renderer
+ // will add a 'Add a line' link at the bottom of the list view (resp.
+ // at the bottom of each group)
+ this.addCreateLine = params.addCreateLine;
+ this.addCreateLineInGroups = params.addCreateLineInGroups;
+
+ // Controls allow overriding "add a line" by custom controls.
+
+ // Each <control> (only one is actually needed) is a container for (multiple) <create>.
+ // Each <create> will be a "add a line" button with custom text and context.
+
+ // The following code will browse the arch to find
+ // all the <create> that are inside <control>
+ this.creates = [];
+ this.arch.children.forEach(child => {
+ if (child.tag !== 'control') {
+ return;
+ }
+ child.children.forEach(child => {
+ if (child.tag !== 'create' || child.attrs.invisible) {
+ return;
+ }
+ this.creates.push({
+ context: child.attrs.context,
+ string: child.attrs.string,
+ });
+ });
+ });
+
+ // Add the default button if we didn't find any custom button.
+ if (this.creates.length === 0) {
+ this.creates.push({
+ string: _t("Add a line"),
+ });
+ }
+
+ // 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;
+
+ // replace the trash icon by X in case of many2many relations
+ // so that it means 'unlink' instead of 'remove'
+ this.isMany2Many = params.isMany2Many;
+
+ this.currentRow = null;
+ this.currentFieldIndex = null;
+ this.isResizing = false;
+ this.eventListeners = [];
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ core.bus.on('click', this, this._onWindowClicked.bind(this));
+ core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY));
+ core.bus.on('DOM_updated', this, () => this._freezeColumnWidths());
+ return this._super();
+ },
+ /**
+ * Overriden to unbind all attached listeners
+ *
+ * @override
+ */
+ destroy: function () {
+ this.eventListeners.forEach(listener => {
+ const { type, el, callback, options } = listener;
+ el.removeEventListener(type, callback, options);
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * The list renderer needs to know if it is in the DOM, and to be notified
+ * when it is attached to the DOM to properly compute column widths.
+ *
+ * @override
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ this._super();
+ // _freezeColumnWidths requests style information, which produces a
+ // repaint, so we call it after _super to prevent flickering (in case
+ // other code would also modify the DOM post rendering/before repaint)
+ this._freezeColumnWidths();
+ },
+ /**
+ * The list renderer needs to know if it is in the DOM to properly compute
+ * column widths.
+ *
+ * @override
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ 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
+ *
+ * If the given recordID is a record in the list, toggle a className on its
+ * row's cells for invalid fields, so that we can style those cells
+ * differently.
+ *
+ * @override
+ * @param {string} [recordID]
+ * @returns {string[]}
+ */
+ canBeSaved: function (recordID) {
+ if ((recordID || this.state.id) === this.state.id) {
+ recordID = this.getEditableRecordID();
+ if (recordID === null) {
+ return [];
+ }
+ }
+ var fieldNames = this._super(recordID);
+ this.$('.o_selected_row .o_data_cell').removeClass('o_invalid_cell');
+ this.$('.o_selected_row .o_data_cell:has(> .o_field_invalid)').addClass('o_invalid_cell');
+ return fieldNames;
+ },
+ /**
+ * 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, recordID) {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (widgets) {
+ if (widgets.length) {
+ var $row = self._getRow(recordID);
+ var record = self._getRecord(recordID);
+ self._setDecorationClasses($row, self.rowDecorations, record);
+ 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.
+ *
+ * Note that this function assumes that the list isn't grouped, which is
+ * fine as it's never the case for x2many lists.
+ *
+ * @param {Object} state
+ * @param {string} id
+ * @param {string[]} fields
+ * @param {OdooEvent} ev
+ * @returns {Promise<AbstractField[]>} resolved with the list of widgets
+ * that have been reset
+ */
+ confirmUpdate: function (state, id, fields, ev) {
+ var self = this;
+
+ var oldData = this.state.data;
+ this._setState(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 = self._getRecord(id);
+ if (!record) {
+ return;
+ }
+
+ _.each(oldData, function (rec) {
+ if (rec.id !== id) {
+ self._destroyFieldWidgets(rec.id);
+ }
+ });
+
+ // re-render whole body (outside the dom)
+ self.defs = [];
+ var $newBody = self._renderBody();
+ var defs = self.defs;
+ delete self.defs;
+
+ return Promise.all(defs).then(function () {
+ // update registered modifiers to edit 'mode' because the call to
+ // _renderBody set baseModeByRecord as 'readonly'
+ _.each(self.columns, function (node) {
+ self._registerModifiers(node, record, null, {mode: 'edit'});
+ });
+
+ // store the selection range to restore it once the table will
+ // be re-rendered, and the current cell re-selected
+ var currentRowID;
+ var currentWidget;
+ var focusedElement;
+ var selectionRange;
+ if (self.currentRow !== null) {
+ currentRowID = self._getRecordID(self.currentRow);
+ currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
+ if (currentWidget) {
+ focusedElement = currentWidget.getFocusableElement().get(0);
+ if (currentWidget.formatType !== 'boolean' && focusedElement) {
+ selectionRange = dom.getSelectionRange(focusedElement);
+ }
+ }
+ }
+
+ // remove all data rows except the one being edited, and insert
+ // data rows of the re-rendered body before and after it
+ var $editedRow = self._getRow(id);
+ $editedRow.nextAll('.o_data_row').remove();
+ $editedRow.prevAll('.o_data_row').remove();
+ var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]');
+ $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) {
+ $(row).insertBefore($editedRow);
+ });
+ $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) {
+ $(row).insertAfter($editedRow);
+ });
+
+ if (self.currentRow !== null) {
+ var newRowIndex = $editedRow.prop('rowIndex') - 1;
+ self.currentRow = newRowIndex;
+ return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true})
+ .then(function () {
+ // restore the selection range
+ currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
+ if (currentWidget) {
+ focusedElement = currentWidget.getFocusableElement().get(0);
+ if (selectionRange) {
+ dom.setSelectionRange(focusedElement, selectionRange);
+ }
+ }
+ });
+ }
+ });
+ });
+ },
+ /**
+ * Edit the first record in the list
+ */
+ editFirstRecord: function (ev) {
+ const $borderRow = this._getBorderRow(ev.data.side || 'first');
+ this._selectCell($borderRow.prop('rowIndex') - 1, ev.data.cellIndex || 0);
+ },
+ /**
+ * Edit a given record in the list
+ *
+ * @param {string} recordID
+ */
+ editRecord: function (recordID) {
+ var $row = this._getRow(recordID);
+ var rowIndex = $row.prop('rowIndex') - 1;
+ this._selectCell(rowIndex, 0);
+ },
+ /**
+ * Gives focus to a specific cell, given its row and its related column.
+ *
+ * @param {string} recordId
+ * @param {Object} column
+ */
+ focusCell: function (recordId, column) {
+ var $row = this._getRow(recordId);
+ var cellIndex = this.columns.indexOf(column);
+ $row.find('.o_data_cell')[cellIndex].focus();
+ },
+ /**
+ * 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._getRecordID(this.currentRow);
+ }
+ return null;
+ },
+ /**
+ * Returns whether the list is in multiple record edition from a given record.
+ *
+ * @private
+ * @param {string} recordId
+ * @returns {boolean}
+ */
+ isInMultipleRecordEdition: function (recordId) {
+ return this.isEditable() && this.isMultiEditable && this.selection.includes(recordId);
+ },
+ /**
+ * Returns whether the list can be edited.
+ * It's true when:
+ * - the list `editable` property is set,
+ * - or at least one record is selected (becomes partially editable)
+ *
+ * @returns {boolean}
+ */
+ isEditable: function () {
+ return this.editable || (this.isMultiEditable && this.selection.length);
+ },
+ /**
+ * 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) {
+ this._setState(state);
+ var $row = this._getRow(recordID);
+ if ($row.length === 0) {
+ return;
+ }
+ if ($row.prop('rowIndex') - 1 === this.currentRow) {
+ this.currentRow = null;
+ this._enableRecordSelectors();
+ }
+
+ // destroy widgets first
+ this._destroyFieldWidgets(recordID);
+ // remove the row
+ if (this.state.count >= 4) {
+ $row.remove();
+ } else {
+ // we want to always keep at least 4 (possibly empty) rows
+ var $emptyRow = this._renderEmptyRow();
+ $row.replaceWith($emptyRow);
+ // move the empty row we just inserted after last data row
+ const $lastDataRow = this.$('.o_data_row:last');
+ if ($lastDataRow.length) {
+ $emptyRow.insertAfter($lastDataRow);
+ }
+ }
+ },
+ /**
+ * Updates the already rendered row associated to the given recordID so that
+ * it fits the given mode.
+ *
+ * @param {string} recordID
+ * @param {string} mode
+ * @returns {Promise}
+ */
+ setRowMode: function (recordID, mode) {
+ var self = this;
+ var record = self._getRecord(recordID);
+ if (!record) {
+ return Promise.resolve();
+ }
+
+ var editMode = (mode === 'edit');
+ var $row = this._getRow(recordID);
+ this.currentRow = editMode ? $row.prop('rowIndex') - 1 : null;
+ var $tds = $row.children('.o_data_cell');
+ var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
+
+ // 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 promise
+ // 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);
+ if (editMode) {
+ this._disableRecordSelectors();
+ } else {
+ this._enableRecordSelectors();
+ }
+
+ return Promise.all(defs).then(function () {
+ // mark Owl sub components as mounted
+ WidgetAdapterMixin.on_attach_callback.call(self);
+
+ // necessary to trigger resize on fieldtexts
+ core.bus.trigger('DOM_updated');
+ });
+ },
+ /**
+ * 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 {Promise} The promise 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 Promise.resolve();
+ }
+ var recordID = this._getRecordID(this.currentRow);
+ var recordWidgets = this.allFieldWidgets[recordID];
+ function toggleWidgets(disabled) {
+ _.each(recordWidgets, function (widget) {
+ var $el = widget.getFocusableElement();
+ $el.prop('disabled', disabled);
+ });
+ }
+
+ toggleWidgets(true);
+ return new Promise((resolve, reject) => {
+ this.trigger_up('save_line', {
+ recordID: recordID,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ }).then(selectNextRow => {
+ this._enableRecordSelectors();
+ // If any field has changed and if the list is in multiple edition,
+ // we send a truthy boolean to _selectRow to tell it not to select
+ // the following record.
+ return selectNextRow;
+ }).guardedCatch(() => {
+ toggleWidgets(false);
+ });
+ },
+ /**
+ * @override
+ */
+ updateState: function (state, params) {
+ // There are some cases where a record is added to an invisible list
+ // e.g. set a quotation template with optionnal products
+ if (params.keepWidths && this.$el.is(':visible')) {
+ this._storeColumnWidths();
+ }
+ if (params.noRender) {
+ // the state changed, but we won't do a re-rendering right now, so
+ // remove computed modifiers data (as they are obsolete) to force
+ // them to be recomputed at next (sub-)rendering
+ this.allModifiersData = [];
+ }
+ if ('addTrashIcon' in params) {
+ if (this.addTrashIcon !== params.addTrashIcon) {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ }
+ this.addTrashIcon = params.addTrashIcon;
+ }
+ if ('addCreateLine' in params) {
+ this.addCreateLine = params.addCreateLine;
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Used to bind event listeners so that they can be unbound when the list
+ * is destroyed.
+ * There is no reverse method (list._removeEventListener) because there is
+ * no issue with removing an non-existing listener.
+ *
+ * @private
+ * @param {string} type event name
+ * @param {EventTarget} el event target
+ * @param {Function} callback callback function to attach
+ * @param {Object} options event listener options
+ */
+ _addEventListener: function (type, el, callback, options) {
+ el.addEventListener(type, callback, options);
+ this.eventListeners.push({ type, el, callback, options });
+ },
+ /**
+ * Handles the assignation of default widths for each column header.
+ * If the list is empty, an arbitrary absolute or relative width will be
+ * given to the header
+ *
+ * @see _getColumnWidth for detailed information about which width is
+ * given to a certain field type.
+ *
+ * @private
+ */
+ _computeDefaultWidths: function () {
+ const isListEmpty = !this._hasVisibleRecords(this.state);
+ const relativeWidths = [];
+ this.columns.forEach(column => {
+ const th = this._getColumnHeader(column);
+ if (th.offsetParent === null) {
+ relativeWidths.push(false);
+ } else {
+ const width = this._getColumnWidth(column);
+ if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px)
+ if (isListEmpty) {
+ th.style.width = width;
+ } else {
+ // If there are records, we force a min-width for fields with an absolute
+ // width to ensure a correct rendering in edition
+ th.style.minWidth = width;
+ }
+ relativeWidths.push(false);
+ } else { // relative width expressed as a weight (e.g. 1.5)
+ relativeWidths.push(parseFloat(width, 10));
+ }
+ }
+ });
+
+ // Assignation of relative widths
+ if (isListEmpty) {
+ const totalWidth = this._getColumnsTotalWidth(relativeWidths);
+ for (let i in this.columns) {
+ if (relativeWidths[i]) {
+ const th = this._getColumnHeader(this.columns[i]);
+ th.style.width = (relativeWidths[i] / totalWidth * 100) + '%';
+ }
+ }
+ // Manualy assigns trash icon header width since it's not in the columns
+ const trashHeader = this.el.getElementsByClassName('o_list_record_remove_header')[0];
+ if (trashHeader) {
+ trashHeader.style.width = '32px';
+ }
+ }
+ },
+ /**
+ * 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];
+ }
+ },
+ /**
+ * When editing a row, we want to disable all record selectors.
+ *
+ * @private
+ */
+ _disableRecordSelectors: function () {
+ this.$('.o_list_record_selector input').attr('disabled', 'disabled');
+ },
+ /**
+ * @private
+ */
+ _enableRecordSelectors: function () {
+ this.$('.o_list_record_selector input').attr('disabled', false);
+ },
+ /**
+ * This function freezes the column widths and forces a fixed table-layout,
+ * once the browser has computed the optimal width of each column according
+ * to the displayed records. We want to freeze widths s.t. it doesn't
+ * flicker when we switch a row in edition.
+ *
+ * We skip this when there is no record as we don't want to fix widths
+ * according to column's labels. In this case, we fallback on the 'weight'
+ * heuristic, which assigns to each column a fixed or relative width
+ * depending on the widget or field type.
+ *
+ * Note that the list must be in the DOM when this function is called.
+ *
+ * @private
+ */
+ _freezeColumnWidths: function () {
+ if (!this.columnWidths && this.el.offsetParent === null) {
+ // there is no record nor widths to restore or the list is not visible
+ // -> don't force column's widths w.r.t. their label
+ return;
+ }
+ const thElements = [...this.el.querySelectorAll('table thead th')];
+ if (!thElements.length) {
+ return;
+ }
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+ let columnWidths = this.columnWidths;
+
+ if (!columnWidths || !columnWidths.length) { // no column widths to restore
+ // Set table layout auto and remove inline style to make sure that css
+ // rules apply (e.g. fixed width of record selector)
+ table.style.tableLayout = 'auto';
+ thElements.forEach(th => {
+ th.style.width = null;
+ th.style.maxWidth = null;
+ });
+
+ // Resets the default widths computation now that the table is visible.
+ this._computeDefaultWidths();
+
+ // Squeeze the table by applying a max-width on largest columns to
+ // ensure that it doesn't overflow
+ columnWidths = this._squeezeTable();
+ }
+
+ thElements.forEach((th, index) => {
+ // Width already set by default relative width computation
+ if (!th.style.width) {
+ th.style.width = `${columnWidths[index]}px`;
+ }
+ });
+
+ // Set the table layout to fixed
+ table.style.tableLayout = 'fixed';
+ },
+ /**
+ * Returns the first or last editable row of the list
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getBorderRow: function (side) {
+ let $borderDataRow = this.$(`.o_data_row:${side}`);
+ if (!this._isRecordEditable($borderDataRow.data('id'))) {
+ $borderDataRow = this._getNearestEditableRow($borderDataRow, side === 'first');
+ }
+ return $borderDataRow;
+ },
+ /**
+ * Compute the sum of the weights for each column, given an array containing
+ * all relative widths. param `$thead` is useful for studio, in order to
+ * show column hooks.
+ *
+ * @private
+ * @param {jQuery} $thead
+ * @param {number[]} relativeWidths
+ * @return {integer}
+ */
+ _getColumnsTotalWidth(relativeWidths) {
+ return relativeWidths.reduce((acc, width) => acc + width, 0);
+ },
+ /**
+ * Returns the width of a column according the 'width' attribute set in the
+ * arch, the widget or the field type. A fixed width is harcoded for some
+ * field types (e.g. date and numeric fields). By default, the remaining
+ * space is evenly distributed between the other fields (with a factor '1').
+ *
+ * This is only used when there is no record in the list (i.e. when we can't
+ * let the browser compute the optimal width of each column).
+ *
+ * @see _renderHeader
+ * @private
+ * @param {Object} column an arch node
+ * @returns {string} either a weight factor (e.g. '1.5') or a css width
+ * description (e.g. '120px')
+ */
+ _getColumnWidth: function (column) {
+ if (column.attrs.width) {
+ return column.attrs.width;
+ }
+ const fieldsInfo = this.state.fieldsInfo.list;
+ const name = column.attrs.name;
+ if (!fieldsInfo[name]) {
+ // Unnamed columns get default value
+ return '1';
+ }
+ const widget = fieldsInfo[name].Widget.prototype;
+ if ('widthInList' in widget) {
+ return widget.widthInList;
+ }
+ const field = this.state.fields[name];
+ if (!field) {
+ // this is not a field. Probably a button or something of unknown
+ // width.
+ return '1';
+ }
+ const fixedWidths = {
+ boolean: '70px',
+ date: '92px',
+ datetime: '146px',
+ float: '92px',
+ integer: '74px',
+ monetary: '104px',
+ };
+ let type = field.type;
+ if (fieldsInfo[name].widget in fixedWidths) {
+ type = fieldsInfo[name].widget;
+ }
+ return fixedWidths[type] || '1';
+ },
+ /**
+ * Gets the th element corresponding to a given column.
+ *
+ * @private
+ * @param {Object} column
+ * @returns {HTMLElement}
+ */
+ _getColumnHeader: function (column) {
+ const { icon, name, string } = column.attrs;
+ if (name) {
+ return this.el.querySelector(`thead th[data-name="${name}"]`);
+ } else if (string) {
+ return this.el.querySelector(`thead th[data-string="${string}"]`);
+ } else if (icon) {
+ return this.el.querySelector(`thead th[data-icon="${icon}"]`);
+ }
+ },
+ /**
+ * Returns the nearest editable row starting from a given table row.
+ * If the list is grouped, jumps to the next unfolded group
+ *
+ * @private
+ * @param {jQuery} $row starting point
+ * @param {boolean} next whether the requested row should be the next or the previous one
+ * @return {jQuery|null}
+ */
+ _getNearestEditableRow: function ($row, next) {
+ const direction = next ? 'next' : 'prev';
+ let $nearestRow;
+ if (this.editable) {
+ $nearestRow = $row[direction]();
+ if (!$nearestRow.hasClass('o_data_row')) {
+ var $nextBody = $row.closest('tbody')[direction]();
+ while ($nextBody.length && !$nextBody.find('.o_data_row').length) {
+ $nextBody = $nextBody[direction]();
+ }
+ $nearestRow = $nextBody.find(`.o_data_row:${next ? 'first' : 'last'}`);
+ }
+ } else {
+ // In readonly lists, look directly into selected records
+ const recordId = $row.data('id');
+ const rowSelectionIndex = this.selection.indexOf(recordId);
+ let nextRowIndex;
+ if (rowSelectionIndex < 0) {
+ nextRowIndex = next ? 0 : this.selection.length - 1;
+ } else {
+ nextRowIndex = rowSelectionIndex + (next ? 1 : -1);
+ }
+ // Index might be out of range, will then return an empty jQuery object
+ $nearestRow = this._getRow(this.selection[nextRowIndex]);
+ }
+ return $nearestRow;
+ },
+ /**
+ * 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;
+ },
+ /**
+ * Traverse this.state to find and return the record with given dataPoint id
+ * (for grouped list views, the record could be deep down in state tree).
+ *
+ * @override
+ * @private
+ */
+ _getRecord: function (recordId) {
+ var record;
+ utils.traverse_records(this.state, function (r) {
+ if (r.id === recordId) {
+ record = r;
+ }
+ });
+ return record;
+ },
+ /**
+ * Retrieve the record dataPoint id from a rowIndex as the row DOM element
+ * stores the record id in data.
+ *
+ * @private
+ * @param {integer} rowIndex
+ * @returns {string} record dataPoint id
+ */
+ _getRecordID: function (rowIndex) {
+ var $tr = this.$('table.o_list_table > tbody tr').eq(rowIndex);
+ return $tr.data('id');
+ },
+ /**
+ * Return the jQuery tr element corresponding to the given record dataPoint
+ * id.
+ *
+ * @private
+ * @param {string} [recordId]
+ * @returns {jQueryElement}
+ */
+ _getRow: function (recordId) {
+ return this.$('.o_data_row[data-id="' + recordId + '"]');
+ },
+ /**
+ * This function returns true iff records are visible in the list, i.e.
+ * if the list is ungrouped: true iff the list isn't empty;
+ * if the list is grouped: true iff there is at least one unfolded group
+ * containing records.
+ *
+ * @param {Object} list a datapoint
+ * @returns {boolean}
+ */
+ _hasVisibleRecords: function (list) {
+ if (!list.groupedBy.length) {
+ return !!list.data.length;
+ } else {
+ var hasVisibleRecords = false;
+ for (var i = 0; i < list.data.length; i++) {
+ hasVisibleRecords = hasVisibleRecords || this._hasVisibleRecords(list.data[i]);
+ }
+ return hasVisibleRecords;
+ }
+ },
+ /**
+ * Returns whether a recordID is currently editable.
+ *
+ * @param {string} recordID
+ * @returns {boolean}
+ */
+ _isRecordEditable: function (recordID) {
+ return this.editable || (this.isMultiEditable && this.selection.includes(recordID));
+ },
+ /**
+ * Moves to the next row in the list
+ *
+ * @private
+ * @params {Object} [options] see @_moveToSideLine
+ */
+ _moveToNextLine: function (options) {
+ this._moveToSideLine(true, options);
+ },
+ /**
+ * Moves to the previous row in the list
+ *
+ * @private
+ * @params {Object} [options] see @_moveToSideLine
+ */
+ _moveToPreviousLine: function (options) {
+ this._moveToSideLine(false, options);
+ },
+ /**
+ * Moves the focus to the nearest editable row before or after the current one.
+ * If we arrive at the end of the list (or of a group in the grouped case) and the list
+ * is editable="bottom", we create a new record, otherwise, we move the
+ * cursor to the first row (of the next group in the grouped case).
+ *
+ * @private
+ * @param {number} next whether to move to the next or previous row
+ * @param {Object} [options]
+ * @param {boolean} [options.forceCreate=false] typically set to true when
+ * navigating with ENTER ; in this case, if the next row is the 'Add a
+ * row' one, always create a new record (never skip it, like TAB does
+ * under some conditions)
+ */
+ _moveToSideLine: function (next, options) {
+ options = options || {};
+ const recordID = this._getRecordID(this.currentRow);
+ this.commitChanges(recordID).then(() => {
+ const record = this._getRecord(recordID);
+ const multiEdit = this.isInMultipleRecordEdition(recordID);
+ if (!multiEdit) {
+ const fieldNames = this.canBeSaved(recordID);
+ if (fieldNames.length && (record.isDirty() || options.forceCreate)) {
+ // the current row is invalid, we only leave it if it is not dirty
+ // (we didn't make any change on this row, which is a new one) and
+ // we are navigating with TAB (forceCreate=false)
+ return;
+ }
+ }
+ // compute the index of the next (record) row to select, if any
+ const side = next ? 'first' : 'last';
+ const borderRowIndex = this._getBorderRow(side).prop('rowIndex') - 1;
+ const cellIndex = next ? 0 : this.allFieldWidgets[recordID].length - 1;
+ const cellOptions = { inc: next ? 1 : -1, force: true };
+ const $currentRow = this._getRow(recordID);
+ const $nextRow = this._getNearestEditableRow($currentRow, next);
+ let nextRowIndex = null;
+ let groupId;
+
+ if (!this.isGrouped) {
+ // ungrouped case
+ if ($nextRow.length) {
+ nextRowIndex = $nextRow.prop('rowIndex') - 1;
+ } else if (!this.editable) {
+ nextRowIndex = borderRowIndex;
+ } else if (!options.forceCreate && !record.isDirty()) {
+ this.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }),
+ });
+ return;
+ }
+ } else {
+ // grouped case
+ var $directNextRow = $currentRow.next();
+ if (next && this.editable === "bottom" && $directNextRow.hasClass('o_add_record_row')) {
+ // the next row is the 'Add a line' row (i.e. the current one is the last record
+ // row of the group)
+ if (options.forceCreate || record.isDirty()) {
+ // if we modified the current record, add a row to create a new record
+ groupId = $directNextRow.data('group-id');
+ } else {
+ // if we didn't change anything to the current line (e.g. we pressed TAB on
+ // each cell without modifying/entering any data), we discard that line (if
+ // it was a new one) and move to the first record of the next group
+ nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null;
+ this.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: () => {
+ if (nextRowIndex !== null) {
+ if (!record.res_id) {
+ // the current record was a new one, so we decrement
+ // nextRowIndex as that row has been removed meanwhile
+ nextRowIndex--;
+ }
+ this._selectCell(nextRowIndex, cellIndex, cellOptions);
+ } else {
+ // we were in the last group, so go back to the top
+ this._selectCell(borderRowIndex, cellIndex, cellOptions);
+ }
+ },
+ });
+ return;
+ }
+ } else {
+ // there is no 'Add a line' row (i.e. the create feature is disabled), or the
+ // list is editable="top", we focus the first record of the next group if any,
+ // or we go back to the top of the list
+ nextRowIndex = $nextRow.length ?
+ ($nextRow.prop('rowIndex') - 1) :
+ borderRowIndex;
+ }
+ }
+
+ // if there is a (record) row to select, select it, otherwise, add a new record (in the
+ // correct group, if the view is grouped)
+ if (nextRowIndex !== null) {
+ // cellOptions.force = true;
+ this._selectCell(nextRowIndex, cellIndex, cellOptions);
+ } else if (this.editable) {
+ // if for some reason (e.g. create feature is disabled) we can't add a new
+ // record, select the first record row
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record', {
+ groupId: groupId,
+ onFail: this._selectCell.bind(this, borderRowIndex, cellIndex, cellOptions),
+ }));
+ }
+ });
+ },
+ /**
+ * Override to compute the (relative or absolute) width of each column.
+ *
+ * @override
+ * @private
+ */
+ _processColumns: function () {
+ const oldColumns = this.columns;
+ this._super.apply(this, arguments);
+ // check if stored widths still apply
+ if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) {
+ for (let i = 0; i < oldColumns.length; i++) {
+ if (oldColumns[i] !== this.columns[i]) {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ break;
+ }
+ }
+ } else {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ }
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ _render: function () {
+ this.currentRow = null;
+ this.currentFieldIndex = null;
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Override to add the 'Add an item' link to the end of last-level opened
+ * groups.
+ *
+ * @override
+ * @private
+ */
+ _renderGroup: function (group) {
+ var result = this._super.apply(this, arguments);
+ if (!group.groupedBy.length && this.addCreateLineInGroups) {
+ var $groupBody = result[0];
+ var $a = $('<a href="#" role="button">')
+ .text(_t("Add a line"))
+ .attr('data-group-id', group.id);
+ var $td = $('<td>')
+ .attr('colspan', this._getNumberOfCols())
+ .addClass('o_group_field_row_add')
+ .attr('tabindex', -1)
+ .append($a);
+ var $tr = $('<tr>', {class: 'o_add_record_row'})
+ .attr('data-group-id', group.id)
+ .append($td);
+ $groupBody.append($tr.prepend($('<td>').html('&nbsp;')));
+ }
+ return result;
+ },
+ /**
+ * The renderer needs to support reordering lines. This is only active in
+ * edit mode. The handleField attribute is set when there is a sequence
+ * widget.
+ *
+ * @override
+ */
+ _renderBody: function () {
+ var self = this;
+ var $body = this._super.apply(this, arguments);
+ if (this.hasHandle) {
+ $body.sortable({
+ axis: 'y',
+ items: '> tr.o_data_row',
+ helper: 'clone',
+ handle: '.o_row_handle',
+ stop: function (event, ui) {
+ // update currentID taking moved line into account
+ if (self.currentRow !== null) {
+ var currentID = self.state.data[self.currentRow].id;
+ self.currentRow = self._getRow(currentID).index();
+ }
+ self.unselectRow().then(function () {
+ self._moveRecord(ui.item.data('id'), ui.item.index());
+ });
+ },
+ });
+ }
+ return $body;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderFooter: function () {
+ const $footer = this._super.apply(this, arguments);
+ if (this.addTrashIcon) {
+ $footer.find('tr').append($('<td>'));
+ }
+ return $footer;
+ },
+ /**
+ * Override to optionally add a th in the header for the remove icon column.
+ *
+ * @override
+ * @private
+ */
+ _renderHeader: function () {
+ var $thead = this._super.apply(this, arguments);
+ if (this.addTrashIcon) {
+ $thead.find('tr').append($('<th>', {class: 'o_list_record_remove_header'}));
+ }
+ return $thead;
+ },
+ /**
+ * Overriden to add a resize handle in editable list column headers.
+ * Only applies to headers containing text.
+ *
+ * @override
+ * @private
+ */
+ _renderHeaderCell: function () {
+ const $th = this._super.apply(this, arguments);
+ if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) {
+ const resizeHandle = document.createElement('span');
+ resizeHandle.classList = 'o_resize';
+ resizeHandle.onclick = this._onClickResize.bind(this);
+ resizeHandle.onmousedown = this._onStartResize.bind(this);
+ $th.append(resizeHandle);
+ }
+ return $th;
+ },
+ /**
+ * Editable rows are possibly extended with a trash icon on their right, to
+ * allow deleting the corresponding record.
+ * For many2many editable lists, the trash bin is replaced by X.
+ *
+ * @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 = this.isMany2Many ?
+ $('<button>', {'class': 'fa fa-times', 'name': 'unlink', 'aria-label': _t('Unlink row ') + (index + 1)}) :
+ $('<button>', {'class': 'fa fa-trash-o', 'name': 'delete', 'aria-label': _t('Delete row ') + (index + 1)});
+ var $td = $('<td>', {class: 'o_list_record_remove'}).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.
+ *
+ * If the list has a handleField, we want to left-align the first button
+ * on the first real column.
+ *
+ * @override
+ * @returns {jQueryElement[]}
+ */
+ _renderRows: function () {
+ var $rows = this._super();
+ if (this.addCreateLine) {
+ var $tr = $('<tr>');
+ var colspan = this._getNumberOfCols();
+
+ if (this.handleField) {
+ colspan = colspan - 1;
+ $tr.append('<td>');
+ }
+
+ var $td = $('<td>')
+ .attr('colspan', colspan)
+ .addClass('o_field_x2many_list_row_add');
+ $tr.append($td);
+ $rows.push($tr);
+
+ if (this.addCreateLine) {
+ _.each(this.creates, function (create, index) {
+ var $a = $('<a href="#" role="button">')
+ .attr('data-context', create.context)
+ .text(create.string);
+ if (index > 0) {
+ $a.addClass('ml16');
+ }
+ $td.append($a);
+ });
+ }
+ }
+ return $rows;
+ },
+ /**
+ * @override
+ * @private
+ * @returns {Promise} this promise is resolved immediately
+ */
+ _renderView: function () {
+ this.currentRow = null;
+ return this._super.apply(this, arguments).then(() => {
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+ if (table) {
+ table.classList.toggle('o_empty_list', !this._hasVisibleRecords(this.state));
+ this._freezeColumnWidths();
+ }
+ });
+ },
+ /**
+ * 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)
+ * @param {integer} [options.inc=1] the increment to use when searching for
+ * the "next" possible cell (if the cell to select can't be selected)
+ * @return {Promise} 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 Promise.resolve();
+ }
+ var wrap = options.wrap === undefined ? true : options.wrap;
+ var recordID = this._getRecordID(rowIndex);
+
+ // Select the row then activate the widget in the correct cell
+ var self = this;
+ return this._selectRow(rowIndex).then(function () {
+ var record = self._getRecord(recordID);
+ if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) {
+ return Promise.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: options.inc || 1,
+ wrap: wrap,
+ event: options && options.event,
+ });
+ if (fieldIndex < 0) {
+ self.currentFieldIndex = oldFieldIndex;
+ return Promise.reject();
+ }
+ self.currentFieldIndex = fieldIndex;
+ });
+ },
+ /**
+ * Activates the row at the given row index.
+ *
+ * @param {integer} rowIndex
+ * @returns {Promise}
+ */
+ _selectRow: function (rowIndex) {
+ // Do nothing if already selected
+ if (rowIndex === this.currentRow) {
+ return Promise.resolve();
+ }
+ if (!this.columnWidths) {
+ // we don't want the column widths to change when selecting rows
+ this._storeColumnWidths();
+ }
+ var recordId = this._getRecordID(rowIndex);
+ // To select a row, the currently selected one must be unselected first
+ var self = this;
+ return this.unselectRow().then((selectNextRow = true) => {
+ if (!selectNextRow) {
+ return Promise.resolve();
+ }
+ if (!recordId) {
+ // The row to selected doesn't exist anymore (probably because
+ // an onchange triggered when unselecting the previous one
+ // removes rows)
+ return Promise.reject();
+ }
+ // Notify the controller we want to make a record editable
+ return new Promise(function (resolve) {
+ self.trigger_up('edit_line', {
+ recordId: recordId,
+ onSuccess: function () {
+ self._disableRecordSelectors();
+ resolve();
+ },
+ });
+ });
+ });
+ },
+ /**
+ * Set a maximum width on the largest columns in the list in case the table
+ * is overflowing. The idea is to shrink largest columns first, but to
+ * ensure that they are still the largest at the end (maybe in equal measure
+ * with other columns). Button columns aren't impacted by this function, as
+ * we assume that they can't be squeezed (we want all buttons to always be
+ * available, not being replaced by ellipsis).
+ *
+ * @private
+ * @returns {integer[]} width (in px) of each column s.t. the table doesn't
+ * overflow
+ */
+ _squeezeTable: function () {
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+
+ // Toggle a className used to remove style that could interfer with the ideal width
+ // computation algorithm (e.g. prevent text fields from being wrapped during the
+ // computation, to prevent them from being completely crushed)
+ table.classList.add('o_list_computing_widths');
+
+ const thead = table.getElementsByTagName('thead')[0];
+ const thElements = [...thead.getElementsByTagName('th')];
+ const columnWidths = thElements.map(th => th.offsetWidth);
+ const getWidth = th => columnWidths[thElements.indexOf(th)] || 0;
+ const getTotalWidth = () => thElements.reduce((tot, th, i) => tot + columnWidths[i], 0);
+ const shrinkColumns = (columns, width) => {
+ let thresholdReached = false;
+ columns.forEach(th => {
+ const index = thElements.indexOf(th);
+ let maxWidth = columnWidths[index] - Math.ceil(width / columns.length);
+ if (maxWidth < 92) { // prevent the columns from shrinking under 92px (~ date field)
+ maxWidth = 92;
+ thresholdReached = true;
+ }
+ th.style.maxWidth = `${maxWidth}px`;
+ columnWidths[index] = maxWidth;
+ });
+ return thresholdReached;
+ };
+ // Sort columns, largest first
+ const sortedThs = [...thead.querySelectorAll('th:not(.o_list_button)')]
+ .sort((a, b) => getWidth(b) - getWidth(a));
+ const allowedWidth = table.parentNode.offsetWidth;
+
+ let totalWidth = getTotalWidth();
+ let stop = false;
+ let index = 0;
+ while (totalWidth > allowedWidth && !stop) {
+ // Find the largest columns
+ index++;
+ const largests = sortedThs.slice(0, index);
+ while (getWidth(largests[0]) === getWidth(sortedThs[index])) {
+ largests.push(sortedThs[index]);
+ index++;
+ }
+
+ // Compute the number of px to remove from the largest columns
+ const nextLargest = sortedThs[index]; // largest column when omitting those in largests
+ const totalToRemove = totalWidth - allowedWidth;
+ const canRemove = (getWidth(largests[0]) - getWidth(nextLargest)) * largests.length;
+
+ // Shrink the largests columns
+ stop = shrinkColumns(largests, Math.min(totalToRemove, canRemove));
+
+ totalWidth = getTotalWidth();
+ }
+
+ // We are no longer computing widths, so restore the normal style
+ table.classList.remove('o_list_computing_widths');
+
+ return columnWidths;
+ },
+ /**
+ * @private
+ */
+ _storeColumnWidths: function () {
+ this.columnWidths = this.$('thead th').toArray().map(function (th) {
+ return $(th).outerWidth();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is called when we click on the 'Add a line' button in a groupby
+ * list view.
+ *
+ * @param {MouseEvent} ev
+ */
+ _onAddRecordToGroup: function (ev) {
+ ev.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
+ ev.stopPropagation();
+
+ var self = this;
+ // This method can be called when selecting the parent of the link.
+ // We need to ensure that the link is the actual target
+ const target = ev.target.tagName !== 'A' ? ev.target.getElementsByTagName('A')[0] : ev.target;
+ const groupId = target.dataset.groupId;
+ this.currentGroupId = groupId;
+ this.unselectRow().then(function () {
+ self.trigger_up('add_record', {
+ groupId: groupId,
+ });
+ });
+ },
+ /**
+ * This method is called when we click on the 'Add a line' button in a sub
+ * list such as a one2many in a form view.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddRecord: function (ev) {
+ // we don't want the browser to navigate to a the # url
+ ev.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
+ ev.stopPropagation();
+
+ // but we do want to unselect current row
+ var self = this;
+ this.unselectRow().then(function () {
+ self.trigger_up('add_record', {context: ev.currentTarget.dataset.context && [ev.currentTarget.dataset.context]}); // TODO write a test, the promise 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.
+ var $td = $(event.currentTarget);
+ var $tr = $td.parent();
+ var rowIndex = $tr.prop('rowIndex') - 1;
+ if (!this._isRecordEditable($tr.data('id')) || $(event.target).prop('special_click')) {
+ return;
+ }
+ var fieldIndex = Math.max($tr.find('.o_field_cell').index($td), 0);
+ this._selectCell(rowIndex, fieldIndex, {event: event});
+ },
+ /**
+ * We want to override any default mouse behaviour when clicking on the resize handles
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickResize: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ },
+ /**
+ * We need to manually unselect row, because no one 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();
+ },
+ /**
+ * Manages the keyboard events on the list. If the list is not editable, when the user navigates to
+ * a cell using the keyboard, if he presses enter, enter the model represented by the line
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ * @override
+ */
+ _onKeyDown: function (ev) {
+ const $target = $(ev.currentTarget);
+ const $tr = $target.closest('tr');
+ const recordEditable = this._isRecordEditable($tr.data('id'));
+
+ if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) {
+ // enter on a textarea for example, let it bubble
+ return;
+ }
+
+ if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER &&
+ !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ if ($target.closest('td').hasClass('o_group_field_row_add')) {
+ this._onAddRecordToGroup(ev);
+ } else {
+ this._onCellClick(ev);
+ }
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyDownEvent} e
+ */
+ _onKeyDownAddRecord: function (e) {
+ switch (e.keyCode) {
+ case $.ui.keyCode.ENTER:
+ e.stopPropagation();
+ e.preventDefault();
+ this._onAddRecord(e);
+ break;
+ }
+ },
+ /**
+ * Handles the keyboard navigation according to events triggered by field
+ * widgets.
+ * - 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 {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ var self = this;
+ // Don't stop the propagation when navigating up while not editing any row
+ if (this.currentRow === null && ev.data.direction === 'up') {
+ return;
+ }
+ ev.stopPropagation(); // stop the event, the action is done by this renderer
+ if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) {
+ ev.data.originalEvent.preventDefault();
+ ev.data.originalEvent.stopPropagation();
+ }
+ switch (ev.data.direction) {
+ case 'previous':
+ if (this.currentFieldIndex > 0) {
+ this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false})
+ .guardedCatch(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})
+ .guardedCatch(this._moveToNextLine.bind(this));
+ } else {
+ this._moveToNextLine();
+ }
+ break;
+ case 'next_line':
+ // If the list is readonly and the current is the only record editable, we unselect the line
+ if (!this.editable && this.selection.length === 1 &&
+ this._getRecordID(this.currentRow) === ev.target.dataPointID) {
+ this.unselectRow();
+ } else {
+ this._moveToNextLine({ forceCreate: true });
+ }
+ break;
+ case 'cancel':
+ // stop the original event (typically an ESCAPE keydown), to
+ // prevent from closing the potential dialog containing this list
+ // also auto-focus the 1st control, if any.
+ ev.data.originalEvent.stopPropagation();
+ var rowIndex = this.currentRow;
+ var cellIndex = this.currentFieldIndex + 1;
+ this.trigger_up('discard_changes', {
+ recordID: ev.target.dataPointID,
+ onSuccess: function () {
+ self._enableRecordSelectors();
+ var recordId = self._getRecordID(rowIndex);
+ if (recordId) {
+ var correspondingRow = self._getRow(recordId);
+ correspondingRow.children().eq(cellIndex).focus();
+ } else if (self.currentGroupId) {
+ self.$('a[data-group-id="' + self.currentGroupId + '"]').focus();
+ } else {
+ self.$('.o_field_x2many_list_row_add a:first').focus(); // FIXME
+ }
+ }
+ });
+ break;
+ }
+ },
+ /**
+ * Triggers a remove event. I don't know why we stop the propagation of the
+ * event.
+ *
+ * @param {MouseEvent} event
+ */
+ _onRemoveIconClick: function (event) {
+ event.stopPropagation();
+ var $row = $(event.target).closest('tr');
+ var id = $row.data('id');
+ if ($row.hasClass('o_selected_row')) {
+ this.trigger_up('list_record_remove', {id: id});
+ } else {
+ var self = this;
+ this.unselectRow().then(function () {
+ self.trigger_up('list_record_remove', {id: id});
+ });
+ }
+ },
+ /**
+ * React to window resize events by recomputing the width of each column.
+ *
+ * @private
+ */
+ _onResize: function () {
+ this.columnWidths = false;
+ this._freezeColumnWidths();
+ },
+ /**
+ * 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 (ev) {
+ if (!this._isRecordEditable(ev.currentTarget.dataset.id)) {
+ // If there is an edited record, tries to save it and do not open the clicked record
+ if (this.getEditableRecordID()) {
+ this.unselectRow();
+ } else {
+ 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.isResizing) {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * Handles the resize feature on the column headers
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onStartResize: function (ev) {
+ // Only triggered by left mouse button
+ if (ev.which !== 1) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.isResizing = true;
+
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+ const th = ev.target.closest('th');
+ table.style.width = `${table.offsetWidth}px`;
+ const thPosition = [...th.parentNode.children].indexOf(th);
+ const resizingColumnElements = [...table.getElementsByTagName('tr')]
+ .filter(tr => tr.children.length === th.parentNode.children.length)
+ .map(tr => tr.children[thPosition]);
+ const optionalDropdown = this.el.getElementsByClassName('o_optional_columns')[0];
+ const initialX = ev.pageX;
+ const initialWidth = th.offsetWidth;
+ const initialTableWidth = table.offsetWidth;
+ const initialDropdownX = optionalDropdown ? optionalDropdown.offsetLeft : null;
+ const resizeStoppingEvents = [
+ 'keydown',
+ 'mousedown',
+ 'mouseup',
+ ];
+
+ // Fix container width to prevent the table from overflowing when being resized
+ if (!this.el.style.width) {
+ this.el.style.width = `${this.el.offsetWidth}px`;
+ }
+
+ // Apply classes to table and selected column
+ table.classList.add('o_resizing');
+ resizingColumnElements.forEach(el => el.classList.add('o_column_resizing'));
+
+ // Mousemove event : resize header
+ const resizeHeader = ev => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const delta = ev.pageX - initialX;
+ const newWidth = Math.max(10, initialWidth + delta);
+ const tableDelta = newWidth - initialWidth;
+ th.style.width = `${newWidth}px`;
+ th.style.maxWidth = `${newWidth}px`;
+ table.style.width = `${initialTableWidth + tableDelta}px`;
+ if (optionalDropdown) {
+ optionalDropdown.style.left = `${initialDropdownX + tableDelta}px`;
+ }
+ };
+ this._addEventListener('mousemove', window, resizeHeader);
+
+ // Mouse or keyboard events : stop resize
+ const stopResize = ev => {
+ // Ignores the initial 'left mouse button down' event in order
+ // to not instantly remove the listener
+ if (ev.type === 'mousedown' && ev.which === 1) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ // We need a small timeout to not trigger a click on column header
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = setTimeout(() => {
+ this.isResizing = false;
+ }, 100);
+ window.removeEventListener('mousemove', resizeHeader);
+ table.classList.remove('o_resizing');
+ resizingColumnElements.forEach(el => el.classList.remove('o_column_resizing'));
+ resizeStoppingEvents.forEach(stoppingEvent => {
+ window.removeEventListener(stoppingEvent, stopResize);
+ });
+
+ // we remove the focus to make sure that the there is no focus inside
+ // the tr. If that is the case, there is some css to darken the whole
+ // thead, and it looks quite weird with the small css hover effect.
+ document.activeElement.blur();
+ };
+ // We have to listen to several events to properly stop the resizing function. Those are:
+ // - mousedown (e.g. pressing right click)
+ // - mouseup : logical flow of the resizing feature (drag & drop)
+ // - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key)
+ resizeStoppingEvents.forEach(stoppingEvent => {
+ this._addEventListener(stoppingEvent, window, stopResize);
+ });
+ },
+ /**
+ * Unselect the row before adding the optional column to the listview
+ *
+ * @override
+ * @private
+ */
+ _onToggleOptionalColumnDropdown: function (ev) {
+ this.unselectRow().then(this._super.bind(this, ev));
+ },
+ /**
+ * 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 on readonly lists with no selected rows
+ if (!this.isEditable()) {
+ return;
+ }
+
+ // 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).closest('.ui-autocomplete').length) {
+ return;
+ }
+
+ // ignore clicks if there is a modal, except if the list is in the last
+ // (active) modal
+ var $modal = $('body > .modal:last');
+ if ($modal.length) {
+ var $listModal = this.$el.closest('.modal');
+ if ($modal.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;
+ }
+
+ // ignore click if search facet is removed as it will re-render whole
+ // listview again
+ if ($(event.target).hasClass('o_facet_remove')) {
+ return;
+ }
+
+ this.unselectRow();
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js
new file mode 100644
index 00000000..b119e7da
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_model.js
@@ -0,0 +1,175 @@
+odoo.define('web.ListModel', function (require) {
+ "use strict";
+
+ var BasicModel = require('web.BasicModel');
+
+ var ListModel = BasicModel.extend({
+
+ /**
+ * @override
+ * @param {Object} params.groupbys
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ this.groupbys = params.groupbys;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * overridden to add `groupData` when performing get on list datapoints.
+ *
+ * @override
+ * @see _readGroupExtraFields
+ */
+ __get: function () {
+ var result = this._super.apply(this, arguments);
+ var dp = result && this.localData[result.id];
+ if (dp && dp.groupData) {
+ result.groupData = this.get(dp.groupData);
+ }
+ return result;
+ },
+ /**
+ * For a list of records, performs a write with all changes and fetches
+ * all data.
+ *
+ * @param {string} listDatapointId id of the parent list
+ * @param {string} referenceRecordId the record datapoint used to
+ * generate the changes to apply to recordIds
+ * @param {string[]} recordIds a list of record datapoint ids
+ * @param {string} fieldName the field to write
+ */
+ saveRecords: function (listDatapointId, referenceRecordId, recordIds, fieldName) {
+ var self = this;
+ var referenceRecord = this.localData[referenceRecordId];
+ var list = this.localData[listDatapointId];
+ // generate all record values to ensure that we'll write something
+ // (e.g. 2 records selected, edit a many2one in the first one, but
+ // reset same value, we still want to save this value on the other
+ // record)
+ var allChanges = this._generateChanges(referenceRecord, {changesOnly: false});
+ var changes = _.pick(allChanges, fieldName);
+ var records = recordIds.map(function (recordId) {
+ return self.localData[recordId];
+ });
+ var model = records[0].model;
+ var recordResIds = _.pluck(records, 'res_id');
+ var fieldNames = records[0].getFieldNames();
+ var context = records[0].getContext();
+
+ return this._rpc({
+ model: model,
+ method: 'write',
+ args: [recordResIds, changes],
+ context: context,
+ }).then(function () {
+ return self._rpc({
+ model: model,
+ method: 'read',
+ args: [recordResIds, fieldNames],
+ context: context,
+ });
+ }).then(function (results) {
+ results.forEach(function (data) {
+ var record = _.findWhere(records, {res_id: data.id});
+ record.data = _.extend({}, record.data, data);
+ record._changes = {};
+ record._isDirty = false;
+ self._parseServerData(fieldNames, record, record.data);
+ });
+ }).then(function () {
+ if (!list.groupedBy.length) {
+ return Promise.all([
+ self._fetchX2ManysBatched(list),
+ self._fetchReferencesBatched(list)
+ ]);
+ } else {
+ return Promise.all([
+ self._fetchX2ManysSingleBatch(list),
+ self._fetchReferencesSingleBatch(list)
+ ]);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @override
+ * @private
+ */
+ _readGroup: function (list, options) {
+ var self = this;
+ options = options || {};
+ options.fetchRecordsWithGroups = true;
+ return this._super(list, options).then(function (result) {
+ return self._readGroupExtraFields(list).then(_.constant(result));
+ });
+ },
+ /**
+ * Fetches group specific fields on the group by relation and stores it
+ * in the column datapoint in a special key `groupData`.
+ * Data for the groups are fetched in batch for all groups, to avoid
+ * doing multiple calls.
+ * Note that the option is only for m2o fields.
+ *
+ * @private
+ * @param {Object} list
+ * @returns {Promise}
+ */
+ _readGroupExtraFields: function (list) {
+ var self = this;
+ var groupByFieldName = list.groupedBy[0].split(':')[0];
+ var groupedByField = list.fields[groupByFieldName];
+ if (groupedByField.type !== 'many2one' || !this.groupbys[groupByFieldName]) {
+ return Promise.resolve();
+ }
+ var groupIds = _.reduce(list.data, function (groupIds, id) {
+ var resId = self.get(id, { raw: true }).res_id;
+ if (resId) { // the field might be undefined when grouping
+ groupIds.push(resId);
+ }
+ return groupIds;
+ }, []);
+ var groupFields = Object.keys(this.groupbys[groupByFieldName].viewFields);
+ var prom;
+ if (groupIds.length && groupFields.length) {
+ prom = this._rpc({
+ model: groupedByField.relation,
+ method: 'read',
+ args: [groupIds, groupFields],
+ context: list.context,
+ });
+ }
+ return Promise.resolve(prom).then(function (result) {
+ var fvg = self.groupbys[groupByFieldName];
+ _.each(list.data, function (id) {
+ var dp = self.localData[id];
+ var groupData = result && _.findWhere(result, {
+ id: dp.res_id,
+ });
+ var groupDp = self._makeDataPoint({
+ context: dp.context,
+ data: groupData,
+ fields: fvg.fields,
+ fieldsInfo: fvg.fieldsInfo,
+ modelName: groupedByField.relation,
+ parentID: dp.id,
+ res_id: dp.res_id,
+ viewType: 'groupby',
+ });
+ dp.groupData = groupDp.id;
+ self._parseServerData(groupFields, groupDp, groupDp.data);
+ });
+ });
+ },
+ });
+ return ListModel;
+});
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
new file mode 100644
index 00000000..4e24ce54
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -0,0 +1,1470 @@
+odoo.define('web.ListRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+var config = require('web.config');
+var core = require('web.core');
+var dom = require('web.dom');
+var field_utils = require('web.field_utils');
+var Pager = require('web.Pager');
+var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+
+// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes
+var DECORATIONS = [
+ 'decoration-bf',
+ 'decoration-it',
+ 'decoration-danger',
+ 'decoration-info',
+ 'decoration-muted',
+ 'decoration-primary',
+ 'decoration-success',
+ 'decoration-warning'
+];
+
+var FIELD_CLASSES = {
+ char: 'o_list_char',
+ float: 'o_list_number',
+ integer: 'o_list_number',
+ monetary: 'o_list_number',
+ text: 'o_list_text',
+ many2one: 'o_list_many2one',
+};
+
+var ListRenderer = BasicRenderer.extend({
+ className: 'o_list_view',
+ events: {
+ "mousedown": "_onMouseDown",
+ "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn",
+ "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown",
+ 'click tbody tr': '_onRowClicked',
+ 'change tbody .o_list_record_selector': '_onSelectRecord',
+ 'click thead th.o_column_sortable': '_onSortColumn',
+ 'click .o_list_record_selector': '_onToggleCheckbox',
+ 'click .o_group_header': '_onToggleGroup',
+ 'change thead .o_list_record_selector input': '_onToggleSelection',
+ 'keypress thead tr td': '_onKeyPress',
+ 'keydown td': '_onKeyDown',
+ 'keydown th': '_onKeyDown',
+ },
+ sampleDataTargets: [
+ '.o_data_row',
+ '.o_group_header',
+ '.o_list_table > tfoot',
+ '.o_list_table > thead .o_list_record_selector',
+ ],
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {any} state
+ * @param {Object} params
+ * @param {boolean} params.hasSelectors
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this._preprocessColumns();
+ this.columnInvisibleFields = params.columnInvisibleFields || {};
+ this.rowDecorations = this._extractDecorationAttrs(this.arch);
+ this.fieldDecorations = {};
+ for (const field of this.arch.children.filter(c => c.tag === 'field')) {
+ const decorations = this._extractDecorationAttrs(field);
+ this.fieldDecorations[field.attrs.name] = decorations;
+ }
+ this.hasSelectors = params.hasSelectors;
+ this.selection = params.selectedRecords || [];
+ this.pagers = []; // instantiated pagers (only for grouped lists)
+ this.isGrouped = this.state.groupedBy.length > 0;
+ this.groupbys = params.groupbys;
+ },
+ /**
+ * Compute columns visilibity. This can't be done earlier as we need the
+ * controller to respond to the load_optional_fields call of processColumns.
+ *
+ * @override
+ */
+ willStart: function () {
+ this._processColumns(this.columnInvisibleFields);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Order to focus to be given to the content of the current view
+ *
+ * @override
+ */
+ giveFocus: function () {
+ this.$('th:eq(0) input, th:eq(1)').first().focus();
+ },
+ /**
+ * @override
+ */
+ updateState: function (state, params) {
+ this._setState(state);
+ this.isGrouped = this.state.groupedBy.length > 0;
+ this.columnInvisibleFields = params.columnInvisibleFields || {};
+ this._processColumns(this.columnInvisibleFields);
+ if (params.selectedRecords) {
+ this.selection = params.selectedRecords;
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method does a in-memory computation of the aggregate values, for
+ * each columns that corresponds to a numeric field with a proper aggregate
+ * function.
+ *
+ * The result of these computations is stored in the 'aggregate' key of each
+ * column of this.columns. This will be then used by the _renderFooter
+ * method to display the appropriate amount.
+ *
+ * @private
+ */
+ _computeAggregates: function () {
+ var self = this;
+ var data = [];
+ if (this.selection.length) {
+ utils.traverse_records(this.state, function (record) {
+ if (_.contains(self.selection, record.id)) {
+ data.push(record); // find selected records
+ }
+ });
+ } else {
+ data = this.state.data;
+ }
+
+ _.each(this.columns, this._computeColumnAggregates.bind(this, data));
+ },
+ /**
+ * Compute the aggregate values for a given column and a set of records.
+ * The aggregate values are then written, if applicable, in the 'aggregate'
+ * key of the column object.
+ *
+ * @private
+ * @param {Object[]} data a list of selected/all records
+ * @param {Object} column
+ */
+ _computeColumnAggregates: function (data, column) {
+ var attrs = column.attrs;
+ var field = this.state.fields[attrs.name];
+ if (!field) {
+ return;
+ }
+ var type = field.type;
+ if (type !== 'integer' && type !== 'float' && type !== 'monetary') {
+ return;
+ }
+ var func = (attrs.sum && 'sum') || (attrs.avg && 'avg') ||
+ (attrs.max && 'max') || (attrs.min && 'min');
+ if (func) {
+ var count = 0;
+ var aggregateValue = 0;
+ if (func === 'max') {
+ aggregateValue = -Infinity;
+ } else if (func === 'min') {
+ aggregateValue = Infinity;
+ }
+ _.each(data, function (d) {
+ count += 1;
+ var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name];
+ if (func === 'avg') {
+ aggregateValue += value;
+ } else if (func === 'sum') {
+ aggregateValue += value;
+ } else if (func === 'max') {
+ aggregateValue = Math.max(aggregateValue, value);
+ } else if (func === 'min') {
+ aggregateValue = Math.min(aggregateValue, value);
+ }
+ });
+ if (func === 'avg') {
+ aggregateValue = count ? aggregateValue / count : aggregateValue;
+ }
+ column.aggregate = {
+ help: attrs[func],
+ value: aggregateValue,
+ };
+ }
+ },
+ /**
+ * Extract the decoration attributes (e.g. decoration-danger) of a node. The
+ * condition is processed such that it is ready to be evaluated.
+ *
+ * @private
+ * @param {Object} node the <tree> or a <field> node
+ * @returns {Object}
+ */
+ _extractDecorationAttrs: function (node) {
+ const decorations = {};
+ for (const [key, expr] of Object.entries(node.attrs)) {
+ if (DECORATIONS.includes(key)) {
+ const cssClass = key.replace('decoration', 'text');
+ decorations[cssClass] = py.parse(py.tokenize(expr));
+ }
+ }
+ return decorations;
+ },
+ /**
+ *
+ * @private
+ * @param {jQuery} $cell
+ * @param {string} direction
+ * @param {integer} colIndex
+ * @returns {jQuery|null}
+ */
+ _findConnectedCell: function ($cell, direction, colIndex) {
+ var $connectedRow = $cell.closest('tr')[direction]('tr');
+
+ if (!$connectedRow.length) {
+ // Is there another group ? Look at our parent's sibling
+ // We can have th in tbody so we can't simply look for thead
+ // if cell is a th and tbody instead
+ var tbody = $cell.closest('tbody, thead');
+ var $connectedGroup = tbody[direction]('tbody, thead');
+ if ($connectedGroup.length) {
+ // Found another group
+ var $connectedRows = $connectedGroup.find('tr');
+ var rowIndex;
+ if (direction === 'prev') {
+ rowIndex = $connectedRows.length - 1;
+ } else {
+ rowIndex = 0;
+ }
+ $connectedRow = $connectedRows.eq(rowIndex);
+ } else {
+ // End of the table
+ return;
+ }
+ }
+
+ var $connectedCell;
+ if ($connectedRow.hasClass('o_group_header')) {
+ $connectedCell = $connectedRow.children();
+ this.currentColIndex = colIndex;
+ } else if ($connectedRow.has('td.o_group_field_row_add').length) {
+ $connectedCell = $connectedRow.find('.o_group_field_row_add');
+ this.currentColIndex = colIndex;
+ } else {
+ var connectedRowChildren = $connectedRow.children();
+ if (colIndex === -1) {
+ colIndex = connectedRowChildren.length - 1;
+ }
+ $connectedCell = connectedRowChildren.eq(colIndex);
+ }
+
+ return $connectedCell;
+ },
+ /**
+ * return the number of visible columns. Note that this number depends on
+ * the state of the renderer. For example, in editable mode, it could be
+ * one more that in non editable mode, because there may be a visible 'trash
+ * icon'.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getNumberOfCols: function () {
+ var n = this.columns.length;
+ return this.hasSelectors ? n + 1 : n;
+ },
+ /**
+ * Returns the local storage key for stored enabled optional columns
+ *
+ * @private
+ * @returns {string}
+ */
+ _getOptionalColumnsStorageKeyParts: function () {
+ var self = this;
+ return {
+ fields: _.map(this.state.fieldsInfo[this.viewType], function (_, fieldName) {
+ return {name: fieldName, type: self.state.fields[fieldName].type};
+ }),
+ };
+ },
+ /**
+ * Adjacent buttons (in the arch) are displayed in a single column. This
+ * function iterates over the arch's nodes and replaces "button" nodes by
+ * "button_group" nodes, with a single "button_group" node for adjacent
+ * "button" nodes. A "button_group" node has a "children" attribute
+ * containing all "button" nodes in the group.
+ *
+ * @private
+ */
+ _groupAdjacentButtons: function () {
+ const children = [];
+ let groupId = 0;
+ let buttonGroupNode = null;
+ for (const c of this.arch.children) {
+ if (c.tag === 'button') {
+ if (!buttonGroupNode) {
+ buttonGroupNode = {
+ tag: 'button_group',
+ children: [c],
+ attrs: {
+ name: `button_group_${groupId++}`,
+ modifiers: {},
+ },
+ };
+ children.push(buttonGroupNode);
+ } else {
+ buttonGroupNode.children.push(c);
+ }
+ } else {
+ buttonGroupNode = null;
+ children.push(c);
+ }
+ }
+ this.arch.children = children;
+ },
+ /**
+ * Processes arch's child nodes for the needs of the list view:
+ * - detects oe_read_only/oe_edit_only classnames
+ * - groups adjacent buttons in a single column.
+ * This function is executed only once, at initialization.
+ *
+ * @private
+ */
+ _preprocessColumns: function () {
+ this._processModeClassNames();
+ this._groupAdjacentButtons();
+
+ // set as readOnly (resp. editOnly) button groups containing only
+ // readOnly (resp. editOnly) buttons, s.t. no column is rendered
+ this.arch.children.filter(c => c.tag === 'button_group').forEach(c => {
+ c.attrs.editOnly = c.children.every(n => n.attrs.editOnly);
+ c.attrs.readOnly = c.children.every(n => n.attrs.readOnly);
+ });
+ },
+ /**
+ * Removes the columns which should be invisible. This function is executed
+ * at each (re-)rendering of the list.
+ *
+ * @param {Object} columnInvisibleFields contains the column invisible modifier values
+ */
+ _processColumns: function (columnInvisibleFields) {
+ var self = this;
+ this.handleField = null;
+ this.columns = [];
+ this.optionalColumns = [];
+ this.optionalColumnsEnabled = [];
+ var storedOptionalColumns;
+ this.trigger_up('load_optional_fields', {
+ keyParts: this._getOptionalColumnsStorageKeyParts(),
+ callback: function (res) {
+ storedOptionalColumns = res;
+ },
+ });
+ _.each(this.arch.children, function (c) {
+ if (c.tag !== 'control' && c.tag !== 'groupby' && c.tag !== 'header') {
+ var reject = c.attrs.modifiers.column_invisible;
+ // If there is an evaluated domain for the field we override the node
+ // attribute to have the evaluated modifier value.
+ if (c.tag === "button_group") {
+ // FIXME: 'column_invisible' attribute is available for fields *and* buttons,
+ // so 'columnInvisibleFields' variable name is misleading, it should be renamed
+ reject = c.children.every(child => columnInvisibleFields[child.attrs.name]);
+ } else if (c.attrs.name in columnInvisibleFields) {
+ reject = columnInvisibleFields[c.attrs.name];
+ }
+ if (!reject && c.attrs.widget === 'handle') {
+ self.handleField = c.attrs.name;
+ if (self.isGrouped) {
+ reject = true;
+ }
+ }
+
+ if (!reject && c.attrs.optional) {
+ self.optionalColumns.push(c);
+ var enabled;
+ if (storedOptionalColumns === undefined) {
+ enabled = c.attrs.optional === 'show';
+ } else {
+ enabled = _.contains(storedOptionalColumns, c.attrs.name);
+ }
+ if (enabled) {
+ self.optionalColumnsEnabled.push(c.attrs.name);
+ }
+ reject = !enabled;
+ }
+
+ if (!reject) {
+ self.columns.push(c);
+ }
+ }
+ });
+ },
+ /**
+ * Classnames "oe_edit_only" and "oe_read_only" aim to only display the cell
+ * in the corresponding mode. This only concerns lists inside form views
+ * (for x2many fields). This function detects the className and stores a
+ * flag on the node's attrs accordingly, to ease further computations.
+ *
+ * @private
+ */
+ _processModeClassNames: function () {
+ this.arch.children.forEach(c => {
+ if (c.attrs.class) {
+ c.attrs.editOnly = /\boe_edit_only\b/.test(c.attrs.class);
+ c.attrs.readOnly = /\boe_read_only\b/.test(c.attrs.class);
+ }
+ });
+ },
+ /**
+ * Render a list of <td>, with aggregates if available. It can be displayed
+ * in the footer, or for each open groups.
+ *
+ * @private
+ * @param {any} aggregateValues
+ * @returns {jQueryElement[]} a list of <td> with the aggregate values
+ */
+ _renderAggregateCells: function (aggregateValues) {
+ var self = this;
+
+ return _.map(this.columns, function (column) {
+ var $cell = $('<td>');
+ if (config.isDebug()) {
+ $cell.addClass(column.attrs.name);
+ }
+ if (column.attrs.editOnly) {
+ $cell.addClass('oe_edit_only');
+ }
+ if (column.attrs.readOnly) {
+ $cell.addClass('oe_read_only');
+ }
+ if (column.attrs.name in aggregateValues) {
+ var field = self.state.fields[column.attrs.name];
+ var value = aggregateValues[column.attrs.name].value;
+ var help = aggregateValues[column.attrs.name].help;
+ var formatFunc = field_utils.format[column.attrs.widget];
+ if (!formatFunc) {
+ formatFunc = field_utils.format[field.type];
+ }
+ var formattedValue = formatFunc(value, field, {
+ escape: true,
+ digits: column.attrs.digits ? JSON.parse(column.attrs.digits) : undefined,
+ });
+ $cell.addClass('o_list_number').attr('title', help).html(formattedValue);
+ }
+ return $cell;
+ });
+ },
+ /**
+ * Render the main body of the table, with all its content. Note that it
+ * has been decided to always render at least 4 rows, even if we have less
+ * data. The reason is that lists with 0 or 1 lines don't really look like
+ * a table.
+ *
+ * @private
+ * @returns {jQueryElement} a jquery element <tbody>
+ */
+ _renderBody: function () {
+ var self = this;
+ var $rows = this._renderRows();
+ while ($rows.length < 4) {
+ $rows.push(self._renderEmptyRow());
+ }
+ return $('<tbody>').append($rows);
+ },
+ /**
+ * Render a cell for the table. For most cells, we only want to display the
+ * formatted value, with some appropriate css class. However, when the
+ * node was explicitely defined with a 'widget' attribute, then we
+ * instantiate the corresponding widget.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} node
+ * @param {integer} colIndex
+ * @param {Object} [options]
+ * @param {Object} [options.mode]
+ * @param {Object} [options.renderInvisible=false]
+ * force the rendering of invisible cell content
+ * @param {Object} [options.renderWidgets=false]
+ * force the rendering of the cell value thanks to a widget
+ * @returns {jQueryElement} a <td> element
+ */
+ _renderBodyCell: function (record, node, colIndex, options) {
+ var tdClassName = 'o_data_cell';
+ if (node.tag === 'button_group') {
+ tdClassName += ' o_list_button';
+ } else if (node.tag === 'field') {
+ tdClassName += ' o_field_cell';
+ var typeClass = FIELD_CLASSES[this.state.fields[node.attrs.name].type];
+ if (typeClass) {
+ tdClassName += (' ' + typeClass);
+ }
+ if (node.attrs.widget) {
+ tdClassName += (' o_' + node.attrs.widget + '_cell');
+ }
+ }
+ if (node.attrs.editOnly) {
+ tdClassName += ' oe_edit_only';
+ }
+ if (node.attrs.readOnly) {
+ tdClassName += ' oe_read_only';
+ }
+ var $td = $('<td>', { class: tdClassName, tabindex: -1 });
+
+ // We register modifiers on the <td> element so that it gets the correct
+ // modifiers classes (for styling)
+ var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode'));
+ // If the invisible modifiers is true, the <td> element is left empty.
+ // Indeed, if the modifiers was to change the whole cell would be
+ // rerendered anyway.
+ if (modifiers.invisible && !(options && options.renderInvisible)) {
+ return $td;
+ }
+
+ if (node.tag === 'button_group') {
+ for (const buttonNode of node.children) {
+ if (!this.columnInvisibleFields[buttonNode.attrs.name]) {
+ $td.append(this._renderButton(record, buttonNode));
+ }
+ }
+ return $td;
+ } else if (node.tag === 'widget') {
+ return $td.append(this._renderWidget(record, node));
+ }
+ if (node.attrs.widget || (options && options.renderWidgets)) {
+ var $el = this._renderFieldWidget(node, record, _.pick(options, 'mode'));
+ return $td.append($el);
+ }
+ this._handleAttributes($td, node);
+ this._setDecorationClasses($td, this.fieldDecorations[node.attrs.name], record);
+
+ var name = node.attrs.name;
+ var field = this.state.fields[name];
+ var value = record.data[name];
+ var formatter = field_utils.format[field.type];
+ var formatOptions = {
+ escape: true,
+ data: record.data,
+ isPassword: 'password' in node.attrs,
+ digits: node.attrs.digits && JSON.parse(node.attrs.digits),
+ };
+ var formattedValue = formatter(value, field, formatOptions);
+ var title = '';
+ if (field.type !== 'boolean') {
+ title = formatter(value, field, _.extend(formatOptions, {escape: false}));
+ }
+ return $td.html(formattedValue).attr('title', title);
+ },
+ /**
+ * Renders the button element associated to the given node and record.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} node
+ * @returns {jQuery} a <button> element
+ */
+ _renderButton: function (record, node) {
+ var self = this;
+ var nodeWithoutWidth = Object.assign({}, node);
+ delete nodeWithoutWidth.attrs.width;
+
+ let extraClass = '';
+ if (node.attrs.icon) {
+ // if there is an icon, we force the btn-link style, unless a btn-xxx
+ // style class is explicitely provided
+ const btnStyleRegex = /\bbtn-[a-z]+\b/;
+ if (!btnStyleRegex.test(nodeWithoutWidth.attrs.class)) {
+ extraClass = 'btn-link o_icon_button';
+ }
+ }
+ var $button = viewUtils.renderButtonFromNode(nodeWithoutWidth, {
+ extraClass: extraClass,
+ });
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, record, $button);
+
+ if (record.res_id) {
+ // TODO this should be moved to a handler
+ $button.on("click", function (e) {
+ e.stopPropagation();
+ self.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ });
+ } else {
+ if (node.attrs.options.warn) {
+ $button.on("click", function (e) {
+ e.stopPropagation();
+ self.do_warn(false, _t('Please click on the "save" button first'));
+ });
+ } else {
+ $button.prop('disabled', true);
+ }
+ }
+
+ return $button;
+ },
+ /**
+ * Render a complete empty row. This is used to fill in the blanks when we
+ * have less than 4 lines to display.
+ *
+ * @private
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderEmptyRow: function () {
+ var $td = $('<td>&nbsp;</td>').attr('colspan', this._getNumberOfCols());
+ return $('<tr>').append($td);
+ },
+ /**
+ * Render the footer. It is a <tfoot> with a single row, containing all
+ * aggregates, if applicable.
+ *
+ * @private
+ * @returns {jQueryElement} a <tfoot> element
+ */
+ _renderFooter: function () {
+ var aggregates = {};
+ _.each(this.columns, function (column) {
+ if ('aggregate' in column) {
+ aggregates[column.attrs.name] = column.aggregate;
+ }
+ });
+ var $cells = this._renderAggregateCells(aggregates);
+ if (this.hasSelectors) {
+ $cells.unshift($('<td>'));
+ }
+ return $('<tfoot>').append($('<tr>').append($cells));
+ },
+ /**
+ * Renders the group button element.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} group
+ * @returns {jQuery} a <button> element
+ */
+ _renderGroupButton: function (list, node) {
+ var $button = viewUtils.renderButtonFromNode(node, {
+ extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
+ textAsTitle: !!node.attrs.icon,
+ });
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, list.groupData, $button);
+
+ // TODO this should be moved to event handlers
+ $button.on("click", this._onGroupButtonClicked.bind(this, list.groupData, node));
+ $button.on("keydown", this._onGroupButtonKeydown.bind(this));
+
+ return $button;
+ },
+ /**
+ * Renders the group buttons.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} group
+ * @returns {jQuery} a <button> element
+ */
+ _renderGroupButtons: function (list, group) {
+ var self = this;
+ var $buttons = $();
+ if (list.value) {
+ // buttons make no sense for 'Undefined' group
+ group.arch.children.forEach(function (child) {
+ if (child.tag === 'button') {
+ $buttons = $buttons.add(self._renderGroupButton(list, child));
+ }
+ });
+ }
+ return $buttons;
+ },
+ /**
+ * Render the row that represent a group
+ *
+ * @private
+ * @param {Object} group
+ * @param {integer} groupLevel the nesting level (0 for root groups)
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderGroupRow: function (group, groupLevel) {
+ var cells = [];
+
+ var name = group.value === undefined ? _t('Undefined') : group.value;
+ var groupBy = this.state.groupedBy[groupLevel];
+ if (group.fields[groupBy.split(':')[0]].type !== 'boolean') {
+ name = name || _t('Undefined');
+ }
+ var $th = $('<th>')
+ .addClass('o_group_name')
+ .attr('tabindex', -1)
+ .text(name + ' (' + group.count + ')');
+ var $arrow = $('<span>')
+ .css('padding-left', 2 + (groupLevel * 20) + 'px')
+ .css('padding-right', '5px')
+ .addClass('fa');
+ if (group.count > 0) {
+ $arrow.toggleClass('fa-caret-right', !group.isOpen)
+ .toggleClass('fa-caret-down', group.isOpen);
+ }
+ $th.prepend($arrow);
+ cells.push($th);
+
+ var aggregateKeys = Object.keys(group.aggregateValues);
+ var aggregateValues = _.mapObject(group.aggregateValues, function (value) {
+ return { value: value };
+ });
+ var aggregateCells = this._renderAggregateCells(aggregateValues);
+ var firstAggregateIndex = _.findIndex(this.columns, function (column) {
+ return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name);
+ });
+ var colspanBeforeAggregate;
+ if (firstAggregateIndex !== -1) {
+ // if there are aggregates, the first $th goes until the first
+ // aggregate then all cells between aggregates are rendered
+ colspanBeforeAggregate = firstAggregateIndex;
+ var lastAggregateIndex = _.findLastIndex(this.columns, function (column) {
+ return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name);
+ });
+ cells = cells.concat(aggregateCells.slice(firstAggregateIndex, lastAggregateIndex + 1));
+ var colSpan = this.columns.length - 1 - lastAggregateIndex;
+ if (colSpan > 0) {
+ cells.push($('<th>').attr('colspan', colSpan));
+ }
+ } else {
+ var colN = this.columns.length;
+ colspanBeforeAggregate = colN > 1 ? colN - 1 : 1;
+ if (colN > 1) {
+ cells.push($('<th>'));
+ }
+ }
+ if (this.hasSelectors) {
+ colspanBeforeAggregate += 1;
+ }
+ $th.attr('colspan', colspanBeforeAggregate);
+
+ if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) {
+ const lastCell = cells[cells.length - 1][0];
+ this._renderGroupPager(group, lastCell);
+ }
+ if (group.isOpen && this.groupbys[groupBy]) {
+ var $buttons = this._renderGroupButtons(group, this.groupbys[groupBy]);
+ if ($buttons.length) {
+ var $buttonSection = $('<div>', {
+ class: 'o_group_buttons',
+ }).append($buttons);
+ $th.append($buttonSection);
+ }
+ }
+ return $('<tr>')
+ .addClass('o_group_header')
+ .toggleClass('o_group_open', group.isOpen)
+ .toggleClass('o_group_has_content', group.count > 0)
+ .data('group', group)
+ .append(cells);
+ },
+ /**
+ * Render the content of a given opened group.
+ *
+ * @private
+ * @param {Object} group
+ * @param {integer} groupLevel the nesting level (0 for root groups)
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderGroup: function (group, groupLevel) {
+ var self = this;
+ if (group.groupedBy.length) {
+ // the opened group contains subgroups
+ return this._renderGroups(group.data, groupLevel + 1);
+ } else {
+ // the opened group contains records
+ var $records = _.map(group.data, function (record) {
+ return self._renderRow(record);
+ });
+ return [$('<tbody>').append($records)];
+ }
+ },
+ /**
+ * Renders the pager for a given group
+ *
+ * @private
+ * @param {Object} group
+ * @param {HTMLElement} target
+ */
+ _renderGroupPager: function (group, target) {
+ const currentMinimum = group.offset + 1;
+ const limit = group.limit;
+ const size = group.count;
+ if (!this._shouldRenderPager(currentMinimum, limit, size)) {
+ return;
+ }
+ const pager = new ComponentWrapper(this, Pager, { currentMinimum, limit, size });
+ const pagerMounting = pager.mount(target).then(() => {
+ // Event binding is done here to get the related group and wrapper.
+ pager.el.addEventListener('pager-changed', ev => this._onPagerChanged(ev, group));
+ // Prevent pager clicks to toggle the group.
+ pager.el.addEventListener('click', ev => ev.stopPropagation());
+ });
+ this.defs.push(pagerMounting);
+ this.pagers.push(pager);
+ },
+ /**
+ * Render all groups in the view. We assume that the view is in grouped
+ * mode.
+ *
+ * Note that each group is rendered inside a <tbody>, which contains a group
+ * row, then possibly a bunch of rows for each record.
+ *
+ * @private
+ * @param {Object} data the dataPoint containing the groups
+ * @param {integer} [groupLevel=0] the nesting level. 0 is for the root group
+ * @returns {jQueryElement[]} a list of <tbody>
+ */
+ _renderGroups: function (data, groupLevel) {
+ var self = this;
+ groupLevel = groupLevel || 0;
+ var result = [];
+ var $tbody = $('<tbody>');
+ _.each(data, function (group) {
+ if (!$tbody) {
+ $tbody = $('<tbody>');
+ }
+ $tbody.append(self._renderGroupRow(group, groupLevel));
+ if (group.data.length) {
+ result.push($tbody);
+ result = result.concat(self._renderGroup(group, groupLevel));
+ $tbody = null;
+ }
+ });
+ if ($tbody) {
+ result.push($tbody);
+ }
+ return result;
+ },
+ /**
+ * Render the main header for the list view. It is basically just a <thead>
+ * with the name of each fields
+ *
+ * @private
+ * @returns {jQueryElement} a <thead> element
+ */
+ _renderHeader: function () {
+ var $tr = $('<tr>')
+ .append(_.map(this.columns, this._renderHeaderCell.bind(this)));
+ if (this.hasSelectors) {
+ $tr.prepend(this._renderSelector('th'));
+ }
+ return $('<thead>').append($tr);
+ },
+ /**
+ * Render a single <th> with the informations for a column. If it is not a
+ * field or nolabel attribute is set to "1", the th will be empty.
+ * Otherwise, it will contains all relevant information for the field.
+ *
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement} a <th> element
+ */
+ _renderHeaderCell: function (node) {
+ const { icon, name, string } = node.attrs;
+ var order = this.state.orderedBy;
+ var isNodeSorted = order[0] && order[0].name === name;
+ var field = this.state.fields[name];
+ var $th = $('<th>');
+ if (name) {
+ $th.attr('data-name', name);
+ } else if (string) {
+ $th.attr('data-string', string);
+ } else if (icon) {
+ $th.attr('data-icon', icon);
+ }
+ if (node.attrs.editOnly) {
+ $th.addClass('oe_edit_only');
+ }
+ if (node.attrs.readOnly) {
+ $th.addClass('oe_read_only');
+ }
+ if (node.tag === 'button_group') {
+ $th.addClass('o_list_button');
+ }
+ if (!field || node.attrs.nolabel === '1') {
+ return $th;
+ }
+ var description = string || field.string;
+ if (node.attrs.widget) {
+ $th.addClass(' o_' + node.attrs.widget + '_cell');
+ const FieldWidget = this.state.fieldsInfo.list[name].Widget;
+ if (FieldWidget.prototype.noLabel) {
+ description = '';
+ } else if (FieldWidget.prototype.label) {
+ description = FieldWidget.prototype.label;
+ }
+ }
+ $th.text(description)
+ .attr('tabindex', -1)
+ .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false)
+ .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false)
+ .addClass((field.sortable || this.state.fieldsInfo.list[name].options.allow_order || false) && 'o_column_sortable');
+
+ if (isNodeSorted) {
+ $th.attr('aria-sort', order[0].asc ? 'ascending' : 'descending');
+ }
+
+ if (field.type === 'float' || field.type === 'integer' || field.type === 'monetary') {
+ $th.addClass('o_list_number_th');
+ }
+
+ if (config.isDebug()) {
+ var fieldDescr = {
+ field: field,
+ name: name,
+ string: description || name,
+ record: this.state,
+ attrs: _.extend({}, node.attrs, this.state.fieldsInfo.list[name]),
+ };
+ this._addFieldTooltip(fieldDescr, $th);
+ } else {
+ $th.attr('title', description);
+ }
+ return $th;
+ },
+ /**
+ * Render a row, corresponding to a record.
+ *
+ * @private
+ * @param {Object} record
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderRow: function (record) {
+ var self = this;
+ var $cells = this.columns.map(function (node, index) {
+ return self._renderBodyCell(record, node, index, { mode: 'readonly' });
+ });
+
+ var $tr = $('<tr/>', { class: 'o_data_row' })
+ .attr('data-id', record.id)
+ .append($cells);
+ if (this.hasSelectors) {
+ $tr.prepend(this._renderSelector('td', !record.res_id));
+ }
+ this._setDecorationClasses($tr, this.rowDecorations, record);
+ return $tr;
+ },
+ /**
+ * Render all rows. This method should only called when the view is not
+ * grouped.
+ *
+ * @private
+ * @returns {jQueryElement[]} a list of <tr>
+ */
+ _renderRows: function () {
+ return this.state.data.map(this._renderRow.bind(this));
+ },
+ /**
+ * Render a single <th> with dropdown menu to display optional columns of view.
+ *
+ * @private
+ * @returns {jQueryElement} a <th> element
+ */
+ _renderOptionalColumnsDropdown: function () {
+ var self = this;
+ var $optionalColumnsDropdown = $('<div>', {
+ class: 'o_optional_columns text-center dropdown',
+ });
+ var $a = $("<a>", {
+ 'class': "dropdown-toggle text-dark o-no-caret",
+ 'href': "#",
+ 'role': "button",
+ 'data-toggle': "dropdown",
+ 'data-display': "static",
+ 'aria-expanded': false,
+ 'aria-label': _t('Optional columns'),
+ });
+ $a.appendTo($optionalColumnsDropdown);
+
+ // Set the expansion direction of the dropdown
+ // The button is located at the end of the list headers
+ // We want the dropdown to expand towards the list rather than away from it
+ // https://getbootstrap.com/docs/4.0/components/dropdowns/#menu-alignment
+ var direction = _t.database.parameters.direction;
+ var dropdownMenuClass = direction === 'rtl' ? 'dropdown-menu-left' : 'dropdown-menu-right';
+ var $dropdown = $("<div>", {
+ class: 'dropdown-menu o_optional_columns_dropdown ' + dropdownMenuClass,
+ role: 'menu',
+ });
+ this.optionalColumns.forEach(function (col) {
+ var txt = (col.attrs.string || self.state.fields[col.attrs.name].string) +
+ (config.isDebug() ? (' (' + col.attrs.name + ')') : '');
+ var $checkbox = dom.renderCheckbox({
+ text: txt,
+ role: "menuitemcheckbox",
+ prop: {
+ name: col.attrs.name,
+ checked: _.contains(self.optionalColumnsEnabled, col.attrs.name),
+ }
+ });
+ $dropdown.append($("<div>", {
+ class: "dropdown-item",
+ }).append($checkbox));
+ });
+ $dropdown.appendTo($optionalColumnsDropdown);
+ return $optionalColumnsDropdown;
+ },
+ /**
+ * A 'selector' is the small checkbox on the left of a record in a list
+ * view. This is rendered as an input inside a div, so we can properly
+ * style it.
+ *
+ * Note that it takes a tag in argument, because selectors in the header
+ * are renderd in a th, and those in the tbody are in a td.
+ *
+ * @private
+ * @param {string} tag either th or td
+ * @param {boolean} disableInput if true, the input generated will be disabled
+ * @returns {jQueryElement}
+ */
+ _renderSelector: function (tag, disableInput) {
+ var $content = dom.renderCheckbox();
+ if (disableInput) {
+ $content.find("input[type='checkbox']").prop('disabled', disableInput);
+ }
+ return $('<' + tag + '>')
+ .addClass('o_list_record_selector')
+ .append($content);
+ },
+ /**
+ * Main render function for the list. It is rendered as a table. For now,
+ * this method does not wait for the field widgets to be ready.
+ *
+ * @override
+ * @returns {Promise} resolved when the view has been rendered
+ */
+ async _renderView() {
+ const oldPagers = this.pagers;
+ let prom;
+ let tableWrapper;
+ if (this.state.count > 0 || !this.noContentHelp) {
+ // render a table if there are records, or if there is no no content
+ // helper (empty table in this case)
+ this.pagers = [];
+
+ const orderedBy = this.state.orderedBy;
+ this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
+ this._computeAggregates();
+
+ const $table = $(
+ '<table class="o_list_table table table-sm table-hover table-striped"/>'
+ );
+ $table.toggleClass('o_list_table_grouped', this.isGrouped);
+ $table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
+ const defs = [];
+ this.defs = defs;
+ if (this.isGrouped) {
+ $table.append(this._renderHeader());
+ $table.append(this._renderGroups(this.state.data));
+ $table.append(this._renderFooter());
+
+ } else {
+ $table.append(this._renderHeader());
+ $table.append(this._renderBody());
+ $table.append(this._renderFooter());
+ }
+ tableWrapper = Object.assign(document.createElement('div'), {
+ className: 'table-responsive',
+ });
+ tableWrapper.appendChild($table[0]);
+ delete this.defs;
+ prom = Promise.all(defs);
+ }
+
+ await Promise.all([this._super.apply(this, arguments), prom]);
+
+ this.el.innerHTML = "";
+ this.el.classList.remove('o_list_optional_columns');
+
+ // destroy the previously instantiated pagers, if any
+ oldPagers.forEach(pager => pager.destroy());
+
+ // append the table (if any) to the main element
+ if (tableWrapper) {
+ this.el.appendChild(tableWrapper);
+ if (document.body.contains(this.el)) {
+ this.pagers.forEach(pager => pager.on_attach_callback());
+ }
+ if (this.optionalColumns.length) {
+ this.el.classList.add('o_list_optional_columns');
+ this.$('table').append(
+ $('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>')
+ );
+ this.$el.append(this._renderOptionalColumnsDropdown());
+ }
+ if (this.selection.length) {
+ const $checked_rows = this.$('tr').filter(
+ (i, el) => this.selection.includes(el.dataset.id)
+ );
+ $checked_rows.find('.o_list_record_selector input').prop('checked', true);
+ if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked
+ this.$('thead .o_list_record_selector input').prop('checked', true);
+ }
+ }
+ }
+
+ // display the no content helper if necessary
+ if (!this._hasContent() && !!this.noContentHelp) {
+ this._renderNoContentHelper();
+ }
+ },
+ /**
+ * Each line or cell can be decorated according to a few simple rules. The
+ * arch description of the list or the field nodes may have one of the
+ * decoration-X attributes with a python expression as value. Then, for each
+ * record, we evaluate the python expression, and conditionnaly add the
+ * text-X css class to the element. This method is concerned with the
+ * computation of the list of css classes for a given record.
+ *
+ * @private
+ * @param {jQueryElement} $el the element to which to add the classes (a tr
+ * or td)
+ * @param {Object} decorations keys are the decoration classes (e.g.
+ * 'text-bf') and values are the python expressions to evaluate
+ * @param {Object} record a basic model record
+ */
+ _setDecorationClasses: function ($el, decorations, record) {
+ for (const [cssClass, expr] of Object.entries(decorations)) {
+ $el.toggleClass(cssClass, py.PY_isTrue(py.evaluate(expr, record.evalContext)));
+ }
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _shouldRenderPager: function (currentMinimum, limit, size) {
+ if (!limit || !size) {
+ return false;
+ }
+ const maximum = Math.min(currentMinimum + limit - 1, size);
+ const singlePage = (1 === currentMinimum) && (maximum === size);
+ return !singlePage;
+ },
+ /**
+ * Update the footer aggregate values. This method should be called each
+ * time the state of some field is changed, to make sure their sum are kept
+ * in sync.
+ *
+ * @private
+ */
+ _updateFooter: function () {
+ this._computeAggregates();
+ this.$('tfoot').replaceWith(this._renderFooter());
+ },
+ /**
+ * Whenever we change the state of the selected rows, we need to call this
+ * method to keep the this.selection variable in sync, and also to recompute
+ * the aggregates.
+ *
+ * @private
+ */
+ _updateSelection: function () {
+ const previousSelection = JSON.stringify(this.selection);
+ this.selection = [];
+ var self = this;
+ var $inputs = this.$('tbody .o_list_record_selector input:visible:not(:disabled)');
+ var allChecked = $inputs.length > 0;
+ $inputs.each(function (index, input) {
+ if (input.checked) {
+ self.selection.push($(input).closest('tr').data('id'));
+ } else {
+ allChecked = false;
+ }
+ });
+ this.$('thead .o_list_record_selector input').prop('checked', allChecked);
+ if (JSON.stringify(this.selection) !== previousSelection) {
+ this.trigger_up('selection_changed', { allChecked, selection: this.selection });
+ }
+ this._updateFooter();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} record a record dataPoint on which the button applies
+ * @param {Object} node arch node of the button
+ * @param {Object} node.attrs the attrs of the button in the arch
+ * @param {jQueryEvent} ev
+ */
+ _onGroupButtonClicked: function (record, node, ev) {
+ ev.stopPropagation();
+ if (node.attrs.type === 'edit') {
+ this.trigger_up('group_edit_button_clicked', {
+ record: record,
+ });
+ } else {
+ this.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ }
+ },
+ /**
+ * If the user presses ENTER on a group header button, we want to execute
+ * the button action. This is done automatically as the click handler is
+ * called. However, we have to stop the propagation of the event to prevent
+ * another handler from closing the group (see _onKeyDown).
+ *
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onGroupButtonKeydown: function (ev) {
+ if (ev.keyCode === $.ui.keyCode.ENTER) {
+ ev.stopPropagation();
+ }
+ },
+ /**
+ * When the user clicks on the checkbox in optional fields dropdown the
+ * column is added to listview and displayed
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleOptionalColumn: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ // when the input's label is clicked, the click event is also raised on the
+ // input itself (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label),
+ // so this handler is executed twice (except if the rendering is quick enough,
+ // as when we render, we empty the HTML)
+ ev.preventDefault();
+ var input = ev.currentTarget.querySelector('input');
+ var fieldIndex = this.optionalColumnsEnabled.indexOf(input.name);
+ if (fieldIndex >= 0) {
+ this.optionalColumnsEnabled.splice(fieldIndex, 1);
+ } else {
+ this.optionalColumnsEnabled.push(input.name);
+ }
+ this.trigger_up('save_optional_fields', {
+ keyParts: this._getOptionalColumnsStorageKeyParts(),
+ optionalColumnsEnabled: this.optionalColumnsEnabled,
+ });
+ this._processColumns(this.columnInvisibleFields);
+ this._render().then(function () {
+ self._onToggleOptionalColumnDropdown(ev);
+ });
+ },
+ /**
+ * When the user clicks on the three dots (ellipsis), toggle the optional
+ * fields dropdown.
+ *
+ * @private
+ */
+ _onToggleOptionalColumnDropdown: function (ev) {
+ // The dropdown toggle is inside the overflow hidden container because
+ // the ellipsis is always in the last column, but we want the actual
+ // dropdown to be outside of the overflow hidden container since it
+ // could easily have a higher height than the table. However, separating
+ // the toggle and the dropdown itself is not supported by popper.js by
+ // default, which is why we need to toggle the dropdown manually.
+ ev.stopPropagation();
+ this.$('.o_optional_columns .dropdown-toggle').dropdown('toggle');
+ // Explicitly set left/right of the optional column dropdown as it is pushed
+ // inside this.$el, so we need to position it at the end of top left corner.
+ var position = (this.$(".table-responsive").css('overflow') === "auto" ? this.$el.width() :
+ this.$('table').width());
+ var direction = "left";
+ if (_t.database.parameters.direction === 'rtl') {
+ position = position - this.$('.o_optional_columns .o_optional_columns_dropdown').width();
+ direction = "right";
+ }
+ this.$('.o_optional_columns').css(direction, position);
+ },
+ /**
+ * Manages the keyboard events on the list. If the list is not editable, when the user navigates to
+ * a cell using the keyboard, if he presses enter, enter the model represented by the line
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyDown: function (ev) {
+ var $cell = $(ev.currentTarget);
+ var $tr;
+ var $futureCell;
+ var colIndex;
+ if (this.state.isSample) {
+ return; // we disable keyboard navigation inside the table in "sample" mode
+ }
+ switch (ev.keyCode) {
+ case $.ui.keyCode.LEFT:
+ ev.preventDefault();
+ $tr = $cell.closest('tr');
+ $tr.closest('tbody').addClass('o_keyboard_navigation');
+ if ($tr.hasClass('o_group_header') && $tr.hasClass('o_group_open')) {
+ this._onToggleGroup(ev);
+ } else {
+ $futureCell = $cell.prev();
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ ev.preventDefault();
+ $tr = $cell.closest('tr');
+ $tr.closest('tbody').addClass('o_keyboard_navigation');
+ if ($tr.hasClass('o_group_header') && !$tr.hasClass('o_group_open')) {
+ this._onToggleGroup(ev);
+ } else {
+ $futureCell = $cell.next();
+ }
+ break;
+ case $.ui.keyCode.UP:
+ ev.preventDefault();
+ $cell.closest('tbody').addClass('o_keyboard_navigation');
+ colIndex = this.currentColIndex || $cell.index();
+ $futureCell = this._findConnectedCell($cell, 'prev', colIndex);
+ if (!$futureCell) {
+ this.trigger_up('navigation_move', { direction: 'up' });
+ }
+ break;
+ case $.ui.keyCode.DOWN:
+ ev.preventDefault();
+ $cell.closest('tbody').addClass('o_keyboard_navigation');
+ colIndex = this.currentColIndex || $cell.index();
+ $futureCell = this._findConnectedCell($cell, 'next', colIndex);
+ break;
+ case $.ui.keyCode.ENTER:
+ ev.preventDefault();
+ $tr = $cell.closest('tr');
+ if ($tr.hasClass('o_group_header')) {
+ this._onToggleGroup(ev);
+ } else {
+ var id = $tr.data('id');
+ if (id) {
+ this.trigger_up('open_record', { id: id, target: ev.target });
+ }
+ }
+ break;
+ }
+ if ($futureCell) {
+ // If the cell contains activable elements, focus them instead (except if it is in a
+ // group header, in which case we want to activate the whole header, so that we can
+ // open/close it with RIGHT/LEFT keystrokes)
+ var isInGroupHeader = $futureCell.closest('tr').hasClass('o_group_header');
+ var $activables = !isInGroupHeader && $futureCell.find(':focusable');
+ if ($activables.length) {
+ $activables[0].focus();
+ } else {
+ $futureCell.focus();
+ }
+ }
+ },
+ /**
+ * @private
+ */
+ _onMouseDown: function () {
+ $('.o_keyboard_navigation').removeClass('o_keyboard_navigation');
+ },
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ * @param {Object} group
+ */
+ _onPagerChanged: async function (ev, group) {
+ ev.stopPropagation();
+ const { currentMinimum, limit } = ev.detail;
+ this.trigger_up('load', {
+ id: group.id,
+ limit: limit,
+ offset: currentMinimum - 1,
+ on_success: reloadedGroup => {
+ Object.assign(group, reloadedGroup);
+ this._render();
+ },
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onRowClicked: function (ev) {
+ // The special_click property explicitely allow events to bubble all
+ // the way up to bootstrap's level rather than being stopped earlier.
+ if (!ev.target.closest('.o_list_record_selector') && !$(ev.target).prop('special_click')) {
+ var id = $(ev.currentTarget).data('id');
+ if (id) {
+ this.trigger_up('open_record', { id: id, target: ev.target });
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSelectRecord: function (ev) {
+ ev.stopPropagation();
+ this._updateSelection();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSortColumn: function (ev) {
+ var name = $(ev.currentTarget).data('name');
+ this.trigger_up('toggle_column_order', { id: this.state.id, name: name });
+ },
+ /**
+ * When the user clicks on the whole record selector cell, we want to toggle
+ * the checkbox, to make record selection smooth.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleCheckbox: function (ev) {
+ const $recordSelector = $(ev.target).find('input[type=checkbox]:not(":disabled")');
+ $recordSelector.prop('checked', !$recordSelector.prop("checked"));
+ $recordSelector.change(); // s.t. th and td checkbox cases are handled by their own handler
+ },
+ /**
+ * @private
+ * @param {DOMEvent} ev
+ */
+ _onToggleGroup: function (ev) {
+ ev.preventDefault();
+ var group = $(ev.currentTarget).closest('tr').data('group');
+ if (group.count) {
+ this.trigger_up('toggle_group', {
+ group: group,
+ onSuccess: () => {
+ this._updateSelection();
+ // Refocus the header after re-render unless the user
+ // already focused something else by now
+ if (document.activeElement.tagName === 'BODY') {
+ var groupHeaders = $('tr.o_group_header:data("group")');
+ var header = groupHeaders.filter(function () {
+ return $(this).data('group').id === group.id;
+ });
+ header.find('.o_group_name').focus();
+ }
+ },
+ });
+ }
+ },
+ /**
+ * When the user clicks on the row selection checkbox in the header, we
+ * need to update the checkbox of the row selection checkboxes in the body.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleSelection: function (ev) {
+ var checked = $(ev.currentTarget).prop('checked') || false;
+ this.$('tbody .o_list_record_selector input:not(":disabled")').prop('checked', checked);
+ this._updateSelection();
+ },
+});
+
+return ListRenderer;
+});
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
new file mode 100644
index 00000000..5c36d01e
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -0,0 +1,137 @@
+odoo.define('web.ListView', function (require) {
+"use strict";
+
+/**
+ * The list view is one of the core and most basic view: it is used to look at
+ * a list of records in a table.
+ *
+ * Note that a list view is not instantiated to display a one2many field in a
+ * form view. Only a ListRenderer is used in that case.
+ */
+
+var BasicView = require('web.BasicView');
+var core = require('web.core');
+var ListModel = require('web.ListModel');
+var ListRenderer = require('web.ListRenderer');
+var ListController = require('web.ListController');
+var pyUtils = require('web.py_utils');
+
+var _lt = core._lt;
+
+var ListView = BasicView.extend({
+ accesskey: "l",
+ display_name: _lt('List'),
+ icon: 'fa-list-ul',
+ config: _.extend({}, BasicView.prototype.config, {
+ Model: ListModel,
+ Renderer: ListRenderer,
+ Controller: ListController,
+ }),
+ viewType: 'list',
+ /**
+ * @override
+ *
+ * @param {Object} viewInfo
+ * @param {Object} params
+ * @param {boolean} params.hasActionMenus
+ * @param {boolean} [params.hasSelectors=true]
+ */
+ init: function (viewInfo, params) {
+ var self = this;
+ this._super.apply(this, arguments);
+ var selectedRecords = []; // there is no selected records by default
+
+ var pyevalContext = py.dict.fromJSON(_.pick(params.context, function(value, key, object) {return !_.isUndefined(value)}) || {});
+ var expandGroups = !!JSON.parse(pyUtils.py_eval(this.arch.attrs.expand || "0", {'context': pyevalContext}));
+
+ this.groupbys = {};
+ this.headerButtons = [];
+ this.arch.children.forEach(function (child) {
+ if (child.tag === 'groupby') {
+ self._extractGroup(child);
+ }
+ if (child.tag === 'header') {
+ self._extractHeaderButtons(child);
+ }
+ });
+
+ let editable = false;
+ if ((!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) && !params.readonly) {
+ editable = this.arch.attrs.editable;
+ }
+
+ this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true;
+ this.controllerParams.editable = editable;
+ this.controllerParams.hasActionMenus = params.hasActionMenus;
+ this.controllerParams.headerButtons = this.headerButtons;
+ this.controllerParams.toolbarActions = viewInfo.toolbar;
+ this.controllerParams.mode = 'readonly';
+ this.controllerParams.selectedRecords = selectedRecords;
+
+ this.rendererParams.arch = this.arch;
+ this.rendererParams.groupbys = this.groupbys;
+ this.rendererParams.hasSelectors =
+ 'hasSelectors' in params ? params.hasSelectors : true;
+ this.rendererParams.editable = editable;
+ this.rendererParams.selectedRecords = selectedRecords;
+ this.rendererParams.addCreateLine = false;
+ this.rendererParams.addCreateLineInGroups = editable && this.controllerParams.activeActions.create;
+ this.rendererParams.isMultiEditable = this.arch.attrs.multi_edit && !!JSON.parse(this.arch.attrs.multi_edit);
+
+ this.modelParams.groupbys = this.groupbys;
+
+ this.loadParams.limit = this.loadParams.limit || 80;
+ this.loadParams.openGroupByDefault = expandGroups;
+ this.loadParams.type = 'list';
+ var groupsLimit = parseInt(this.arch.attrs.groups_limit, 10);
+ this.loadParams.groupsLimit = groupsLimit || (expandGroups ? 10 : 80);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} node
+ */
+ _extractGroup: function (node) {
+ var innerView = this.fields[node.attrs.name].views.groupby;
+ this.groupbys[node.attrs.name] = this._processFieldsView(innerView, 'groupby');
+ },
+ /**
+ * Extracts action buttons definitions from the <header> node of the list
+ * view definition
+ *
+ * @private
+ * @param {Object} node
+ */
+ _extractHeaderButtons(node) {
+ node.children.forEach(child => {
+ if (child.tag === 'button' && !child.attrs.modifiers.invisible) {
+ this.headerButtons.push(child);
+ }
+ });
+ },
+ /**
+ * @override
+ */
+ _extractParamsFromAction: function (action) {
+ var params = this._super.apply(this, arguments);
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
+ params.hasActionMenus = !inDialog && !inline;
+ return params;
+ },
+ /**
+ * @override
+ */
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
+ },
+});
+
+return ListView;
+
+});