## Summary - implement the Action Surface Contract v1.1 runtime changes for Spec 169 - add the new explicit ActionSurfaceType contract, validator/discovery updates, and enrolled surface declarations - update Filament action-surface documentation, focused guard tests, and spec artifacts for the completed feature ## Included - clickable-row vs explicit-inspect enforcement across monitoring, reporting, CRUD, and system reference surfaces - helper-first, workflow-next, destructive-last overflow ordering checks - system panel list discovery in the primary action-surface validator - Spec 169 artifacts: spec, plan, tasks, research, data model, quickstart, and logical contract ## Verification - focused Pest verification pack completed for: - tests/Feature/Guards/ActionSurfaceValidatorTest.php - tests/Feature/Guards/ActionSurfaceContractTest.php - tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php - integrated browser smoke test completed for admin-side reference surfaces: - /admin/operations - /admin/audit-log - /admin/finding-exceptions/queue - /admin/reviews - /admin/tenants ## Notes - system panel browser smoke coverage could not be exercised in the same session because /system routes require platform authentication in the integrated browser - Livewire target remains v4-compliant and no provider registration or asset strategy changes are introduced by this PR Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #200
328 lines
9.0 KiB
PHP
328 lines
9.0 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 Filament\Tables\Contracts\HasTable;
|
|
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->systemPageFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
if (! $this->isDeclaredSystemTablePage($className)) {
|
|
continue;
|
|
}
|
|
|
|
$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 systemPageFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/System/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;
|
|
}
|
|
|
|
private function isDeclaredSystemTablePage(string $className): bool
|
|
{
|
|
if (! class_exists($className)) {
|
|
return false;
|
|
}
|
|
|
|
return is_subclass_of($className, HasTable::class)
|
|
&& method_exists($className, 'actionSurfaceDeclaration');
|
|
}
|
|
|
|
/**
|
|
* @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));
|
|
}
|
|
}
|