From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- .../src/js/views/list/list_confirm_dialog.js | 104 ++ .../static/src/js/views/list/list_controller.js | 992 +++++++++++ .../src/js/views/list/list_editable_renderer.js | 1851 ++++++++++++++++++++ addons/web/static/src/js/views/list/list_model.js | 175 ++ .../web/static/src/js/views/list/list_renderer.js | 1470 ++++++++++++++++ addons/web/static/src/js/views/list/list_view.js | 137 ++ 6 files changed, 4729 insertions(+) create mode 100644 addons/web/static/src/js/views/list/list_confirm_dialog.js create mode 100644 addons/web/static/src/js/views/list/list_controller.js create mode 100644 addons/web/static/src/js/views/list/list_editable_renderer.js create mode 100644 addons/web/static/src/js/views/list/list_model.js create mode 100644 addons/web/static/src/js/views/list/list_renderer.js create mode 100644 addons/web/static/src/js/views/list/list_view.js (limited to 'addons/web/static/src/js/views/list') 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 = $('
'); + } 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 (only one is actually needed) is a container for (multiple) . + // Each will be a "add a line" button with custom text and context. + + // The following code will browse the arch to find + // all the that are inside + 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} 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 = $('') + .text(_t("Add a line")) + .attr('data-group-id', group.id); + var $td = $('') + .attr('colspan', this._getNumberOfCols()) + .addClass('o_group_field_row_add') + .attr('tabindex', -1) + .append($a); + var $tr = $('', {class: 'o_add_record_row'}) + .attr('data-group-id', group.id) + .append($td); + $groupBody.append($tr.prepend($('').html(' '))); + } + 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($('')); + } + 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($('', {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 ? + $('