feat(modules): give each module its own Filament panel with a switcher

Modules previously had their Filament resources injected into the main
admin panel and surfaced legacy addAdminLink() items as sidebar nav.
Invert this: each module owns a dedicated Filament panel and a navbar
panel switcher moves between the main panel and module panels.

- Add App\Contracts\Modules\PanelProvider: an abstract base that
  pre-configures id (= module key), path (admin/{key}), middleware,
  auth, branding, the shared admin theme/sidebar (viteTheme +
  sidebarWidth 14.5rem for visual consistency), a Dashboard page, the
  shared plugins, and discovery of the module's own
  Filament/{Resources,Pages,Widgets}. Modules register it via their
  providers list and override only moduleKey().
- Add PanelSwitcherPlugin: a topbar-left dropdown listing the main
  panel plus every module panel the user can access, on every panel.
- Stop injecting module components into the core admin/system panels:
  remove FilamentPanelExtender, its beforeResolving('filament') hook,
  probeFilament(), and the boot-cache filament field (schema bump).
- Remove the legacy link APIs (addAdminLink/addFrontendLink/
  registerLinks) and their blade/view consumers.
- Gate module panels via access:{module-key} in canAccessPanel(),
  with the view:modules fallback.
- Migrate the Sample module to ship its own panel provider and drop
  its old admin controller/route.
This commit is contained in:
Arthur Parienté 2026-06-17 22:41:34 +02:00
parent 6bb8041824
commit 13a2249b0c
No known key found for this signature in database
33 changed files with 585 additions and 842 deletions

View File

@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Addons\Filament;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
use Filament\PanelRegistry;
/**
* Applies cached Filament discovery paths from enabled addons to the
* registered Filament panels (admin / system).
*
* Stateless and Octane-safe: no mutable instance state.
* Wiring (beforeResolving hook) is handled by AddonServiceProvider not here.
*/
class FilamentPanelExtender
{
/** @var array<string, string> Maps component key → Panel discover method name */
private const array COMPONENT_METHODS = [
'Resources' => 'discoverResources',
'Pages' => 'discoverPages',
'Widgets' => 'discoverWidgets',
];
/**
* @var array<string, string> Maps panel id namespace segment for the `for:` string.
*
* The segment is inserted between the addon namespace and the component
* name when constructing the fully-qualified namespace prefix.
*/
private const array PANEL_NAMESPACE_SEGMENT = [
'admin' => 'Filament',
'system' => 'Filament\\System',
];
public function __construct(
private readonly BootCache $registry,
) {}
/**
* Return a side-effect-free descriptor of what would be discovered for a
* single addon entry, keyed by panel id.
*
* Only panel keys / component entries present in the entry's filament data
* are included. Returns an empty array for addons with no Filament data.
*
* An entry with an empty namespace is skipped it would produce a broken
* leading-double-backslash `for:` string.
*
* @return array<string, list<array{method: string, in: string, for: string}>>
*/
public function discoveriesFor(AddonBootCache $entry): array
{
if ($entry->filament === []) {
return [];
}
$ns = rtrim($entry->namespace, '\\');
if ($ns === '') {
return [];
}
$result = [];
foreach (self::PANEL_NAMESPACE_SEGMENT as $panelId => $nsSegment) {
$panelData = $entry->filament[$panelId] ?? [];
if (empty($panelData)) {
continue;
}
$entries = [];
foreach (self::COMPONENT_METHODS as $component => $method) {
if (!isset($panelData[$component])) {
continue;
}
$entries[] = [
'method' => $method,
'in' => $panelData[$component],
'for' => $ns.'\\'.$nsSegment.'\\'.$component,
];
}
if ($entries !== []) {
$result[$panelId] = $entries;
}
}
return $result;
}
/**
* Apply each enabled addon's Filament discovery paths to the matching
* registered panels.
*
* Resolves panels via PanelRegistry directly (not the Filament facade) to
* avoid triggering the beforeResolving('filament') hook recursively.
*
* Calling `discoverResources/Pages/Widgets` on a Panel accumulates entries
* (each call appends). Safe to call when no addon has Filament dirs.
*/
public function apply(): void
{
// Direct PanelRegistry access — DO NOT replace with Filament::getPanels()/app('filament');
// that resolves the 'filament' binding and re-triggers this beforeResolving('filament') hook recursively.
$panels = app(PanelRegistry::class)->panels;
$allowedMethods = array_values(self::COMPONENT_METHODS);
foreach ($this->registry->enabled() as $entry) {
$discoveries = $this->discoveriesFor($entry);
foreach ($discoveries as $panelId => $entries) {
if (!isset($panels[$panelId])) {
continue;
}
$panel = $panels[$panelId];
foreach ($entries as $entry) {
$method = $entry['method'];
if (!in_array($method, $allowedMethods, strict: true)) {
continue;
}
$panel->{$method}(in: $entry['in'], for: $entry['for']);
}
}
}
}
}

View File

@ -13,19 +13,18 @@ namespace App\Addons\Models;
final readonly class AddonBootCache
{
/**
* @param string $name Human-readable module name.
* @param string|null $alias Short machine alias; null when absent.
* @param string $type Addon type ('module', 'theme', etc.).
* @param string|null $registryId Registry canonical identity; null for bundled addons.
* @param string|null $version Version string; null when absent.
* @param string $namespace PSR-4 root namespace.
* @param list<string> $providers Service-provider class names.
* @param string $path Absolute path to the addon directory.
* @param string $autoloadPath Absolute path the PSR-4 namespace resolves to.
* @param string $layout Layout hint: 'root' or 'app'.
* @param string|null $description Human-readable description; null when absent.
* @param bool $enabled Whether the addon is active.
* @param array<string, array<string, string>> $filament Panel component directory map.
* @param string $name Human-readable module name.
* @param string|null $alias Short machine alias; null when absent.
* @param string $type Addon type ('module', 'theme', etc.).
* @param string|null $registryId Registry canonical identity; null for bundled addons.
* @param string|null $version Version string; null when absent.
* @param string $namespace PSR-4 root namespace.
* @param list<string> $providers Service-provider class names.
* @param string $path Absolute path to the addon directory.
* @param string $autoloadPath Absolute path the PSR-4 namespace resolves to.
* @param string $layout Layout hint: 'root' or 'app'.
* @param string|null $description Human-readable description; null when absent.
* @param bool $enabled Whether the addon is active.
*/
public function __construct(
public string $name,
@ -40,7 +39,6 @@ final readonly class AddonBootCache
public string $layout,
public ?string $description,
public bool $enabled,
public array $filament,
) {}
/**
@ -63,7 +61,6 @@ final readonly class AddonBootCache
layout: (string) ($data['layout'] ?? 'app'),
description: isset($data['description']) ? (string) $data['description'] : null,
enabled: (bool) ($data['enabled'] ?? false),
filament: (array) ($data['filament'] ?? []),
);
}
@ -87,7 +84,6 @@ final readonly class AddonBootCache
'layout' => $this->layout,
'description' => $this->description,
'enabled' => $this->enabled,
'filament' => $this->filament,
];
}
}

View File

@ -263,7 +263,6 @@ class AddonDiscoveryService
layout: $m->layout,
description: $m->description,
enabled: $enabled,
filament: $this->probeFilament($m),
);
}
@ -303,58 +302,4 @@ class AddonDiscoveryService
return $addon;
}
/**
* Probe convention-based Filament directories for a given addon.
*
* Filament base dir is derived from the already-computed autoloadPath:
* - 'root' layout: autoloadPath = addon dir {autoloadPath}/Filament
* - 'app' layout: autoloadPath = addon/app {autoloadPath}/Filament
*
* Probes for Resources, Pages, Widgets under both 'admin' and 'system' panels.
* Only includes subdirs that actually exist on disk.
*
* @return array<string, array<string, string>> panel => component => absolute path
*/
private function probeFilament(AddonManifest $m): array
{
$filamentBase = $m->autoloadPath.'/Filament';
$subdirs = ['Resources', 'Pages', 'Widgets'];
$result = [];
// Admin panel: {filamentBase}/{subdir}
$adminPaths = [];
foreach ($subdirs as $sub) {
$absPath = $filamentBase.'/'.$sub;
if (is_dir($absPath)) {
$real = realpath($absPath);
$adminPaths[$sub] = $real !== false ? $real : $absPath;
}
}
if ($adminPaths !== []) {
$result['admin'] = $adminPaths;
}
// System panel: {filamentBase}/System/{subdir}
$systemPaths = [];
foreach ($subdirs as $sub) {
$absPath = $filamentBase.'/System/'.$sub;
if (is_dir($absPath)) {
$real = realpath($absPath);
$systemPaths[$sub] = $real !== false ? $real : $absPath;
}
}
if ($systemPaths !== []) {
$result['system'] = $systemPaths;
}
return $result;
}
}

View File

@ -26,7 +26,7 @@ class BootCache
* Cache schema version. Increment when the on-disk shape changes
* so stale-schema files are treated as absent (D2-09).
*/
public const int SCHEMA = 2;
public const int SCHEMA = 3;
/**
* Return enabled addons from the boot cache (DB-free hot path, D-10).

View File

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Modules;
use App\Enums\NavigationGroup;
use App\Filament\Plugins\ClearCachesPlugin;
use App\Filament\Plugins\LanguageSwitcherPlugin;
use App\Filament\Plugins\PanelSwitcherPlugin;
use App\Filament\Plugins\SidebarCollapseTogglePlugin;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider as FilamentPanelProvider;
use Filament\Support\Colors\Color;
use Filament\Support\Icons\Heroicon;
use Filament\View\PanelsRenderHook;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Override;
use ReflectionClass;
/**
* Batteries-included base Filament panel provider for modules.
*
* A module ships its own Filament panel by extending this class and supplying
* only {@see moduleKey()}; everything else id, path, middleware, auth,
* branding, theme, navigation, the panel switcher, and convention-based
* discovery of the module's own Filament/{Resources,Pages,Widgets} is
* pre-configured here.
*
* Convention: the extending provider lives at
* {module-root}/Providers/Filament/XxxAdminPanelProvider.php
* so the module root is three levels up from the provider file, and the root
* namespace is everything before `\Providers\`. Override moduleBasePath() /
* moduleRootNamespace() for non-standard layouts.
*
* The panel id equals the module key so per-module access (access:{module-key},
* resolved in User::canAccessPanel()) gates the panel.
*
* Octane-safe: no mutable instance state; all registration is idempotent.
*/
abstract class PanelProvider extends FilamentPanelProvider
{
/**
* The module's short machine key. Used as the panel id and path segment
* (path = `admin/{moduleKey}`) and as the access-permission suffix.
*/
abstract protected function moduleKey(): string;
#[Override]
public function panel(Panel $panel): Panel
{
$panel = $panel
->id($this->moduleKey())
->path('admin/'.$this->moduleKey())
->colors($this->colors())
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
])
->sidebarCollapsibleOnDesktop()
->sidebarWidth('14.5rem')
->navigationGroups([
NavigationGroup::Operations->name,
NavigationGroup::Config->name,
NavigationGroup::Developers->name,
])
->navigationItems([
// Labels should be in a closure to allow for translation
NavigationItem::make()
->label(fn (): string => __('common.go_back_to', ['name' => config('app.name')]))
->icon(Heroicon::OutlinedArrowUturnLeft)
->url('/'),
])
->pages([
Dashboard::class,
])
->plugins([
PanelSwitcherPlugin::make(),
ClearCachesPlugin::make(),
LanguageSwitcherPlugin::make(),
SidebarCollapseTogglePlugin::make(),
])
->bootUsing(function (): void {
activity()->enableLogging();
})
->brandName('phpvms')
->brandLogo(fn (): Factory|View => view('filament.shared.brand'))
->brandLogoHeight('3rem')
->font('Geist')
->favicon(asset('assets/img/favicon.png'))
->renderHook(
PanelsRenderHook::HEAD_END,
fn (): string => Blade::render("@vite('resources/js/admin/app.js')"),
)
->viteTheme('resources/css/filament/admin/theme.css')
->unsavedChangesAlerts()
->spa(hasPrefetching: config('phpvms.use_prefetching_in_admin', false))
->breadcrumbs(false)
->databaseNotifications()
->errorNotifications();
$this->discoverModuleComponents($panel);
return $panel;
}
/**
* The panel colour palette. Override to rebrand the module panel.
*
* @return array<string, array<int, string>|string>
*/
protected function colors(): array
{
return [
'primary' => Color::generatePalette('#067ec1'),
];
}
/**
* Discover the module's own Filament components and register them on the
* panel. Only directories that exist are registered.
*/
protected function discoverModuleComponents(Panel $panel): void
{
$base = $this->moduleBasePath();
$namespace = rtrim($this->moduleRootNamespace(), '\\');
if ($namespace === '') {
return;
}
$filamentBase = $base.'/Filament';
$components = [
'Resources' => 'discoverResources',
'Pages' => 'discoverPages',
'Widgets' => 'discoverWidgets',
];
foreach ($components as $component => $method) {
$dir = $filamentBase.'/'.$component;
if (!is_dir($dir)) {
continue;
}
$panel->{$method}(
in: $dir,
for: $namespace.'\\Filament\\'.$component,
);
}
}
/**
* Resolve the module root directory.
*
* Default: 3 levels up from the provider file, which lives at
* `{module-root}/Providers/Filament/XxxAdminPanelProvider.php`.
*/
protected function moduleBasePath(): string
{
return dirname((string) new ReflectionClass(static::class)->getFileName(), 3);
}
/**
* Resolve the root PHP namespace of this module.
*
* Default: everything before `\Providers\` in the provider's FQCN, e.g.
* `Modules\Sample\Providers\Filament\SampleAdminPanelProvider` `Modules\Sample`.
* Returns '' when it can't be inferred; discovery is then skipped.
*/
protected function moduleRootNamespace(): string
{
if (!str_contains(static::class, '\\Providers\\')) {
return '';
}
return Str::beforeLast(static::class, '\\Providers\\');
}
}

View File

@ -66,18 +66,6 @@ abstract class ServiceProvider extends \Illuminate\Support\ServiceProvider
$this->registerListeners();
}
/**
* This is required to register the links in either the public or admin toolbar
* For example, adding a frontend link:
*
* $this->moduleSvc->addFrontendLink('Sample', '/sample', '', $logged_in=true);
*
* Or an admin link:
*
* $this->moduleSvc->addAdminLink('Sample', '/admin/sample');
*/
public function registerLinks(): void {}
/**
* Deferred providers:
* https://laravel.com/docs/7.x/providers#deferred-providers

View File

@ -1,125 +0,0 @@
<?php
namespace App\Filament\Plugins;
use App\Services\ModuleService;
use Filament\Contracts\Plugin;
use Filament\Facades\Filament;
use Filament\Navigation\NavigationGroup;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\Support\Icons\Heroicon;
use Filament\View\PanelsRenderHook;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
class ModuleLinksPlugin implements Plugin
{
/**
* Panel ids that have already had legacy links appended, keyed for
* idempotency. The plugin instance lives on the Panel, so this persists
* per Octane worker (append once) but resets on a fresh app boot.
*
* @var array<string, true>
*/
private array $registeredPanels = [];
public function getId(): string
{
return 'module-links';
}
public static function make(): static
{
return app(static::class);
}
public function register(Panel $panel): void
{
// Topbar (wide screen): links to *other* Filament panels only. Legacy
// addAdminLink() links now render as native left-sidebar items below.
$panel->renderHook(PanelsRenderHook::TOPBAR_LOGO_AFTER, fn (): Factory|View => view('filament.plugins.module-links-topbar', [
'current_panel' => Filament::getCurrentOrDefaultPanel(),
'group' => $this->getPanelGroup(),
]));
// Backwards compatibility: surface legacy addAdminLink() links as native
// sidebar nav items under the AddOns group, alongside addon Filament
// resources. Deferred to serving() because the links are populated during
// module boot(), which runs after the panel is configured. The static
// guard keeps the append idempotent per worker (Octane-safe).
Filament::serving(function () use ($panel): void {
if (isset($this->registeredPanels[$id = $panel->getId()])) {
return;
}
$this->registeredPanels[$id] = true;
$panel->navigationItems($this->legacyNavigationItems());
});
}
public function boot(Panel $panel): void
{
//
}
/**
* Topbar group of links to other registered Filament panels, excluding the
* admin and system panels themselves.
*/
private function getPanelGroup(): NavigationGroup
{
$items = [];
foreach (Filament::getPanels() as $panel) {
if (in_array($panel->getId(), ['admin', 'system'], true)) {
continue;
}
$items[] = NavigationItem::make(ucfirst(str_replace('::admin', '', $panel->getId())))
->icon(Heroicon::OutlinedPuzzlePiece)
->url(url($panel->getPath()));
}
$group = \App\Enums\NavigationGroup::AddOns;
return NavigationGroup::make($group->name)
->label($group->getLabel())
->items($items);
}
/**
* Legacy addAdminLink() links as native sidebar nav items under the AddOns
* group. Sample is excluded it ships a Filament resource instead.
*
* @return list<NavigationItem>
*/
private function legacyNavigationItems(): array
{
// Pass the enum (not ->name): NavigationManager buckets items by the
// serialized group, so a string would land in a separate group from the
// addon Filament resources (which use the enum) and render the raw
// "AddOns" label instead of the enum's "Add-Ons" getLabel().
$group = \App\Enums\NavigationGroup::AddOns;
$links = array_filter(
app(ModuleService::class)->getAdminLinks(),
static fn (array $link): bool => !str_contains((string) $link['title'], 'Sample'),
);
return array_values(array_map(
static fn (array $link): NavigationItem => NavigationItem::make($link['title'])
->group($group)
->icon(Heroicon::OutlinedFolder)
->url($link['url'])
->visible(fn (): bool => auth()->user()?->can('view:modules') ?? false)
->isActiveWhen(function () use ($link): bool {
$path = trim((string) parse_url((string) $link['url'], PHP_URL_PATH), '/');
return $path !== '' && request()->is($path, $path.'/*');
}),
$links,
));
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Filament\Plugins;
use Filament\Contracts\Plugin;
use Filament\Facades\Filament;
use Filament\Panel;
use Filament\View\PanelsRenderHook;
use Illuminate\Contracts\View\View;
/**
* Renders a panel-switcher dropdown at the left of the topbar.
*
* Registered on the main admin panel and on every module panel (via the base
* module panel-provider contract), it lists the main panel plus each module
* panel the current user may access, so the user can switch context and return.
*
* The `system` panel is excluded it is an internal phpVMS panel, not a
* context a user navigates to from the switcher.
*
* Stateless and Octane-safe: no mutable instance state; the panel list is
* resolved per request from the Filament registry.
*/
final class PanelSwitcherPlugin implements Plugin
{
public static function make(): self
{
return new self();
}
public function getId(): string
{
return 'panel-switcher';
}
public function register(Panel $panel): void
{
$panel->renderHook(
PanelsRenderHook::TOPBAR_LOGO_AFTER,
fn (): View => $this->renderSwitcher(),
);
}
public function boot(Panel $panel): void
{
//
}
/**
* Build the switcher view with the panels the current user can access.
*/
private function renderSwitcher(): View
{
$user = auth()->user();
$current = Filament::getCurrentOrDefaultPanel();
$panels = [];
foreach (Filament::getPanels() as $panel) {
if ($panel->getId() === 'system') {
continue;
}
// Only show panels the user is permitted to access; mirrors the
// gate Filament itself applies via User::canAccessPanel().
if ($user !== null && !$user->canAccessPanel($panel)) {
continue;
}
$panels[] = $panel;
}
return view('filament.plugins.panel-switcher', [
'panels' => $panels,
'current' => $current,
]);
}
}

View File

@ -486,10 +486,15 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, MustVerif
return $this->hasAdminAccess();
}
// For modules panels
// For module panels: the panel id equals the module key, so access is
// gated by the per-module `access:{module-key}` permission (registered
// via PermissionRegistry), with the legacy `view:modules` as fallback.
if ($this->hasRole(Utils::getSuperAdminName())) {
return true;
}
if ($this->can('access:'.$panel->getId())) {
return true;
}
return $this->can('view:modules');
}

View File

@ -6,7 +6,6 @@ namespace App\Providers;
use App\Addons\AddonAutoLoader;
use App\Addons\AddonRegistry;
use App\Addons\Filament\FilamentPanelExtender;
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\AddonAssetLinker;
use App\Addons\Support\AutoloadGuard;
@ -25,8 +24,10 @@ use Override;
* Skipped in console (D-17) to avoid blocking migrate/install/tests.
* 3. Runs the AddonLoader in all contexts so artisan sees addon
* commands and migrations (D2-11). No-ops on empty cache.
* 4. Hooks FilamentPanelExtender into beforeResolving('filament', ...)
* so addon Filament discovery is applied before panels resolve (D2-07).
*
* Modules own their Filament UI via their own panel (see
* App\Contracts\Modules\PanelProvider); the engine no longer injects module
* Filament components into the core admin/system panels.
*
* boot() auto-primes the boot cache when absent/stale (D2-09). This is the
* only step that queries the database, so it is deferred out of register()
@ -37,7 +38,7 @@ use Override;
class AddonServiceProvider extends ServiceProvider
{
/**
* Bind engine services and run loader + Filament hook.
* Bind engine services and run the addon loader.
*
* All singletons are Octane-safe: no mutable instance properties.
*/
@ -52,7 +53,6 @@ class AddonServiceProvider extends ServiceProvider
// ── Phase 2 singletons ──────────────────────────────────────────────
$this->app->singleton(AddonAutoLoader::class);
$this->app->singleton(FilamentPanelExtender::class);
$this->app->singleton(AddonAssetLinker::class, fn (): AddonAssetLinker => AddonAssetLinker::fromConfig());
$this->app->singleton(AddonRegistry::class);
@ -72,12 +72,6 @@ class AddonServiceProvider extends ServiceProvider
// The loader contains its own guard call (re-checks the resolved ClassLoader);
// both guards are intentional — see provider-level comment above (LOAD-08).
$this->app->make(AddonAutoLoader::class)->register($this->app);
// ── Filament hook (D2-07) ────────────────────────────────────────────
// Apply addon Filament discovery paths before panels resolve.
$this->app->beforeResolving('filament', function (): void {
$this->app->make(FilamentPanelExtender::class)->apply();
});
}
/**

View File

@ -105,7 +105,7 @@ class AppServiceProvider extends ServiceProvider
* Str::nanoid() generates an ID via the hidehalo/nanoid client using
* the project's alphabet/length; Str::isNanoid() validates one.
*/
Str::macro('nanoid', fn (int $length = BaseModel::ID_MAX_LENGTH): string => (new NanoidClient($length))->formattedId(BaseModel::ID_ALPHABET, $length));
Str::macro('nanoid', fn (int $length = BaseModel::ID_MAX_LENGTH): string => new NanoidClient($length)->formattedId(BaseModel::ID_ALPHABET, $length));
Str::macro('isNanoid', fn (mixed $value): bool => is_string($value) && preg_match('/^['.BaseModel::ID_ALPHABET.']{'.BaseModel::ID_MAX_LENGTH.'}$/', $value) === 1);
@ -160,7 +160,6 @@ class AppServiceProvider extends ServiceProvider
/**
* Data automatically injected in views
*/
View::share('moduleSvc', app(ModuleService::class));
View::composer('admin.sidebar', VersionComposer::class);
/** @noinspection LaravelUnknownViewInspection */
@ -201,9 +200,8 @@ class AppServiceProvider extends ServiceProvider
$app['config']['view.paths']
));
// Module nav links accumulate across each addon provider's boot() via
// addAdminLink()/addFrontendLink(); the reader (ModuleLinksPlugin,
// nav views) must see the same instance, so it has to be a singleton.
// ModuleService exposes deprecated enable/disable/delete delegators to
// AddonRegistry; bound as a singleton for a stable instance.
$this->app->singleton(ModuleService::class);
// RouteForge lint catalog: tag every concrete rule class so adding a

View File

@ -6,7 +6,7 @@ use App\Enums\NavigationGroup as EnumsNavigationGroup;
use App\Filament\Pages\Backups;
use App\Filament\Plugins\ClearCachesPlugin;
use App\Filament\Plugins\LanguageSwitcherPlugin;
use App\Filament\Plugins\ModuleLinksPlugin;
use App\Filament\Plugins\PanelSwitcherPlugin;
use App\Filament\Plugins\SidebarCollapseTogglePlugin;
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
use Filament\Http\Middleware\Authenticate;
@ -77,7 +77,6 @@ class AdminPanelProvider extends PanelProvider
->navigationGroups([
EnumsNavigationGroup::Operations->name,
EnumsNavigationGroup::Config->name,
EnumsNavigationGroup::AddOns->name,
EnumsNavigationGroup::Developers->name,
])
->navigationItems([
@ -103,7 +102,7 @@ class AdminPanelProvider extends PanelProvider
FilamentSpatieLaravelBackupPlugin::make()
->usingPage(Backups::class),
ModuleLinksPlugin::make(),
PanelSwitcherPlugin::make(),
ClearCachesPlugin::make(),
LanguageSwitcherPlugin::make(),
SidebarCollapseTogglePlugin::make(),

View File

@ -10,60 +10,6 @@ use Deprecated;
class ModuleService extends Service
{
/**
* Module-registered admin nav links. Populated once per worker via each
* module's boot()->registerLinks() call; not a per-request accumulator.
*/
protected array $adminLinks = [];
/**
* @var array 0 == logged out, 1 == logged in
*/
protected array $frontendLinks = [
0 => [],
1 => [],
];
/**
* Add a module link in the frontend
*/
public function addFrontendLink(string $title, string $url, string $icon = 'bi bi-people', bool $logged_in = true): void
{
$this->frontendLinks[$logged_in][] = [
'title' => $title,
'url' => $url,
'icon' => $icon,
];
}
/**
* Get all of the frontend links
*/
public function getFrontendLinks(mixed $logged_in): array
{
return $this->frontendLinks[$logged_in];
}
/**
* Add a module link in the admin panel
*/
public function addAdminLink(string $title, string $url, string $icon = 'bi bi-people'): void
{
$this->adminLinks[] = [
'title' => $title,
'url' => $url,
'icon' => $icon,
];
}
/**
* Get all of the module links in the admin panel
*/
public function getAdminLinks(): array
{
return $this->adminLinks;
}
/**
* Update module with the status passed by user.
*/

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Modules\Sample\Filament\Resources;
use App\Enums\NavigationGroup;
use BackedEnum;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
@ -13,18 +13,17 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Sample\Filament\Resources\SampleResource\Pages\ListSampleItems;
use Modules\Sample\Models\SampleTable;
use Override;
class SampleResource extends Resource
{
protected static ?string $model = SampleTable::class;
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::AddOns;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
protected static ?string $navigationLabel = 'Sample Items';
#[\Override]
#[Override]
public static function form(Schema $schema): Schema
{
return $schema
@ -35,7 +34,7 @@ class SampleResource extends Resource
]);
}
#[\Override]
#[Override]
public static function table(Table $table): Table
{
return $table
@ -46,13 +45,13 @@ class SampleResource extends Resource
]);
}
#[\Override]
#[Override]
public static function getRelations(): array
{
return [];
}
#[\Override]
#[Override]
public static function getPages(): array
{
return [

View File

@ -1,59 +0,0 @@
<?php
namespace Modules\Sample\Http\Controllers\Admin;
use App\Contracts\Controller;
use Illuminate\Http\Request;
/**
* Class AdminController
*/
class AdminController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('sample::admin.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('sample::admin.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request) {}
/**
* Show the specified resource.
*/
public function show()
{
return view('sample::admin.show');
}
/**
* Show the form for editing the specified resource.
*/
public function edit()
{
return view('sample::admin.edit');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request) {}
/**
* Remove the specified resource from storage.
*/
public function destroy() {}
}

View File

@ -1,8 +0,0 @@
<?php
// This is the admin path. Comment this out if you don't have
// an admin panel component.
Route::group([], function () {
Route::get('/', 'AdminController@index');
Route::get('/create', 'AdminController@create');
});

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Modules\Sample\Providers\Filament;
use App\Contracts\Modules\PanelProvider;
/**
* Filament panel for the Sample module.
*
* Everything (id, path, middleware, auth, branding, theme, panel switcher, and
* discovery of this module's Filament/{Resources,Pages,Widgets}) is supplied by
* the base contract; the module only declares its key.
*
* The panel is served at /admin/sample and gated via access:sample.
*/
class SampleAdminPanelProvider extends PanelProvider
{
protected function moduleKey(): string
{
return 'sample';
}
}

View File

@ -2,51 +2,38 @@
namespace Modules\Sample\Providers;
use App\Services\ModuleService;
use Config;
use Illuminate\Support\ServiceProvider;
use Override;
use Route;
class SampleServiceProvider extends ServiceProvider
{
protected $moduleSvc;
/**
* Boot the application events.
*
* The module's admin UI lives in its own Filament panel see
* SampleAdminPanelProvider. There is no admin/frontend "link" registration.
*/
public function boot()
public function boot(): void
{
$this->moduleSvc = app(ModuleService::class);
$this->registerRoutes();
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerLinks();
$this->loadMigrationsFrom(__DIR__.'/../Database/migrations');
}
/**
* Register the service provider.
*/
public function register()
#[Override]
public function register(): void
{
//
}
/**
* Add module links here
*/
public function registerLinks()
{
// Show this link if logged in
// $this->moduleSvc->addFrontendLink('Sample', '/sample', '', $logged_in=true);
// Admin links:
$this->moduleSvc->addAdminLink('Sample', '/admin/sample', 'pe-7s-note');
}
/**
* Register the routes
*/
@ -61,23 +48,10 @@ class SampleServiceProvider extends ServiceProvider
// If you want a RESTful module, change this to 'api'
'middleware' => ['web'],
'namespace' => 'Modules\Sample\Http\Controllers',
], function () {
], function (): void {
$this->loadRoutesFrom(__DIR__.'/../Http/Routes/web.php');
});
/*
* Routes for the admin
*/
Route::group([
'as' => 'sample.',
'prefix' => 'admin/sample',
// If you want a RESTful module, change this to 'api'
'middleware' => ['web', 'role:admin'],
'namespace' => 'Modules\Sample\Http\Controllers\Admin',
], function () {
$this->loadRoutesFrom(__DIR__.'/../Http/Routes/admin.php');
});
/*
* Routes for an API
*/
@ -87,7 +61,7 @@ class SampleServiceProvider extends ServiceProvider
// If you want a RESTful module, change this to 'api'
'middleware' => ['api'],
'namespace' => 'Modules\Sample\Http\Controllers\Api',
], function () {
], function (): void {
$this->loadRoutesFrom(__DIR__.'/../Http/Routes/api.php');
});
}
@ -109,7 +83,7 @@ class SampleServiceProvider extends ServiceProvider
/**
* Register views.
*/
public function registerViews()
public function registerViews(): void
{
$viewPath = resource_path('views/modules/sample');
$sourcePath = __DIR__.'/../Resources/views';
@ -119,10 +93,8 @@ class SampleServiceProvider extends ServiceProvider
], 'views');
$paths = array_map(
function ($path) {
return $path.'/modules/sample';
},
\Config::get('view.paths')
fn (string $path): string => $path.'/modules/sample',
Config::get('view.paths')
);
$paths[] = $sourcePath;
@ -132,7 +104,7 @@ class SampleServiceProvider extends ServiceProvider
/**
* Register translations.
*/
public function registerTranslations()
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/sample');
@ -146,6 +118,7 @@ class SampleServiceProvider extends ServiceProvider
/**
* Get the services provided by the provider.
*/
#[Override]
public function provides(): array
{
return [];

View File

@ -9,7 +9,8 @@
"laravel": {
"providers": [
"Modules\\Sample\\Providers\\SampleServiceProvider",
"Modules\\Sample\\Providers\\EventServiceProvider"
"Modules\\Sample\\Providers\\EventServiceProvider",
"Modules\\Sample\\Providers\\Filament\\SampleAdminPanelProvider"
]
}
},

View File

@ -10,7 +10,8 @@
"order": 0,
"providers": [
"Modules\\Sample\\Providers\\SampleServiceProvider",
"Modules\\Sample\\Providers\\EventServiceProvider"
"Modules\\Sample\\Providers\\EventServiceProvider",
"Modules\\Sample\\Providers\\Filament\\SampleAdminPanelProvider"
],
"aliases": {},
"files": [],

View File

@ -114,19 +114,3 @@
</div>
</li>
<li>
<a data-toggle="collapse" href="#addons_menu" class="menu addons_menu" aria-expanded="true">
<h5>addons&nbsp;<b class="pe-7s-angle-right"></b></h5>
</a>
<div class="collapse" id="addons_menu" aria-expanded="true">
<ul class="nav">
@can('view:modules')
@foreach($moduleSvc->getAdminLinks() as &$link)
<li><a href="{{ url($link['url']) }}"><i class="{{ $link['icon'] }}"></i>{{ $link['title'] }}</a></li>
@endforeach
@endcan
</ul>
</div>
</li>

View File

@ -1,23 +0,0 @@
@can('view:modules')
<ul class="fi-topbar-nav-groups">
@if(count($group->getItems()) > 0)
<x-filament-panels::topbar.item
:active="$current_panel->getId() === 'admin'"
icon="heroicon-o-home"
:url="url(\Filament\Facades\Filament::getPanel('admin')->getPath())"
>
{{ __('common.administration') }}
</x-filament-panels::topbar.item>
@endif
@foreach($group->getItems() as $item)
<x-filament-panels::topbar.item
:active="str_contains(request()->path(), strtolower($item->getLabel()))"
:icon="$item->getIcon()"
:url="$item->getUrl()"
>
{{ $item->getLabel() }}
</x-filament-panels::topbar.item>
@endforeach
</ul>
@endcan

View File

@ -0,0 +1,41 @@
@php
/**
* @var \Filament\Panel[] $panels
* @var \Filament\Panel $current
*/
$panelLabel = static function (\Filament\Panel $panel): string {
return $panel->getId() === 'admin'
? __('common.administration')
: \Illuminate\Support\Str::headline($panel->getId());
};
@endphp
@if (count($panels) > 1)
<x-filament::dropdown placement="bottom-start" teleport>
<x-slot name="trigger">
<button
type="button"
class="fi-topbar-item-btn flex items-center gap-x-1.5 ml-4 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-700 outline-none transition duration-75 hover:bg-gray-100 focus-visible:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-5 w-5" />
<span>{{ $panelLabel($current) }}</span>
<x-filament::icon icon="heroicon-m-chevron-down" class="h-4 w-4" />
</button>
</x-slot>
<x-filament::dropdown.list>
@foreach ($panels as $panel)
@php($isCurrent = $panel->getId() === $current->getId())
<x-filament::dropdown.list.item
:href="url($panel->getPath())"
tag="a"
:icon="$isCurrent ? 'heroicon-m-check' : 'heroicon-o-puzzle-piece'"
:color="$isCurrent ? 'primary' : 'gray'"
:aria-current="$isCurrent ? 'page' : false"
>
{{ $panelLabel($panel) }}
</x-filament::dropdown.list.item>
@endforeach
</x-filament::dropdown.list>
</x-filament::dropdown>
@endif

View File

@ -32,16 +32,6 @@
</a>
</li>
{{-- Show the module links that don't require being logged in --}}
@foreach ($moduleSvc->getFrontendLinks($logged_in = false) as &$link)
<li class="nav-item d-flex align-items-center">
<a class="nav-link d-flex gap-1" href="{{ url($link['url']) }}">
<i class="{{ $link['icon'] }}"></i>
{{ $link['title'] }}
</a>
</li>
@endforeach
@foreach ($page_links as $page)
<li class="nav-item d-flex align-items-center">
<a class="nav-link d-flex gap-1" href="{{ $page->url }}"
@ -79,15 +69,6 @@
</a>
</li>
{{-- Show the module links for being logged in --}}
@foreach ($moduleSvc->getFrontendLinks($logged_in = true) as &$link)
<li class="nav-item d-flex align-items-center">
<a class="nav-link d-flex gap-1" href="{{ url($link['url']) }}">
<i class="{{ $link['icon'] }}"></i>
{{ $link['title'] }}
</a>
</li>
@endforeach
<li class="nav-item py-2 py-lg-1 col-12 col-lg-auto">
<div class="d-none d-lg-flex h-100 mx-lg-2 text-body-secondary"></div>
</li>

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
use App\Addons\Filament\FilamentPanelExtender;
use Filament\Facades\Filament;
it('apply() does not throw when no addon has Filament dirs and admin panel resolves', function (): void {
$extender = app(FilamentPanelExtender::class);
// Must not throw even when no addon provides Filament classes.
expect(fn () => $extender->apply())->not->toThrow(Throwable::class);
// The core admin panel must still be accessible after apply().
expect(Filament::getPanel('admin'))->not->toBeNull();
});
it('apply() is idempotent — calling twice does not throw and admin panel remains accessible', function (): void {
$extender = app(FilamentPanelExtender::class);
// First call.
$extender->apply();
// Second call — must not throw and must not corrupt panel state.
expect(fn () => $extender->apply())->not->toThrow(Throwable::class);
expect(Filament::getPanel('admin'))->not->toBeNull();
});

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Addons\Services\AddonDiscoveryService;
use App\Addons\Support\BootCache;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use BezhanSalleh\FilamentShield\Support\Utils;
use Filament\Facades\Filament;
use Filament\Panel;
use Modules\Sample\Filament\Resources\SampleResource;
use Modules\Sample\Providers\Filament\SampleAdminPanelProvider;
function samplePanel(): Panel
{
return new SampleAdminPanelProvider(app())->panel(Panel::make());
}
it('builds the Sample panel at /admin/sample from the base contract', function (): void {
$panel = samplePanel();
expect($panel->getId())->toBe('sample')
->and($panel->getPath())->toBe('admin/sample');
});
it('declares its panel provider in the boot cache so the engine registers it', function (): void {
app(AddonDiscoveryService::class)->run();
$sample = app(BootCache::class)->all()
->firstWhere(fn ($entry): bool => $entry->namespace === 'Modules\\Sample');
expect($sample)->not->toBeNull()
->and($sample->providers)->toContain(SampleAdminPanelProvider::class);
});
it('does not register the Sample resource on the main admin panel', function (): void {
$adminResources = Filament::getPanel('admin')->getResources();
expect($adminResources)->not->toContain(SampleResource::class);
});
it('admits a user holding the per-module access permission', function (): void {
Permission::create(['name' => 'access:sample', 'guard_name' => 'web']);
$user = User::factory()->create();
$user->givePermissionTo('access:sample');
expect($user->fresh()->canAccessPanel(samplePanel()))->toBeTrue();
});
it('admits a user via the legacy view:modules fallback', function (): void {
Permission::create(['name' => 'view:modules', 'guard_name' => 'web']);
$user = User::factory()->create();
$user->givePermissionTo('view:modules');
expect($user->fresh()->canAccessPanel(samplePanel()))->toBeTrue();
});
it('admits a super admin', function (): void {
$role = Role::create(['name' => Utils::getSuperAdminName(), 'guard_name' => 'web']);
$user = User::factory()->create();
$user->assignRole($role);
expect($user->fresh()->canAccessPanel(samplePanel()))->toBeTrue();
});
it('denies a user without access:sample, view:modules, or super admin', function (): void {
$user = User::factory()->create();
expect($user->canAccessPanel(samplePanel()))->toBeFalse();
});

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\ModuleService;
/**
* ModuleService used to hold its admin/frontend link arrays as `protected
* static array`. Static state survives across requests on the same Octane
* worker, which is fine when registration happens once at module boot but
* was a footgun for any future per-request caller. The arrays are now
* instance state on a shared singleton; this test asserts that fetching
* the link list multiple times in a row does not grow it.
*
* @group octane
*/
pest()->group('octane');
test('admin link count is stable across repeated reads', function (): void {
/** @var ModuleService $service */
$service = app(ModuleService::class);
$service->addAdminLink('Sample', '/admin/sample', 'pe-7s-note');
$baseline = count($service->getAdminLinks());
foreach (range(1, 5) as $_) {
expect(count($service->getAdminLinks()))->toBe($baseline);
}
});
test('frontend link count is stable across repeated reads', function (): void {
/** @var ModuleService $service */
$service = app(ModuleService::class);
$service->addFrontendLink('Sample', '/sample', 'bi bi-people', true);
$baseline = count($service->getFrontendLinks(true));
foreach (range(1, 5) as $_) {
expect(count($service->getFrontendLinks(true)))->toBe($baseline);
}
});

View File

@ -33,7 +33,6 @@ function addonRow(string $namespace, string $autoloadPath, array $providers = []
'providers' => $providers,
'autoload_path' => $autoloadPath,
'layout' => 'app',
'filament' => [],
'name' => 'Sample',
'alias' => 'sample',
'description' => '',

View File

@ -30,7 +30,6 @@ function makeAddonBootCache(array $overrides = []): AddonBootCache
'layout' => 'app',
'description' => null,
'enabled' => true,
'filament' => [],
], $overrides));
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
it('no longer serializes a filament field in boot-cache rows', function (): void {
$row = new AddonBootCache(
name: 'Sample',
alias: 'sample',
type: 'module',
registryId: null,
version: '1.0.0',
namespace: 'Modules\\Sample',
providers: [],
path: '/modules/Sample',
autoloadPath: '/modules/Sample',
layout: 'root',
description: null,
enabled: true,
);
expect($row->toArray())->not->toHaveKey('filament');
});
it('ignores a stale filament key when hydrating an old cache row', function (): void {
$row = AddonBootCache::fromArray([
'name' => 'Legacy',
'filament' => ['admin' => ['Resources' => '/x']],
]);
expect($row)->toBeInstanceOf(AddonBootCache::class)
->and($row->toArray())->not->toHaveKey('filament');
});
it('bumped the boot-cache schema so pre-change caches rebuild', function (): void {
expect(BootCache::SCHEMA)->toBeGreaterThanOrEqual(3);
});

View File

@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
use App\Addons\Filament\FilamentPanelExtender;
use App\Addons\Models\AddonBootCache;
use App\Addons\Support\BootCache;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a FilamentPanelExtender backed by a real (no-cache) AddonRuntime.
*
* These tests call discoveriesFor() directly, so no cache reading happens.
*/
function makeFilamentPanelExtender(): FilamentPanelExtender
{
return new FilamentPanelExtender(new BootCache());
}
/**
* Build an AddonBootCache from a minimal array, filling in required fields.
*
* @param array<string, mixed> $data
*/
function makeEntry(array $data): AddonBootCache
{
return AddonBootCache::fromArray(array_merge([
'name' => 'Test',
'alias' => null,
'type' => 'module',
'registry_id' => null,
'version' => null,
'namespace' => '',
'providers' => [],
'path' => '/tmp/test',
'autoload_path' => '/tmp/test',
'layout' => 'app',
'description' => null,
'enabled' => true,
'filament' => [],
], $data));
}
// ---------------------------------------------------------------------------
// discoveriesFor() — pure mapping logic
// ---------------------------------------------------------------------------
it('produces three admin entries for a row with all admin components', function (): void {
$extender = makeFilamentPanelExtender();
$entry = makeEntry([
'namespace' => 'Modules\\Acme',
'filament' => [
'admin' => [
'Resources' => '/var/app/modules/Acme/Filament/Resources',
'Pages' => '/var/app/modules/Acme/Filament/Pages',
'Widgets' => '/var/app/modules/Acme/Filament/Widgets',
],
],
]);
$result = $extender->discoveriesFor($entry);
expect($result)->toHaveKey('admin')
->and($result)->not->toHaveKey('system')
->and($result['admin'])->toHaveCount(3);
$byMethod = collect($result['admin'])->keyBy('method');
expect($byMethod['discoverResources'])->toBe([
'method' => 'discoverResources',
'in' => '/var/app/modules/Acme/Filament/Resources',
'for' => 'Modules\\Acme\\Filament\\Resources',
]);
expect($byMethod['discoverPages'])->toBe([
'method' => 'discoverPages',
'in' => '/var/app/modules/Acme/Filament/Pages',
'for' => 'Modules\\Acme\\Filament\\Pages',
]);
expect($byMethod['discoverWidgets'])->toBe([
'method' => 'discoverWidgets',
'in' => '/var/app/modules/Acme/Filament/Widgets',
'for' => 'Modules\\Acme\\Filament\\Widgets',
]);
});
it('produces one system entry for a row with only system Resources', function (): void {
$extender = makeFilamentPanelExtender();
$entry = makeEntry([
'namespace' => 'Modules\\Acme',
'filament' => [
'system' => [
'Resources' => '/var/app/modules/Acme/Filament/System/Resources',
],
],
]);
$result = $extender->discoveriesFor($entry);
expect($result)->not->toHaveKey('admin')
->and($result)->toHaveKey('system')
->and($result['system'])->toHaveCount(1)
->and($result['system'][0])->toBe([
'method' => 'discoverResources',
'in' => '/var/app/modules/Acme/Filament/System/Resources',
'for' => 'Modules\\Acme\\Filament\\System\\Resources',
]);
});
it('returns an empty array when filament key is empty', function (): void {
$extender = makeFilamentPanelExtender();
$entry = makeEntry([
'namespace' => 'Modules\\Acme',
'filament' => [],
]);
expect($extender->discoveriesFor($entry))->toBe([]);
});
it('strips trailing backslash from namespace before building for: string', function (): void {
$extender = makeFilamentPanelExtender();
$entry = makeEntry([
'namespace' => 'Modules\\Acme\\', // trailing backslash
'filament' => [
'admin' => [
'Resources' => '/abs/path/Resources',
],
],
]);
$result = $extender->discoveriesFor($entry);
expect($result['admin'][0]['for'])->toBe('Modules\\Acme\\Filament\\Resources');
});
it('returns an empty array when namespace is absent and filament data is present', function (): void {
$extender = makeFilamentPanelExtender();
// namespace key missing — fromArray defaults to '' which triggers the empty guard.
$entry = makeEntry([
'filament' => [
'admin' => [
'Resources' => '/abs/path/Resources',
],
],
]);
expect($extender->discoveriesFor($entry))->toBe([]);
});
it('returns an empty array when namespace is empty string and filament data is present', function (): void {
$extender = makeFilamentPanelExtender();
$entry = makeEntry([
'namespace' => '',
'filament' => [
'admin' => [
'Resources' => '/abs/path/Resources',
],
],
]);
expect($extender->discoveriesFor($entry))->toBe([]);
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Filament\Plugins\PanelSwitcherPlugin;
use Filament\Panel;
it('exposes the panel-switcher id', function (): void {
expect(PanelSwitcherPlugin::make()->getId())->toBe('panel-switcher');
});
it('renders an entry per panel and marks the current one active', function (): void {
$admin = Panel::make()->id('admin')->path('admin');
$sample = Panel::make()->id('sample')->path('admin/sample');
$html = view('filament.plugins.panel-switcher', [
'panels' => [$admin, $sample],
'current' => $admin,
])->render();
// Both panels are linked by their path...
expect($html)->toContain(url('admin'))
->and($html)->toContain(url('admin/sample'))
// ...the human labels are present...
->and($html)->toContain('Sample')
// ...and the current (admin) panel is flagged active.
->and($html)->toContain('aria-current="page"');
});
it('does not render a dropdown when only one panel is accessible', function (): void {
$admin = Panel::make()->id('admin')->path('admin');
$html = trim(view('filament.plugins.panel-switcher', [
'panels' => [$admin],
'current' => $admin,
])->render());
// Guarded by `@if (count($panels) > 1)` — nothing to switch to.
expect($html)->toBe('');
});

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Filament\Panel;
use Modules\Sample\Filament\Resources\SampleResource;
use Modules\Sample\Providers\Filament\SampleAdminPanelProvider;
it('configures id, path, brand and the panel switcher from the base contract', function (): void {
$provider = new SampleAdminPanelProvider(app());
$panel = $provider->panel(Panel::make());
expect($panel->getId())->toBe('sample')
->and($panel->getPath())->toBe('admin/sample')
->and($panel->hasPlugin('panel-switcher'))->toBeTrue();
});
it("discovers the module's own Filament resources", function (): void {
$provider = new SampleAdminPanelProvider(app());
$panel = $provider->panel(Panel::make());
$panel->register();
expect($panel->getResources())->toContain(SampleResource::class);
});