This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
espocrm-base/client/src/views/main.js
T
Yuri Kuznetsov 6b1a844fea jsdoc fix
2025-01-21 14:06:06 +02:00

796 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
/** @module views/main */
import View from 'view';
/**
* A base main view. The detail, edit, list views to be extended from.
*/
class MainView extends View {
/**
* A scope name.
*
* @type {string} scope
*/
scope = ''
/**
* A name.
*
* @type {string} name
*/
name = ''
/**
* A top-right menu item (button or dropdown action).
* Handled by a class method `action{Action}`, a click handler or a handler class.
*
* @typedef {Object} module:views/main~MenuItem
*
* @property {string} [name] A name.
* @property {string} [action] An action.
* @property {string} [link] A link.
* @property {string} [label] A translatable label.
* @property {string} [labelTranslation] A label translation path.
* @property {'default'|'danger'|'success'|'warning'} [style] A style. Only for buttons.
* @property {boolean} [hidden] Hidden.
* @property {boolean} [disabled] Disabled.
* @property {Object.<string,string|number|boolean>} [data] Data attribute values.
* @property {string} [title] A title.
* @property {string} [iconHtml] An icon HTML.
* @property {string} [iconClass] An icon class.
* @property {string} [html] An HTML.
* @property {string} [text] A text.
* @property {string} [className] An additional class name. Only for buttons.
* @property {'create'|'read'|'edit'|'stream'|'delete'} [acl] Access to a record (or a scope if `aclScope` specified)
* required for a menu item.
* @property {string} [aclScope] A scope to check access to with the `acl` parameter.
* @property {string} [configCheck] A config parameter defining a menu item availability.
* If starts with `!`, then the result is negated.
* @property {module:utils~AccessDefs[]} [accessDataList] Access definitions.
* @property {string} [handler] A handler.
* @property {string} [initFunction] An init method in the handler.
* @property {string} [actionFunction] An action method in the handler.
* @property {string} [checkVisibilityFunction] A method in the handler that determine whether an item is available.
* @property {function()} [onClick] A click handler.
*/
/**
* Top-right menu definitions.
*
* @type {{
* buttons: module:views/main~MenuItem[],
* dropdown: module:views/main~MenuItem[],
* actions: module:views/main~MenuItem[],
* }} menu
* @private
* @internal
*/
menu = {}
/**
* @private
* @type {JQuery|null}
*/
$headerActionsContainer = null
/**
* A shortcut-key => action map.
*
* @protected
* @type {?Object.<string, string|function (KeyboardEvent): void>}
*/
shortcutKeys = null
/** @inheritDoc */
events = {
/** @this MainView */
'click .action': function (e) {
Espo.Utils.handleAction(this, e.originalEvent, e.currentTarget, {
actionItems: [...this.menu.buttons, ...this.menu.dropdown],
className: 'main-header-manu-action',
});
},
}
lastUrl
/** @inheritDoc */
init() {
this.scope = this.options.scope || this.scope;
this.menu = {};
this.options.params = this.options.params || {};
if (this.name && this.scope) {
const key = this.name.charAt(0).toLowerCase() + this.name.slice(1);
this.menu = this.getMetadata().get(['clientDefs', this.scope, 'menu', key]) || {};
}
/**
* @private
* @type {string[]}
*/
this.headerActionItemTypeList = ['buttons', 'dropdown', 'actions'];
this.menu = Espo.Utils.cloneDeep(this.menu);
let globalMenu = {};
if (this.name) {
globalMenu = Espo.Utils.cloneDeep(
this.getMetadata()
.get(['clientDefs', 'Global', 'menu',
this.name.charAt(0).toLowerCase() + this.name.slice(1)]) || {}
);
}
this._reRenderHeaderOnSync = false;
this._menuHandlers = {};
this.headerActionItemTypeList.forEach(type => {
this.menu[type] = this.menu[type] || [];
this.menu[type] = this.menu[type].concat(globalMenu[type] || []);
const itemList = this.menu[type];
itemList.forEach(item => {
const viewObject = this;
if (
(item.initFunction || item.checkVisibilityFunction) &&
(item.handler || item.data && item.data.handler)
) {
this.wait(new Promise(resolve => {
const handler = item.handler || item.data.handler;
Espo.loader.require(handler, Handler => {
const handler = new Handler(viewObject);
const name = item.name || item.action;
if (name) {
this._menuHandlers[name] = handler;
}
if (item.initFunction) {
handler[item.initFunction].call(handler);
}
if (item.checkVisibilityFunction && this.model) {
this._reRenderHeaderOnSync = true;
}
resolve();
});
}));
}
});
});
if (this.model) {
this.whenReady().then(() => {
if (!this._reRenderHeaderOnSync) {
return;
}
this.listenTo(this.model, 'sync', () => {
if (!this.getHeaderView()) {
return;
}
this.getHeaderView().reRender();
});
});
}
this.updateLastUrl();
this.on('after:render-internal', () => {
this.$headerActionsContainer = this.$el.find('.page-header .header-buttons');
});
this.on('header-rendered', () => {
this.$headerActionsContainer = this.$el.find('.page-header .header-buttons');
this.adjustButtons();
});
this.on('after:render', () => this.adjustButtons());
if (this.shortcutKeys) {
this.shortcutKeys = Espo.Utils.cloneDeep(this.shortcutKeys);
}
}
setupFinal() {
if (this.shortcutKeys) {
this.events['keydown.main'] = e => {
const key = Espo.Utils.getKeyFromKeyEvent(e);
if (typeof this.shortcutKeys[key] === 'function') {
this.shortcutKeys[key].call(this, e.originalEvent);
return;
}
const actionName = this.shortcutKeys[key];
if (!actionName) {
return;
}
e.preventDefault();
e.stopPropagation();
const methodName = 'action' + Espo.Utils.upperCaseFirst(actionName);
if (typeof this[methodName] === 'function') {
this[methodName]();
return;
}
this[actionName]();
};
}
}
/**
* Update a last history URL.
*/
updateLastUrl() {
this.lastUrl = this.getRouter().getCurrentUrl();
}
/**
* @internal
* @returns {{
* buttons?: module:views/main~MenuItem[],
* dropdown?: module:views/main~MenuItem[],
* actions?: module:views/main~MenuItem[],
* }}
*/
getMenu() {
if (this.menuDisabled || !this.menu) {
return {};
}
const menu = {};
this.headerActionItemTypeList.forEach(type => {
(this.menu[type] || []).forEach(item => {
if (item === false) {
menu[type].push(false);
return;
}
item = Espo.Utils.clone(item);
menu[type] = menu[type] || [];
if (!Espo.Utils.checkActionAvailability(this.getHelper(), item)) {
return;
}
if (!Espo.Utils.checkActionAccess(this.getAcl(), this.model || this.scope, item)) {
return;
}
if (item.accessDataList) {
if (!Espo.Utils
.checkAccessDataList(item.accessDataList, this.getAcl(), this.getUser())
) {
return;
}
}
item.name = item.name || item.action;
item.action = item.action || null;
if (this._menuHandlers[item.name] && item.checkVisibilityFunction) {
const handler = this._menuHandlers[item.name];
if (!handler[item.checkVisibilityFunction](item.name)) {
return;
}
}
if (item.labelTranslation) {
item.html = this.getHelper().escapeString(
this.getLanguage().translatePath(item.labelTranslation)
);
}
menu[type].push(item);
});
});
return menu;
}
/**
* Get a header HTML. To be overridden.
*
* @returns {string} HTML.
*/
getHeader() {
return '';
}
/**
* Build a header HTML. To be called from the #getHeader method.
* Beware of XSS.
*
* @param {(string|Element|JQuery)[]} itemList A breadcrumb path. Like: Account > Name > edit.
* @returns {string} HTML
*/
buildHeaderHtml(itemList) {
const $itemList = itemList.map(item => {
return $('<div>')
.addClass('breadcrumb-item')
.append(item);
});
const $div = $('<div>')
.addClass('header-breadcrumbs');
$itemList.forEach(($item, i) => {
$div.append($item);
if (i === $itemList.length - 1) {
return;
}
$div.append(
$('<div>')
.addClass('breadcrumb-separator')
.append(
$('<span>')
)
)
});
return $div.get(0).outerHTML;
}
/**
* Get an icon HTML.
*
* @returns {string} HTML
*/
getHeaderIconHtml() {
return this.getHelper().getScopeColorIconHtml(this.scope);
}
// noinspection JSUnusedGlobalSymbols
/**
* Action 'showModal'.
*
* @todo Revise. To be removed?
*
* @param {Object} data
*/
actionShowModal(data) {
const view = data.view;
if (!view) {
return;
}
this.createView('modal', view, {
model: this.model,
collection: this.collection,
}, view => {
view.render();
this.listenTo(view, 'after:save', () => {
if (this.model) {
this.model.fetch();
}
if (this.collection) {
this.collection.fetch();
}
});
});
}
/**
* Update a menu item.
*
* @param {string} name An item name.
* @param {module:views/main~MenuItem} item New item definitions to write.
* @param {boolean} [doNotReRender=false] Skip re-render.
*
* @since 8.2.0
*/
updateMenuItem(name, item, doNotReRender) {
const actionItem = this._getHeaderActionItem(name);
if (!actionItem) {
return;
}
for (const key in item) {
actionItem[key] = item[key];
}
if (doNotReRender) {
return;
}
if (this.isRendered()) {
this.getHeaderView().reRender();
return;
}
if (this.isBeingRendered()) {
this.whenRendered().then(() => {
this.getHeaderView().reRender();
})
}
}
/**
* Add a menu item.
*
* @param {'buttons'|'dropdown'} type A type.
* @param {module:views/main~MenuItem|false} item Item definitions.
* @param {boolean} [toBeginning=false] To beginning.
* @param {boolean} [doNotReRender=false] Skip re-render.
*/
addMenuItem(type, item, toBeginning, doNotReRender) {
if (item) {
item.name = item.name || item.action || Espo.Utils.generateId();
const name = item.name;
let index = -1;
this.menu[type].forEach((data, i) => {
data = data || {};
if (data.name === name) {
index = i;
}
});
if (~index) {
this.menu[type].splice(index, 1);
}
}
let method = 'push';
if (toBeginning) {
method = 'unshift';
}
this.menu[type][method](item);
if (!doNotReRender && this.isRendered()) {
this.getHeaderView().reRender();
return;
}
if (!doNotReRender && this.isBeingRendered()) {
this.once('after:render', () => {
this.getHeaderView().reRender();
});
}
}
/**
* Remove a menu item.
*
* @param {string} name An item name.
* @param {boolean} [doNotReRender] Skip re-render.
*/
removeMenuItem(name, doNotReRender) {
let index = -1;
let type = false;
this.headerActionItemTypeList.forEach(t => {
(this.menu[t] || []).forEach((item, i) => {
item = item || {};
if (item.name === name) {
index = i;
type = t;
}
});
});
if (~index && type) {
this.menu[type].splice(index, 1);
}
if (!doNotReRender && this.isRendered()) {
this.getHeaderView().reRender();
return;
}
if (!doNotReRender && this.isBeingRendered()) {
this.once('after:render', () => {
this.getHeaderView().reRender();
});
return;
}
if (doNotReRender && this.isRendered()) {
this.$headerActionsContainer.find('[data-name="' + name + '"]').remove();
}
}
/**
* Disable a menu item.
*
* @param {string} name A name.
*/
disableMenuItem(name) {
const item = this._getHeaderActionItem(name);
if (item) {
item.disabled = true;
}
const process = () => {
this.$headerActionsContainer
.find(`[data-name="${name}"]`)
.addClass('disabled')
.attr('disabled');
};
if (this.isBeingRendered()) {
this.whenRendered().then(() => process());
return;
}
if (!this.isRendered()) {
return;
}
process();
}
/**
* Enable a menu item.
*
* @param {string} name A name.
*/
enableMenuItem(name) {
const item = this._getHeaderActionItem(name);
if (item) {
item.disabled = false;
}
const process = () => {
this.$headerActionsContainer
.find(`[data-name="${name}"]`)
.removeClass('disabled')
.removeAttr('disabled');
};
if (this.isBeingRendered()) {
this.whenRendered().then(() => process());
return;
}
if (!this.isRendered()) {
return;
}
process();
}
// noinspection JSUnusedGlobalSymbols
/**
* Action 'navigateToRoot'.
*
* @param {Object} data
* @param {MouseEvent} event
*/
actionNavigateToRoot(data, event) {
event.stopPropagation();
this.getRouter().checkConfirmLeaveOut(() => {
const rootUrl = this.options.rootUrl || this.options.params.rootUrl || '#' + this.scope;
this.getRouter().navigate(rootUrl, {trigger: true, isReturn: true});
});
}
/**
* @private
* @param {string} name
* @return {module:views/main~MenuItem|undefined}
*/
_getHeaderActionItem(name) {
for (const type of this.headerActionItemTypeList) {
if (!this.menu[type]) {
continue;
}
for (const item of this.menu[type]) {
if (item && item.name === name) {
return item;
}
}
}
return undefined;
}
/**
* Hide a menu item.
*
* @param {string} name A name.
*/
hideHeaderActionItem(name) {
const item = this._getHeaderActionItem(name);
if (item) {
item.hidden = true;
}
if (!this.isRendered()) {
return;
}
this.$headerActionsContainer.find(`li > .action[data-name="${name}"]`).parent().addClass('hidden');
this.$headerActionsContainer.find(`a.action[data-name="${name}"]`).addClass('hidden');
this.controlMenuDropdownVisibility();
this.adjustButtons();
if (this.getHeaderView()) {
this.getHeaderView().trigger('action-item-update');
}
}
/**
* Show a hidden menu item.
*
* @param {string} name A name.
*/
showHeaderActionItem(name) {
const item = this._getHeaderActionItem(name);
if (item) {
item.hidden = false;
}
const processUi = () => {
this.$headerActionsContainer.find(`li > .action[data-name="${name}"]`).parent().removeClass('hidden');
this.$headerActionsContainer.find(`a.action[data-name="${name}"]`).removeClass('hidden');
this.controlMenuDropdownVisibility();
this.adjustButtons();
if (this.getHeaderView()) {
this.getHeaderView().trigger('action-item-update');
}
};
if (!this.isRendered()) {
if (this.isBeingRendered()) {
this.whenRendered().then(() => processUi());
}
return;
}
processUi();
}
/**
* Whether a menu has any non-hidden dropdown items.
*
* @private
* @returns {boolean}
*/
hasMenuVisibleDropdownItems() {
let hasItems = false;
(this.menu.dropdown || []).forEach(item => {
if (!item.hidden) {
hasItems = true;
}
});
return hasItems;
}
/**
* @private
*/
controlMenuDropdownVisibility() {
const $group = this.$headerActionsContainer.find('.dropdown-group');
if (this.hasMenuVisibleDropdownItems()) {
$group.removeClass('hidden');
$group.find('> button').removeClass('hidden');
return;
}
$group.addClass('hidden');
$group.find('> button').addClass('hidden');
}
/**
* @protected
* @return {module:views/header}
*/
getHeaderView() {
return this.getView('header');
}
/**
* @private
*/
adjustButtons() {
const $buttons = this.$headerActionsContainer.find('.btn');
$buttons
.removeClass('radius-left')
.removeClass('radius-right');
const $buttonsVisible = $buttons.filter(':not(.hidden)');
$buttonsVisible.first().addClass('radius-left');
$buttonsVisible.last().addClass('radius-right');
}
/**
* Called when a stored view is reused (by the controller).
*
* @public
* @param {Object.<string, *>} params Routing params.
*/
setupReuse(params) {}
}
export default MainView;