From 50816a955698eb9b3b7822f78be6fc0613cdcdf5 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Tue, 10 Nov 2020 12:53:17 +0800 Subject: [PATCH] Add support for defining quick actions in the Backend's main nav (#5344) Plugins now have the ability to define quick actions through a "registerQuickActions" method, which follows the same configuration as the "registerNavigation" method. It is still recommended and preferred that most plugin functionality be defined in their own main menu items, but this will allow a plugin to easily define a shortcut (or remove one). --- modules/backend/assets/css/october.css | 17 +- .../backend/assets/less/layout/mainmenu.less | 10 +- modules/backend/classes/NavigationManager.php | 170 ++++++++++++++++-- modules/backend/classes/QuickActionItem.php | 105 +++++++++++ modules/backend/layouts/_mainmenu.htm | 27 ++- modules/cms/ServiceProvider.php | 14 ++ modules/system/classes/PluginBase.php | 23 +++ 7 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 modules/backend/classes/QuickActionItem.php diff --git a/modules/backend/assets/css/october.css b/modules/backend/assets/css/october.css index e11e70041..5703da3d0 100644 --- a/modules/backend/assets/css/october.css +++ b/modules/backend/assets/css/october.css @@ -679,9 +679,10 @@ nav#layout-mainmenu .toolbar-item:before {left:-12px} nav#layout-mainmenu .toolbar-item:after {right:-12px} nav#layout-mainmenu .toolbar-item.scroll-active-before:before {color:#fff} nav#layout-mainmenu .toolbar-item.scroll-active-after:after {color:#fff} -nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-preview {margin:0 0 0 21px} -nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-preview i {font-size:20px} -nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-preview a {position:relative;padding:0 10px;top:-1px} +nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-quick-action {margin:0} +nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-quick-action:first-child {margin-left:21px} +nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-quick-action i {font-size:20px} +nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-quick-action a {position:relative;padding:0 10px;top:-1px} nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-account {margin-right:0} nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-account >a {padding:0 15px 0 10px;font-size:13px;position:relative} nav#layout-mainmenu ul.mainmenu-toolbar li.mainmenu-account.highlight >a {z-index:600} @@ -706,8 +707,8 @@ nav#layout-mainmenu ul li .mainmenu-accountmenu li:first-child a:active:after {c nav#layout-mainmenu ul li .mainmenu-accountmenu li.divider {height:1px;width:100%;background-color:#e0e0e0} nav#layout-mainmenu.navbar-mode-inline, nav#layout-mainmenu.navbar-mode-inline_no_icons {height:60px} -nav#layout-mainmenu.navbar-mode-inline ul.mainmenu-toolbar li.mainmenu-preview a, -nav#layout-mainmenu.navbar-mode-inline_no_icons ul.mainmenu-toolbar li.mainmenu-preview a {height:60px;line-height:60px} +nav#layout-mainmenu.navbar-mode-inline ul.mainmenu-toolbar li.mainmenu-quick-action a, +nav#layout-mainmenu.navbar-mode-inline_no_icons ul.mainmenu-toolbar li.mainmenu-quick-action a {height:60px;line-height:60px} nav#layout-mainmenu.navbar-mode-inline ul.mainmenu-toolbar li.mainmenu-account >a, nav#layout-mainmenu.navbar-mode-inline_no_icons ul.mainmenu-toolbar li.mainmenu-account >a {height:60px;line-height:60px} nav#layout-mainmenu.navbar-mode-inline ul li .mainmenu-accountmenu, @@ -730,7 +731,7 @@ nav#layout-mainmenu.navbar-mode-inline ul.mainmenu-nav li:last-child, nav#layout-mainmenu.navbar-mode-inline_no_icons ul.mainmenu-nav li:last-child {margin-right:0} nav#layout-mainmenu.navbar-mode-inline_no_icons .nav-icon {display:none !important} nav#layout-mainmenu.navbar-mode-tile {height:78px} -nav#layout-mainmenu.navbar-mode-tile ul.mainmenu-toolbar li.mainmenu-preview a {height:78px;line-height:78px} +nav#layout-mainmenu.navbar-mode-tile ul.mainmenu-toolbar li.mainmenu-quick-action a {height:78px;line-height:78px} nav#layout-mainmenu.navbar-mode-tile ul.mainmenu-toolbar li.mainmenu-account >a {height:78px;line-height:78px} nav#layout-mainmenu.navbar-mode-tile ul li .mainmenu-accountmenu {top:88px} nav#layout-mainmenu.navbar-mode-tile ul.mainmenu-nav li a {position:relative;width:65px;height:65px} @@ -749,14 +750,14 @@ nav#layout-mainmenu .menu-toggle .menu-toggle-title {margin-left:10px} nav#layout-mainmenu .menu-toggle:hover .menu-toggle-icon {opacity:1} body.mainmenu-open nav#layout-mainmenu .menu-toggle-icon {opacity:1} nav#layout-mainmenu.navbar-mode-collapse {padding-left:0;height:45px} -nav#layout-mainmenu.navbar-mode-collapse ul.mainmenu-toolbar li.mainmenu-preview a {height:45px;line-height:45px} +nav#layout-mainmenu.navbar-mode-collapse ul.mainmenu-toolbar li.mainmenu-quick-action a {height:45px;line-height:45px} nav#layout-mainmenu.navbar-mode-collapse ul.mainmenu-toolbar li.mainmenu-account >a {height:45px;line-height:45px} nav#layout-mainmenu.navbar-mode-collapse ul li .mainmenu-accountmenu {top:55px} nav#layout-mainmenu.navbar-mode-collapse ul.mainmenu-toolbar li.mainmenu-account >a {padding-right:0} nav#layout-mainmenu.navbar-mode-collapse ul li .mainmenu-accountmenu:after {right:13px} nav#layout-mainmenu.navbar-mode-collapse ul.nav {display:none} nav#layout-mainmenu.navbar-mode-collapse .menu-toggle {display:inline-block;color:#fff !important} -@media (max-width:769px) {nav#layout-mainmenu.navbar {padding-left:0;height:45px }nav#layout-mainmenu.navbar ul.mainmenu-toolbar li.mainmenu-preview a {height:45px;line-height:45px }nav#layout-mainmenu.navbar ul.mainmenu-toolbar li.mainmenu-account >a {height:45px;line-height:45px }nav#layout-mainmenu.navbar ul li .mainmenu-accountmenu {top:55px }nav#layout-mainmenu.navbar ul.mainmenu-toolbar li.mainmenu-account >a {padding-right:0 }nav#layout-mainmenu.navbar ul li .mainmenu-accountmenu:after {right:13px }nav#layout-mainmenu.navbar ul.nav {display:none }nav#layout-mainmenu.navbar .menu-toggle {display:inline-block;color:#fff !important }} +@media (max-width:769px) {nav#layout-mainmenu.navbar {padding-left:0;height:45px }nav#layout-mainmenu.navbar ul.mainmenu-toolbar li.mainmenu-quick-action a {height:45px;line-height:45px }nav#layout-mainmenu.navbar ul.mainmenu-toolbar li.mainmenu-account >a {height:45px;line-height:45px }nav#layout-mainmenu.navbar ul li .mainmenu-accountmenu {top:55px }nav#layout-mainmenu.navbar ul.mainmenu-toolbar li.mainmenu-account >a {padding-right:0 }nav#layout-mainmenu.navbar ul li .mainmenu-accountmenu:after {right:13px }nav#layout-mainmenu.navbar ul.nav {display:none }nav#layout-mainmenu.navbar .menu-toggle {display:inline-block;color:#fff !important }} .mainmenu-collapsed {position:absolute;height:100%;top:0;left:0;margin:0;background:#000} .mainmenu-collapsed >div {display:block;height:100%} .mainmenu-collapsed >div ul.mainmenu-nav li a {position:relative;width:65px;height:65px} diff --git a/modules/backend/assets/less/layout/mainmenu.less b/modules/backend/assets/less/layout/mainmenu.less index 5d3492c26..f4b8c37cb 100644 --- a/modules/backend/assets/less/layout/mainmenu.less +++ b/modules/backend/assets/less/layout/mainmenu.less @@ -47,7 +47,7 @@ body.mainmenu-open { height: @height; ul.mainmenu-toolbar { - li.mainmenu-preview { + li.mainmenu-quick-action { a { height: @height; line-height: @height; @@ -191,8 +191,12 @@ nav#layout-mainmenu { // ul.mainmenu-toolbar { - li.mainmenu-preview { - margin: 0 0 0 21px; + li.mainmenu-quick-action { + margin: 0; + + &:first-child { + margin-left: 21px; + } i { font-size: 20px; diff --git a/modules/backend/classes/NavigationManager.php b/modules/backend/classes/NavigationManager.php index 6d54d9550..f88baf6c9 100644 --- a/modules/backend/classes/NavigationManager.php +++ b/modules/backend/classes/NavigationManager.php @@ -28,6 +28,11 @@ class NavigationManager */ protected $items; + /** + * @var QuickActionItem[] List of registered quick actions. + */ + protected $quickActions; + protected $contextSidenavPartials = []; protected $contextOwner; @@ -54,6 +59,9 @@ class NavigationManager */ protected function loadItems() { + $this->items = []; + $this->quickActions = []; + /* * Load module items */ @@ -68,11 +76,18 @@ class NavigationManager foreach ($plugins as $id => $plugin) { $items = $plugin->registerNavigation(); - if (!is_array($items)) { + $quickActions = $plugin->registerQuickActions(); + + if (!is_array($items) && !is_array($quickActions)) { continue; } - $this->registerMenuItems($id, $items); + if (is_array($items)) { + $this->registerMenuItems($id, $items); + } + if (is_array($quickActions)) { + $this->registerQuickActions($id, $quickActions); + } } /** @@ -91,17 +106,21 @@ class NavigationManager Event::fire('backend.menu.extendItems', [$this]); /* - * Sort menu items + * Sort menu items and quick actions */ uasort($this->items, static function ($a, $b) { return $a->order - $b->order; }); + uasort($this->quickActions, static function ($a, $b) { + return $a->order - $b->order; + }); /* - * Filter items user lacks permission for + * Filter items and quick actions that the user lacks permission for */ $user = BackendAuth::getUser(); $this->items = $this->filterItemPermissions($user, $this->items); + $this->quickActions = $this->filterItemPermissions($user, $this->quickActions); foreach ($this->items as $item) { if (!$item->sideMenu || !count($item->sideMenu)) { @@ -183,10 +202,6 @@ class NavigationManager */ public function registerMenuItems($owner, array $definitions) { - if (!$this->items) { - $this->items = []; - } - $validator = Validator::make($definitions, [ '*.label' => 'required', '*.icon' => 'required_without:*.iconSvg', @@ -319,7 +334,7 @@ class NavigationManager $this->items[$itemKey]->addSideMenuItem($item); return true; } - + /** * Remove multiple side menu items * @@ -361,10 +376,14 @@ class NavigationManager */ public function listMainMenuItems() { - if ($this->items === null) { + if ($this->items === null && $this->quickActions === null) { $this->loadItems(); } + if ($this->items === null) { + return []; + } + foreach ($this->items as $item) { if ($item->badge) { $item->counter = (string) $item->badge; @@ -444,6 +463,137 @@ class NavigationManager return $items; } + /** + * Registers quick actions in the main navigation. + * + * Quick actions are single purpose links displayed to the left of the user menu in the + * backend main navigation. + * + * The argument is an array of the quick action items. The array keys represent the + * quick action item codes, specific for the plugin/module. Each element in the + * array should be an associative array with the following keys: + * - label - specifies the action label localization string key, used as a tooltip, required. + * - icon - an icon name from the Font Awesome icon collection, required if iconSvg is unspecified. + * - iconSvg - a custom SVG icon to use for the icon, required if icon is unspecified. + * - url - the back-end relative URL the quick action item should point to, required. + * - permissions - an array of permissions the back-end user should have, optional. + * The item will be displayed if the user has any of the specified permissions. + * - order - a position of the item in the menu, optional. + * + * @param string $owner Specifies the quick action items owner plugin or module in the format Author.Plugin. + * @param array $definitions An array of the quick action item definitions. + * @return void + * @throws SystemException If the validation of the quick action configuration fails + */ + public function registerQuickActions($owner, array $definitions) + { + $validator = Validator::make($definitions, [ + '*.label' => 'required', + '*.icon' => 'required_without:*.iconSvg', + '*.url' => 'required' + ]); + + if ($validator->fails()) { + $errorMessage = 'Invalid quick action item detected in ' . $owner . '. Contact the plugin author to fix (' . $validator->errors()->first() . ')'; + if (Config::get('app.debug', false)) { + throw new SystemException($errorMessage); + } + + Log::error($errorMessage); + } + + $this->addQuickActionItems($owner, $definitions); + } + + /** + * Dynamically add an array of quick action items + * + * @param string $owner + * @param array $definitions + * @return void + */ + public function addQuickActionItems($owner, array $definitions) + { + foreach ($definitions as $code => $definition) { + $this->addQuickActionItem($owner, $code, $definition); + } + } + + /** + * Dynamically add a single quick action item + * + * @param string $owner + * @param string $code + * @param array $definition + * @return void + */ + public function addQuickActionItem($owner, $code, array $definition) + { + $itemKey = $this->makeItemKey($owner, $code); + + if (isset($this->quickActions[$itemKey])) { + $definition = array_merge((array) $this->quickActions[$itemKey], $definition); + } + + $item = array_merge($definition, [ + 'code' => $code, + 'owner' => $owner + ]); + + $this->quickActions[$itemKey] = QuickActionItem::createFromArray($item); + } + + /** + * Gets the instance of a specified quick action item. + * + * @param string $owner + * @param string $code + * @return QuickActionItem + * @throws SystemException + */ + public function getQuickActionItem(string $owner, string $code) + { + $itemKey = $this->makeItemKey($owner, $code); + + if (!array_key_exists($itemKey, $this->quickActions)) { + throw new SystemException('No quick action item found with key ' . $itemKey); + } + + return $this->quickActions[$itemKey]; + } + + /** + * Removes a single quick action item + * + * @param $owner + * @param $code + * @return void + */ + public function removeQuickActionItem($owner, $code) + { + $itemKey = $this->makeItemKey($owner, $code); + unset($this->quickActions[$itemKey]); + } + + /** + * Returns a list of quick action items. + * + * @return array + * @throws SystemException + */ + public function listQuickActionItems() + { + if ($this->items === null && $this->quickActions === null) { + $this->loadItems(); + } + + if ($this->quickActions === null) { + return []; + } + + return $this->quickActions; + } + /** * Sets the navigation context. * The function sets the navigation owner, main menu item code and the side menu item code. diff --git a/modules/backend/classes/QuickActionItem.php b/modules/backend/classes/QuickActionItem.php new file mode 100644 index 000000000..bf30cadc1 --- /dev/null +++ b/modules/backend/classes/QuickActionItem.php @@ -0,0 +1,105 @@ +attributes[$attribute] = $value; + } + + public function removeAttribute($attribute) + { + unset($this->attributes[$attribute]); + } + + /** + * @param string $permission + * @param array $definition + */ + public function addPermission(string $permission, array $definition) + { + $this->permissions[$permission] = $definition; + } + + /** + * @param string $permission + * @return void + */ + public function removePermission(string $permission) + { + unset($this->permissions[$permission]); + } + + /** + * @param array $data + * @return static + */ + public static function createFromArray(array $data) + { + $instance = new static(); + $instance->code = $data['code']; + $instance->owner = $data['owner']; + $instance->label = $data['label']; + $instance->url = $data['url']; + $instance->icon = $data['icon'] ?? null; + $instance->iconSvg = $data['iconSvg'] ?? null; + $instance->attributes = $data['attributes'] ?? $instance->attributes; + $instance->permissions = $data['permissions'] ?? $instance->permissions; + $instance->order = $data['order'] ?? $instance->order; + return $instance; + } +} diff --git a/modules/backend/layouts/_mainmenu.htm b/modules/backend/layouts/_mainmenu.htm index 0cf31ac6d..3327218e4 100644 --- a/modules/backend/layouts/_mainmenu.htm +++ b/modules/backend/layouts/_mainmenu.htm @@ -49,15 +49,24 @@