diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/list | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (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.js | 104 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_controller.js | 992 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_editable_renderer.js | 1851 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_model.js | 175 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_renderer.js | 1470 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_view.js | 137 |
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(' '))); + } + 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> </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; + +}); |
