Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve the menu builder class #1277

Merged
merged 2 commits into from
May 11, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 153 additions & 81 deletions src/Menu/Builder.php
Original file line number Diff line number Diff line change
@@ -5,25 +5,55 @@
use Illuminate\Support\Arr;
use JeroenNoten\LaravelAdminLte\Helpers\MenuItemHelper;

/**
* Class Builder.
* Responsible of building and compiling the menu.
*
* @property array $menu
*/
class Builder
{
/**
* A set of constants that will be used to identify where to insert new
* items regarding a particular other item (identified by a key).
*/
protected const ADD_AFTER = 0;
protected const ADD_BEFORE = 1;
protected const ADD_INSIDE = 2;

/**
* The set of menu items.
* Holds the raw (uncompiled) version of the menu. The menu is a tree-like
* structure where a submenu item plays the role of a node with children.
* All dynamic changes on the menu will be applied over this structure.
*
* @var array
*/
public $menu = [];
protected $rawMenu = [];

/**
* The set of filters applied to menu items.
* Holds the compiled version of the menu, that results of applying all the
* filters to the raw menu items.
*
* @var array
*/
private $filters;
protected $compiledMenu = [];

/**
* Tells whether the compiled version of the menu should be compiled again
* from the raw version. The idea is to only compile the menu when a client
* is retrieving it and the raw version differs from the compiled version.
*
* @var bool
*/
protected $shouldCompile;

/**
* Holds the set of filters that will be applied to the menu items. These
* filters will be used in the menu compilation process.
*
* @var array
*/
protected $filters;

/**
* Constructor.
@@ -33,138 +63,178 @@ class Builder
public function __construct(array $filters = [])
{
$this->filters = $filters;
$this->shouldCompile = false;
}

/**
* A magic method that allows retrieving properties of the objects generated
* from this class dynamically. We will mainly use this for backward
* compatibility, note the menu was previously accessed through the 'menu'
* property.
*
* @param string $key The name of the property to retrieve
* @return mixed
*/
public function __get($key)
{
return $key === 'menu' ? $this->menu() : null;
}

/**
* Add new items at the end of the menu.
* Gets the compiled version of the menu.
*
* @param mixed $newItems Items to be added
* @return array
*/
public function add(...$newItems)
public function menu()
{
$items = $this->transformItems($newItems);
// First, check if we need to compile the menu again or we can use the
// already compiled version.

if (! empty($items)) {
array_push($this->menu, ...$items);
if (! $this->shouldCompile) {
return $this->compiledMenu;
}

$this->compiledMenu = $this->compileItems($this->rawMenu);
$this->shouldCompile = false;

return $this->compiledMenu;
}

/**
* Adds new items at the end of the menu.
*
* @param mixed $items The new items to be added
*/
public function add(...$items)
{
array_push($this->rawMenu, ...$items);
$this->shouldCompile = true;
}

/**
* Add new items after a specific menu item.
* Adds new items after the specified target menu item.
*
* @param mixed $itemKey The key that represents the specific menu item
* @param mixed $newItems Items to be added
* @param string $itemKey The key that identifies the target menu item
* @param mixed $items The new items to be added
*/
public function addAfter($itemKey, ...$newItems)
public function addAfter($itemKey, ...$items)
{
$this->addItem($itemKey, self::ADD_AFTER, ...$newItems);
$this->addItems($itemKey, self::ADD_AFTER, ...$items);
}

/**
* Add new items before a specific menu item.
* Adds new items before the specified target menu item.
*
* @param mixed $itemKey The key that represents the specific menu item
* @param mixed $newItems Items to be added
* @param string $itemKey The key that identifies the target menu item
* @param mixed $items The new items to be added
*/
public function addBefore($itemKey, ...$newItems)
public function addBefore($itemKey, ...$items)
{
$this->addItem($itemKey, self::ADD_BEFORE, ...$newItems);
$this->addItems($itemKey, self::ADD_BEFORE, ...$items);
}

/**
* Add new submenu items inside a specific menu item.
* Adds new items inside the specified target menu item. This may be used
* to create or extend a submenu.
*
* @param mixed $itemKey The key that represents the specific menu item
* @param mixed $newItems Items to be added
* @param string $itemKey The key that identifies the target menu item
* @param mixed $items The new items to be added
*/
public function addIn($itemKey, ...$newItems)
public function addIn($itemKey, ...$items)
{
$this->addItem($itemKey, self::ADD_INSIDE, ...$newItems);
$this->addItems($itemKey, self::ADD_INSIDE, ...$items);
}

/**
* Remove a specific menu item.
* Removes the specified menu item.
*
* @param mixed $itemKey The key of the menu item to remove
* @param string $itemKey The key that identifies the item to remove
*/
public function remove($itemKey)
{
// Find the specific menu item. Return if not found.
// Check if a path can be found for the specified menu item.

if (! ($itemPath = $this->findItem($itemKey, $this->menu))) {
if (empty($itemPath = $this->findItemPath($itemKey, $this->rawMenu))) {
return;
}

// Remove the item.
// Remove the item from the raw menu.

Arr::forget($this->menu, implode('.', $itemPath));

// Normalize the menu (remove holes in the numeric indexes).

$holedArrPath = implode('.', array_slice($itemPath, 0, -1)) ?: null;
$holedArr = Arr::get($this->menu, $holedArrPath, $this->menu);
Arr::set($this->menu, $holedArrPath, array_values($holedArr));
Arr::forget($this->rawMenu, implode('.', $itemPath));
$this->shouldCompile = true;
}

/**
* Check if exists a menu item with the specified key.
* Checks if exists a menu item with the specified key.
*
* @param mixed $itemKey The key of the menu item to check for
* @param string $itemKey The key of the menu item to check for
* @return bool
*/
public function itemKeyExists($itemKey)
{
return (bool) $this->findItem($itemKey, $this->menu);
return ! empty($this->findItemPath($itemKey, $this->rawMenu));
}

/**
* Transform the items by applying the filters.
* Compiles the specified items by applying the filters. Returns an array
* with the compiled items.
*
* @param array $items An array with items to be transformed
* @return array Array with the new transformed items
* @param array $items An array with the items to be compiled
* @return array
*/
protected function transformItems($items)
protected function compileItems($items)
{
return array_filter(
// Get the set of compiled items.

$items = array_filter(
array_map([$this, 'applyFilters'], $items),
[MenuItemHelper::class, 'isAllowed']
);

// Return the set of compiled items without array holes, that's why we
// use the array_values() method.

return array_values($items);
}

/**
* Find a menu item by the item key and return the path to it.
* Finds the path (an array with a sequence of access keys) to the menu item
* specified by its key inside the provided array of elements. A null value
* will be returned if the menu item can't be found.
*
* @param mixed $itemKey The key of the item to find
* @param array $items The array to look up for the item
* @return mixed Array with the path sequence, or empty array if not found
* @param string $itemKey The key of the menu item to find
* @param array $items The array from where to search for the menu item
* @return ?array
*/
protected function findItem($itemKey, $items)
protected function findItemPath($itemKey, $items)
{
// Look up on all the items.
// Traverse all the specified items. For each item, we first check if
// the item has the specified key. Otherwise, if the item is a submenu,
// we recursively search for the key and path inside that submenu.

foreach ($items as $key => $item) {
if (isset($item['key']) && $item['key'] === $itemKey) {
return [$key];
} elseif (MenuItemHelper::isSubmenu($item)) {
// Do the recursive call to search on submenu. If we found the
// item, merge the path with the current one.
$subPath = $this->findItemPath($itemKey, $item['submenu']);

if ($subPath = $this->findItem($itemKey, $item['submenu'])) {
if (! empty($subPath)) {
return array_merge([$key, 'submenu'], $subPath);
}
}
}

// Return empty array when the item is not found.
// Return null when the item is not found.

return [];
return null;
}

/**
* Apply all the available filters to a menu item.
* Applies all the available filters to a menu item and return the compiled
* version of the item.
*
* @param mixed $item A menu item
* @return mixed A new item with all the filters applied
* @return mixed
*/
protected function applyFilters($item)
{
@@ -174,20 +244,20 @@ protected function applyFilters($item)
return $item;
}

// If the item is a submenu, transform all the submenu items first.
// These items need to be transformed first because some of the submenu
// filters (like the ActiveFilter) depends on these results.
// If the item is a submenu, compile all the child items first (i.e we
// use a depth-first tree traversal). Note child items needs to be
// compiled first because some of the filters (like the ActiveFilter)
// depends on the children properties when applied on a submenu item.

if (MenuItemHelper::isSubmenu($item)) {
$item['submenu'] = $this->transformItems($item['submenu']);
$item['submenu'] = $this->compileItems($item['submenu']);
}

// Now, apply all the filters on the item.
// Now, apply all the filters on the root item. Note there is no need
// to continue applying the filters if we detect that the item is not
// allowed to be shown.

foreach ($this->filters as $filter) {
// If the item is not allowed to be shown, there is no sense to
// continue applying the filters.

if (! MenuItemHelper::isAllowed($item)) {
return $item;
}
@@ -199,41 +269,43 @@ protected function applyFilters($item)
}

/**
* Add new items to the menu in a particular place, relative to a
* specific menu item.
* Adds new items to the menu in a particular place, relative to a target
* menu item identified by its key.
*
* @param mixed $itemKey The key that represents the specific menu item
* @param int $where Where to add the new items
* @param mixed $items Items to be added
* @param string $itemKey The key that identifies the target menu item
* @param int $where Identifier for where to place the new items
* @param mixed $items The new items to be added
*/
protected function addItem($itemKey, $where, ...$items)
protected function addItems($itemKey, $where, ...$items)
{
// Find the specific menu item. Return if not found.
// Check if a path can be found for the specified menu item.

if (! ($itemPath = $this->findItem($itemKey, $this->menu))) {
if (empty($itemPath = $this->findItemPath($itemKey, $this->rawMenu))) {
return;
}

// Get the target array and add the new items there.
// Get the index of the specified menu item relative to its parent.

$itemKeyIdx = end($itemPath);
reset($itemPath);

// Get the target array where the items should be added, and insert the
// new items there.

if ($where === self::ADD_INSIDE) {
$targetPath = implode('.', array_merge($itemPath, ['submenu']));
$targetArr = Arr::get($this->menu, $targetPath, []);
$targetArr = Arr::get($this->rawMenu, $targetPath, []);
array_push($targetArr, ...$items);
} else {
$targetPath = implode('.', array_slice($itemPath, 0, -1)) ?: null;
$targetArr = Arr::get($this->menu, $targetPath, $this->menu);
$targetArr = Arr::get($this->rawMenu, $targetPath, $this->rawMenu);
$offset = ($where === self::ADD_AFTER) ? 1 : 0;
array_splice($targetArr, $itemKeyIdx + $offset, 0, $items);
}

Arr::set($this->menu, $targetPath, $targetArr);

// Apply the filters because the menu now have new items.
// Apply changes on the raw menu.

$this->menu = $this->transformItems($this->menu);
Arr::set($this->rawMenu, $targetPath, $targetArr);
$this->shouldCompile = true;
}
}
Loading