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
300 lines
11 KiB
PHP
300 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Ui\ActionSurface;
|
|
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
|
|
final class ActionSurfaceValidator
|
|
{
|
|
private ActionSurfaceDiscovery $discovery;
|
|
|
|
private ActionSurfaceProfileDefinition $profileDefinition;
|
|
|
|
private ActionSurfaceExemptions $exemptions;
|
|
|
|
public function __construct(
|
|
?ActionSurfaceDiscovery $discovery = null,
|
|
?ActionSurfaceProfileDefinition $profileDefinition = null,
|
|
?ActionSurfaceExemptions $exemptions = null,
|
|
) {
|
|
$this->discovery = $discovery ?? new ActionSurfaceDiscovery;
|
|
$this->profileDefinition = $profileDefinition ?? new ActionSurfaceProfileDefinition;
|
|
$this->exemptions = $exemptions ?? ActionSurfaceExemptions::baseline();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, ActionSurfaceDiscoveredComponent>
|
|
*/
|
|
public function discoveredComponents(): array
|
|
{
|
|
return $this->discovery->discover();
|
|
}
|
|
|
|
public static function withBaselineExemptions(): self
|
|
{
|
|
return new self(
|
|
discovery: new ActionSurfaceDiscovery,
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: ActionSurfaceExemptions::baseline(),
|
|
);
|
|
}
|
|
|
|
public function validate(): ActionSurfaceValidationResult
|
|
{
|
|
return $this->validateComponents($this->discoveredComponents());
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceDiscoveredComponent> $components
|
|
*/
|
|
public function validateComponents(array $components): ActionSurfaceValidationResult
|
|
{
|
|
$issues = [];
|
|
|
|
foreach ($components as $component) {
|
|
if (! class_exists($component->className)) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $component->className,
|
|
message: 'Discovered class does not exist or is not autoloadable.',
|
|
hint: 'Verify namespace/path and run composer dump-autoload if needed.',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$declaration = $this->resolveDeclarationForComponent($component, $issues);
|
|
|
|
if ($declaration === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($declaration->componentType !== $component->componentType) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $component->className,
|
|
message: sprintf(
|
|
'Declaration component type mismatch (%s declared, %s discovered).',
|
|
$declaration->componentType->name,
|
|
$component->componentType->name,
|
|
),
|
|
hint: 'Use ActionSurfaceDeclaration::forResource/forPage/forRelationManager consistently.',
|
|
);
|
|
}
|
|
|
|
if ($declaration->defaults->moreGroupLabel !== 'More') {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $component->className,
|
|
message: sprintf(
|
|
'Invalid more-group label "%s".',
|
|
$declaration->defaults->moreGroupLabel,
|
|
),
|
|
hint: 'Set ActionSurfaceDefaults->moreGroupLabel to "More".',
|
|
);
|
|
}
|
|
|
|
$this->validateRequiredSlots($component->className, $declaration, $issues);
|
|
$this->validateExemptions($component->className, $declaration, $issues);
|
|
$this->validateExportDefaults($component->className, $declaration, $issues);
|
|
}
|
|
|
|
return new ActionSurfaceValidationResult(
|
|
issues: $issues,
|
|
componentCount: count($components),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
private function resolveDeclarationForComponent(
|
|
ActionSurfaceDiscoveredComponent $component,
|
|
array &$issues,
|
|
): ?ActionSurfaceDeclaration {
|
|
$className = $component->className;
|
|
|
|
if (! method_exists($className, 'actionSurfaceDeclaration')) {
|
|
$this->validateClassExemptionOrFail($className, $issues);
|
|
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$declaration = $className::actionSurfaceDeclaration();
|
|
} catch (\Throwable $throwable) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
message: 'actionSurfaceDeclaration() threw an exception: '.$throwable->getMessage(),
|
|
hint: 'Ensure actionSurfaceDeclaration() is static and does not depend on request state.',
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
if (! $declaration instanceof ActionSurfaceDeclaration) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
message: 'actionSurfaceDeclaration() must return ActionSurfaceDeclaration.',
|
|
hint: 'Return ActionSurfaceDeclaration::forResource/forPage/forRelationManager(...).',
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $declaration;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
private function validateClassExemptionOrFail(string $className, array &$issues): void
|
|
{
|
|
$reason = $this->exemptions->reasonForClass($className);
|
|
|
|
if ($reason === null) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
message: 'Missing action-surface declaration and no component exemption exists.',
|
|
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (trim($reason) === '') {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
message: 'Component exemption reason must be non-empty.',
|
|
hint: 'Provide a concrete, non-empty justification in ActionSurfaceExemptions::baseline().',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
private function validateRequiredSlots(
|
|
string $className,
|
|
ActionSurfaceDeclaration $declaration,
|
|
array &$issues,
|
|
): void {
|
|
foreach ($this->profileDefinition->requiredSlots($declaration->profile) as $slot) {
|
|
$requirement = $declaration->slot($slot);
|
|
|
|
if ($requirement === null) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
slot: $slot,
|
|
message: 'Required slot is not declared.',
|
|
hint: 'Declare slot as satisfied or exempt with a reason.',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! $requirement->isExempt()) {
|
|
if ($slot === ActionSurfaceSlot::InspectAffordance) {
|
|
$this->validateInspectAffordanceSlot($className, $requirement, $issues);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$exemption = $declaration->exemption($slot);
|
|
|
|
if ($exemption === null || ! $exemption->hasReason()) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
slot: $slot,
|
|
message: 'Slot is marked exempt but exemption reason is missing or empty.',
|
|
hint: 'Use ->exempt(slot, "reason") with a non-empty reason.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
private function validateInspectAffordanceSlot(
|
|
string $className,
|
|
ActionSurfaceSlotRequirement $requirement,
|
|
array &$issues,
|
|
): void {
|
|
$mode = $requirement->details;
|
|
|
|
if (! is_string($mode) || trim($mode) === '') {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
slot: ActionSurfaceSlot::InspectAffordance,
|
|
message: 'Inspect affordance must declare how inspection is provided (clickable_row, view_action, or primary_link_column).',
|
|
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) {
|
|
return;
|
|
}
|
|
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
slot: ActionSurfaceSlot::InspectAffordance,
|
|
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
|
|
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
private function validateExemptions(
|
|
string $className,
|
|
ActionSurfaceDeclaration $declaration,
|
|
array &$issues,
|
|
): void {
|
|
foreach ($declaration->exemptions() as $slotValue => $exemption) {
|
|
if (! $exemption->hasReason()) {
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
slot: ActionSurfaceSlot::from($slotValue),
|
|
message: 'Exemption reason must be non-empty.',
|
|
hint: 'Provide a concise reason for each exempted slot.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
private function validateExportDefaults(
|
|
string $className,
|
|
ActionSurfaceDeclaration $declaration,
|
|
array &$issues,
|
|
): void {
|
|
if (! $this->profileDefinition->requiresExportDefaultBulk($declaration->profile)) {
|
|
return;
|
|
}
|
|
|
|
if ($declaration->defaults->exportIsDefaultBulkActionForReadOnly) {
|
|
return;
|
|
}
|
|
|
|
$bulkExemption = $declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup);
|
|
|
|
if ($bulkExemption instanceof ActionSurfaceExemption && $bulkExemption->hasReason()) {
|
|
return;
|
|
}
|
|
|
|
$issues[] = new ActionSurfaceValidationIssue(
|
|
className: $className,
|
|
slot: ActionSurfaceSlot::ListBulkMoreGroup,
|
|
message: 'ReadOnly/RunLog profile disables Export default but no bulk-slot exemption reason was provided.',
|
|
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
|
|
);
|
|
}
|
|
}
|