From 4bd56c22d93cd4919a6267309e964602fb37d09d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Mon, 9 Mar 2026 14:37:14 -0400 Subject: [PATCH] feat: add customizable navbar/sidebar menu via Options > Menu tab Add Menu_Items table to store per-item enabled state, custom labels, and sort order. Admins can enable/disable menu items, set custom labels, and reorder via drag-and-drop in Options > Menu tab. Refactor hardcoded get*HTML() calls in buildSidebarMenu(), getNormalNavBarHTML(), and getCollapsedNavBarHTML() to data-driven renderMenuItems() that reads from DB with fallback for empty table. Remove deprecated Cycle view from menu (functionality merged into Watch view). Add reset button to restore default menu configuration. Co-Authored-By: Claude Opus 4.6 --- db/zm_create.sql.in | 31 +++ db/zm_update-1.39.3.sql | 51 ++++ web/includes/MenuItem.php | 31 +++ web/includes/actions/options.php | 42 +++ web/skins/classic/includes/functions.php | 312 ++++++++++++---------- web/skins/classic/views/_options_menu.php | 65 +++++ web/skins/classic/views/js/options.js | 27 ++ web/skins/classic/views/options.php | 4 +- 8 files changed, 428 insertions(+), 135 deletions(-) create mode 100644 db/zm_update-1.39.3.sql create mode 100644 web/includes/MenuItem.php create mode 100644 web/skins/classic/views/_options_menu.php diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 4b5e40f75..976d0c2ad 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -1348,6 +1348,37 @@ CREATE TABLE `Notifications` ( CONSTRAINT `Notifications_ibfk_1` FOREIGN KEY (`UserId`) REFERENCES `Users` (`Id`) ON DELETE CASCADE ) ENGINE=@ZM_MYSQL_ENGINE@; +-- +-- Table structure for table `Menu_Items` +-- + +DROP TABLE IF EXISTS `Menu_Items`; +CREATE TABLE `Menu_Items` ( + `Id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `MenuKey` varchar(32) NOT NULL, + `Enabled` tinyint(1) NOT NULL DEFAULT 1, + `Label` varchar(64) DEFAULT NULL, + `SortOrder` smallint NOT NULL DEFAULT 0, + PRIMARY KEY (`Id`), + UNIQUE KEY `Menu_Items_MenuKey_idx` (`MenuKey`) +) ENGINE=@ZM_MYSQL_ENGINE@; + +INSERT INTO `Menu_Items` (`MenuKey`, `Enabled`, `SortOrder`) VALUES + ('Console', 1, 10), + ('Montage', 1, 20), + ('MontageReview', 1, 30), + ('Events', 1, 40), + ('Options', 1, 50), + ('Log', 1, 60), + ('Devices', 1, 70), + ('IntelGpu', 1, 80), + ('Groups', 1, 90), + ('Filters', 1, 100), + ('Snapshots', 1, 110), + ('Reports', 1, 120), + ('ReportEventAudit', 1, 130), + ('Map', 1, 140); + source @PKGDATADIR@/db/Object_Types.sql -- We generally don't alter triggers, we drop and re-create them, so let's keep them in a separate file that we can just source in update scripts. source @PKGDATADIR@/db/triggers.sql diff --git a/db/zm_update-1.39.3.sql b/db/zm_update-1.39.3.sql new file mode 100644 index 000000000..14c1b9b51 --- /dev/null +++ b/db/zm_update-1.39.3.sql @@ -0,0 +1,51 @@ +-- +-- Add Menu_Items table for customizable navbar/sidebar menu +-- + +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() + AND table_name = 'Menu_Items' + ) > 0, +"SELECT 'Table Menu_Items already exists'", +"CREATE TABLE `Menu_Items` ( + `Id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `MenuKey` varchar(32) NOT NULL, + `Enabled` tinyint(1) NOT NULL DEFAULT 1, + `Label` varchar(64) DEFAULT NULL, + `SortOrder` smallint NOT NULL DEFAULT 0, + PRIMARY KEY (`Id`), + UNIQUE KEY `Menu_Items_MenuKey_idx` (`MenuKey`) +) ENGINE=InnoDB" +)); + +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- +-- Seed default menu items if table is empty +-- + +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM `Menu_Items`) > 0, +"SELECT 'Menu_Items already has data'", +"INSERT INTO `Menu_Items` (`MenuKey`, `Enabled`, `SortOrder`) VALUES + ('Console', 1, 10), + ('Montage', 1, 20), + ('MontageReview', 1, 30), + ('Events', 1, 40), + ('Options', 1, 50), + ('Log', 1, 60), + ('Devices', 1, 70), + ('IntelGpu', 1, 80), + ('Groups', 1, 90), + ('Filters', 1, 100), + ('Snapshots', 1, 110), + ('Reports', 1, 120), + ('ReportEventAudit', 1, 130), + ('Map', 1, 140)" +)); + +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/web/includes/MenuItem.php b/web/includes/MenuItem.php new file mode 100644 index 000000000..2d6ad64ce --- /dev/null +++ b/web/includes/MenuItem.php @@ -0,0 +1,31 @@ + null, + 'MenuKey' => '', + 'Enabled' => 1, + 'Label' => null, + 'SortOrder' => 0, + ); + + public static function find($parameters = array(), $options = array()) { + return ZM_Object::_find(self::class, $parameters, $options); + } + + public static function find_one($parameters = array(), $options = array()) { + return ZM_Object::_find_one(self::class, $parameters, $options); + } + + public function displayLabel() { + if ($this->{'Label'} !== null && $this->{'Label'} !== '') { + return $this->{'Label'}; + } + return translate($this->{'MenuKey'}); + } +} diff --git a/web/includes/actions/options.php b/web/includes/actions/options.php index e90d3ed1c..c072e99d5 100644 --- a/web/includes/actions/options.php +++ b/web/includes/actions/options.php @@ -189,5 +189,47 @@ if ( $action == 'delete' ) { } } } +} else if ($action == 'menuitems') { + if (!canEdit('System')) { + ZM\Warning('Need System permission to edit menu items'); + } else if (isset($_REQUEST['items'])) { + require_once('includes/MenuItem.php'); + $allItems = ZM\MenuItem::find(); + foreach ($allItems as $item) { + $id = $item->Id(); + $enabled = isset($_REQUEST['items'][$id]['Enabled']) ? 1 : 0; + $label = isset($_REQUEST['items'][$id]['Label']) ? trim($_REQUEST['items'][$id]['Label']) : null; + $sortOrder = isset($_REQUEST['items'][$id]['SortOrder']) ? intval($_REQUEST['items'][$id]['SortOrder']) : $item->SortOrder(); + if ($label === '') $label = null; + $item->save([ + 'Enabled' => $enabled, + 'Label' => $label, + 'SortOrder' => $sortOrder, + ]); + } + } + $redirect = '?view=options&tab=menu'; +} else if ($action == 'resetmenu') { + if (!canEdit('System')) { + ZM\Warning('Need System permission to reset menu items'); + } else { + dbQuery('DELETE FROM Menu_Items'); + dbQuery("INSERT INTO `Menu_Items` (`MenuKey`, `Enabled`, `SortOrder`) VALUES + ('Console', 1, 10), + ('Montage', 1, 20), + ('MontageReview', 1, 30), + ('Events', 1, 40), + ('Options', 1, 50), + ('Log', 1, 60), + ('Devices', 1, 70), + ('IntelGpu', 1, 80), + ('Groups', 1, 90), + ('Filters', 1, 100), + ('Snapshots', 1, 110), + ('Reports', 1, 120), + ('ReportEventAudit', 1, 130), + ('Map', 1, 140)"); + } + $redirect = '?view=options&tab=menu'; } // end if object vs action ?> diff --git a/web/skins/classic/includes/functions.php b/web/skins/classic/includes/functions.php index d462eeae1..9c390ddc9 100644 --- a/web/skins/classic/includes/functions.php +++ b/web/skins/classic/includes/functions.php @@ -179,7 +179,7 @@ function getBodyTopHTML() { } } // end function getBodyTopHTML -function buildMenuItem($viewItemName, $id, $itemName, $href, $icon, $classNameForTag_A = '', $subMenu = '') { +function buildMenuItem($viewItemName, $id, $itemName, $href, $icon, $classNameForTag_A = '', $subMenu = '', $skipTranslate = false) { global $view; /* Highlighting the active menu section */ if ($viewItemName == 'watch') { @@ -187,40 +187,96 @@ function buildMenuItem($viewItemName, $id, $itemName, $href, $icon, $classNameFo } else { $activeClass = $view == $viewItemName ? ' active' : ''; } - $itemName = translate($itemName); + if (!$skipTranslate) $itemName = translate($itemName); $result = ' '.PHP_EOL; return $result; } +function getMenuItemFunctions() { + return [ + 'Console' => 'getConsoleHTML', + 'Montage' => 'getMontageHTML', + 'MontageReview' => 'getMontageReviewHTML', + 'Events' => 'getEventsHTML', + 'Options' => 'getOptionsHTML', + 'Log' => 'getLogHTML', + 'Devices' => 'getDevicesHTML', + 'IntelGpu' => 'getIntelGpuHTML', + 'Groups' => 'getGroupsHTML', + 'Filters' => 'getFilterHTML', + 'Snapshots' => 'getSnapshotsHTML', + 'Reports' => 'getReportsHTML', + 'ReportEventAudit' => 'getRprtEvntAuditHTML', + 'Map' => 'getMapHTML', + ]; +} + +function getMenuItems() { + static $cached = null; + if ($cached === null) { + require_once('includes/MenuItem.php'); + $cached = ZM\MenuItem::find([], ['order' => 'SortOrder ASC']); + } + return $cached; +} + +// Functions that take only ($forLeftBar, $customLabel) - no $view parameter +function getMenuFuncsNoView() { + return ['getConsoleHTML', 'getOptionsHTML', 'getLogHTML', 'getDevicesHTML', 'getIntelGpuHTML']; +} + +function renderMenuItems($forLeftBar = false) { + global $view; + $menuItems = getMenuItems(); + $funcMap = getMenuItemFunctions(); + $result = ''; + + if (empty($menuItems)) { + // Fallback: no DB rows yet, render all in default order + foreach ($funcMap as $key => $funcName) { + $noViewFuncs = getMenuFuncsNoView(); + if (in_array($funcName, $noViewFuncs)) { + $result .= $funcName($forLeftBar); + } else { + $result .= $funcName($view, $forLeftBar); + } + } + } else { + $noViewFuncs = getMenuFuncsNoView(); + foreach ($menuItems as $item) { + if (!$item->Enabled()) continue; + $key = $item->MenuKey(); + if (!isset($funcMap[$key])) continue; + + $funcName = $funcMap[$key]; + $customLabel = ($item->Label() !== null && $item->Label() !== '') ? $item->displayLabel() : null; + + if (in_array($funcName, $noViewFuncs)) { + $result .= $funcName($forLeftBar, $customLabel); + } else { + $result .= $funcName($view, $forLeftBar, $customLabel); + } + } + } + + $result .= getAdditionalLinksHTML($view, $forLeftBar); + return $result; +} + function buildSidebarMenu() { global $view; global $user; if ( $user and $user->Username() ) { $menuForAuthUser = ' ' . - getConsoleHTML($forLeftBar = true) . - getMontageHTML($view, $forLeftBar = true) . - getCycleHTML($view, $forLeftBar = true) . - getMontageReviewHTML($view, $forLeftBar = true) . - getEventsHTML($view, $forLeftBar = true) . - getOptionsHTML($forLeftBar = true) . - getLogHTML($forLeftBar = true) . - getDevicesHTML($forLeftBar = true) . - getIntelGpuHTML($forLeftBar = true) . - getGroupsHTML($view, $forLeftBar = true) . - getFilterHTML($view, $forLeftBar = true) . - getSnapshotsHTML($view, $forLeftBar = true) . - getReportsHTML($view, $forLeftBar = true) . - getRprtEvntAuditHTML($view, $forLeftBar = true) . - getMapHTML($view, $forLeftBar = true) . - getAdditionalLinksHTML($view, $forLeftBar = true) + renderMenuItems($forLeftBar = true) ; } else { // USER IS NOT AUTHORIZED! $menuForAuthUser = ''; @@ -511,21 +567,7 @@ function getNormalNavBarHTML($running, $user, $bandwidth_options, $view, $skin) // *** Build the navigation bar menu items *** echo '
'; echo '