diff --git a/application/Espo/Resources/metadata/app/clientNavbar.json b/application/Espo/Resources/metadata/app/clientNavbar.json
index 905e54a2a5..e6726b18be 100644
--- a/application/Espo/Resources/metadata/app/clientNavbar.json
+++ b/application/Espo/Resources/metadata/app/clientNavbar.json
@@ -18,5 +18,46 @@
"order": 15,
"disabled": false
}
+ },
+ "menuItems": {
+ "admin": {
+ "order": 0,
+ "groupIndex": 1,
+ "link": "#Admin",
+ "labelTranslation": "Global.labels.Administration",
+ "accessDataList": [
+ {
+ "isAdminOnly": true
+ }
+ ]
+ },
+ "preferences": {
+ "order": 1,
+ "groupIndex": 1,
+ "link": "#Preferences",
+ "labelTranslation": "Global.labels.Preferences"
+ },
+ "lastViewed": {
+ "order": 0,
+ "groupIndex": 5,
+ "link": "#LastViewed",
+ "labelTranslation": "Global.scopeNamesPlural.LastViewed",
+ "configCheck": "!actionHistoryDisabled",
+ "handler": "handlers/navbar-menu",
+ "actionFunction": "lastViewed"
+ },
+ "about": {
+ "order": 0,
+ "groupIndex": 10,
+ "link": "#LastViewed",
+ "labelTranslation": "Global.labels.About"
+ },
+ "logout": {
+ "order": 1,
+ "groupIndex": 10,
+ "labelTranslation": "Global.labels.Log Out",
+ "handler": "handlers/navbar-menu",
+ "actionFunction": "logout"
+ }
}
}
diff --git a/client/res/templates/site/navbar.tpl b/client/res/templates/site/navbar.tpl
index bf9dc65e66..54ca460edc 100644
--- a/client/res/templates/site/navbar.tpl
+++ b/client/res/templates/site/navbar.tpl
@@ -195,11 +195,11 @@
{{#each menuDataList}}
{{#unless divider}}
{{#if html}}{{{html}}}{{else}}{{label}}{{/if}}
+ {{#if name}}data-name="{{name}}"{{/if}}
+ {{#if link}}href="{{link}}"{{else}}role="button"{{/if}}
+ tabindex="0"
+ class="nav-link{{#if handler}} action{{/if}}"
+ >{{#if html}}{{{html}}}{{else}}{{label}}{{/if}}
{{else}}
{{/unless}}
diff --git a/client/src/handlers/navbar-menu.js b/client/src/handlers/navbar-menu.js
new file mode 100644
index 0000000000..2d905d9dd3
--- /dev/null
+++ b/client/src/handlers/navbar-menu.js
@@ -0,0 +1,48 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 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 .
+ *
+ * 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.
+ ************************************************************************/
+
+import ActionHandler from 'action-handler';
+
+class NavbarMenuHandler extends ActionHandler {
+
+ logout() {
+ this.view.getRouter().logout();
+ }
+
+ // noinspection JSUnusedGlobalSymbols
+ lastViewed() {
+ Espo.Ui.notify(' ... ');
+
+ this.view.createView('dialog', 'views/modals/last-viewed', {}, view => {
+ view.render();
+ Espo.Ui.notify(false);
+ });
+ }
+}
+
+export default NavbarMenuHandler;
diff --git a/client/src/views/site/navbar.js b/client/src/views/site/navbar.js
index 9367517354..5bdedfd029 100644
--- a/client/src/views/site/navbar.js
+++ b/client/src/views/site/navbar.js
@@ -64,10 +64,6 @@ class NavbarSiteView extends View {
this.switchSideMenu();
},
/** @this NavbarSiteView */
- 'click a.action': function (e) {
- Espo.Utils.handleAction(this, e.originalEvent, e.currentTarget);
- },
- /** @this NavbarSiteView */
'click [data-action="toggleCollapsable"]': function () {
this.toggleCollapsable();
},
@@ -91,7 +87,7 @@ class NavbarSiteView extends View {
tabDefsList1: this.tabDefsList.filter(item => !item.isInMore),
tabDefsList2: this.tabDefsList.filter(item => item.isInMore),
title: this.options.title,
- menuDataList: this.getMenuDataList(),
+ menuDataList: this.menuDataList,
userId: this.getUser().id,
logoSrc: this.getLogoSrc(),
itemDataList: this.getItemDataList(),
@@ -362,6 +358,24 @@ class NavbarSiteView extends View {
}
setup() {
+ this.addHandler('click', 'a.action', (/** MouseEvent */event, target) => {
+ let actionData;
+ const name = target.dataset.name;
+
+ if (name) {
+ const item = this.menuDataList.find(it => it.name === name);
+
+ if (item.handler && item.actionFunction) {
+ actionData = {
+ handler: item.handler,
+ actionFunction: item.actionFunction,
+ };
+ }
+ }
+
+ Espo.Utils.handleAction(this, event, target, actionData);
+ });
+
this.getRouter().on('routed', (e) => {
if (e.controller) {
this.selectTab(e.controller);
@@ -424,6 +438,8 @@ class NavbarSiteView extends View {
this.$body.removeClass('has-navbar');
});
+
+ this.setupMenu();
}
getItemDataList() {
@@ -1292,82 +1308,96 @@ class NavbarSiteView extends View {
/**
* @typedef {Object} MenuDataItem
* @property {string} [link]
+ * @property {string} [name]
* @property {string} [html]
+ * @property {string} [handler]
+ * @property {string} [actionFunction]
* @property {true} [divider]
*/
- /**
- * @return {MenuDataItem[]}
- */
- getMenuDataList() {
+
+ setupMenu() {
let avatarHtml = this.getHelper().getAvatarHtml(this.getUser().id, 'small', 20, 'avatar-link');
if (avatarHtml) {
avatarHtml += ' ';
}
- /** @type {MenuDataItem[]}*/
- let list = [
+ /** @type {MenuDataItem[]} */
+ this.menuDataList = [
{
- link: '#User/view/' + this.getUser().id,
+ link: `#User/view/${this.getUser().id}`,
html: avatarHtml + this.getHelper().escapeString(this.getUser().get('name')),
},
{divider: true}
];
- if (this.getUser().isAdmin()) {
- list.push({
- link: '#Admin',
- label: this.getLanguage().translate('Administration'),
- });
- }
+ /**
+ * @type {Record} items
+ */
+ const items = this.getMetadata().get('app.clientNavbar.menuItems') || {};
- list.push({
- link: '#Preferences',
- label: this.getLanguage().translate('Preferences'),
+ const nameList = Object.keys(items).sort((n1, n2) => {
+ const o1 = items[n1].order;
+ const o2 = items[n2].order;
+
+ const g1 = items[n1].groupIndex;
+ const g2 = items[n2].groupIndex;
+
+ if (g2 === g1) {
+ return o1 - o2;
+ }
+
+ return g1 - g2;
});
- if (!this.getConfig().get('actionHistoryDisabled')) {
- list.push({divider: true});
+ let currentGroup = 0;
- list.push({
- action: 'showLastViewed',
- link: '#LastViewed',
- label: this.getLanguage().translate('LastViewed', 'scopeNamesPlural'),
+ for (const name of nameList) {
+ const item = items[name];
+
+ if (item.groupIndex !== currentGroup) {
+ currentGroup = item.groupIndex;
+
+ this.menuDataList.push({divider: true});
+ }
+
+ if (item.disabled) {
+ continue;
+ }
+
+ if (
+ item.configCheck &&
+ !Espo.Utils.checkActionAvailability(this.getHelper(), item)
+ ) {
+ continue;
+ }
+
+ if (
+ item.accessDataList &&
+ !Espo.Utils.checkAccessDataList(item.accessDataList, this.getAcl(), this.getUser())
+ ) {
+ continue;
+ }
+
+ this.menuDataList.push({
+ name: name,
+ link: item.link,
+ label: this.getLanguage().translatePath(item.labelTranslation),
+ handler: item.handler,
+ actionFunction: item.actionFunction,
});
}
-
- list = list.concat([
- {
- divider: true
- },
- {
- link: '#About',
- label: this.getLanguage().translate('About')
- },
- {
- action: 'logout',
- label: this.getLanguage().translate('Log Out')
- },
- ]);
-
- return list;
- }
-
- // noinspection JSUnusedGlobalSymbols
- actionLogout() {
- this.getRouter().logout();
- }
-
- // noinspection JSUnusedGlobalSymbols
- actionShowLastViewed() {
- Espo.Ui.notify(' ... ');
-
- this.createView('dialog', 'views/modals/last-viewed', {}, (view) => {
- view.render();
-
- Espo.Ui.notify(false);
- });
}
showMoreTabs() {
diff --git a/schema/metadata/app/clientNavbar.json b/schema/metadata/app/clientNavbar.json
index cde3180db2..70d020cfca 100644
--- a/schema/metadata/app/clientNavbar.json
+++ b/schema/metadata/app/clientNavbar.json
@@ -6,7 +6,7 @@
"type": "object",
"properties": {
"items": {
- "description": "Navbar item definitions",
+ "description": "Navbar item definitions.",
"type": "object",
"additionalProperties": {
"description": "An item.",
@@ -30,6 +30,51 @@
}
}
}
+ },
+ "menuItems": {
+ "description": "Menu item definitions.",
+ "type": "object",
+ "additionalProperties": {
+ "description": "An item.",
+ "type": "object",
+ "properties": {
+ "labelTranslation": {
+ "type": "string",
+ "description": "A label translation path."
+ },
+ "link": {
+ "type": "string",
+ "description": "A link."
+ },
+ "order": {
+ "type": "integer",
+ "description": "An order position."
+ },
+ "groupIndex": {
+ "type": "integer",
+ "description": "A group index. Groups are separated by a divider."
+ },
+ "disabled": {
+ "type": "boolean",
+ "description": "An item will be hidden."
+ },
+ "handler": {
+ "type": "string",
+ "description": "A handler class."
+ },
+ "actionFunction": {
+ "type": "string",
+ "description": "An action function in the handler."
+ },
+ "configCheck": {
+ "type": "string",
+ "description": "A config path to check. Path items are separated by a dot. If a config value is not empty, then the action is allowed. The `!` prefix reverses the check."
+ },
+ "accessDataList": {
+ "$ref": "../clientDefs.json#/definitions/accessDataList"
+ }
+ }
+ }
}
}
}