TenantAtlas/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
ahmido a770b32e87 feat: action-surface contract inspect affordance + clickable rows (#100)
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
2026-02-08 20:31:36 +00:00

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.',
);
}
}