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
210 lines
7.7 KiB
PHP
210 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\InventoryItemResource;
|
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
|
use App\Filament\Resources\InventorySyncRunResource;
|
|
use App\Filament\Resources\InventorySyncRunResource\Pages\ListInventorySyncRuns;
|
|
use App\Filament\Resources\OperationRunResource;
|
|
use App\Filament\Resources\PolicyResource;
|
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
|
use App\Jobs\SyncPoliciesJob;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\InventorySyncRun;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('passes the action surface contract guard for current repository state', function (): void {
|
|
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('excludes widgets from action surface discovery scope', function (): void {
|
|
$classes = array_map(
|
|
static fn ($component): string => $component->className,
|
|
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
|
);
|
|
|
|
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
|
|
return str_starts_with($className, 'App\\Filament\\Widgets\\');
|
|
}));
|
|
|
|
expect($widgetClasses)->toBeEmpty();
|
|
});
|
|
|
|
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
|
|
$exemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
|
|
});
|
|
|
|
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
|
|
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
|
->keyBy('className');
|
|
|
|
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
|
|
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
|
|
|
|
expect($tenantResource)->not->toBeNull();
|
|
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
|
|
|
|
expect($policyResource)->not->toBeNull();
|
|
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
|
|
});
|
|
|
|
it('requires non-empty reasons for every baseline exemption', function (): void {
|
|
$reasons = ActionSurfaceExemptions::baseline()->all();
|
|
|
|
foreach ($reasons as $className => $reason) {
|
|
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
|
|
}
|
|
});
|
|
|
|
it('ensures representative declarations satisfy required slots', function (): void {
|
|
$profiles = new ActionSurfaceProfileDefinition;
|
|
|
|
$declarations = [
|
|
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
|
|
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
|
|
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
|
|
];
|
|
|
|
foreach ($declarations as $className => $declaration) {
|
|
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
|
|
expect($declaration->slot($slot))
|
|
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
|
|
}
|
|
}
|
|
});
|
|
|
|
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListPolicies::class)
|
|
->assertTableEmptyStateActionsExistInOrder(['sync']);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
$rowActions = $table->getActions();
|
|
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
|
|
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
|
|
expect($rowGroup?->getLabel())->toBe('More');
|
|
|
|
$primaryRowActionCount = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->count();
|
|
|
|
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
|
|
|
|
$bulkActions = $table->getBulkActions();
|
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
|
|
|
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
|
|
expect($bulkGroup?->getLabel())->toBe('More');
|
|
});
|
|
|
|
it('uses canonical tenantless View run links on representative operation links', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
expect(OperationRunLinks::view($run, $tenant))
|
|
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
|
});
|
|
|
|
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$item = InventoryItem::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
]);
|
|
|
|
$livewire = Livewire::test(ListInventoryItems::class);
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty();
|
|
|
|
$recordUrl = $table->getRecordUrl($item);
|
|
|
|
expect($recordUrl)->not->toBeNull();
|
|
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
|
|
});
|
|
|
|
it('removes lone View buttons and uses clickable rows on the inventory sync runs list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$run = InventorySyncRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
]);
|
|
|
|
$livewire = Livewire::test(ListInventorySyncRuns::class);
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty();
|
|
|
|
$recordUrl = $table->getRecordUrl($run);
|
|
|
|
expect($recordUrl)->not->toBeNull();
|
|
expect($recordUrl)->toBe(InventorySyncRunResource::getUrl('view', ['record' => $run]));
|
|
});
|
|
|
|
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
|
|
Queue::fake();
|
|
bindFailHardGraphClient();
|
|
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
Livewire::test(ListPolicies::class)
|
|
->mountAction('sync')
|
|
->callMountedAction()
|
|
->assertHasNoActionErrors();
|
|
|
|
Queue::assertPushed(SyncPoliciesJob::class);
|
|
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'policy.sync')
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($run)->not->toBeNull();
|
|
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
|
|
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
|
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
|
});
|