Skip to content
Merged
Show file tree
Hide file tree
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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,25 @@ Menu::register(function (MenuManager $menu): void {
}, id: 'pages', after: 'blog');
```

The `group` and item label are optional. The `permission` argument hides an item when the current user cannot pass Laravel's `can()` check; a guest also cannot see it. Badge callbacks for hidden items are not evaluated. Without a label, the default Blade template translates the route name with `__($route)`, so define keys such as `admin.blog.index` and `admin.pages.index` in your translation files. Without a translation, Laravel displays the route key. The group label is translated the same way. Existing explicit labels and manual `$menu->push(['group' => 'Blog'])` calls remain supported.
The `group` and item label are optional. `permission` accepts a single Laravel ability, an array of abilities (visible if **any** passes), or a callback receiving the authenticated user for custom logic. Items with a permission are hidden from guests; badge callbacks for hidden items are not evaluated.

```php
// Any of these permissions is enough:
$menu->addItem('admin.blog.index', 'Articles', permission: ['blog.view', 'blog.manage']);

// Require both permissions and an application-specific condition:
$menu->addItem(
'admin.pages.index',
'Pages',
permission: fn ($user): bool => $user->can('pages.view')
&& $user->can('pages.publish')
&& $user->active,
);

// The same options work on nested items:
$menu->createMenu('admin.blog.create', 'Create')
->permission(fn ($user): bool => $user->can('blog.create') && $user->is_editor);
``` Without a label, the default Blade template translates the route name with `__($route)`, so define keys such as `admin.blog.index` and `admin.pages.index` in your translation files. Without a translation, Laravel displays the route key. The group label is translated the same way. Existing explicit labels and manual `$menu->push(['group' => 'Blog'])` calls remain supported.

Use `Menu::order('pages', before: 'blog')` in the host application's provider to change the order without editing installed modules. A lower priority appears first, and equal priorities preserve registration order. A missing anchor module is ignored: `Menu::order('pages', before: 'blog')` still shows Pages if Blog is absent. If Blog is registered later, the relative order applies automatically. Overrides for modules that never register are ignored. Cycles between installed modules throw a logic exception. Registration callbacks run once per request, when the menu renders. Cache database results inside callbacks where needed and filter by permissions with the `permission` argument.

Expand Down
4 changes: 3 additions & 1 deletion src/Navigation/Menu/Menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Step2dev\LazyMenu\Navigation\Menu;

use Closure;

/** @phpstan-consistent-constructor */
class Menu
{
Expand All @@ -25,7 +27,7 @@ public function children(callable $callback): static
return $this;
}

public function permission(string $permission): static
public function permission(string|array|Closure $permission): static
{
$this->items[count($this->items) - 1]['permission'] = $permission;

Expand Down
26 changes: 22 additions & 4 deletions src/Navigation/Menu/MenuManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use LogicException;
use Throwable;
Expand Down Expand Up @@ -89,7 +90,7 @@ public function addItem(
?string $label = null,
?string $icon = null,
?array $children = null,
?string $permission = null,
string|array|Closure|null $permission = null,
?string $iconView = null,
?array $parameters = null,
mixed $badge = null,
Expand Down Expand Up @@ -169,9 +170,7 @@ private function visible(array $items): array
continue;
}

$permission = $item['permission'] ?? null;

if ($permission && ! auth()->user()?->can($permission)) {
if (! $this->permitted($item['permission'] ?? null)) {
continue;
}

Expand Down Expand Up @@ -199,6 +198,25 @@ private function visible(array $items): array
return $visible;
}

private function permitted(string|array|Closure|null $permission): bool
{
if ($permission === null) {
return true;
}

$user = auth()->user();

if ($user === null) {
return false;
}

if ($permission instanceof Closure) {
return (bool) $permission($user);
}

return Gate::forUser($user)->any((array) $permission);
}

/**
* @throws Throwable
*/
Expand Down
38 changes: 38 additions & 0 deletions tests/Feature/MenuTest.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Auth\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
Expand Down Expand Up @@ -301,3 +303,39 @@
expect($html)->toContain('bg-slate-900', 'data-menu-group', 'rounded-full bg-cyan-500')
->not->toContain('bg-base-200', 'menu-title', 'class="badge"');
});

it('supports multiple abilities and custom access conditions without evaluating hidden badges', function (): void {
Gate::define('blog.view', fn (User $user): bool => true);
Gate::define('pages.view', fn (User $user): bool => false);

$badgeCalls = 0;
$makeMenu = function () use (&$badgeCalls): MenuManager {
$menu = new MenuManager(new Menu);
$menu->addItem('/public', 'Public');
$menu->addItem('/any', 'Any allowed', permission: ['blog.view', 'pages.view']);
$menu->addItem('/both', 'Both required', permission: fn (User $user): bool => $user->can('blog.view') && $user->can('pages.view'), badge: function () use (&$badgeCalls): int {
$badgeCalls++;

return 5;
});
$menu->addItem('/custom', 'Custom rule', permission: fn (User $user): bool => $user->getAuthIdentifier() === 7);
$menu->addItem('/parent', 'Parent', children: [
Menu::make('/child', 'Child')->permission(['pages.view', 'blog.view']),
]);

return $menu;
};

expect(array_column($makeMenu()->visibleItems(), 'label'))->toBe(['Public', 'Parent'])
->and($badgeCalls)->toBe(0);

$user = new User;
$user->id = 7;
$this->be($user);

$visible = $makeMenu()->visibleItems();

expect(array_column($visible, 'label'))->toBe(['Public', 'Any allowed', 'Custom rule', 'Parent'])
->and($visible[3]['children'][0]['label'])->toBe('Child')
->and($badgeCalls)->toBe(0);
});
Loading