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:
parent
6bb8041824
commit
13a2249b0c
@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
205
app/Contracts/Modules/PanelProvider.php
Normal file
205
app/Contracts/Modules/PanelProvider.php
Normal 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\\');
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
80
app/Filament/Plugins/PanelSwitcherPlugin.php
Normal file
80
app/Filament/Plugins/PanelSwitcherPlugin.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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() {}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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 [];
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Modules\\Sample\\Providers\\SampleServiceProvider",
|
||||
"Modules\\Sample\\Providers\\EventServiceProvider"
|
||||
"Modules\\Sample\\Providers\\EventServiceProvider",
|
||||
"Modules\\Sample\\Providers\\Filament\\SampleAdminPanelProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@ -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": [],
|
||||
|
||||
@ -114,19 +114,3 @@
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a data-toggle="collapse" href="#addons_menu" class="menu addons_menu" aria-expanded="true">
|
||||
<h5>addons <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>
|
||||
|
||||
|
||||
@ -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
|
||||
41
resources/views/filament/plugins/panel-switcher.blade.php
Normal file
41
resources/views/filament/plugins/panel-switcher.blade.php
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
75
tests/Feature/Modules/SampleModulePanelTest.php
Normal file
75
tests/Feature/Modules/SampleModulePanelTest.php
Normal 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();
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
@ -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' => '',
|
||||
|
||||
@ -30,7 +30,6 @@ function makeAddonBootCache(array $overrides = []): AddonBootCache
|
||||
'layout' => 'app',
|
||||
'description' => null,
|
||||
'enabled' => true,
|
||||
'filament' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
|
||||
39
tests/Unit/Addons/BootCacheNoFilamentTest.php
Normal file
39
tests/Unit/Addons/BootCacheNoFilamentTest.php
Normal 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);
|
||||
});
|
||||
@ -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([]);
|
||||
});
|
||||
40
tests/Unit/Filament/PanelSwitcherPluginTest.php
Normal file
40
tests/Unit/Filament/PanelSwitcherPluginTest.php
Normal 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('');
|
||||
});
|
||||
26
tests/Unit/Modules/ModulePanelProviderTest.php
Normal file
26
tests/Unit/Modules/ModulePanelProviderTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user