├── .github
└── FUNDING.yml
├── Api
└── IsAllowedInterface.php
├── LICENSE
├── Model
├── AllowedRule
│ ├── AllowedByDefault.php
│ └── DeniedByDefault.php
├── Config.php
├── Config
│ ├── Backend
│ │ └── Cache.php
│ ├── Source
│ │ ├── Options.php
│ │ └── Rules.php
│ └── Structure
│ │ └── Data.php
├── CustomModuleList.php
├── CustomTabList.php
├── IsAllowedMenuChildren.php
├── IsAllowedMenuId.php
├── IsAllowedModule.php
├── IsAllowedStrategy.php
├── RuleConfig.php
└── RuleFactory.php
├── Plugin
├── Block
│ └── MenuBlock.php
└── Model
│ ├── MenuBuilderCommand.php
│ ├── MenuItem.php
│ └── MenuPlugin.php
├── README.md
├── Spi
├── ListInterface.php
└── RuleConfigInterface.php
├── composer.json
├── etc
├── acl.xml
├── adminhtml
│ ├── di.xml
│ ├── menu.xml
│ └── system.xml
├── config.xml
├── di.xml
└── module.xml
├── registration.php
└── view
└── adminhtml
├── layout
└── adminhtml_system_config_edit.xml
├── templates
└── system
│ └── config
│ └── tabs.phtml
└── web
├── css
└── source
│ └── _module.less
└── js
└── collapsible.js
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: ["https://www.paypal.me/redchamps"]
13 |
--------------------------------------------------------------------------------
/Api/IsAllowedInterface.php:
--------------------------------------------------------------------------------
1 | forbiddenList = $items;
30 | }
31 |
32 | public function isAllowed(string $name): bool
33 | {
34 | return !in_array($name, $this->forbiddenList, true);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Model/AllowedRule/DeniedByDefault.php:
--------------------------------------------------------------------------------
1 | forbiddenList = array_diff($defaultItems, $items);
31 | }
32 |
33 | public function isAllowed(string $name): bool
34 | {
35 | return !in_array($name, $this->forbiddenList, true);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Model/Config.php:
--------------------------------------------------------------------------------
1 | cacheManager = $cacheManager;
37 | parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data);
38 | }
39 |
40 | public function afterSave()
41 | {
42 | if ($this->isValueChanged()) {
43 | $this->cacheManager->flush($this->getData('cache_tags'));
44 | }
45 |
46 | return $this;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Model/Config/Source/Options.php:
--------------------------------------------------------------------------------
1 | list = $list;
30 | }
31 |
32 | public function toOptionArray(): array
33 | {
34 | return $this->options ?? $this->options = $this->loadOptions();
35 | }
36 |
37 | private function loadOptions(): array
38 | {
39 | $options = [];
40 |
41 | foreach ($this->list->getList() as $value => $label) {
42 | $options[] = ['label' => new Phrase($label), 'value' => $value];
43 | }
44 |
45 | return $options;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Model/Config/Source/Rules.php:
--------------------------------------------------------------------------------
1 | options = $options;
23 | }
24 |
25 | public function toOptionArray(): array
26 | {
27 | return $this->options;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Model/Config/Structure/Data.php:
--------------------------------------------------------------------------------
1 | isAllowed = $isAllowed;
33 | parent::__construct($reader, $configScope, $cache, $cacheId, $serializer);
34 | }
35 |
36 | public function get($path = null, $default = null)
37 | {
38 | $data = parent::get($path, $default);
39 | if (isset($data['tabs'], $data['sections'])) {
40 | $thirdPartyTabs = [];
41 | foreach ($data['sections'] as $sectionId => $section) {
42 | $sectionTab = $section['tab'] ?? '';
43 | if ($sectionTab && !$this->isAllowed->isAllowed($sectionTab)) {
44 | if (isset($data['tabs'][$sectionTab])) {
45 | $section['tab_original'] = $data['tabs'][$sectionTab];
46 | }
47 | $section['tab'] = 'extensions_list';
48 | $thirdPartyTabs[$sectionTab][$sectionId] = $section;
49 | unset($data['sections'][$sectionId]);
50 | }
51 | }
52 | ksort($thirdPartyTabs);
53 |
54 | $data['sections'] = array_merge($data['sections'], array_merge(...array_values($this->sortByLabel($thirdPartyTabs))));
55 | }
56 |
57 | return $data;
58 | }
59 |
60 | private function sortByLabel($array): array
61 | {
62 | return array_map(
63 | static function (array $sections): array {
64 | uasort(
65 | $sections,
66 | static function (array $a, array $b): int {
67 | return ($a['label'] ?? '') <=> ($b['label'] ?? '');
68 | }
69 | );
70 |
71 | return $sections;
72 | },
73 | $array
74 | );
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Model/CustomModuleList.php:
--------------------------------------------------------------------------------
1 | componentRegistrar = $componentRegistrar;
39 | }
40 |
41 | public function getList(): array
42 | {
43 | return $this->list ?? $this->list = $this->prepareList();
44 | }
45 |
46 | private function prepareList(): array
47 | {
48 | $modules = $this->resolveCustomModules();
49 |
50 | return array_combine($modules, $modules);
51 | }
52 |
53 | private function resolveCustomModules(): array
54 | {
55 | return array_keys(array_filter(
56 | $this->componentRegistrar->getPaths(ComponentRegistrar::MODULE),
57 | static function (string $moduleName): bool {
58 | return strncmp($moduleName, self::MAGENTO_MODULE_PREFIX, strlen(self::MAGENTO_MODULE_PREFIX)) !== 0
59 | && $moduleName !== self::MODULE_NAME;
60 | },
61 | ARRAY_FILTER_USE_KEY
62 | ));
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Model/CustomTabList.php:
--------------------------------------------------------------------------------
1 | structureData = $structureData;
44 | }
45 |
46 | public function getList(): array
47 | {
48 | return $this->list ?? $this->list = $this->resolveCustomTabs();
49 | }
50 |
51 | private function resolveCustomTabs(): array
52 | {
53 | $data = $this->structureData->get();
54 |
55 | $tabs = array_diff_key($data['tabs'], array_flip(self::MANDATORY_TABS));
56 |
57 | return array_combine(array_keys($tabs), array_column($tabs, 'label'));
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Model/IsAllowedMenuChildren.php:
--------------------------------------------------------------------------------
1 | getChildren() as $child) {
17 | if ($child->isAllowed() && !$child->isDisabled()) {
18 | return true;
19 | }
20 | }
21 |
22 | return false;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Model/IsAllowedMenuId.php:
--------------------------------------------------------------------------------
1 | scopeConfig = $scopeConfig;
28 | }
29 |
30 | public function isAllowed(string $name): bool
31 | {
32 | return in_array($name, $this->resolveAllowedMenus(), true);
33 | }
34 |
35 | public function resolveAllowedMenus(): array
36 | {
37 | return preg_split(
38 | '/\r\n|[\r\n]/',
39 | (string) $this->scopeConfig->getValue(self::CONFIG_PATH_ALLOWED_MENU_IDS)
40 | ) ?: [];
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Model/IsAllowedModule.php:
--------------------------------------------------------------------------------
1 | isAllowed = $isAllowed;
34 | $this->scopeConfig = $scopeConfig;
35 | }
36 |
37 | public function isAllowed(string $name): bool
38 | {
39 | $isAllowed = true;
40 |
41 | if ($name === self::MODULE_NAME) {
42 | $isAllowed = !$this->scopeConfig->isSetFlag(self::CONFIG_PATH_MAGENTO_MARKETPLACE_MOVED);
43 | }
44 |
45 | return $isAllowed && $this->isAllowed->isAllowed($name);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Model/IsAllowedStrategy.php:
--------------------------------------------------------------------------------
1 | ruleFactory = $ruleFactory;
38 | $this->config = $config;
39 | }
40 |
41 | public function isAllowed(string $name): bool
42 | {
43 | if (!$this->isAllowed) {
44 | $this->isAllowed = $this->ruleFactory->create(
45 | $this->config->getRuleId(),
46 | ['defaultItems' => $this->config->getDefaultItems(), 'items' => $this->config->getItems()]
47 | );
48 | }
49 |
50 | return $this->isAllowed->isAllowed($name);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Model/RuleConfig.php:
--------------------------------------------------------------------------------
1 | scopeConfig = $scopeConfig;
43 | $this->list = $list;
44 | $this->configPaths = $configPaths;
45 | }
46 |
47 | public function getRuleId(): string
48 | {
49 | return $this->configCache['ruleId'] ??
50 | $this->configCache['ruleId'] = (string) $this->scopeConfig->getValue($this->configPaths['ruleId']);
51 | }
52 |
53 | public function getItems(): array
54 | {
55 | return $this->configCache['items'] ??
56 | $this->configCache['items'] = explode(
57 | ',',
58 | $this->scopeConfig->getValue($this->configPaths['items']) ?? ''
59 | );
60 | }
61 |
62 | public function getDefaultItems(): array
63 | {
64 | return $this->configCache['defaultItems'] ??
65 | $this->configCache['defaultItems'] = array_keys($this->list->getList());
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Model/RuleFactory.php:
--------------------------------------------------------------------------------
1 | objectManager = $objectManager;
30 | $this->rules = $rules;
31 | }
32 |
33 | public function create(string $ruleId, array $arguments): IsAllowedInterface
34 | {
35 | return $this->objectManager->create($this->rules[$ruleId], $arguments);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Plugin/Block/MenuBlock.php:
--------------------------------------------------------------------------------
1 | getFirstAvailable();
26 |
27 | if ($level === 1 && $firstItem && $firstItem->toArray()['toolTip'] === Config::MENU_ID) {
28 | $level = 0;
29 | $limit = self::MAX_ITEMS;
30 | foreach ($colBrakes ?? [] as $key => $colBrake) {
31 | if (isset($colBrake['colbrake'])) {
32 | if ($colBrake['colbrake']) {
33 | $colBrakes[$key]['colbrake'] = false;
34 | } elseif ((($key - 1) % $limit) === 0) {
35 | $colBrakes[$key]['colbrake'] = true;
36 | }
37 | }
38 | }
39 | }
40 |
41 | return [$menu, $level, $limit, $colBrakes];
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Plugin/Model/MenuBuilderCommand.php:
--------------------------------------------------------------------------------
1 | isAllowedModule = $isAllowedModule;
32 | $this->isAllowedMenuId = $isAllowedMenuId;
33 | }
34 |
35 | public function afterExecute(AbstractCommand $subject, $result): array
36 | {
37 | if ((isset($result['id']) && $this->isAllowedMenuId->isAllowed($result['id'])) ||
38 | (
39 | isset($result['module']) &&
40 | !($result['parent'] ?? '') &&
41 | !$this->isAllowedModule->isAllowed($result['module'])
42 | )
43 | ) {
44 | $result['parent'] = Config::MENU_ID;
45 | }
46 |
47 | return $result;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Plugin/Model/MenuItem.php:
--------------------------------------------------------------------------------
1 | isAllowedMenuChildren = $isAllowedMenuChildren;
21 | }
22 |
23 | public function afterGetChildren(Item $subject, $result)
24 | {
25 | if ($subject->getId() === Config::MENU_ID) {
26 | $firstItem = $result->getFirstAvailable();
27 | if ($firstItem && $firstItem->getId()) {
28 | $firstItem->setTooltip(Config::MENU_ID);
29 | }
30 | }
31 |
32 | return $result;
33 | }
34 |
35 | public function afterIsAllowed(Item $subject, bool $result): bool
36 | {
37 | return ($subject->toArray()['resource'] ?? '') === Config::MENU_ID
38 | ? $this->isAllowedMenuChildren->execute($subject)
39 | : $result;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Plugin/Model/MenuPlugin.php:
--------------------------------------------------------------------------------
1 | isAllowedModule = $isAllowedModule;
33 | $this->isAllowedMenuId = $isAllowedMenuId;
34 | }
35 |
36 | public function beforeAdd(Menu $subject, Item $item, $parentId = null, $index = null): array
37 | {
38 | if ($parentId === null &&
39 | $subject->get(Config::MENU_ID) &&
40 | (
41 | $this->isAllowedMenuId->isAllowed($item->getId()) ||
42 | !$this->isAllowedModule->isAllowed($item->toArray()['module'])
43 | )
44 | ) {
45 | $parentId = Config::MENU_ID;
46 | }
47 |
48 | return [$item, $parentId, $index];
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Clean Admin Menu - Magento 2 Extension
2 |
3 | [](https://packagist.org/packages/redchamps/module-clean-admin-menu)
4 | [](./LICENSE)
5 | [](https://packagist.org/packages/redchamps/module-clean-admin-menu/stats)
6 | [](https://packagist.org/packages/redchamps/module-clean-admin-menu/stats)
7 |
8 |
9 | ## Overview
10 |
11 | Clean Admin Menu is a Magento 2 extension that organizes and simplifies your admin panel by consolidating third-party extension menus. It follows Magento's best practices for admin menu organization by:
12 |
13 | 1. Merging all third-party extension menu items into a single "**Extensions**" menu item in the backend's primary navigation
14 | 2. Consolidating third-party extension configuration tabs under `Stores > Configuration` into a single "**Extensions**" tab
15 | 3. Placing the consolidated "Extensions" tab after native Magento tabs
16 |
17 | This organization aligns with Magento's official [Admin Best Practices](https://developer.adobe.com/commerce/php/best-practices/admin/placement-and-design/#feature-extensions) for feature-level extensions.
18 |
19 | ## Features
20 |
21 | - Consolidates all third-party extension menus into a single "Extensions" section
22 | - Organizes extension configuration settings under a unified tab
23 | - Follows Magento's recommended admin menu structure
24 | - Customizable menu organization through admin configuration
25 | - Compatible with Magento 2.4.0 through 2.4.8+
26 |
27 | ## Installation
28 |
29 | Install via Composer:
30 |
31 | ```bash
32 | composer require redchamps/module-clean-admin-menu
33 | ```
34 |
35 | After installation:
36 | 1. Run `bin/magento setup:upgrade`
37 | 2. Run `bin/magento setup:di:compile`
38 | 3. Run `bin/magento cache:clean`
39 |
40 | ## Configuration
41 |
42 | Access the extension settings at:
43 | > Stores > Configuration > Extensions > RedChamps > Clean Admin Menu
44 |
45 | ### Available Settings:
46 | - Menu organization preferences
47 | - Configuration tab placement
48 | - Developer tools for custom menu ID management
49 |
50 | ## Visual Examples
51 |
52 | ### Main Navigation
53 | 
54 |
55 | ### Extensions Configuration
56 | 
57 |
58 | ### Before and After Comparison
59 | 
60 |
61 | ## Troubleshooting
62 |
63 | If any extension menu item is not automatically moved under the "Extensions" menu:
64 |
65 | 1. Navigate to: Stores > Configuration > Extensions > RedChamps > Clean Admin Menu > Developer Tools
66 | 2. Find the menu ID of the extension that needs to be moved
67 | 3. Add the menu ID to the "Move Menu ID's" setting
68 |
69 | ## Requirements
70 |
71 | - Magento 2.4.0 or higher
72 | - PHP 7.4 or higher
73 |
74 | ## Support
75 |
76 | - [GitHub Issues](https://github.com/redchamps/clean-admin-menu/issues/)
77 | - [Documentation](https://redchamps.com/clean-admin-menu-magento-2-extension.html)
78 | - [Contact Support](mailto:hello@redchamps.com)
79 |
80 | ## Authors
81 |
82 | - **RedChamps** [Maintainer] [](https://twitter.com/_redChamps)
83 | - **Ravinder** [Maintainer] [](https://twitter.com/_iAmRav)
84 | - **Thomas Klein** [Contributor] [](https://twitter.com/lead_dave)
85 | - **Prince Antil** [Contributor] [](https://twitter.com/prince_antil)
86 |
87 | ## License
88 |
89 | This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
90 |
91 | ## More Extensions
92 |
93 | Visit our [store](https://redchamps.com) for more free and paid Magento 2 extensions.
94 |
--------------------------------------------------------------------------------
/Spi/ListInterface.php:
--------------------------------------------------------------------------------
1 |
2 |
8 |