From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- .../static/src/js/views/form/form_controller.js | 691 +++++++++++ .../web/static/src/js/views/form/form_renderer.js | 1211 ++++++++++++++++++++ addons/web/static/src/js/views/form/form_view.js | 201 ++++ 3 files changed, 2103 insertions(+) create mode 100644 addons/web/static/src/js/views/form/form_controller.js create mode 100644 addons/web/static/src/js/views/form/form_renderer.js create mode 100644 addons/web/static/src/js/views/form/form_view.js (limited to 'addons/web/static/src/js/views/form') diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js new file mode 100644 index 00000000..323f7a75 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -0,0 +1,691 @@ +odoo.define('web.FormController', function (require) { +"use strict"; + +var BasicController = require('web.BasicController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); + +var _t = core._t; +var qweb = core.qweb; + +var FormController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + button_clicked: '_onButtonClicked', + edited_list: '_onEditedList', + open_one2many_record: '_onOpenOne2ManyRecord', + open_record: '_onOpenRecord', + toggle_column_order: '_onToggleColumnOrder', + focus_control_button: '_onFocusControlButton', + form_dialog_discarded: '_onFormDialogDiscarded', + }), + /** + * @override + * + * @param {boolean} params.hasActionMenus + * @param {Object} params.toolbarActions + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.actionButtons = params.actionButtons; + this.disableAutofocus = params.disableAutofocus; + this.footerToButtons = params.footerToButtons; + this.defaultButtons = params.defaultButtons; + this.hasActionMenus = params.hasActionMenus; + this.toolbarActions = params.toolbarActions || {}; + }, + /** + * Called each time the form view is attached into the DOM + * + * @todo convert to new style + */ + on_attach_callback: function () { + this._super.apply(this, arguments); + this.autofocus(); + }, + /** + * This hook is called when a form view is restored (by clicking on the + * breadcrumbs). In general, we force mode back to readonly, because + * whenever we leave a form view by stacking another action on the top of + * it, it is saved, and should no longer be in edit mode. However, there is + * a special case for new records for which we still want to be in 'edit' + * as no record has been created (changes have been discarded before + * leaving). + * + * @override + */ + willRestore: function () { + this.mode = this.model.isNew(this.handle) ? 'edit' : 'readonly'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Calls autofocus on the renderer + */ + autofocus: function () { + if (!this.disableAutofocus) { + var isControlActivted = this.renderer.autofocus(); + if (!isControlActivted) { + // this can happen in read mode if there are no buttons with + // btn-primary class + if (this.$buttons && this.mode === 'readonly') { + return this.$buttons.find('.o_form_button_edit').focus(); + } + } + } + }, + /** + * This method switches the form view in edit mode, with a new record. + * + * @todo make record creation a basic controller feature + * @param {string} [parentID] if given, the parentID will be used as parent + * for the new record. + * @param {Object} [additionalContext] + * @returns {Promise} + */ + createRecord: async function (parentID, additionalContext) { + const record = this.model.get(this.handle, { raw: true }); + const handle = await this.model.load({ + context: record.getContext({ additionalContext: additionalContext}), + fields: record.fields, + fieldsInfo: record.fieldsInfo, + modelName: this.modelName, + parentID: parentID, + res_ids: record.res_ids, + type: 'record', + viewType: 'form', + }); + this.handle = handle; + this._updateControlPanel(); + return this._setMode('edit'); + }, + /** + * Returns the current res_id, wrapped in a list. This is only used by the + * action menus (and the debugmanager) + * + * @override + * + * @returns {number[]} either [current res_id] or [] + */ + getSelectedIds: function () { + var env = this.model.get(this.handle, {env: true}); + return env.currentId ? [env.currentId] : []; + }, + /** + * @override method from AbstractController + * @returns {string} + */ + getTitle: function () { + return this.model.getName(this.handle); + }, + /** + * Add the current ID to the state pushed in the url. + * + * @override + */ + getState: function () { + const state = this._super.apply(this, arguments); + const env = this.model.get(this.handle, {env: true}); + state.id = env.currentId; + return state; + }, + /** + * Render buttons for the control panel. The form view can be rendered in + * a dialog, and in that case, if we have buttons defined in the footer, we + * have to use them instead of the standard buttons. + * + * @override method from AbstractController + * @param {jQuery} [$node] + */ + renderButtons: function ($node) { + var $footer = this.footerToButtons ? this.renderer.$el && this.renderer.$('footer') : null; + var mustRenderFooterButtons = $footer && $footer.length; + if ((this.defaultButtons && !this.$buttons) || mustRenderFooterButtons) { + this.$buttons = $('
'); + if (mustRenderFooterButtons) { + this.$buttons.append($footer); + } else { + this.$buttons.append(qweb.render("FormView.buttons", {widget: this})); + this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this)); + this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this)); + this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this)); + this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this)); + this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit')); + this.$buttons.find('.o_form_buttons_edit').tooltip({ + delay: {show: 200, hide:0}, + title: function(){ + return qweb.render('SaveCancelButton.tooltip'); + }, + trigger: 'manual', + }); + } + } + if (this.$buttons && $node) { + this.$buttons.appendTo($node); + } + }, + /** + * The form view has to prevent a click on the pager if the form is dirty + * + * @override method from BasicController + * @param {jQueryElement} $node + * @param {Object} options + * @returns {Promise} + */ + _getPagingInfo: function () { + // Only display the pager if we are not on a new record. + if (this.model.isNew(this.handle)) { + return null; + } + return Object.assign(this._super(...arguments), { + validate: this.canBeDiscarded.bind(this), + }); + }, + /** + * @override + * @private + **/ + _getActionMenuItems: function (state) { + if (!this.hasActionMenus || this.mode === 'edit') { + return null; + } + const props = this._super(...arguments); + const activeField = this.model.getActiveField(state); + const otherActionItems = []; + if (this.archiveEnabled && activeField in state.data) { + if (state.data[activeField]) { + otherActionItems.push({ + description: _t("Archive"), + callback: () => { + Dialog.confirm(this, _t("Are you sure that you want to archive this record?"), { + confirm_callback: () => this._toggleArchiveState(true), + }); + }, + }); + } else { + otherActionItems.push({ + description: _t("Unarchive"), + callback: () => this._toggleArchiveState(false), + }); + } + } + if (this.activeActions.create && this.activeActions.duplicate) { + otherActionItems.push({ + description: _t("Duplicate"), + callback: () => this._onDuplicateRecord(this), + }); + } + if (this.activeActions.delete) { + otherActionItems.push({ + description: _t("Delete"), + callback: () => this._onDeleteRecord(this), + }); + } + return Object.assign(props, { + items: Object.assign(this.toolbarActions, { other: otherActionItems }), + }); + }, + /** + * Show a warning message if the user modified a translated field. For each + * field, the notification provides a link to edit the field's translations. + * + * @override + */ + saveRecord: async function () { + const changedFields = await this._super(...arguments); + // the title could have been changed + this._updateControlPanel(); + + if (_t.database.multi_lang && changedFields.length) { + // need to make sure changed fields that should be translated + // are displayed with an alert + var fields = this.renderer.state.fields; + var data = this.renderer.state.data; + var alertFields = {}; + for (var k = 0; k < changedFields.length; k++) { + var field = fields[changedFields[k]]; + var fieldData = data[changedFields[k]]; + if (field.translate && fieldData && fieldData !== '


') { + alertFields[changedFields[k]] = field; + } + } + if (!_.isEmpty(alertFields)) { + this.renderer.updateAlertFields(alertFields); + } + } + return changedFields; + }, + /** + * Overrides to force the viewType to 'form', so that we ensure that the + * correct fields are reloaded (this is only useful for one2many form views). + * + * @override + */ + update: async function (params, options) { + if ('currentId' in params && !params.currentId) { + this.mode = 'edit'; // if there is no record, we are in 'edit' mode + } + params = _.extend({viewType: 'form', mode: this.mode}, params); + await this._super(params, options); + this.autofocus(); + }, + /** + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + if (this.footerToButtons) { + var $footer = this.renderer.$el && this.renderer.$('footer'); + if ($footer && $footer.length) { + this.$buttons.empty().append($footer); + } + } + var edit_mode = (this.mode === 'edit'); + this.$buttons.find('.o_form_buttons_edit') + .toggleClass('o_hidden', !edit_mode); + this.$buttons.find('.o_form_buttons_view') + .toggleClass('o_hidden', edit_mode); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _applyChanges: async function () { + const result = await this._super.apply(this, arguments); + core.bus.trigger('DOM_updated'); + return result; + }, + /** + * Assign on the buttons save and discard additionnal behavior to facilitate + * the work of the users doing input only using the keyboard + * + * @param {jQueryElement} $saveCancelButtonContainer The div containing the + * save and cancel buttons + * @private + */ + _assignSaveCancelKeyboardBehavior: function ($saveCancelButtonContainer) { + var self = this; + $saveCancelButtonContainer.children().on('keydown', function (e) { + switch(e.which) { + case $.ui.keyCode.ENTER: + e.preventDefault(); + self.saveRecord(); + break; + case $.ui.keyCode.ESCAPE: + e.preventDefault(); + self._discardChanges(); + break; + case $.ui.keyCode.TAB: + if (!e.shiftKey && e.target.classList.contains('btn-primary')) { + $saveCancelButtonContainer.tooltip('show'); + e.preventDefault(); + } + break; + } + }); + }, + /** + * When a save operation has been confirmed from the model, this method is + * called. + * + * @private + * @override method from field manager mixin + * @param {string} id - id of the previously changed record + * @returns {Promise} + */ + _confirmSave: function (id) { + if (id === this.handle) { + if (this.mode === 'readonly') { + return this.reload(); + } else { + return this._setMode('readonly'); + } + } else { + // A subrecord has changed, so update the corresponding relational field + // i.e. the one whose value is a record with the given id or a list + // having a record with the given id in its data + var record = this.model.get(this.handle); + + // Callback function which returns true + // if a value recursively contains a record with the given id. + // This will be used to determine the list of fields to reload. + var containsChangedRecord = function (value) { + return _.isObject(value) && + (value.id === id || _.find(value.data, containsChangedRecord)); + }; + + var changedFields = _.findKey(record.data, containsChangedRecord); + return this.renderer.confirmChange(record, record.id, [changedFields]); + } + }, + /** + * Override to disable buttons in the renderer. + * + * @override + * @private + */ + _disableButtons: function () { + this._super.apply(this, arguments); + this.renderer.disableButtons(); + }, + /** + * Override to enable buttons in the renderer. + * + * @override + * @private + */ + _enableButtons: function () { + this._super.apply(this, arguments); + this.renderer.enableButtons(); + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @override + */ + _onDeletedRecords: function () { + var state = this.model.get(this.handle, {raw: true}); + if (!state.res_ids.length) { + this.trigger_up('history_back'); + } else { + this._super.apply(this, arguments); + } + }, + /** + * Overrides to reload the form when saving failed in readonly (e.g. after + * a change on a widget like priority or statusbar). + * + * @override + * @private + */ + _rejectSave: function () { + if (this.mode === 'readonly') { + return this.reload(); + } + return this._super.apply(this, arguments); + }, + /** + * Calls unfreezeOrder when changing the mode. + * Also, when there is a change of mode, the tracking of last activated + * field is reset, so that the following field activation process starts + * with the 1st field. + * + * @override + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) === this.handle) { + this.model.unfreezeOrder(this.handle); + } + if (this.mode !== mode) { + this.renderer.resetLastActivatedField(); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + _shouldBounceOnClick(element) { + return this.mode === 'readonly' && !!element.closest('.oe_title, .o_inner_group'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + // stop the event's propagation as a form controller might have other + // form controllers in its descendants (e.g. in a FormViewDialog) + ev.stopPropagation(); + var self = this; + var def; + + this._disableButtons(); + + function saveAndExecuteAction () { + return self.saveRecord(self.handle, { + stayInEdit: true, + }).then(function () { + // we need to reget the record to make sure we have changes made + // by the basic model, such as the new res_id, if the record is + // new. + var record = self.model.get(ev.data.record.id); + return self._callButtonAction(attrs, record); + }); + } + var attrs = ev.data.attrs; + if (attrs.confirm) { + def = new Promise(function (resolve, reject) { + Dialog.confirm(self, attrs.confirm, { + confirm_callback: saveAndExecuteAction, + }).on("closed", null, resolve); + }); + } else if (attrs.special === 'cancel') { + def = this._callButtonAction(attrs, ev.data.record); + } else if (!attrs.special || attrs.special === 'save') { + // save the record but don't switch to readonly mode + def = saveAndExecuteAction(); + } else { + console.warn('Unhandled button event', ev); + return; + } + + // Kind of hack for FormViewDialog: button on footer should trigger the dialog closing + // if the `close` attribute is set + def.then(function () { + self._enableButtons(); + if (attrs.close) { + self.trigger_up('close_dialog'); + } + }).guardedCatch(this._enableButtons.bind(this)); + }, + /** + * Called when the user wants to create a new record -> @see createRecord + * + * @private + */ + _onCreate: function () { + this.createRecord(); + }, + /** + * Deletes the current record + * + * @private + */ + _onDeleteRecord: function () { + this._deleteRecords([this.handle]); + }, + /** + * Called when the user wants to discard the changes made to the current + * record -> @see discardChanges + * + * @private + */ + _onDiscard: function () { + this._disableButtons(); + this._discardChanges() + .then(this._enableButtons.bind(this)) + .guardedCatch(this._enableButtons.bind(this)); + }, + /** + * Called when the user clicks on 'Duplicate Record' in the action menus + * + * @private + */ + _onDuplicateRecord: async function () { + const handle = await this.model.duplicateRecord(this.handle); + this.handle = handle; + this._updateControlPanel(); + this._setMode('edit'); + }, + /** + * Called when the user wants to edit the current record -> @see _setMode + * + * @private + */ + _onEdit: function () { + this._disableButtons(); + // wait for potential pending changes to be saved (done with widgets + // allowing to edit in readonly) + this.mutex.getUnlockedDef() + .then(this._setMode.bind(this, 'edit')) + .then(this._enableButtons.bind(this)) + .guardedCatch(this._enableButtons.bind(this)); + }, + /** + * This method is called when someone tries to freeze the order, most likely + * in a x2many list view + * + * @private + * @param {OdooEvent} ev + * @param {integer} ev.id of the list to freeze while editing a line + */ + _onEditedList: function (ev) { + ev.stopPropagation(); + if (ev.data.id) { + this.model.save(ev.data.id, {savePoint: true}); + } + this.model.freezeOrder(ev.data.id); + }, + /** + * Set the focus on the first primary button of the controller (likely Edit) + * + * @private + * @param {OdooEvent} event + */ + _onFocusControlButton:function(e) { + if (this.$buttons) { + e.stopPropagation(); + this.$buttons.find('.btn-primary:visible:first()').focus(); + } + }, + /** + * Reset the focus on the control that openned a Dialog after it was closed + * + * @private + * @param {OdooEvent} event + */ + _onFormDialogDiscarded: function(ev) { + ev.stopPropagation(); + var isFocused = this.renderer.focusLastActivatedWidget(); + if (ev.data.callback) { + ev.data.callback(_.str.toBool(isFocused)); + } + }, + /** + * Opens a one2many record (potentially new) in a dialog. This handler is + * o2m specific as in this case, the changes done on the related record + * shouldn't be saved in DB when the user clicks on 'Save' in the dialog, + * but later on when he clicks on 'Save' in the main form view. For this to + * work correctly, the main model and the local id of the opened record must + * be given to the dialog, which will complete the viewInfo of the record + * with the one of the form view. + * + * @private + * @param {OdooEvent} ev + */ + _onOpenOne2ManyRecord: async function (ev) { + ev.stopPropagation(); + var data = ev.data; + var record; + if (data.id) { + record = this.model.get(data.id, {raw: true}); + } + + // Sync with the mutex to wait for potential onchanges + await this.model.mutex.getUnlockedDef(); + + new dialogs.FormViewDialog(this, { + context: data.context, + domain: data.domain, + fields_view: data.fields_view, + model: this.model, + on_saved: data.on_saved, + on_remove: data.on_remove, + parentID: data.parentID, + readonly: data.readonly, + deletable: record ? data.deletable : false, + recordID: record && record.id, + res_id: record && record.res_id, + res_model: data.field.relation, + shouldSaveLocally: true, + title: (record ? _t("Open: ") : _t("Create ")) + (ev.target.string || data.field.string), + }).open(); + }, + /** + * Open an existing record in a form view dialog + * + * @private + * @param {OdooEvent} ev + */ + _onOpenRecord: function (ev) { + ev.stopPropagation(); + var self = this; + var record = this.model.get(ev.data.id, {raw: true}); + new dialogs.FormViewDialog(self, { + context: ev.data.context, + fields_view: ev.data.fields_view, + on_saved: ev.data.on_saved, + on_remove: ev.data.on_remove, + readonly: ev.data.readonly, + deletable: ev.data.deletable, + res_id: record.res_id, + res_model: record.model, + title: _t("Open: ") + ev.data.string, + }).open(); + }, + /** + * Called when the user wants to save the current record -> @see saveRecord + * + * @private + * @param {MouseEvent} ev + */ + _onSave: function (ev) { + ev.stopPropagation(); // Prevent x2m lines to be auto-saved + this._disableButtons(); + this.saveRecord().then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }, + /** + * This method is called when someone tries to sort a column, most likely + * in a x2many list view + * + * @private + * @param {OdooEvent} ev + */ + _onToggleColumnOrder: function (ev) { + ev.stopPropagation(); + var self = this; + this.model.setSort(ev.data.id, ev.data.name).then(function () { + var field = ev.data.field; + var state = self.model.get(self.handle); + self.renderer.confirmChange(state, state.id, [field]); + }); + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the action menus. + * + * @private + * @param {boolean} archive + */ + _toggleArchiveState: function (archive) { + const resIds = this.model.localIdsToResIds([this.handle]); + this._archive(resIds, archive); + }, +}); + +return FormController; + +}); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js new file mode 100644 index 00000000..e4c1b187 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -0,0 +1,1211 @@ +odoo.define('web.FormRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +// symbol used as key to set the node id on its widget +const symbol = Symbol('form'); + +var FormRenderer = BasicRenderer.extend({ + className: "o_form_view", + events: _.extend({}, BasicRenderer.prototype.events, { + 'click .o_notification_box .oe_field_translate': '_onTranslate', + 'click .o_notification_box .close': '_onTranslateNotificationClose', + 'shown.bs.tab a[data-toggle="tab"]': '_onNotebookTabChanged', + }), + custom_events: _.extend({}, BasicRenderer.prototype.custom_events, { + 'navigation_move':'_onNavigationMove', + 'activate_next_widget' : '_onActivateNextWidget', + }), + // default col attributes for the rendering of groups + INNER_GROUP_COL: 2, + OUTER_GROUP_COL: 2, + + /** + * @override + * @param {Object} params.fieldIdsToNames maps node ids to field names + * (useful when there are several occurrences of the same field in the arch) + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.fieldIdsToNames = params.fieldIdsToNames; + this.idsForLabels = {}; + this.lastActivatedFieldIndex = -1; + this.alertFields = {}; + // The form renderer doesn't render invsible fields (invisible="1") by + // default, to speed up the rendering. However, we sometimes have to + // display them (e.g. in Studio, in "show invisible" mode). This flag + // allows to disable this optimization. + this.renderInvisible = false; + }, + /** + * @override + */ + start: function () { + this._applyFormSizeClass(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focuses the field having attribute 'default_focus' set, if any, or the + * first focusable field otherwise. + * In read mode, delegate which button to give the focus to, to the form_renderer + * + * @returns {int | undefined} the index of the widget activated else + * undefined + */ + autofocus: function () { + if (this.mode === 'readonly') { + var firstPrimaryFormButton = this.$el.find('button.btn-primary:enabled:visible:first()'); + if (firstPrimaryFormButton.length > 0) { + return firstPrimaryFormButton.focus(); + } else { + return; + } + } + var focusWidget = this.defaultFocusField; + if (!focusWidget || !focusWidget.isFocusable()) { + var widgets = this.allFieldWidgets[this.state.id]; + for (var i = 0; i < (widgets ? widgets.length : 0); i++) { + var widget = widgets[i]; + if (widget.isFocusable()) { + focusWidget = widget; + break; + } + } + } + if (focusWidget) { + return focusWidget.activate({noselect: true, noAutomaticCreate: true}); + } + }, + /** + * Extend the method so that labels also receive the 'o_field_invalid' class + * if necessary. + * + * @override + * @see BasicRenderer.canBeSaved + * @param {string} recordID + * @returns {string[]} + */ + canBeSaved: function () { + var fieldNames = this._super.apply(this, arguments); + + var $labels = this.$('label'); + $labels.removeClass('o_field_invalid'); + + const allWidgets = this.allFieldWidgets[this.state.id] || []; + const widgets = allWidgets.filter(w => fieldNames.includes(w.name)); + for (const widget of widgets) { + const idForLabel = this.idsForLabels[widget[symbol]]; + if (idForLabel) { + $labels + .filter('[for=' + idForLabel + ']') + .addClass('o_field_invalid'); + } + } + return fieldNames; + }, + /* + * Updates translation alert fields for the current state and display updated fields + * + * @param {Object} alertFields + */ + updateAlertFields: function (alertFields) { + this.alertFields[this.state.res_id] = _.extend(this.alertFields[this.state.res_id] || {}, alertFields); + this.displayTranslationAlert(); + }, + /** + * Show a warning message if the user modified a translated field. For each + * field, the notification provides a link to edit the field's translations. + */ + displayTranslationAlert: function () { + this.$('.o_notification_box').remove(); + if (this.alertFields[this.state.res_id]) { + var $notification = $(qweb.render('notification-box', {type: 'info'})) + .append(qweb.render('translation-alert', { + fields: this.alertFields[this.state.res_id], + lang: _t.database.parameters.name + })); + if (this.$('.o_form_statusbar').length) { + this.$('.o_form_statusbar').after($notification); + } else if (this.$('.o_form_sheet_bg').length) { + this.$('.o_form_sheet_bg').prepend($notification); + } else { + this.$el.prepend($notification); + } + } + }, + /** + * @see BasicRenderer.confirmChange + * + * We need to reapply the idForLabel postprocessing since some widgets may + * have recomputed their dom entirely. + * + * @override + */ + confirmChange: function () { + var self = this; + return this._super.apply(this, arguments).then(function (resetWidgets) { + _.each(resetWidgets, function (widget) { + self._setIDForLabel(widget, self.idsForLabels[widget[symbol]]); + }); + if (self.$('.o_field_invalid').length) { + self.canBeSaved(self.state.id); + } + return resetWidgets; + }); + }, + /** + * Disable statusbar buttons and stat buttons so that they can't be clicked anymore + * + */ + disableButtons: function () { + this.$('.o_statusbar_buttons button, .oe_button_box button') + .attr('disabled', true); + }, + /** + * Enable statusbar buttons and stat buttons so they can be clicked again + * + */ + enableButtons: function () { + this.$('.o_statusbar_buttons button, .oe_button_box button') + .removeAttr('disabled'); + }, + /** + * Put the focus on the last activated widget. + * This function is used when closing a dialog to give the focus back to the + * form that has opened it and ensures that the focus is in the correct + * field. + */ + focusLastActivatedWidget: function () { + if (this.lastActivatedFieldIndex !== -1) { + return this._activateNextFieldWidget(this.state, this.lastActivatedFieldIndex - 1, + { noAutomaticCreate: true }); + } + return false; + }, + /** + * returns the active tab pages for each notebook + * + * @todo currently, this method is unused... + * + * @see setLocalState + * @returns {Object} a map from notebook name to the active tab index + */ + getLocalState: function () { + const state = {}; + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + const name = notebook.dataset.name; + const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item > .nav-link'); + state[name] = Math.max([...navs].findIndex( + nav => nav.classList.contains('active') + ), 0); + } + return state; + }, + /** + * Reset the tracking of the last activated field. The fast entry with + * keyboard navigation needs to track the last activated field in order to + * set the focus. + * + * In particular, when there are changes of mode (e.g. edit -> readonly -> + * edit), we do not want to auto-set the focus on the previously last + * activated field. To avoid this issue, this method should be called + * whenever there is a change of mode. + */ + resetLastActivatedField: function () { + this.lastActivatedFieldIndex = -1; + }, + /** + * Resets state which stores information like scroll position, curently + * active page, ... + * + * @override + */ + resetLocalState() { + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + [...notebook.querySelectorAll(':scope .o_notebook_headers .nav-item .nav-link')] + .map(nav => nav.classList.remove('active')); + [...notebook.querySelectorAll(':scope .tab-content > .tab-pane')] + .map(tab => tab.classList.remove('active')); + } + + }, + /** + * Restore active tab pages for each notebook. It relies on the implicit fact + * that each nav header corresponds to a tab page. + * + * @param {Object} state the result from a getLocalState call + */ + setLocalState: function (state) { + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + if (notebook.closest(".o_field_widget")) { + continue; + } + const name = notebook.dataset.name; + if (name in state) { + const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item'); + const pages = notebook.querySelectorAll(':scope > .tab-content > .tab-pane'); + // We can't base the amount on the 'navs' length since some overrides + // are adding pageless nav items. + const validTabsAmount = pages.length; + if (!validTabsAmount) { + continue; // No page defined on the notebook. + } + let activeIndex = state[name]; + if (navs[activeIndex].classList.contains('o_invisible_modifier')) { + activeIndex = [...navs].findIndex( + nav => !nav.classList.contains('o_invisible_modifier') + ); + } + if (activeIndex <= 0) { + continue; // No visible tab OR first tab = active tab (no change to make). + } + for (let i = 0; i < validTabsAmount; i++) { + navs[i].querySelector('.nav-link').classList.toggle('active', activeIndex === i); + pages[i].classList.toggle('active', activeIndex === i); + } + core.bus.trigger('DOM_updated'); + } + } + }, + /** + * @override method from AbstractRenderer + * @param {Object} state a valid state given by the model + * @param {Object} params + * @param {string} [params.mode] new mode, either 'edit' or 'readonly' + * @param {string[]} [params.fieldNames] if given, the renderer will only + * update the fields in this list + * @returns {Promise} + */ + updateState: function (state, params) { + this._setState(state); + this.mode = (params && 'mode' in params) ? params.mode : this.mode; + + // if fieldNames are given, we update the corresponding field widget. + // I think this is wrong, and the caller could directly call the + // confirmChange method + if (params.fieldNames) { + // only update the given fields + return this.confirmChange(this.state, this.state.id, params.fieldNames); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activates the first visible tab from a given list of tab objects. The + * first tab having an "autofocus" attribute set will be focused in + * priority. + * + * @private + * @param {Object[]} tabs + */ + _activateFirstVisibleTab(tabs) { + const visibleTabs = tabs.filter( + (tab) => !tab.$header.hasClass("o_invisible_modifier") + ); + const autofocusTab = visibleTabs.findIndex( + (tab) => tab.node.attrs.autofocus === "autofocus" + ); + const tabToFocus = visibleTabs[Math.max(0, autofocusTab)]; + if (tabToFocus) { + tabToFocus.$header.find('.nav-link').addClass('active'); + tabToFocus.$page.addClass('active'); + } + }, + /** + * @override + */ + _activateNextFieldWidget: function (record, currentIndex) { + //if we are the last widget, we should give the focus to the first Primary Button in the form + //else do the default behavior + if ( (currentIndex + 1) >= (this.allFieldWidgets[record.id] || []).length) { + this.trigger_up('focus_control_button'); + this.lastActivatedFieldIndex = -1; + } else { + var activatedIndex = this._super.apply(this, arguments); + if (activatedIndex === -1 ) { // no widget have been activated, we should go to the edit/save buttons + this.trigger_up('focus_control_button'); + this.lastActivatedFieldIndex = -1; + } + else { + this.lastActivatedFieldIndex = activatedIndex; + } + } + return this.lastActivatedFieldIndex; + }, + /** + * Add a tooltip on a button + * + * @private + * @param {Object} node + * @param {jQuery} $button + */ + _addButtonTooltip: function (node, $button) { + var self = this; + $button.tooltip({ + title: function () { + return qweb.render('WidgetButton.tooltip', { + debug: config.isDebug(), + state: self.state, + node: node, + }); + }, + }); + }, + /** + * @private + * @param {jQueryElement} $el + * @param {Object} node + */ + _addOnClickAction: function ($el, node) { + if (node.attrs.special || node.attrs.confirm || node.attrs.type || $el.hasClass('oe_stat_button')) { + var self = this; + $el.on("click", function () { + self.trigger_up('button_clicked', { + attrs: node.attrs, + record: self.state, + }); + }); + } + }, + _applyFormSizeClass: function () { + const formEl = this.$el[0]; + if (config.device.size_class <= config.device.SIZES.XS) { + formEl.classList.add('o_xxs_form_view'); + } else { + formEl.classList.remove('o_xxs_form_view'); + } + if (config.device.size_class === config.device.SIZES.XXL) { + formEl.classList.add('o_xxl_form_view'); + } else { + formEl.classList.remove('o_xxl_form_view'); + } + }, + /** + * @private + * @param {string} uid a node id + * @returns {string} + */ + _getIDForLabel: function (uid) { + if (!this.idsForLabels[uid]) { + this.idsForLabels[uid] = _.uniqueId('o_field_input_'); + } + return this.idsForLabels[uid]; + }, + /** + * @override + * @private + */ + _getRecord: function (recordId) { + return this.state.id === recordId ? this.state : null; + }, + /** + * @override + * @private + */ + _postProcessField: function (widget, node) { + this._super.apply(this, arguments); + // set the node id on the widget, as it might be necessary later (tooltips, confirmChange...) + widget[symbol] = node.attrs.id; + this._setIDForLabel(widget, this._getIDForLabel(node.attrs.id)); + if (JSON.parse(node.attrs.default_focus || "0")) { + this.defaultFocusField = widget; + } + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderButtonBox: function (node) { + var self = this; + var $result = $('<' + node.tag + '>', {class: 'o_not_full'}); + + // The rendering of buttons may be async (see renderFieldWidget), so we + // must wait for the buttons to be ready (and their modifiers to be + // applied) before manipulating them, as we check if they are visible or + // not. To do so, we extract from this.defs the promises corresponding + // to the buttonbox buttons, and wait for them to be resolved. + var nextDefIndex = this.defs.length; + var buttons = _.map(node.children, function (child) { + if (child.tag === 'button') { + return self._renderStatButton(child); + } else { + return self._renderNode(child); + } + }); + + // At this point, each button is an empty div that will be replaced by + // the real $el of the button when it is ready (with replaceWith). + // However, this only works if the empty div is appended somewhere, so + // we here append them into a wrapper, and unwrap them once they have + // been replaced. + var $tempWrapper = $('
'); + _.each(buttons, function ($button) { + $button.appendTo($tempWrapper); + }); + var defs = this.defs.slice(nextDefIndex); + Promise.all(defs).then(function () { + buttons = $tempWrapper.children(); + var buttons_partition = _.partition(buttons, function (button) { + return $(button).is('.o_invisible_modifier'); + }); + var invisible_buttons = buttons_partition[0]; + var visible_buttons = buttons_partition[1]; + + // Get the unfolded buttons according to window size + var nb_buttons = self._renderButtonBoxNbButtons(); + var unfolded_buttons = visible_buttons.slice(0, nb_buttons).concat(invisible_buttons); + + // Get the folded buttons + var folded_buttons = visible_buttons.slice(nb_buttons); + if (folded_buttons.length === 1) { + unfolded_buttons = buttons; + folded_buttons = []; + } + + // Toggle class to tell if the button box is full (CSS requirement) + var full = (visible_buttons.length > nb_buttons); + $result.toggleClass('o_full', full).toggleClass('o_not_full', !full); + + // Add the unfolded buttons + _.each(unfolded_buttons, function (button) { + $(button).appendTo($result); + }); + + // Add the dropdown with folded buttons if any + if (folded_buttons.length) { + $result.append(dom.renderButton({ + attrs: { + 'class': 'oe_stat_button o_button_more dropdown-toggle', + 'data-toggle': 'dropdown', + }, + text: _t("More"), + })); + + var $dropdown = $("
", {class: "dropdown-menu o_dropdown_more", role: "menu"}); + _.each(folded_buttons, function (button) { + $(button).addClass('dropdown-item').appendTo($dropdown); + }); + $dropdown.appendTo($result); + } + }); + + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + return $result; + }, + /** + * @private + * @returns {integer} + */ + _renderButtonBoxNbButtons: function () { + return [2, 2, 2, 4][config.device.size_class] || 7; + }, + /** + * Do not render a field widget if it is always invisible. + * + * @override + */ + _renderFieldWidget(node) { + if (!this.renderInvisible && node.attrs.modifiers.invisible === true) { + return $(); + } + return this._super(...arguments); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderGenericTag: function (node) { + var $result = $('<' + node.tag + '>', _.omit(node.attrs, 'modifiers')); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderHeaderButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node); + + // Current API of odoo for rendering buttons is "if classes are given + // use those on top of the 'btn' and 'btn-{size}' classes, otherwise act + // as if 'btn-secondary' class was given". The problem is that, for + // header buttons only, we allowed users to only indicate their custom + // classes without having to explicitely ask for the 'btn-secondary' + // class to be added. We force it so here when no bootstrap btn type + // class is found. + if ($button.not('.btn-primary, .btn-secondary, .btn-link, .btn-success, .btn-info, .btn-warning, .btn-danger').length) { + $button.addClass('btn-secondary'); + } + + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + + // Display tooltip + if (config.isDebug() || node.attrs.help) { + this._addButtonTooltip(node, $button); + } + return $button; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderHeaderButtons: function (node) { + var self = this; + var buttons = []; + _.each(node.children, function (child) { + if (child.tag === 'button') { + buttons.push(self._renderHeaderButton(child)); + } + if (child.tag === 'widget') { + buttons.push(self._renderTagWidget(child)); + } + }); + return this._renderStatusbarButtons(buttons); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroup: function (node) { + var self = this; + var $result = $('', {class: 'o_group o_inner_group'}); + var $tbody = $('').appendTo($result); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + + var col = parseInt(node.attrs.col, 10) || this.INNER_GROUP_COL; + + if (node.attrs.string) { + var $sep = $(''); + $result.append($sep); + } + + var rows = []; + var $currentRow = $(''); + var currentColspan = 0; + node.children.forEach(function (child) { + if (child.tag === 'newline') { + rows.push($currentRow); + $currentRow = $(''); + currentColspan = 0; + return; + } + + var colspan = parseInt(child.attrs.colspan, 10); + var isLabeledField = (child.tag === 'field' && child.attrs.nolabel !== '1'); + if (!colspan) { + if (isLabeledField) { + colspan = 2; + } else { + colspan = 1; + } + } + var finalColspan = colspan - (isLabeledField ? 1 : 0); + currentColspan += colspan; + + if (currentColspan > col) { + rows.push($currentRow); + $currentRow = $(''); + currentColspan = colspan; + } + + var $tds; + if (child.tag === 'field') { + $tds = self._renderInnerGroupField(child); + } else if (child.tag === 'label') { + $tds = self._renderInnerGroupLabel(child); + } else { + var $td = $('
' + node.attrs.string + '
'); + var $child = self._renderNode(child); + if ($child.hasClass('o_td_label')) { // transfer classname to outer td for css reasons + $td.addClass('o_td_label'); + $child.removeClass('o_td_label'); + } + $tds = $td.append($child); + } + if (finalColspan > 1) { + $tds.last().attr('colspan', finalColspan); + } + $currentRow.append($tds); + }); + rows.push($currentRow); + + _.each(rows, function ($tr) { + var nonLabelColSize = 100 / (col - $tr.children('.o_td_label').length); + _.each($tr.children(':not(.o_td_label)'), function (el) { + var $el = $(el); + $el.css('width', ((parseInt($el.attr('colspan'), 10) || 1) * nonLabelColSize) + '%'); + }); + $tbody.append($tr); + }); + + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroupField: function (node) { + var $el = this._renderFieldWidget(node, this.state); + var $tds = $('').append($el); + + if (node.attrs.nolabel !== '1') { + var $labelTd = this._renderInnerGroupLabel(node); + $tds = $labelTd.add($tds); + + // apply the oe_(edit|read)_only className on the label as well + if (/\boe_edit_only\b/.test(node.attrs.class)) { + $tds.addClass('oe_edit_only'); + } + if (/\boe_read_only\b/.test(node.attrs.class)) { + $tds.addClass('oe_read_only'); + } + } + + return $tds; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroupLabel: function (node) { + return $('', {class: 'o_td_label'}) + .append(this._renderTagLabel(node)); + }, + /** + * Render a node, from the arch of the view. It is a generic method, that + * will dispatch on specific other methods. The rendering of a node is a + * jQuery element (or a string), with the correct classes, attrs, and + * content. + * + * For fields, it will return the $el of the field widget. Note that this + * method is synchronous, field widgets are instantiated and appended, but + * if they are asynchronous, they register their promises in this.defs, and + * the _renderView method will properly wait. + * + * @private + * @param {Object} node + * @returns {jQueryElement | string} + */ + _renderNode: function (node) { + var renderer = this['_renderTag' + _.str.capitalize(node.tag)]; + if (renderer) { + return renderer.call(this, node); + } + if (node.tag === 'div' && node.attrs.name === 'button_box') { + return this._renderButtonBox(node); + } + if (_.isString(node)) { + return node; + } + return this._renderGenericTag(node); + }, + /** + * Renders a 'group' node, which contains 'group' nodes in its children. + * + * @param {Object} node] + * @returns {JQueryElement} + */ + _renderOuterGroup: function (node) { + var self = this; + var $result = $('
', {class: 'o_group'}); + var nbCols = parseInt(node.attrs.col, 10) || this.OUTER_GROUP_COL; + var colSize = Math.max(1, Math.round(12 / nbCols)); + if (node.attrs.string) { + var $sep = $('
', {class: 'o_horizontal_separator'}).text(node.attrs.string); + $result.append($sep); + } + $result.append(_.map(node.children, function (child) { + if (child.tag === 'newline') { + return $('
'); + } + var $child = self._renderNode(child); + $child.addClass('o_group_col_' + (colSize * (parseInt(child.attrs.colspan, 10) || 1))); + return $child; + })); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderStatButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node, { + extraClass: 'oe_stat_button', + }); + $button.append(_.map(node.children, this._renderNode.bind(this))); + if (node.attrs.help) { + this._addButtonTooltip(node, $button); + } + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + return $button; + }, + /** + * @private + * @param {Array} buttons + * @return {jQueryElement} + */ + _renderStatusbarButtons: function (buttons) { + var $statusbarButtons = $('
', {class: 'o_statusbar_buttons'}); + buttons.forEach(button => $statusbarButtons.append(button)); + return $statusbarButtons; + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabHeader: function (page, page_id) { + var $a = $('', { + 'data-toggle': 'tab', + disable_anchor: 'true', + href: '#' + page_id, + class: 'nav-link', + role: 'tab', + text: page.attrs.string, + }); + return $('
  • ', {class: 'nav-item'}).append($a); + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabPage: function (page, page_id) { + var $result = $('
    '); + $result.append(_.map(page.children, this._renderNode.bind(this))); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node); + $button.append(_.map(node.children, this._renderNode.bind(this))); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + + // Display tooltip + if (config.isDebug() || node.attrs.help) { + this._addButtonTooltip(node, $button); + } + + return $button; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagField: function (node) { + return this._renderFieldWidget(node, this.state); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagForm: function (node) { + var $result = $('
    '); + if (node.attrs.class) { + $result.addClass(node.attrs.class); + } + var allNodes = node.children.map(this._renderNode.bind(this)); + $result.append(allNodes); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagGroup: function (node) { + var isOuterGroup = _.some(node.children, function (child) { + return child.tag === 'group'; + }); + if (!isOuterGroup) { + return this._renderInnerGroup(node); + } + return this._renderOuterGroup(node); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagHeader: function (node) { + var self = this; + var $statusbar = $('
    ', {class: 'o_form_statusbar'}); + $statusbar.append(this._renderHeaderButtons(node)); + _.each(node.children, function (child) { + if (child.tag === 'field') { + var $el = self._renderFieldWidget(child, self.state); + $statusbar.append($el); + } + }); + this._handleAttributes($statusbar, node); + this._registerModifiers(node, this.state, $statusbar); + return $statusbar; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagLabel: function (node) { + if (!this.renderInvisible && node.tag === 'field' && + node.attrs.modifiers.invisible === true) { + // skip rendering of invisible fields/labels + return $(); + } + var self = this; + var text; + let fieldName; + if (node.tag === 'label') { + fieldName = this.fieldIdsToNames[node.attrs.for]; // 'for' references a node id + } else { + fieldName = node.attrs.name; + } + if ('string' in node.attrs) { // allow empty string + text = node.attrs.string; + } else if (fieldName) { + text = this.state.fields[fieldName].string; + } else { + return this._renderGenericTag(node); + } + var $result = $('