Implements Spec 082 updates to the Filament Action Surface Contract: - New required list/table slot: InspectAffordance (clickable row via recordUrl preferred; also supports View action or primary link column) - Retrofit view-only tables to remove lone View row action buttons and use clickable rows - Update validator + guard tests, add golden regression assertions - Add docs: docs/ui/action-surface-contract.md Tests (local via Sail): - vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php - vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php - vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php - vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupSyncRunResourceTest.php Notes: - Filament v5 / Livewire v4 compatible. - No destructive-action behavior changed in this PR. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #100
293 lines
7.9 KiB
PHP
293 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Ui\ActionSurface;
|
|
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use SplFileInfo;
|
|
|
|
final class ActionSurfaceDiscovery
|
|
{
|
|
private string $basePath;
|
|
|
|
private string $appPath;
|
|
|
|
private string $routesPath;
|
|
|
|
private string $adminPanelProviderPath;
|
|
|
|
public function __construct(
|
|
?string $basePath = null,
|
|
?string $appPath = null,
|
|
?string $routesPath = null,
|
|
?string $adminPanelProviderPath = null,
|
|
) {
|
|
$this->basePath = $basePath ?? base_path();
|
|
$this->appPath = $appPath ?? app_path();
|
|
$this->routesPath = $routesPath ?? base_path('routes/web.php');
|
|
$this->adminPanelProviderPath = $adminPanelProviderPath ?? app_path('Providers/Filament/AdminPanelProvider.php');
|
|
}
|
|
|
|
/**
|
|
* @return array<int, ActionSurfaceDiscoveredComponent>
|
|
*/
|
|
public function discover(): array
|
|
{
|
|
$adminScopedClasses = $this->discoverAdminScopedClasses();
|
|
|
|
/** @var array<string, ActionSurfaceDiscoveredComponent> $components */
|
|
$components = [];
|
|
|
|
foreach ($this->resourceFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::Resource,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
foreach ($this->pageFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::Page,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
foreach ($this->relationManagerFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::RelationManager,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
ksort($components);
|
|
|
|
return array_values($components);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $adminScopedClasses
|
|
* @return array<int, ActionSurfacePanelScope>
|
|
*/
|
|
private function panelScopesFor(string $className, array $adminScopedClasses): array
|
|
{
|
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
|
|
|
if (in_array($className, $adminScopedClasses, true)) {
|
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
|
}
|
|
|
|
return $scopes;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function resourceFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
|
|
if (! str_ends_with($path, 'Resource.php')) {
|
|
return false;
|
|
}
|
|
|
|
if (str_contains($path, '/Pages/')) {
|
|
return false;
|
|
}
|
|
|
|
if (str_contains($path, '/RelationManagers/')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function pageFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/Pages', static function (string $path): bool {
|
|
return str_ends_with($path, '.php');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function relationManagerFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
|
|
if (! str_contains($path, '/RelationManagers/')) {
|
|
return false;
|
|
}
|
|
|
|
return str_ends_with($path, 'RelationManager.php');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param callable(string): bool $filter
|
|
* @return array<int, string>
|
|
*/
|
|
private function collectPhpFiles(string $directory, callable $filter): array
|
|
{
|
|
if (! is_dir($directory)) {
|
|
return [];
|
|
}
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
|
|
);
|
|
|
|
$paths = [];
|
|
|
|
/** @var SplFileInfo $file */
|
|
foreach ($iterator as $file) {
|
|
if (! $file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$path = str_replace('\\', '/', $file->getPathname());
|
|
|
|
if (! $filter($path)) {
|
|
continue;
|
|
}
|
|
|
|
$paths[] = $path;
|
|
}
|
|
|
|
sort($paths);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function discoverAdminScopedClasses(): array
|
|
{
|
|
$classes = array_merge(
|
|
$this->parseFilamentClassReferences($this->adminPanelProviderPath),
|
|
$this->parseFilamentClassReferences($this->routesPath),
|
|
);
|
|
|
|
$classes = array_values(array_unique(array_filter($classes, static function (string $className): bool {
|
|
return str_starts_with($className, 'App\\Filament\\');
|
|
})));
|
|
|
|
sort($classes);
|
|
|
|
return $classes;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function parseFilamentClassReferences(string $filePath): array
|
|
{
|
|
if (! is_file($filePath)) {
|
|
return [];
|
|
}
|
|
|
|
$contents = file_get_contents($filePath);
|
|
|
|
if (! is_string($contents) || $contents === '') {
|
|
return [];
|
|
}
|
|
|
|
$imports = $this->parseUseStatements($contents);
|
|
|
|
preg_match_all('/\\\\?([A-Z][A-Za-z0-9_\\\\]*)::(?:class|registerRoutes)\b/', $contents, $matches);
|
|
|
|
$classes = [];
|
|
|
|
foreach ($matches[1] as $token) {
|
|
$resolved = $this->resolveClassToken($token, $imports);
|
|
|
|
if ($resolved === null) {
|
|
continue;
|
|
}
|
|
|
|
$classes[] = $resolved;
|
|
}
|
|
|
|
return $classes;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function parseUseStatements(string $contents): array
|
|
{
|
|
preg_match_all('/^use\s+([^;]+);/m', $contents, $matches);
|
|
|
|
$imports = [];
|
|
|
|
foreach ($matches[1] as $importExpression) {
|
|
$normalized = trim($importExpression);
|
|
|
|
if (! str_contains($normalized, '\\')) {
|
|
continue;
|
|
}
|
|
|
|
$parts = preg_split('/\s+as\s+/i', $normalized);
|
|
$fqcn = ltrim($parts[0], '\\');
|
|
|
|
$alias = $parts[1] ?? null;
|
|
|
|
if (! is_string($alias) || trim($alias) === '') {
|
|
$segments = explode('\\', $fqcn);
|
|
$alias = end($segments);
|
|
}
|
|
|
|
if (! is_string($alias) || trim($alias) === '') {
|
|
continue;
|
|
}
|
|
|
|
$imports[trim($alias)] = $fqcn;
|
|
}
|
|
|
|
return $imports;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $imports
|
|
*/
|
|
private function resolveClassToken(string $token, array $imports): ?string
|
|
{
|
|
$token = ltrim(trim($token), '\\');
|
|
|
|
if ($token === '') {
|
|
return null;
|
|
}
|
|
|
|
if (str_contains($token, '\\')) {
|
|
return $token;
|
|
}
|
|
|
|
return $imports[$token] ?? null;
|
|
}
|
|
|
|
private function classNameFromPath(string $path): string
|
|
{
|
|
$normalizedPath = str_replace('\\', '/', $path);
|
|
$normalizedAppPath = str_replace('\\', '/', $this->appPath);
|
|
$relative = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
|
|
|
|
return 'App\\'.str_replace('/', '\\', substr($relative, 0, -4));
|
|
}
|
|
}
|