TenantAtlas/app/Support/Badges/BadgeCatalog.php
ahmido 0b5cadc234 feat: add shared diff presentation foundation (#170)
## Summary
- add a shared diff presentation layer under `app/Support/Diff` with deterministic row classification, summary derivation, and value stringification
- centralize diff-state badge semantics through `BadgeCatalog` with a dedicated `DiffRowStatusBadge`
- add reusable Filament diff partials, focused Pest coverage, and the full SpecKit artifact set for spec 141

## Testing
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Diff/DiffRowStatusTest.php tests/Unit/Support/Diff/DiffRowTest.php tests/Unit/Support/Diff/DiffPresenterTest.php tests/Unit/Support/Diff/ValueStringifierTest.php tests/Unit/Badges/DiffRowStatusBadgeTest.php tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php tests/Feature/Support/Diff/SharedDiffRowPartialTest.php tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Filament / Livewire Contract
- Livewire v4.0+ compliance: unchanged and respected; this feature adds presentation support only within the existing Filament v5 / Livewire v4 stack
- Provider registration: unchanged; no panel/provider changes were required, so `bootstrap/providers.php` remains the correct registration location
- Global search: unchanged; no Resource or global-search behavior was added or modified
- Destructive actions: none introduced in this feature
- Asset strategy: no new registered Filament assets; shared Blade partials rely on the existing asset pipeline and standard deploy step for `php artisan filament:assets` when assets change generally
- Testing coverage: presenter, DTOs, stringifier, badge semantics, summary partial, row partial, and inline-list partial are covered by focused Pest unit and feature tests

## Notes
- Spec checklist status is complete for `specs/141-shared-diff-presentation-foundation/checklists/requirements.md`
- This PR preserves specialized diff renderers and documents incremental adoption rather than forcing migration in the same change

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #170
2026-03-14 12:32:08 +00:00

175 lines
6.6 KiB
PHP

<?php
namespace App\Support\Badges;
use BackedEnum;
use Stringable;
use Throwable;
final class BadgeCatalog
{
/**
* @var array<string, class-string<BadgeMapper>>
*/
private const DOMAIN_MAPPERS = [
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,
BadgeDomain::FindingSeverity->value => Domains\FindingSeverityBadge::class,
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
];
/**
* @var array<string, BadgeMapper|null>
*/
private static array $mapperCache = [];
public static function spec(BadgeDomain $domain, mixed $value): BadgeSpec
{
$mapper = self::mapper($domain);
if (! $mapper) {
return BadgeSpec::unknown();
}
try {
return $mapper->spec($value);
} catch (Throwable) {
return BadgeSpec::unknown();
}
}
public static function mapper(BadgeDomain $domain): ?BadgeMapper
{
$key = $domain->value;
if (array_key_exists($key, self::$mapperCache)) {
return self::$mapperCache[$key];
}
$mapper = self::buildMapper($domain);
self::$mapperCache[$key] = $mapper;
return $mapper;
}
public static function normalizeState(mixed $value): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if ($value instanceof Stringable) {
$value = (string) $value;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (! is_string($value)) {
return null;
}
$normalized = strtolower(trim($value));
$normalized = str_replace([' ', '-'], '_', $normalized);
return $normalized === '' ? null : $normalized;
}
public static function normalizeProviderConnectionStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'granted', 'connected' => 'connected',
'consent_required', 'required', 'needs_admin_consent', 'needs_consent', 'unknown' => 'needs_consent',
'failed', 'revoked', 'blocked' => 'error',
default => $state,
};
}
public static function normalizeProviderConnectionHealth(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'healthy' => 'ok',
'blocked', 'error' => 'down',
default => $state,
};
}
public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'unknown', 'not_started' => 'not_started',
'pending', 'in_progress' => 'in_progress',
'degraded', 'needs_attention' => 'needs_attention',
'blocked', 'error' => 'blocked',
'healthy', 'ready' => 'ready',
default => $state,
};
}
private static function buildMapper(BadgeDomain $domain): ?BadgeMapper
{
$mapperClass = self::DOMAIN_MAPPERS[$domain->value] ?? null;
if (! $mapperClass) {
return null;
}
if (! class_exists($mapperClass)) {
return null;
}
$mapper = new $mapperClass;
return $mapper instanceof BadgeMapper ? $mapper : null;
}
}