diff --git a/Block/Adminhtml/Menu.php b/Block/Adminhtml/Menu.php new file mode 100644 index 0000000..56a60a3 --- /dev/null +++ b/Block/Adminhtml/Menu.php @@ -0,0 +1,76 @@ +groupsProvider = $groupsProvider; + $this->serializer = $serializer; + } + + /** + * Get Groups Json + * @return string + */ + public function getGroupsJson(): string + { + $groups = []; + foreach ($this->groupsProvider->get() as $group) { + $modules = []; + foreach ($group['extensions'] as $moduleName) { + $modules[] = $this->moduleNameToPrefix($moduleName); + } + $groups[] = [ + 'name' => $group['name'], + 'base' => isset($group['base']) ? $this->moduleNameToPrefix($group['base']) : null, + 'modules' => $modules, + ]; + } + + return $this->serializer->serialize($groups); + } + + /** + * Convert Magento module name to data-ui-id prefix. + * Magefan_Seo => menu-magefan-seo- + * @param string $moduleName + * @return string + */ + private function moduleNameToPrefix(string $moduleName): string + { + return 'menu-' . strtolower(str_replace('_', '-', $moduleName)) . '-'; + } +} diff --git a/Block/Adminhtml/System/Config/Tabs.php b/Block/Adminhtml/System/Config/Tabs.php new file mode 100644 index 0000000..cfa07ef --- /dev/null +++ b/Block/Adminhtml/System/Config/Tabs.php @@ -0,0 +1,265 @@ +groupsProvider = $groupsProvider; + $this->configStructure = $configStructure; + $this->currentSectionId = $this->getRequest()->getParam('section'); + } + + /** + * @return array[] + */ + public function getConfigData(): array + { + return $this->buildConfigStructure(); + } + + /** + * Build a flat A-Z sorted list of groups and standalone extensions. + * @return array[] + */ + private function buildConfigStructure(): array + { + $allActive = $this->fetchMagefanSections(); + $assignedModules = []; + $items = []; + + foreach ($this->groupsProvider->get() as $group) { + list($groupItems, $baseItem, $assigned) = $this->buildGroupItems($allActive, $group); + $assignedModules = array_merge($assignedModules, $assigned); + + if (empty($groupItems) && $baseItem === null) { + continue; + } + + $this->applySortOrder($groupItems); + + if ($baseItem !== null) { + array_unshift($groupItems, $baseItem); + } + + $items[] = [ + self::ITEM_TYPE => self::TYPE_GROUP, + self::ITEM_NAME => (string)__($group[self::ITEM_NAME]), + self::EXTENSIONS => $groupItems, + ]; + } + + foreach ($this->excludeGroupedExtensions($allActive, $assignedModules) as $ext) { + $ext[self::ITEM_TYPE] = self::TYPE_SINGLE; + $items[] = $ext; + } + + usort($items, function ($a, $b) { + return strcmp($a[self::ITEM_NAME], $b[self::ITEM_NAME]); + }); + + return ['items' => $items]; + } + + /** + * Extract sub-items and base item for a single group config entry. + * Returns [groupItems, baseItem|null, assignedModuleNames]. + * @param array $allActive + * @param array $group + * @return array + */ + private function buildGroupItems(array $allActive, array $group): array + { + $groupItems = []; + $baseItem = null; + $assigned = []; + $baseModule = $group['base'] ?? null; + + foreach ($group[self::EXTENSIONS] as $moduleName) { + if (!isset($allActive[$moduleName])) { + continue; + } + + $item = $allActive[$moduleName]; + $assigned[] = $moduleName; + + if ($baseModule && $moduleName === $baseModule) { + $item[self::ITEM_NAME] = (string)__('General'); + $baseItem = $item; + } else { + $groupItems[] = $item; + } + } + + return [$groupItems, $baseItem, $assigned]; + } + + /** + * Filters out extensions already assigned to groups using hash map lookups + * @param array $allExtensions + * @param array $assignedModules + * @return array + */ + private function excludeGroupedExtensions(array $allExtensions, array $assignedModules): array + { + $removeMap = array_flip(array_unique($assignedModules)); + $filtered = []; + + foreach ($allExtensions as $key => $value) { + if (!isset($removeMap[$key])) { + $filtered[] = $value; + } + } + + return $filtered; + } + + /** + * Map Magento sections to internal data array + * @return array + */ + private function fetchMagefanSections(): array + { + $output = []; + $nodes = $this->getMagefanConfigChildrenNode(); + + if ($nodes) { + foreach ($nodes as $section) { + if (!$section->isVisible()) { + continue; + } + + $resource = (string)$section->getAttribute('resource'); + $moduleName = current(explode('::', $resource)); + $output[$moduleName] = $this->mapSectionToData($section); + } + } + + return $output; + } + + /** + * Extract attributes from Section element + * @param Section $section + * @return array + */ + private function mapSectionToData(Section $section): array + { + return [ + self::ITEM_NAME => $this->resolveLabel($section), + self::ITEM_CLASS => (string)$section->getClass(), + self::ITEM_URL => $this->generateUrl($section), + self::IS_ACTIVE => $section->getId() === $this->currentSectionId, + self::SORT_ORDER => (int)$section->getAttribute('sortOrder'), + ]; + } + + /** + * @param Section $section + * @return string + */ + private function resolveLabel(Section $section): string + { + $label = $section->getLabel() ? (string)__($section->getLabel()) : ''; + return $this->_escaper->escapeHtml($label); + } + + /** + * @param Section $section + * @return string + */ + private function generateUrl(Section $section): string + { + return $this->getUrl('*/*/*', [ + '_current' => true, + 'section' => $section->getId() + ]); + } + + /** + * @param array $list + * @return void + */ + private function applySortOrder(array &$list): void + { + usort($list, function ($a, $b) { + $sortA = $a[self::SORT_ORDER]; + $sortB = $b[self::SORT_ORDER]; + + if ($sortA == $sortB) { + return 0; + } + return ($sortA < $sortB) ? -1 : 1; + }); + } + + /** + * @return null + */ + public function getMagefanConfigChildrenNode() + { + $configTabs = $this->configStructure->getTabs(); + foreach ($configTabs as $node) { + if ($node->getId() == 'magefan') { + return $node->getChildren(); + } + } + + return null; + } +} diff --git a/Model/Menu/MagefanGroupsProvider.php b/Model/Menu/MagefanGroupsProvider.php new file mode 100644 index 0000000..e5c61d3 --- /dev/null +++ b/Model/Menu/MagefanGroupsProvider.php @@ -0,0 +1,37 @@ +groups = $groups; + } + + /** + * Returns groups config. + * Each group: ['name' => string, 'extensions' => [ModuleName, ...]] + * + * @return array + */ + public function get(): array + { + return $this->groups; + } +} diff --git a/Plugin/Magento/Config/Block/System/Config/Tabs/AddTabs.php b/Plugin/Magento/Config/Block/System/Config/Tabs/AddTabs.php new file mode 100644 index 0000000..ea615ca --- /dev/null +++ b/Plugin/Magento/Config/Block/System/Config/Tabs/AddTabs.php @@ -0,0 +1,90 @@ +domFactory = $domFactory; + $this->logger = $logger; + } + + /** + * @param Tabs $subject + * @param string $result + * @return string + */ + public function afterToHtml(Tabs $subject, string $result): string + { + try { + $domDocument = $this->domFactory->create(); + $domDocument->loadXML($result); + + if ($tabElelent = $this->getMagefanTabElement($domDocument)) { + $fragment = $domDocument->createDocumentFragment(); + + $tabsHtml = $subject + ->getLayout() + ->createBlock( + \Magefan\Community\Block\Adminhtml\System\Config\Tabs::class, + 'mf_dynamic_config_tabs' + )->toHtml(); + + $fragment->appendXML($tabsHtml); + $tabElelent->appendChild($fragment); + $result = $domDocument->saveHTML(); + } + } catch (\Exception $e) { + $this->logger->critical($e); + } + + return $result; + } + + /** + * @param \DOMDocument $domDocument + * @return \DOMElement|null + */ + private function getMagefanTabElement(\DOMDocument $domDocument): ?\DOMElement + { + foreach ($domDocument->getElementsByTagName('div') as $element) { + if (stripos($element->getAttribute('class'), self::TAB_CLASS) !== false) { + foreach ($element->getElementsByTagName('ul') as $ulElement) { + $element->removeChild($ulElement); + } + + return $element; + } + } + + return null; + } +} diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml index ed54255..8ca8fcc 100644 --- a/etc/adminhtml/di.xml +++ b/etc/adminhtml/di.xml @@ -12,4 +12,9 @@ type="Magefan\Community\Plugin\Magento\Backend\Model\Menu\BuilderPlugin" /> + + + + diff --git a/view/adminhtml/layout/default.xml b/view/adminhtml/layout/default.xml index 0d2e6ed..c5135e8 100644 --- a/view/adminhtml/layout/default.xml +++ b/view/adminhtml/layout/default.xml @@ -8,7 +8,7 @@ - + diff --git a/view/adminhtml/templates/magefan-config-tabs.phtml b/view/adminhtml/templates/magefan-config-tabs.phtml new file mode 100644 index 0000000..29979d9 --- /dev/null +++ b/view/adminhtml/templates/magefan-config-tabs.phtml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/view/adminhtml/templates/menu-magefan.phtml b/view/adminhtml/templates/menu-magefan.phtml index bef20cc..5063042 100644 --- a/view/adminhtml/templates/menu-magefan.phtml +++ b/view/adminhtml/templates/menu-magefan.phtml @@ -9,7 +9,8 @@ @@ -26,7 +27,7 @@ $script =" script.async = true; script.onload = function() { - MagefanMenuManager.init(); + MagefanMenuManager.init(" . /* @noEscape */ $block->getGroupsJson() . "); }; script.onerror = function() { diff --git a/view/adminhtml/web/css/source/_module.less b/view/adminhtml/web/css/source/_module.less index 87fb64c..cd06c3e 100644 --- a/view/adminhtml/web/css/source/_module.less +++ b/view/adminhtml/web/css/source/_module.less @@ -158,7 +158,11 @@ > ul[role=menu] { display: flex; flex-direction: column; + overflow-y: auto; + max-height: ~'calc(100vh - 90px)'; margin: 0 1.5rem; + scrollbar-color: rgb(110, 106, 106) transparent; + scrollbar-width: thin; } } &:hover { @@ -228,6 +232,23 @@ } } + // Group headers inside Magefan menu + #menu-magefan-community-elements { + .mf-group-header { + display: block; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: @submenu-link__color; + opacity: .5; + padding: .8rem 3rem .4rem; + margin-top: .8rem; + pointer-events: none; + list-style: none; + } + } + // Content [data-ui-id^=menu-magento-].level-0 { [data-ui-id^=menu-magefan-].level-1 { @@ -295,7 +316,7 @@ // Store Configuration Magefan Extension -.config-nav .magefan-tab .admin__page-nav-title:before { +.config-nav .magefan-tab .admin__page-nav-title:not(.magefan-nav):before { content: '\e900'; font-family: 'Magefan-Icons'; font-size: 30px; @@ -310,9 +331,27 @@ margin-left: 40px; } +.config-nav .magefan-tab .admin__page-nav-title.magefan-nav { + text-transform: none; +} + +.config-nav .magefan-tab .mf-group .admin__page-nav-item { + margin-left: 1.4rem; +} + +.config-nav .magefan-tab .mf-group._hide .admin__page-nav-title._collapsible:after {content: '\e628';} + +.config-nav .magefan-tab .mf-group { + border-left: none; + border-right: none; + border-bottom: none; +} + .accordion .form-inline .config .data-grid .magefan-section td { padding: 1rem; } .accordion .form-inline .config .magefan-section tr:nth-child(even) td { background-color: #f5f5f5; } + + diff --git a/view/adminhtml/web/js/magefan-menu-config.js b/view/adminhtml/web/js/magefan-menu-config.js new file mode 100644 index 0000000..df6ef32 --- /dev/null +++ b/view/adminhtml/web/js/magefan-menu-config.js @@ -0,0 +1,32 @@ +/** + * Copyright © Magefan (support@magefan.com). All rights reserved. + * Please visit Magefan.com for license details (https://magefan.com/end-user-license-agreement). + */ + +define([ + 'uiComponent' +], function (Component) { + 'use strict'; + + return Component.extend({ + defaults: { + template: 'Magefan_Community/config/menu', + templates: { + items: 'Magefan_Community/config/items' + }, + items: [] + }, + + /** + * Is tab active + * + * @param {Array} extensions + * @returns {Boolean} + */ + isActive: function (extensions) { + return extensions.some(function (item) { + return item.is_active; + }); + } + }); +}); diff --git a/view/adminhtml/web/js/magefan-menu.js b/view/adminhtml/web/js/magefan-menu.js index e281348..6e91d50 100644 --- a/view/adminhtml/web/js/magefan-menu.js +++ b/view/adminhtml/web/js/magefan-menu.js @@ -38,7 +38,8 @@ var MagefanMenuManager = { defaultBgColor: '#4a4542', searchThreshold: 16, menuOffsetHeight: 147, - menuLeftPosition: '88px' + menuLeftPosition: '88px', + groups: [] }, // Cache DOM elements @@ -46,8 +47,11 @@ var MagefanMenuManager = { /** * Initialize the menu manager + * + * @param {Array} groups */ - init: function() { + init: function(groups) { + this.config.groups = groups || []; this.elements.menu = document.querySelector(this.config.menuSelector); if (!this.elements.menu) { @@ -84,8 +88,18 @@ var MagefanMenuManager = { setupMenu: function() { var bgColor = this.getSubmenuBgColor(); - this.processLevel1Parents(bgColor); + // Reorganize first so group items exist before processing this.reorganizeSubmenu(); + + // After reorganization, level1Parents = only direct-child items (groups + ungrouped) + this.elements.level1Parents = this.elements.submenuContainer + ? Array.from(this.elements.submenuContainer.children).filter(function(el) { + return el.classList.contains('level-1') && el.classList.contains('parent'); + }) + : []; + + this.processLevel1Parents(bgColor); + this.processLevel2Parents(bgColor); this.setupMenuObserver(); }, @@ -130,6 +144,69 @@ var MagefanMenuManager = { }); }, + /** + * Process level-2 module items inside group submenus (level-3 panels) + */ + processLevel2Parents: function(bgColor) { + var self = this; + + var level2Items = Array.from( + this.elements.menu.querySelectorAll('.level-1.parent[data-mf-level2]') + ); + + level2Items.forEach(function(parent) { + // Get the direct child .submenu (module options panel) + var submenu = null; + var children = parent.children; + for (var i = 0; i < children.length; i++) { + if (children[i].classList.contains('submenu')) { + submenu = children[i]; + break; + } + } + if (!submenu) return; + + submenu.style.backgroundColor = bgColor; + + // Skip if already processed + if (submenu.querySelector('.submenu-item-title')) return; + + // Convert to + var strongTitle = parent.querySelector('strong.submenu-group-title'); + if (strongTitle) { + var link = document.createElement('a'); + link.href = '#'; + link.className = 'mf-submenu-group-title'; + link.innerHTML = strongTitle.innerHTML; + strongTitle.replaceWith(link); + } + + var titleEl = parent.querySelector('.mf-submenu-group-title span') || + parent.querySelector('.submenu-group-title span'); + var titleText = titleEl ? titleEl.textContent.trim() : ''; + + self.addSubmenuHeader(submenu, titleText); + + // Close button for level-3 panel + var closeBtn = submenu.querySelector('.action-close-submenu'); + if (closeBtn) { + closeBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + setTimeout(function() { + self.closeActiveLevel2Submenus(); + }, 50); + }); + } + + // Click on module item opens level-3 panel; stop propagation to parent group + parent.addEventListener('click', function(e) { + e.stopPropagation(); + self.toggleLevel2Submenu(parent); + }); + }); + }, + /** * Add header elements to submenu */ @@ -197,6 +274,33 @@ var MagefanMenuManager = { } }, + /** + * Toggle module options panel (level-3) + */ + toggleLevel2Submenu: function(parent) { + var wasOpened = parent.classList.contains('active'); + + this.closeActiveLevel2Submenus(); + + if (wasOpened) { + return; + } + + parent.classList.add('active'); + + var submenu = null; + var children = parent.children; + for (var i = 0; i < children.length; i++) { + if (children[i].classList.contains('submenu')) { + submenu = children[i]; + break; + } + } + if (submenu) { + submenu.classList.add('_show'); + } + }, + /** * Reorganize submenu structure */ @@ -215,19 +319,25 @@ var MagefanMenuManager = { this.elements.submenuContainer.appendChild(parent); }, this); - // Sort items alphabetically - this.sortSubmenuItems(); + // Sort and optionally group items + this.sortAndGroupSubmenuItems(); - // Add search if threshold met + // Add search if threshold met. + // Collect top-level items AFTER grouping so group wrappers (SEO, Page Speed Optimization) + // are included and can be properly hidden/shown during search. if (allLevel1Parents.length > this.config.searchThreshold) { - this.addSearchFunctionality(allLevel1Parents); + var topLevelItems = Array.from(this.elements.submenuContainer.children).filter(function(el) { + return el.classList.contains('parent') && el.classList.contains('level-1'); + }); + this.addSearchFunctionality(topLevelItems); } }, /** - * Sort submenu items alphabetically + * Sort submenu items alphabetically and group them if groups are configured */ - sortSubmenuItems: function() { + sortAndGroupSubmenuItems: function() { + var self = this; var LAST_ITEMS = [ 'menu-magefan-community-magefan-extensions', 'menu-magefan-community-magefan-user-guides', @@ -235,32 +345,135 @@ var MagefanMenuManager = { var items = Array.from(this.elements.submenuContainer.querySelectorAll('.parent.level-1')); - // separate last items from sortable items + var lastItems = items.filter(function(item) { + return LAST_ITEMS.includes(item.getAttribute('data-ui-id')); + }); + var sortableItems = items.filter(function(item) { return !LAST_ITEMS.includes(item.getAttribute('data-ui-id')); }); - var lastItems = items.filter(function(item) { - return LAST_ITEMS.includes(item.getAttribute('data-ui-id')); + var getItemText = function(item) { + var span = item.querySelector('.mf-submenu-group-title span') || + item.querySelector('.submenu-group-title span'); + return (span ? span.textContent : '').trim(); + }; + + var groups = this.config.groups || []; + + if (!groups.length) { + sortableItems.sort(function(a, b) { + return getItemText(a).localeCompare(getItemText(b)); + }); + sortableItems.forEach(function(item) { + self.elements.submenuContainer.appendChild(item); + }); + lastItems.forEach(function(item) { + self.elements.submenuContainer.appendChild(item); + }); + return; + } + + // Pull items into groups; remaining go ungrouped + var remaining = sortableItems.slice(); + var groupSections = []; + + groups.forEach(function(group) { + var groupItems = []; + var baseItem = null; + + group.modules.forEach(function(prefix) { + for (var i = 0; i < remaining.length; i++) { + var uiId = remaining[i].getAttribute('data-ui-id') || ''; + if (uiId.indexOf(prefix) === 0) { + var item = remaining.splice(i, 1)[0]; + if (group.base && uiId.indexOf(group.base) === 0) { + baseItem = item; + } else { + groupItems.push(item); + } + break; + } + } + }); + + if (!baseItem && !groupItems.length) { + return; + } + + groupItems.sort(function(a, b) { + return getItemText(a).localeCompare(getItemText(b)); + }); + + if (baseItem) { + var titleEl = baseItem.querySelector('.submenu-group-title span') || + baseItem.querySelector('.mf-submenu-group-title span'); + if (titleEl) { + titleEl.textContent = 'General'; + titleEl.setAttribute('data-original-text', 'General'); + } + groupItems.unshift(baseItem); + } + + groupSections.push({name: group.name, items: groupItems}); + }); + + // Build group wrapper elements (do not append yet) + var allSortable = []; + + groupSections.forEach(function(section) { + if (!section.items.length) return; + + // Create group wrapper .level-1.parent + var groupLi = document.createElement('li'); + groupLi.className = 'level-1 parent'; + groupLi.setAttribute('data-mf-group', '1'); + + // Group title — same structure processLevel1Parents expects + var groupTitle = document.createElement('strong'); + groupTitle.className = 'submenu-group-title'; + var groupTitleSpan = document.createElement('span'); + groupTitleSpan.textContent = section.name; + groupTitle.appendChild(groupTitleSpan); + groupLi.appendChild(groupTitle); + + // Group submenu panel + var groupSubmenu = document.createElement('div'); + groupSubmenu.className = 'submenu'; + + var groupUl = document.createElement('ul'); + groupUl.setAttribute('role', 'menu'); + + // Move module items inside group panel and mark them as level-2 + section.items.forEach(function(item) { + item.setAttribute('data-mf-level2', '1'); + groupUl.appendChild(item); + }); + + groupSubmenu.appendChild(groupUl); + groupLi.appendChild(groupSubmenu); + + allSortable.push({ name: section.name, element: groupLi }); }); - // sort only sortable items - sortableItems.sort(function(a, b) { - var spanA = a.querySelector('.mf-submenu-group-title span'); - var spanB = b.querySelector('.mf-submenu-group-title span'); - var textA = (spanA ? spanA.textContent : '').trim(); - var textB = (spanB ? spanB.textContent : '').trim(); - return textA.localeCompare(textB); + // Add ungrouped items to the same sortable list + remaining.forEach(function(item) { + allSortable.push({ name: getItemText(item), element: item }); }); - // append sorted items first, then last items at the end - sortableItems.forEach(function(item) { - this.elements.submenuContainer.appendChild(item); - }, this); + // Sort all items (groups and ungrouped) together A-Z + allSortable.sort(function(a, b) { + return a.name.localeCompare(b.name); + }); + + // Rebuild: all items sorted A-Z, then pinned last items + allSortable.forEach(function(entry) { + self.elements.submenuContainer.appendChild(entry.element); + }); lastItems.forEach(function(item) { - this.elements.submenuContainer.appendChild(item); - }, this); + self.elements.submenuContainer.appendChild(item); + }); }, /** @@ -435,11 +648,32 @@ var MagefanMenuManager = { }); }, + /** + * Close all active level-2 (module options) submenus + */ + closeActiveLevel2Submenus: function() { + var activeL2 = Array.from( + this.elements.menu.querySelectorAll('.level-1.parent[data-mf-level2].active') + ); + activeL2.forEach(function(parent) { + parent.classList.remove('active'); + var children = parent.children; + for (var i = 0; i < children.length; i++) { + if (children[i].classList.contains('submenu') && + children[i].classList.contains('_show')) { + children[i].classList.remove('_show'); + break; + } + } + }); + }, + /** * Close all active submenus */ closeActiveSubmenus: function() { - var activeSubmenu = this.elements.menu.querySelector('.level-1.parent.active .submenu._show'); + this.closeActiveLevel2Submenus(); + var activeSubmenu = this.elements.menu.querySelector('.level-1.parent.active > .submenu._show'); if (activeSubmenu) { activeSubmenu.classList.remove('_show'); } diff --git a/view/adminhtml/web/template/config/items.html b/view/adminhtml/web/template/config/items.html new file mode 100644 index 0000000..ade20f3 --- /dev/null +++ b/view/adminhtml/web/template/config/items.html @@ -0,0 +1,8 @@ + +
  • + + +
  • + diff --git a/view/adminhtml/web/template/config/menu.html b/view/adminhtml/web/template/config/menu.html new file mode 100644 index 0000000..2e51545 --- /dev/null +++ b/view/adminhtml/web/template/config/menu.html @@ -0,0 +1,29 @@ +