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; });