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/form | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/form')
| -rw-r--r-- | addons/web/static/src/js/views/form/form_controller.js | 691 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/form/form_renderer.js | 1211 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/form/form_view.js | 201 |
3 files changed, 2103 insertions, 0 deletions
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 = $('<div/>'); + 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 !== '<p><br></p>') { + 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 <field> 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 <field> 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 <field> 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 = $('<div>'); + _.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 = $("<div>", {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 = $('<table/>', {class: 'o_group o_inner_group'}); + var $tbody = $('<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 = $('<tr><td colspan="' + col + '" style="width: 100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>'); + $result.append($sep); + } + + var rows = []; + var $currentRow = $('<tr/>'); + var currentColspan = 0; + node.children.forEach(function (child) { + if (child.tag === 'newline') { + rows.push($currentRow); + $currentRow = $('<tr/>'); + 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 = $('<tr/>'); + currentColspan = colspan; + } + + var $tds; + if (child.tag === 'field') { + $tds = self._renderInnerGroupField(child); + } else if (child.tag === 'label') { + $tds = self._renderInnerGroupLabel(child); + } else { + var $td = $('<td/>'); + 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 = $('<td/>').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 $('<td/>', {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 = $('<div/>', {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 = $('<div/>', {class: 'o_horizontal_separator'}).text(node.attrs.string); + $result.append($sep); + } + $result.append(_.map(node.children, function (child) { + if (child.tag === 'newline') { + return $('<br/>'); + } + 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 = $('<div>', {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 = $('<a>', { + 'data-toggle': 'tab', + disable_anchor: 'true', + href: '#' + page_id, + class: 'nav-link', + role: 'tab', + text: page.attrs.string, + }); + return $('<li>', {class: 'nav-item'}).append($a); + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabPage: function (page, page_id) { + var $result = $('<div class="tab-pane" id="' + page_id + '">'); + $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 = $('<div/>'); + 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 = $('<div>', {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 <field> 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 = $('<label>', { + class: 'o_form_label', + for: this._getIDForLabel(node.tag === 'label' ? node.attrs.for : node.attrs.id), + text: text, + }); + if (node.tag === 'label') { + this._handleAttributes($result, node); + } + var modifiersOptions; + if (fieldName) { + modifiersOptions = { + callback: function (element, modifiers, record) { + var widgets = self.allFieldWidgets[record.id]; + var widget = _.findWhere(widgets, {name: fieldName}); + if (!widget) { + return; // FIXME this occurs if the widget is created + // after the label (explicit <label/> tag in the + // arch), so this won't work on first rendering + // only on reevaluation + } + element.$el.toggleClass('o_form_label_empty', !!( // FIXME condition is evaluated twice (label AND widget...) + record.data.id + && (modifiers.readonly || self.mode === 'readonly') + && !widget.isSet() + )); + }, + }; + } + // FIXME if the function is called with a <label/> node, the registered + // modifiers will be those on this node. Maybe the desired behavior + // would be to merge them with associated field node if any... note: + // this worked in 10.0 for "o_form_label_empty" reevaluation but not for + // "o_invisible_modifier" reevaluation on labels... + this._registerModifiers(node, this.state, $result, modifiersOptions); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagNotebook: function (node) { + var self = this; + var $headers = $('<ul class="nav nav-tabs">'); + var $pages = $('<div class="tab-content">'); + // renderedTabs is used to aggregate the generated $headers and $pages + // alongside their node, so that their modifiers can be registered once + // all tabs have been rendered, to ensure that the first visible tab + // is correctly activated + var renderedTabs = _.map(node.children, function (child, index) { + var pageID = _.uniqueId('notebook_page_'); + var $header = self._renderTabHeader(child, pageID); + var $page = self._renderTabPage(child, pageID); + self._handleAttributes($header, child); + $headers.append($header); + $pages.append($page); + return { + $header: $header, + $page: $page, + node: child, + }; + }); + // register the modifiers for each tab + _.each(renderedTabs, function (tab) { + self._registerModifiers(tab.node, self.state, tab.$header, { + callback: function (element, modifiers) { + // if the active tab is invisible, activate the first visible tab instead + var $link = element.$el.find('.nav-link'); + if (modifiers.invisible && $link.hasClass('active')) { + $link.removeClass('active'); + tab.$page.removeClass('active'); + self.inactiveNotebooks.push(renderedTabs); + } + if (!modifiers.invisible) { + // make first page active if there is only one page to display + var $visibleTabs = $headers.find('li:not(.o_invisible_modifier)'); + if ($visibleTabs.length === 1) { + self.inactiveNotebooks.push(renderedTabs); + } + } + }, + }); + }); + this._activateFirstVisibleTab(renderedTabs); + var $notebookHeaders = $('<div class="o_notebook_headers">').append($headers); + var $notebook = $('<div class="o_notebook">').append($notebookHeaders, $pages); + $notebook[0].dataset.name = node.attrs.name || '_default_'; + this._registerModifiers(node, this.state, $notebook); + this._handleAttributes($notebook, node); + return $notebook; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagSeparator: function (node) { + var $separator = $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + this._handleAttributes($separator, node); + this._registerModifiers(node, this.state, $separator); + return $separator; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagSheet: function (node) { + this.has_sheet = true; + var $sheet = $('<div>', {class: 'clearfix position-relative o_form_sheet'}); + $sheet.append(node.children.map(this._renderNode.bind(this))); + return $sheet; + }, + /** + * Instantiate custom widgets + * + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagWidget: function (node) { + return this._renderWidget(this.state, node); + }, + /** + * Main entry point for the rendering. From here, we call _renderNode on + * the root of the arch, then, when every promise (from the field widgets) + * are done, it will resolves itself. + * + * @private + * @override method from BasicRenderer + * @returns {Promise} + */ + _renderView: function () { + var self = this; + + // render the form and evaluate the modifiers + var defs = []; + this.defs = defs; + this.inactiveNotebooks = []; + var $form = this._renderNode(this.arch).addClass(this.className); + delete this.defs; + + return Promise.all(defs).then(() => this.__renderView()).then(function () { + self._updateView($form.contents()); + if (self.state.res_id in self.alertFields) { + self.displayTranslationAlert(); + } + }).then(function(){ + if (self.lastActivatedFieldIndex >= 0) { + self._activateNextFieldWidget(self.state, self.lastActivatedFieldIndex); + } + }).guardedCatch(function () { + $form.remove(); + }); + }, + /** + * Meant to be overridden if asynchronous work needs to be done when + * rendering the view. This is called right before attaching the new view + * content. + * @private + * @returns {Promise<any>} + */ + async __renderView() {}, + /** + * This method is overridden to activate the first notebook page if the + * current active page is invisible due to modifiers. This is done after + * all modifiers are applied on all page elements. + * + * @override + */ + async _updateAllModifiers() { + await this._super(...arguments); + for (const tabs of this.inactiveNotebooks) { + this._activateFirstVisibleTab(tabs); + } + this.inactiveNotebooks = []; + }, + /** + * Updates the form's $el with new content. + * + * @private + * @see _renderView + * @param {JQuery} $newContent + */ + _updateView: function ($newContent) { + var self = this; + + // Set the new content of the form view, and toggle classnames + this.$el.html($newContent); + this.$el.toggleClass('o_form_nosheet', !this.has_sheet); + if (this.has_sheet) { + this.$el.children().not('.o_FormRenderer_chatterContainer') + .wrapAll($('<div/>', {class: 'o_form_sheet_bg'})); + } + this.$el.toggleClass('o_form_editable', this.mode === 'edit'); + this.$el.toggleClass('o_form_readonly', this.mode === 'readonly'); + + // Attach the tooltips on the fields' label + _.each(this.allFieldWidgets[this.state.id], function (widget) { + const idForLabel = self.idsForLabels[widget[symbol]]; + var $label = idForLabel ? self.$('.o_form_label[for=' + idForLabel + ']') : $(); + self._addFieldTooltip(widget, $label); + if (widget.attrs.widget === 'upgrade_boolean') { + // this widget needs a reference to its $label to be correctly + // rendered + widget.renderWithLabel($label); + } + }); + }, + /** + * Sets id attribute of given widget to idForLabel + * + * @private + * @param {AbstractField} widget + * @param {idForLabel} string + */ + _setIDForLabel: function (widget, idForLabel) { + widget.setIDForLabel(idForLabel); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onActivateNextWidget: function (ev) { + ev.stopPropagation(); + var index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target); + this._activateNextFieldWidget(this.state, index); + }, + /** + * @override + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + ev.stopPropagation(); + // We prevent the default behaviour and stop the propagation of the + // originalEvent when the originalEvent is a tab keydown to not let + // the browser do it. 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(); + } + var index; + let target = ev.data.target || ev.target; + if (target.__owl__) { + target = target.__owl__.parent; // Owl fields are wrapped by the FieldWrapper + } + if (ev.data.direction === "next") { + index = this.allFieldWidgets[this.state.id].indexOf(target); + this._activateNextFieldWidget(this.state, index); + } else if (ev.data.direction === "previous") { + index = this.allFieldWidgets[this.state.id].indexOf(target); + this._activatePreviousFieldWidget(this.state, index); + } + }, + /** + * Listen to notebook tab changes and trigger a DOM_updated event such that + * widgets in the visible tab can correctly compute their dimensions (e.g. + * autoresize on field text) + * + * @private + */ + _onNotebookTabChanged: function () { + core.bus.trigger('DOM_updated'); + }, + /** + * open the translation view for the current field + * + * @private + * @param {MouseEvent} ev + */ + _onTranslate: function (ev) { + ev.preventDefault(); + this.trigger_up('translate', { + fieldName: ev.target.name, + id: this.state.id, + isComingFromTranslationAlert: true, + }); + }, + /** + * remove alert fields of record from alertFields object + * + * @private + * @param {MouseEvent} ev + */ + _onTranslateNotificationClose: function(ev) { + delete this.alertFields[this.state.res_id]; + }, +}); + +return FormRenderer; +}); diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js new file mode 100644 index 00000000..a7885e0c --- /dev/null +++ b/addons/web/static/src/js/views/form/form_view.js @@ -0,0 +1,201 @@ +odoo.define('web.FormView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var Context = require('web.Context'); +var core = require('web.core'); +var FormController = require('web.FormController'); +var FormRenderer = require('web.FormRenderer'); +const { generateID } = require('web.utils'); + +var _lt = core._lt; + +var FormView = BasicView.extend({ + config: _.extend({}, BasicView.prototype.config, { + Renderer: FormRenderer, + Controller: FormController, + }), + display_name: _lt('Form'), + icon: 'fa-edit', + multi_record: false, + withSearchBar: false, + searchMenuTypes: [], + viewType: 'form', + /** + * @override + */ + init: function (viewInfo, params) { + var hasActionMenus = params.hasActionMenus; + this._super.apply(this, arguments); + + var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); + this.loadParams.type = 'record'; + + // this is kind of strange, but the param object is modified by + // AbstractView, so we only need to use its hasActionMenus value if it was + // not already present in the beginning of this method + if (hasActionMenus === undefined) { + hasActionMenus = params.hasActionMenus; + } + this.controllerParams.hasActionMenus = hasActionMenus; + this.controllerParams.disableAutofocus = params.disable_autofocus || this.arch.attrs.disable_autofocus; + this.controllerParams.toolbarActions = viewInfo.toolbar; + this.controllerParams.footerToButtons = params.footerToButtons; + + var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; + this.controllerParams.defaultButtons = defaultButtons; + this.controllerParams.mode = mode; + + this.rendererParams.mode = mode; + this.rendererParams.isFromFormViewDialog = params.isFromFormViewDialog; + this.rendererParams.fieldIdsToNames = this.fieldsView.fieldIdsToNames; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getController: function (parent) { + return this._loadSubviews(parent).then(this._super.bind(this, parent)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _extractParamsFromAction: function (action) { + var params = this._super.apply(this, arguments); + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + var fullscreen = action.target === 'fullscreen'; + params.withControlPanel = !(inDialog || inline); + params.footerToButtons = inDialog; + params.hasSearchView = inDialog ? false : params.hasSearchView; + params.hasActionMenus = !inDialog && !inline; + params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes; + if (inDialog || inline || fullscreen) { + params.mode = 'edit'; + } else if (action.context && action.context.form_view_initial_mode) { + params.mode = action.context.form_view_initial_mode; + } + return params; + }, + /** + * Loads the subviews for x2many fields when they are not inline + * + * @private + * @param {Widget} parent the parent of the model, if it has to be created + * @returns {Promise} + */ + _loadSubviews: function (parent) { + var self = this; + var defs = []; + if (this.loadParams && this.loadParams.fieldsInfo) { + var fields = this.loadParams.fields; + + _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) { + var field = fields[fieldName]; + if (!field) { + // when a one2many record is opened in a form view, the fields + // of the main one2many view (list or kanban) are added to the + // fieldsInfo of its form view, but those fields aren't in the + // loadParams.fields, as they are not displayed in the view, so + // we can ignore them. + return; + } + if (field.type !== 'one2many' && field.type !== 'many2many') { + return; + } + + if (attrs.Widget.prototype.useSubview && !attrs.__no_fetch && !attrs.views[attrs.mode]) { + var context = {}; + var regex = /'([a-z]*_view_ref)' *: *'(.*?)'/g; + var matches; + while (matches = regex.exec(attrs.context)) { + context[matches[1]] = matches[2]; + } + + // Remove *_view_ref coming from parent view + var refinedContext = _.pick(self.loadParams.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); + // Specify the main model to prevent access rights defined in the context + // (e.g. create: 0) to apply to subviews. We use here the same logic as + // the one applied by the server for inline views. + refinedContext.base_model_name = self.controllerParams.modelName; + defs.push(parent.loadViews( + field.relation, + new Context(context, self.userContext, refinedContext).eval(), + [[null, attrs.mode === 'tree' ? 'list' : attrs.mode]]) + .then(function (views) { + for (var viewName in views) { + // clone to make runbot green? + attrs.views[viewName] = self._processFieldsView(views[viewName], viewName); + attrs.views[viewName].fields = attrs.views[viewName].viewFields; + self._processSubViewAttrs(attrs.views[viewName], attrs); + } + self._setSubViewLimit(attrs); + })); + } else { + self._setSubViewLimit(attrs); + } + }); + } + return Promise.all(defs); + }, + /** + * @override + */ + _processArch(arch, fv) { + fv.fieldIdsToNames = {}; // maps field ids (identifying <field> nodes) to field names + return this._super(...arguments); + }, + /** + * Override to populate the 'fieldIdsToNames' dict mapping <field> node ids + * to field names. Those ids are computed as follows: + * - if set on the node, we use the 'id' attribute + * - otherwise + * - if this is the first occurrence of the field in the arch, we use + * its name as id ('name' attribute) + * - otherwise we generate an id by concatenating the field name with + * a unique id + * - in both cases, we set the id we generated in the attrs, as it + * will be used by the renderer. + * + * @override + */ + _processNode(node, fv) { + if (node.tag === 'field') { + const name = node.attrs.name; + let uid = node.attrs.id; + if (!uid) { + uid = name in fv.fieldIdsToNames ? `${name}__${generateID()}__` : name; + node.attrs.id = uid; + } + fv.fieldIdsToNames[uid] = name; + } + return this._super(...arguments); + }, + /** + * We set here the limit for the number of records fetched (in one page). + * This method is only called for subviews, not for main views. + * + * @private + * @param {Object} attrs + */ + _setSubViewLimit: function (attrs) { + var view = attrs.views && attrs.views[attrs.mode]; + var limit = view && view.arch.attrs.limit && parseInt(view.arch.attrs.limit, 10); + attrs.limit = limit || attrs.Widget.prototype.limit || 40; + }, +}); + +return FormView; + +}); |
