feat: tag badge catalog (060) (#72)
Summary: completes Feature 060 by adding the suite-wide TagBadge catalog (spec/domain/renderer) plus migration notes/tests/docs/specs/plan/checklist. standardizes all inert “tag-like” badges (policy type/category/platform, tenant environment, backup schedule frequency, etc.) to use the new catalog so only neutral colors are emitted. fixes remaining Feature 059 regressions (inventory run/restore badges, Inventory Coverage tables, Boolean-enabled streak) and adds the BooleanEnabled badge mappings/guards/tests plus new QA tasks/checklist. Testing: BooleanEnabledBadgesTest.php PolicyGeneralViewTest.php PolicySettingsStandardViewTest.php SettingsCatalogPolicyNormalizedDisplayTest.php PolicyViewSettingsCatalogReadableTest.php (partial/visual checks skipped) TagBadgeCatalogTest.php TagBadgePaletteInvariantTest.php NoForbiddenTagBadgeColorsTest.php NoAdHocStatusBadgesTest.php Manual QA per quickstart.md confirmed. Next steps: Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #72
This commit is contained in:
parent
0b6600b926
commit
1bc6600fcc
@ -17,6 +17,8 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -39,7 +41,6 @@
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
@ -175,9 +176,13 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('next_run_at', 'asc')
|
||||
->columns([
|
||||
IconColumn::make('is_enabled')
|
||||
TextColumn::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->boolean()
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled))
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('name')
|
||||
@ -187,16 +192,8 @@ public static function table(Table $table): Table
|
||||
TextColumn::make('frequency')
|
||||
->label('Frequency')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'daily' => 'Daily',
|
||||
'weekly' => 'Weekly',
|
||||
default => (string) $state,
|
||||
})
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'daily' => 'success',
|
||||
'weekly' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::BackupScheduleFrequency))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::BackupScheduleFrequency)),
|
||||
|
||||
TextColumn::make('time_of_day')
|
||||
->label('Time')
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -53,7 +55,8 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('restore_mode')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
@ -73,7 +76,10 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_identifier')
|
||||
->label('Policy ID')
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('platform')->badge(),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('assignments')
|
||||
->label('Assignments')
|
||||
->badge()
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@ -46,8 +48,20 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
||||
TextEntry::make('security_enabled')->label('Security')->badge(),
|
||||
TextEntry::make('mail_enabled')->label('Mail')->badge(),
|
||||
TextEntry::make('security_enabled')
|
||||
->label('Security')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
TextEntry::make('mail_enabled')
|
||||
->label('Mail')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
|
||||
@ -8,6 +8,10 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
@ -49,12 +53,18 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
TextEntry::make('category')
|
||||
->badge()
|
||||
->state(fn (InventoryItem $record): string => $record->category
|
||||
?: (static::typeMeta($record->policy_type)['category'] ?? 'Unknown')),
|
||||
TextEntry::make('platform')->badge(),
|
||||
->state(fn (InventoryItem $record): ?string => $record->category
|
||||
?: (static::typeMeta($record->policy_type)['category'] ?? null))
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
TextEntry::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
TextEntry::make('external_id')->label('External ID'),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
|
||||
TextEntry::make('last_seen_run_id')
|
||||
@ -70,11 +80,19 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('support_restore')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||
TextEntry::make('support_risk')
|
||||
->label('Risk')
|
||||
->badge()
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal'),
|
||||
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -144,17 +162,52 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->badge(),
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge(),
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('last_seen_at')
|
||||
->label('Last seen')
|
||||
->since(),
|
||||
Tables\Columns\TextColumn::make('lastSeenRun.status')
|
||||
->label('Run')
|
||||
->badge(),
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state): string {
|
||||
if (! filled($state)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
|
||||
})
|
||||
->color(function (?string $state): string {
|
||||
if (! filled($state)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
|
||||
})
|
||||
->icon(function (?string $state): ?string {
|
||||
if (! filled($state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
|
||||
})
|
||||
->iconColor(function (?string $state): ?string {
|
||||
if (! filled($state)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -241,17 +243,28 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state),
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label('Category')
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'),
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
Tables\Columns\TextColumn::make('restore_mode')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('settings_status')
|
||||
->label('Settings')
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -24,7 +26,11 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('policy_type')->badge()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('version_number', 'desc')
|
||||
->filters([])
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
use App\Services\Intune\VersionDiff;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use BackedEnum;
|
||||
@ -52,8 +54,14 @@ public static function infolist(Schema $schema): Schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
|
||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
||||
Infolists\Components\TextEntry::make('policy_type'),
|
||||
Infolists\Components\TextEntry::make('platform'),
|
||||
Infolists\Components\TextEntry::make('policy_type')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Infolists\Components\TextEntry::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||
Tabs::make()
|
||||
@ -177,8 +185,14 @@ public static function table(Table $table): Table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('policy_type')->badge(),
|
||||
Tables\Columns\TextColumn::make('platform')->badge(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
])
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -128,12 +130,8 @@ public static function table(Table $table): Table
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('environment')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'prod' => 'danger',
|
||||
'dev' => 'warning',
|
||||
'staging' => 'info',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::TenantEnvironment))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('policies_count')
|
||||
->label('Policies')
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -83,11 +85,15 @@ public function table(Table $table): Table
|
||||
TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')),
|
||||
->placeholder('—')
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
TextColumn::make('platform')
|
||||
->label('Platform')
|
||||
->badge()
|
||||
->default('—')
|
||||
->placeholder('—')
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
|
||||
324
app/Support/Badges/TagBadgeCatalog.php
Normal file
324
app/Support/Badges/TagBadgeCatalog.php
Normal file
@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Illuminate\Support\Str;
|
||||
use Stringable;
|
||||
|
||||
final class TagBadgeCatalog
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
private static array $policyTypeMetaCache = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private static array $policyCategoryLabelCache = [];
|
||||
|
||||
public static function spec(TagBadgeDomain $domain, mixed $value): TagBadgeSpec
|
||||
{
|
||||
return match ($domain) {
|
||||
TagBadgeDomain::PolicyType => self::policyType($value),
|
||||
TagBadgeDomain::PolicyCategory => self::policyCategory($value),
|
||||
TagBadgeDomain::Platform => self::platform($value),
|
||||
TagBadgeDomain::TenantEnvironment => self::tenantEnvironment($value),
|
||||
TagBadgeDomain::Visibility => self::visibility($value),
|
||||
TagBadgeDomain::SnapshotType => self::snapshotType($value),
|
||||
TagBadgeDomain::BackupScheduleFrequency => self::backupScheduleFrequency($value),
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeValue(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;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private static function policyType(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$type = self::normalizeValue($value);
|
||||
|
||||
if (! filled($type)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::PolicyType));
|
||||
}
|
||||
|
||||
$meta = self::policyTypeMeta($type);
|
||||
$label = $meta['label'] ?? null;
|
||||
|
||||
if (is_string($label) && $label !== '') {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::PolicyType));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::PolicyType, $type);
|
||||
}
|
||||
|
||||
private static function policyCategory(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$category = self::normalizeValue($value);
|
||||
|
||||
if (! filled($category)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::PolicyCategory));
|
||||
}
|
||||
|
||||
$normalized = Str::of($category)
|
||||
->trim()
|
||||
->lower()
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$label = self::policyCategoryLabels()[$normalized] ?? null;
|
||||
|
||||
if (is_string($label) && $label !== '') {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::PolicyCategory));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::PolicyCategory, $category);
|
||||
}
|
||||
|
||||
private static function platform(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$platform = self::normalizeValue($value);
|
||||
|
||||
if (! filled($platform)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::Platform));
|
||||
}
|
||||
|
||||
$normalized = Str::of($platform)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(['_', '-'], ' ')
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'windows' => 'Windows',
|
||||
'android' => 'Android',
|
||||
'ios' => 'iOS',
|
||||
'macos' => 'macOS',
|
||||
'all' => 'All',
|
||||
'mobile' => 'Mobile',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_string($label)) {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::Platform));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::Platform, $platform);
|
||||
}
|
||||
|
||||
private static function tenantEnvironment(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$environment = self::normalizeValue($value);
|
||||
|
||||
if (! filled($environment)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::TenantEnvironment));
|
||||
}
|
||||
|
||||
$normalized = Str::of($environment)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(['_', '-'], ' ')
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'prod', 'production' => 'Prod',
|
||||
'staging' => 'Staging',
|
||||
'dev', 'development' => 'Dev',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_string($label)) {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::TenantEnvironment));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::TenantEnvironment, $environment);
|
||||
}
|
||||
|
||||
private static function visibility(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$visibility = self::normalizeValue($value);
|
||||
|
||||
if (! filled($visibility)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::Visibility));
|
||||
}
|
||||
|
||||
$normalized = Str::of($visibility)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(['_', '-'], ' ')
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'active' => 'Active',
|
||||
'archived' => 'Archived',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_string($label)) {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::Visibility));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::Visibility, $visibility);
|
||||
}
|
||||
|
||||
private static function snapshotType(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$type = self::normalizeValue($value);
|
||||
|
||||
if (! filled($type)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::SnapshotType));
|
||||
}
|
||||
|
||||
$normalized = Str::of($type)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(['_', '-'], ' ')
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'full' => 'Full',
|
||||
'incremental' => 'Incremental',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_string($label)) {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::SnapshotType));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::SnapshotType, $type);
|
||||
}
|
||||
|
||||
private static function backupScheduleFrequency(mixed $value): TagBadgeSpec
|
||||
{
|
||||
$frequency = self::normalizeValue($value);
|
||||
|
||||
if (! filled($frequency)) {
|
||||
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::BackupScheduleFrequency));
|
||||
}
|
||||
|
||||
$normalized = Str::of($frequency)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(['_', '-'], ' ')
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'daily' => 'Daily',
|
||||
'weekly' => 'Weekly',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_string($label)) {
|
||||
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::BackupScheduleFrequency));
|
||||
}
|
||||
|
||||
return self::other(TagBadgeDomain::BackupScheduleFrequency, $frequency);
|
||||
}
|
||||
|
||||
private static function other(TagBadgeDomain $domain, string $value): TagBadgeSpec
|
||||
{
|
||||
$normalizedValue = Str::of($value)
|
||||
->trim()
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
return new TagBadgeSpec(
|
||||
label: 'Other: '.$normalizedValue,
|
||||
color: self::colorForDomain($domain),
|
||||
);
|
||||
}
|
||||
|
||||
private static function colorForDomain(TagBadgeDomain $domain): string
|
||||
{
|
||||
return match ($domain) {
|
||||
TagBadgeDomain::PolicyType => 'primary',
|
||||
TagBadgeDomain::PolicyCategory => 'gray',
|
||||
TagBadgeDomain::Platform => 'info',
|
||||
TagBadgeDomain::TenantEnvironment => 'gray',
|
||||
TagBadgeDomain::Visibility => 'gray',
|
||||
TagBadgeDomain::SnapshotType => 'primary',
|
||||
TagBadgeDomain::BackupScheduleFrequency => 'info',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function policyTypeMeta(string $type): array
|
||||
{
|
||||
if (self::$policyTypeMetaCache === []) {
|
||||
self::$policyTypeMetaCache = InventoryPolicyTypeMeta::byType();
|
||||
}
|
||||
|
||||
return self::$policyTypeMetaCache[$type] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function policyCategoryLabels(): array
|
||||
{
|
||||
if (self::$policyCategoryLabelCache !== []) {
|
||||
return self::$policyCategoryLabelCache;
|
||||
}
|
||||
|
||||
$labels = [];
|
||||
|
||||
foreach (InventoryPolicyTypeMeta::all() as $meta) {
|
||||
if (! is_array($meta)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = $meta['category'] ?? null;
|
||||
if (! is_string($category) || trim($category) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = Str::of($category)
|
||||
->trim()
|
||||
->lower()
|
||||
->replaceMatches('/\s+/', ' ')
|
||||
->toString();
|
||||
|
||||
$labels[$normalized] ??= $category;
|
||||
}
|
||||
|
||||
self::$policyCategoryLabelCache = $labels;
|
||||
|
||||
return self::$policyCategoryLabelCache;
|
||||
}
|
||||
}
|
||||
14
app/Support/Badges/TagBadgeDomain.php
Normal file
14
app/Support/Badges/TagBadgeDomain.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum TagBadgeDomain: string
|
||||
{
|
||||
case PolicyType = 'policy_type';
|
||||
case PolicyCategory = 'policy_category';
|
||||
case Platform = 'platform';
|
||||
case TenantEnvironment = 'tenant_environment';
|
||||
case Visibility = 'visibility';
|
||||
case SnapshotType = 'snapshot_type';
|
||||
case BackupScheduleFrequency = 'backup_schedule_frequency';
|
||||
}
|
||||
43
app/Support/Badges/TagBadgeRenderer.php
Normal file
43
app/Support/Badges/TagBadgeRenderer.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
use Closure;
|
||||
|
||||
final class TagBadgeRenderer
|
||||
{
|
||||
public static function label(TagBadgeDomain $domain): Closure
|
||||
{
|
||||
return static function (mixed $state, mixed ...$args) use ($domain): string {
|
||||
return TagBadgeCatalog::spec($domain, $state)->label;
|
||||
};
|
||||
}
|
||||
|
||||
public static function color(TagBadgeDomain $domain): Closure
|
||||
{
|
||||
return static function (mixed $state, mixed ...$args) use ($domain): string {
|
||||
return TagBadgeCatalog::spec($domain, $state)->color;
|
||||
};
|
||||
}
|
||||
|
||||
public static function icon(TagBadgeDomain $domain): Closure
|
||||
{
|
||||
return static function (mixed $state, mixed ...$args) use ($domain): ?string {
|
||||
return TagBadgeCatalog::spec($domain, $state)->icon;
|
||||
};
|
||||
}
|
||||
|
||||
public static function iconColor(TagBadgeDomain $domain): Closure
|
||||
{
|
||||
return static function (mixed $state, mixed ...$args) use ($domain): ?string {
|
||||
$spec = TagBadgeCatalog::spec($domain, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
};
|
||||
}
|
||||
|
||||
public static function spec(TagBadgeDomain $domain, mixed $state): TagBadgeSpec
|
||||
{
|
||||
return TagBadgeCatalog::spec($domain, $state);
|
||||
}
|
||||
}
|
||||
48
app/Support/Badges/TagBadgeSpec.php
Normal file
48
app/Support/Badges/TagBadgeSpec.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class TagBadgeSpec
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ALLOWED_COLORS = [
|
||||
'gray',
|
||||
'primary',
|
||||
'info',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
public readonly string $label,
|
||||
public readonly string $color,
|
||||
public readonly ?string $icon = null,
|
||||
public readonly ?string $iconColor = null,
|
||||
) {
|
||||
if (trim($this->label) === '') {
|
||||
throw new InvalidArgumentException('TagBadgeSpec label must be a non-empty string.');
|
||||
}
|
||||
|
||||
if (! in_array($this->color, self::ALLOWED_COLORS, true)) {
|
||||
throw new InvalidArgumentException('TagBadgeSpec color must be one of: '.implode(', ', self::ALLOWED_COLORS));
|
||||
}
|
||||
|
||||
if ($this->icon !== null && trim($this->icon) === '') {
|
||||
throw new InvalidArgumentException('TagBadgeSpec icon must be null or a non-empty string.');
|
||||
}
|
||||
|
||||
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
|
||||
throw new InvalidArgumentException('TagBadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedColors(): array
|
||||
{
|
||||
return self::ALLOWED_COLORS;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@ -113,7 +117,7 @@
|
||||
$isJsonValue = is_array($value) && ! (array_is_list($value) && array_reduce($value, fn ($carry, $item) => $carry && is_scalar($item), true));
|
||||
$isListValue = is_array($value) && array_is_list($value) && array_reduce($value, fn ($carry, $item) => $carry && is_scalar($item), true);
|
||||
$isBooleanValue = is_bool($value);
|
||||
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
|
||||
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled', 'yes', 'no', '1', '0'], true);
|
||||
$isNumericValue = is_numeric($value);
|
||||
|
||||
$badgeItems = null;
|
||||
@ -173,22 +177,29 @@
|
||||
@elseif (is_array($badgeItems) && $badgeItems !== [])
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($badgeItems as $item)
|
||||
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
|
||||
{{ $item }}
|
||||
</x-filament::badge>
|
||||
@if ($isPlatform)
|
||||
@php
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::Platform, $item);
|
||||
@endphp
|
||||
|
||||
<x-filament::badge :color="$spec->color" size="sm">
|
||||
{{ $spec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($isJsonValue)
|
||||
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
@elseif ($isBooleanValue || $isBooleanString)
|
||||
@php
|
||||
$boolValue = $isBooleanValue
|
||||
? $value
|
||||
: in_array(strtolower($value), ['true', 'enabled'], true);
|
||||
$boolLabel = $boolValue ? 'Enabled' : 'Disabled';
|
||||
$boolSpec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
@endphp
|
||||
<x-filament::badge :color="$boolValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $boolLabel }}
|
||||
<x-filament::badge :color="$boolSpec->color" :icon="$boolSpec->icon" size="sm">
|
||||
{{ $boolSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif ($isNumericValue)
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white tabular-nums">
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// Extract state from Filament ViewEntry
|
||||
@ -60,22 +63,10 @@
|
||||
return true;
|
||||
};
|
||||
|
||||
$asEnabledDisabledBadgeValue = function (mixed $value): ?bool {
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($value));
|
||||
|
||||
return match ($normalized) {
|
||||
'enabled', 'true', 'yes', '1' => true,
|
||||
'disabled', 'false', 'no', '0' => false,
|
||||
default => null,
|
||||
};
|
||||
return $spec->label === 'Unknown' ? null : $spec;
|
||||
};
|
||||
@endphp
|
||||
|
||||
@ -117,12 +108,12 @@
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
@php
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
@if($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
|
||||
@ -171,12 +162,12 @@
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@php
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
@if($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@ -185,9 +176,19 @@
|
||||
@elseif($shouldRenderBadges($row['value'] ?? null))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($row['value'] ?? []) as $item)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
@ -225,7 +226,7 @@
|
||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($rawValue);
|
||||
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
|
||||
@endphp
|
||||
|
||||
@if($isScriptContent)
|
||||
@ -305,14 +306,24 @@
|
||||
@elseif($shouldRenderBadges($rawValue))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach(($rawValue ?? []) as $item)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@php
|
||||
$itemSpec = $asEnabledDisabledBadgeSpec($item);
|
||||
@endphp
|
||||
|
||||
@if($itemSpec)
|
||||
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
|
||||
{{ $itemSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ is_null($item) ? '—' : (string) $item }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
@elseif($badgeSpec)
|
||||
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
|
||||
{{ $badgeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// Extract groups from Filament ViewEntry state using $getState()
|
||||
@ -71,25 +73,34 @@
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
|
||||
{{ $setting['label'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 md:mt-0 md:col-span-2 break-words">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@if(is_bool($setting['value_raw']))
|
||||
<x-filament::badge :color="$setting['value_raw'] ? 'success' : 'gray'" size="sm">
|
||||
{{ $setting['value_display'] }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_int($setting['value_raw']))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $setting['value_display'] }}
|
||||
</span>
|
||||
@elseif(str_contains(strtolower($setting['value_display']), 'enabled') || str_contains(strtolower($setting['value_display']), 'disabled'))
|
||||
<x-filament::badge :color="str_contains(strtolower($setting['value_display']), 'enabled') ? 'success' : 'gray'" size="sm">
|
||||
{{ $setting['value_display'] }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ $setting['value_display'] }}
|
||||
</span>
|
||||
@endif
|
||||
<dd class="mt-1 md:mt-0 md:col-span-2 break-words">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@php
|
||||
$rawValue = $setting['value_raw'] ?? null;
|
||||
$displayValue = $setting['value_display'] ?? null;
|
||||
|
||||
$booleanSpec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $rawValue);
|
||||
|
||||
if ($booleanSpec->label === 'Unknown') {
|
||||
$booleanSpec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $displayValue);
|
||||
}
|
||||
|
||||
$hasBooleanBadge = $booleanSpec->label !== 'Unknown';
|
||||
@endphp
|
||||
|
||||
@if($hasBooleanBadge)
|
||||
<x-filament::badge :color="$booleanSpec->color" :icon="$booleanSpec->icon" size="sm">
|
||||
{{ $booleanSpec->label }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_int($rawValue))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $displayValue }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ $displayValue }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if(strlen($setting['value_display'] ?? '') > 200)
|
||||
<button
|
||||
|
||||
@ -15,13 +15,49 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
@foreach ($supportedPolicyTypes as $row)
|
||||
@php
|
||||
$typeSpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyType, $row['type'] ?? null);
|
||||
$categorySpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
|
||||
$restoreSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $row['restore'] ?? 'enabled');
|
||||
$riskSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRisk, $row['risk'] ?? 'n/a');
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
|
||||
<td class="py-2 pr-4">{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span class="font-mono text-xs">{{ $row['type'] ?? '' }}</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$typeSpec->color" size="sm">
|
||||
{{ $typeSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$categorySpec->color" size="sm">
|
||||
{{ $categorySpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
@if ($row['dependencies'] ?? false)
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-check-circle"
|
||||
class="h-5 w-5 text-success-600 dark:text-success-400"
|
||||
/>
|
||||
@else
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-minus-circle"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$restoreSpec->color" :icon="$restoreSpec->icon" size="sm">
|
||||
{{ $restoreSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$riskSpec->color" :icon="$riskSpec->icon" size="sm">
|
||||
{{ $riskSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@ -45,13 +81,49 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
@foreach ($foundationTypes as $row)
|
||||
@php
|
||||
$typeSpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyType, $row['type'] ?? null);
|
||||
$categorySpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
|
||||
$restoreSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $row['restore'] ?? 'enabled');
|
||||
$riskSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRisk, $row['risk'] ?? 'n/a');
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
|
||||
<td class="py-2 pr-4">{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
|
||||
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span class="font-mono text-xs">{{ $row['type'] ?? '' }}</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$typeSpec->color" size="sm">
|
||||
{{ $typeSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$categorySpec->color" size="sm">
|
||||
{{ $categorySpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
@if ($row['dependencies'] ?? false)
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-check-circle"
|
||||
class="h-5 w-5 text-success-600 dark:text-success-400"
|
||||
/>
|
||||
@else
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-minus-circle"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$restoreSpec->color" :icon="$restoreSpec->icon" size="sm">
|
||||
{{ $restoreSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<x-filament::badge :color="$riskSpec->color" :icon="$riskSpec->icon" size="sm">
|
||||
{{ $riskSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
35
specs/060-tag-badge-catalog/checklists/requirements.md
Normal file
35
specs/060-tag-badge-catalog/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Tag Badge Catalog (Neutral, Suite-wide) v1
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-01-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation run: 2026-01-22 — all items pass.
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
29
specs/060-tag-badge-catalog/contracts/guardrails.md
Normal file
29
specs/060-tag-badge-catalog/contracts/guardrails.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Guardrails Contract — Tag Badge Catalog (v1)
|
||||
|
||||
This feature enforces a single source of truth for tag-like (metadata) badge rendering and prevents regression back to ad-hoc tag colors.
|
||||
|
||||
## What is enforced (v1)
|
||||
|
||||
- Tag-like badges must not use forbidden health/status colors (`success|warning|danger`).
|
||||
- Tag-like badges should be rendered via the centralized tag badge catalog on targeted surfaces.
|
||||
|
||||
Status-like badges remain governed by BADGE-001 and are out of scope for these guardrails.
|
||||
|
||||
## Guard strategy (v1)
|
||||
|
||||
### Mapping tests
|
||||
- Tag badge mapping tests assert:
|
||||
- Canonical label normalization for representative values.
|
||||
- Palette invariants (allowlist only).
|
||||
- Unknown/unmapped fallback (“Other: <value>”).
|
||||
|
||||
### Lightweight repo guard
|
||||
- A test scans the codebase and flags newly introduced forbidden colors used on tag-like badge surfaces.
|
||||
- The guard is intentionally lightweight (pattern-based) to keep noise low and avoid blocking legitimate status-like semantics.
|
||||
|
||||
## Examples of patterns the guard should flag
|
||||
|
||||
- Tag-like table columns or Blade badges using `success`, `warning`, or `danger` colors to represent metadata.
|
||||
- Newly introduced inline per-value color maps for tag domains that bypass the central catalog.
|
||||
|
||||
The exact patterns are defined by the guard test implementation and may be refined to reduce false positives.
|
||||
56
specs/060-tag-badge-catalog/contracts/tag-badge-semantics.md
Normal file
56
specs/060-tag-badge-catalog/contracts/tag-badge-semantics.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Tag Badge Semantics Contract — Tag Badge Catalog (v1)
|
||||
|
||||
This feature does not introduce HTTP APIs. These contracts define stable UI semantics for tag-like (metadata) badges across the admin panel.
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
### In scope (tag-like / metadata)
|
||||
- Policy type and policy category.
|
||||
- Platform.
|
||||
- Tenant environment.
|
||||
- Visibility/lifecycle tags when used as metadata chips (not health/status).
|
||||
- Snapshot type.
|
||||
|
||||
### Out of scope (status-like)
|
||||
- Status/health/progress signals (queued/running/completed; succeeded/failed/partial; enabled/disabled; availability; severity/risk).
|
||||
- Any status-like boolean that is intentionally “attention-worthy”.
|
||||
|
||||
## Neutral palette policy (v1)
|
||||
|
||||
### Allowed colors
|
||||
- `gray`
|
||||
- `primary`
|
||||
- `info`
|
||||
|
||||
### Forbidden colors for tag-like badges
|
||||
- `success`
|
||||
- `warning`
|
||||
- `danger`
|
||||
|
||||
## Domain → color mapping (v1)
|
||||
|
||||
Tag colors are assigned per domain (domain → color), not per individual tag value:
|
||||
|
||||
- Policy type → `gray`
|
||||
- Policy category → `primary`
|
||||
- Platform → `info`
|
||||
- Tenant environment → `primary`
|
||||
- Visibility/lifecycle → `gray`
|
||||
- Snapshot type → `gray`
|
||||
|
||||
## Canonical labels (v1)
|
||||
|
||||
### Platform casing
|
||||
Platform labels must be Title Case:
|
||||
- “Windows”, “Android”, “iOS”, “macOS”, “All”
|
||||
|
||||
### Unknown / unmapped values
|
||||
Unknown/unmapped values must render as:
|
||||
- Label: “Other: <value>” (normalized)
|
||||
- Color: neutral (from allowlist)
|
||||
|
||||
## Invariants (v1)
|
||||
|
||||
- Tag-like badges must not masquerade as health/status (no `success|warning|danger` colors).
|
||||
- Unknown values must render safely and transparently (“Other: …”), never as an “official” curated tag.
|
||||
- Tag badge mapping must be pure and must not trigger outbound HTTP, queued work, or DB lookups beyond already-loaded data.
|
||||
55
specs/060-tag-badge-catalog/data-model.md
Normal file
55
specs/060-tag-badge-catalog/data-model.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Data Model — Tag Badge Catalog (Neutral, Suite-wide) v1
|
||||
|
||||
This feature is UI-only. It introduces no database schema changes.
|
||||
|
||||
## Entities (existing)
|
||||
|
||||
### Tenant
|
||||
- **Role**: scope boundary for all queries and UI rendering.
|
||||
- **Tag-like fields used**:
|
||||
- `environment` (for example: `prod|staging|dev|other`)
|
||||
- **Status-like fields (explicitly out of scope)**:
|
||||
- `status` (`active|archived`) is status-like/lifecycle and is standardized separately.
|
||||
|
||||
### Policy
|
||||
- **Role**: policy inventory and entry point for policy-related UI.
|
||||
- **Tag-like fields used** (source may be top-level columns and/or `metadata`):
|
||||
- policy type
|
||||
- policy category
|
||||
- platform
|
||||
- **Status-like fields (explicitly out of scope)**:
|
||||
- `ignored_at` is a boolean-like status signal (“ignored”) and is handled via centralized status-like semantics.
|
||||
|
||||
### PolicyVersion
|
||||
- **Role**: immutable snapshots/versions of a policy.
|
||||
- **Tag-like fields used** (from `metadata` and/or `snapshot`):
|
||||
- policy type
|
||||
- platform
|
||||
- snapshot type (for example: `full|incremental`)
|
||||
|
||||
### InventoryItem
|
||||
- **Role**: “last observed” inventory record for an Intune object.
|
||||
- **Tag-like fields used** (source may include `meta_jsonb`):
|
||||
- inventory type/category
|
||||
- platform
|
||||
|
||||
### BackupSet / BackupItem / BackupSchedule (selected)
|
||||
- **Role**: backup metadata surfaces may display tag-like chips (schedule/frequency/type).
|
||||
- **Tag-like fields used**: schedule/frequency/snapshot metadata where presented as chips (domain discovered during migration sweep).
|
||||
|
||||
## Entities (code-only)
|
||||
|
||||
### Tag badge domain
|
||||
- **Role**: a named namespace for metadata tags that must render consistently (for example, “platform”, “policy category”, “tenant environment”).
|
||||
- **Uniqueness**: domain name is unique within the application.
|
||||
|
||||
### Tag badge spec
|
||||
- **Role**: the resolved UI meaning of a tag value (label + neutral color + optional icon).
|
||||
- **Constraints**:
|
||||
- Must be pure (no DB queries, no HTTP, no side effects).
|
||||
- Color must be from the neutral allowlist: `gray|primary|info`.
|
||||
- Unknown values must render as “Other: <value>” (normalized).
|
||||
|
||||
## State transitions
|
||||
|
||||
This feature does not introduce new state machines. It standardizes how existing metadata values render as neutral tag badges.
|
||||
75
specs/060-tag-badge-catalog/discovery.md
Normal file
75
specs/060-tag-badge-catalog/discovery.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Discovery: Tag-like Badges (060-tag-badge-catalog)
|
||||
|
||||
**Purpose**: Inventory current *tag-like* (non-status) badge usage and observed values before migrating to the centralized `TagBadgeCatalog`.
|
||||
|
||||
**Hard rule reminder**: Status-like badges (health/workflow/severity/risk/availability) remain governed by `BadgeCatalog` / `BadgeRenderer` (Feature 059). This discovery focuses on tag-like metadata only.
|
||||
|
||||
## Scope scanned (initial)
|
||||
|
||||
- `app/Filament/**` (Resources / RelationManagers / Pages / Widgets)
|
||||
- `app/Livewire/**`
|
||||
- `resources/views/filament/**`
|
||||
|
||||
## Canonical tag domains (v1)
|
||||
|
||||
- **Policy Type** (`policy_type`)
|
||||
- Examples (from config): `deviceConfiguration`, `settingsCatalogPolicy`, `groupPolicyConfiguration`, `deviceCompliancePolicy`
|
||||
- **Policy Category** (`category`)
|
||||
- Examples (from config): `Configuration`, `Compliance`, `Update Management`, `Apps/MAM`
|
||||
- **Platform** (`platform`)
|
||||
- Examples observed/expected: `windows`, `android`, `iOS`, `macOS`, `all`, `mobile`
|
||||
- **Tenant Environment** (`environment`)
|
||||
- Examples: `prod`, `staging`, `dev`, (fallback: other)
|
||||
- **Backup Schedule Frequency** (`frequency`)
|
||||
- Examples: `daily`, `weekly`
|
||||
|
||||
## File/line hit list (pre-migration snapshot)
|
||||
|
||||
### Policies (list)
|
||||
|
||||
- `app/Filament/Resources/PolicyResource.php:243` — `policy_type` badge (tag-like)
|
||||
- `app/Filament/Resources/PolicyResource.php:250` — `category` badge (tag-like, derived from policy type meta)
|
||||
- `app/Filament/Resources/PolicyResource.php:260` — `platform` badge (tag-like)
|
||||
|
||||
### Policy Versions (list + relation manager)
|
||||
|
||||
- `app/Filament/Resources/PolicyVersionResource.php:180` — `policy_type` badge (tag-like)
|
||||
- `app/Filament/Resources/PolicyVersionResource.php:181` — `platform` badge (tag-like)
|
||||
- `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php:27` — `policy_type` badge (tag-like)
|
||||
|
||||
### Inventory Items (list + detail)
|
||||
|
||||
- `app/Filament/Resources/InventoryItemResource.php:146` — `policy_type` badge (tag-like)
|
||||
- `app/Filament/Resources/InventoryItemResource.php:150` — `category` badge (tag-like)
|
||||
- `app/Filament/Resources/InventoryItemResource.php:152` — `platform` badge (tag-like)
|
||||
|
||||
### Tenants (list)
|
||||
|
||||
- `app/Filament/Resources/TenantResource.php:129` — `environment` badge (tag-like, currently treated with status-like colors)
|
||||
|
||||
### Backups (tables / pickers)
|
||||
|
||||
- `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php:53` — `policy_type` badge (tag-like)
|
||||
- `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php:76` — `platform` badge (tag-like)
|
||||
- `app/Livewire/BackupSetPolicyPickerTable.php:83` — `policy_type` badge (tag-like)
|
||||
- `app/Livewire/BackupSetPolicyPickerTable.php:87` — `platform` badge (tag-like)
|
||||
- `app/Filament/Resources/BackupScheduleResource.php:187` — `frequency` badge (tag-like, currently treated with status-like colors)
|
||||
|
||||
### Policy detail (Blade)
|
||||
|
||||
- `resources/views/filament/infolists/entries/policy-general.blade.php:176` — list badge color varies by whether the field is “platform” (`info`) or not (`gray`)
|
||||
|
||||
## Top inconsistencies (pre-migration)
|
||||
|
||||
- Environment tags in Tenants list are using status-like palette (`danger`/`warning`), which reads as health severity rather than metadata.
|
||||
- Backup schedule frequency tags are using `success`/`warning`, which reads like health/progress.
|
||||
- Platform labels are not consistently canonicalized (mixture of `macOS` / `macos` / `iOS` / `ios` possible across sources).
|
||||
- Several tag badges rely on default Filament color (or ad-hoc formatting) instead of a central catalog.
|
||||
|
||||
## Post-migration checklist (to be completed)
|
||||
|
||||
- [X] All listed tag badges render via `TagBadgeCatalog` / `TagBadgeRenderer`
|
||||
- [X] Tag badges use only `gray|primary|info`
|
||||
- [X] Platform labels display as “Windows / Android / iOS / macOS / All”
|
||||
- [X] Tenants environment badge no longer uses `danger|warning|success`
|
||||
- [X] Backup schedule frequency badge no longer uses `success|warning`
|
||||
145
specs/060-tag-badge-catalog/plan.md
Normal file
145
specs/060-tag-badge-catalog/plan.md
Normal file
@ -0,0 +1,145 @@
|
||||
# Implementation Plan: Tag Badge Catalog (Neutral, Suite-wide) v1
|
||||
|
||||
**Branch**: `060-tag-badge-catalog` | **Date**: 2026-01-23 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Introduce a centralized, suite-wide tag badge catalog for non-status metadata (type/category/platform/environment/visibility/snapshot type).
|
||||
- Enforce a neutral, explicit palette allowlist (`gray|primary|info`) and domain-based color assignment (domain → color), so tags don’t read as health signals.
|
||||
- Migrate the minimum targeted surfaces plus any additional tag-like badges found during the discovery sweep.
|
||||
- Add automated guardrails to prevent ad-hoc tag badge styling from reappearing.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5 + Livewire v4
|
||||
**Storage**: PostgreSQL
|
||||
**Testing**: Pest v4 (PHPUnit v12 runtime via `php artisan test`)
|
||||
**Target Platform**: Web application (Filament admin panel)
|
||||
**Project Type**: Web (Laravel monolith)
|
||||
**Performance Goals**: Tag badge mapping is constant-time; no added queries; no N+1; typical list pages render <2s for normal tenant sizes.
|
||||
**Constraints**: Tenant-scoped; tag badge rendering must be DB-only and must not trigger outbound HTTP or job dispatch during render/polling/hydration.
|
||||
**Scale/Scope**: Suite-wide migration for tag-like (metadata) badges across tables, detail views, and dashboard lists.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first / Snapshots-second: no changes to inventory or snapshot semantics (UI-only rendering changes).
|
||||
- Read/write separation: no write behavior introduced.
|
||||
- Graph contract path: no Graph calls introduced.
|
||||
- Deterministic capabilities: no capability derivation changes.
|
||||
- Tenant isolation: all surfaces remain tenant-scoped; catalog mapping is pure.
|
||||
- Run observability: no new operations/jobs/queues introduced; render remains DB-only.
|
||||
- Automation: N/A (no queued/scheduled work introduced by this feature).
|
||||
- Data minimization: no new data storage.
|
||||
- Badge semantics (BADGE-001): status-like badges remain governed by `BadgeCatalog` / `BadgeRenderer`; this feature applies to tag-like badges only.
|
||||
|
||||
Status: ✅ No constitution violations (UI-only semantics; no new Graph calls; no new write behavior; tag badge mapping is pure and tenant-safe).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/
|
||||
├── Filament/
|
||||
│ ├── Pages/ # Update: replace tag badge usage where present
|
||||
│ ├── Resources/ # Update: migrate tag-like badges to the tag catalog
|
||||
│ └── Widgets/ # Update: migrate dashboard tag chips
|
||||
└── Support/
|
||||
└── Badges/ # New/Update: introduce tag badge catalog (separate from status-like catalog)
|
||||
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/
|
||||
└── filament/ # Update: migrate any tag badge Blade usage
|
||||
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/
|
||||
├── Feature/Guards/ # New/Update: lightweight “no forbidden tag colors” guard
|
||||
└── Unit/ # New: tag badge mapping tests per domain
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith + Filament v5 conventions. Keep status-like badge semantics in `app/Support/Badges/*` (Feature 059). Add a separate, neutral tag badge catalog under the same namespace (but not mixed with status domains) and consume it from Filament resources/pages/widgets + Blade views.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
None.
|
||||
|
||||
## Phase 0 — Outline & Research (complete)
|
||||
|
||||
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/research.md`
|
||||
- Key decisions captured:
|
||||
- Neutral palette allowlist: `gray|primary|info`.
|
||||
- Domain-based colors (domain → color), not per-tag-value colors.
|
||||
- Unknown/unmapped fallback: “Other: <value>” (normalized).
|
||||
- v1 coverage: minimum targeted surfaces plus any additional tag-like badges found in discovery.
|
||||
|
||||
## Phase 1 — Design & Contracts (complete)
|
||||
|
||||
### Data model
|
||||
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/data-model.md`
|
||||
- No schema changes required; tag badge semantics derive from existing metadata fields.
|
||||
|
||||
### Contracts
|
||||
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/contracts/tag-badge-semantics.md`
|
||||
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/contracts/guardrails.md`
|
||||
|
||||
### Quickstart
|
||||
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/quickstart.md`
|
||||
|
||||
### Agent context update
|
||||
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/Agents.md`
|
||||
|
||||
### Provider registration (Laravel 11+)
|
||||
- Panel providers remain registered in `bootstrap/providers.php` (no changes required for this feature unless adding a new provider).
|
||||
|
||||
### Livewire / Filament version safety
|
||||
- Livewire v4.0+ (required by Filament v5) is in use.
|
||||
|
||||
### Asset strategy
|
||||
- No new assets expected. If new panel assets are introduced during implementation, ensure deployment runs `php artisan filament:assets`.
|
||||
|
||||
### Destructive actions
|
||||
- None introduced in this feature.
|
||||
|
||||
### Constitution re-check (post-design)
|
||||
- ✅ Inventory-first / Snapshots-second: unaffected (UI-only semantics).
|
||||
- ✅ Read/write separation: this feature is read-only.
|
||||
- ✅ Graph contract path: no Graph calls added.
|
||||
- ✅ Tenant isolation: tag badge mapping is pure and uses already-available tenant-scoped data.
|
||||
- ✅ Run observability: no new long-running work introduced.
|
||||
- ✅ Data minimization: no new payload storage.
|
||||
- ✅ BADGE-001: status-like badge semantics remain centralized; tag badge semantics are handled separately and remain neutral.
|
||||
|
||||
**Gate status (post-design)**: PASS
|
||||
|
||||
## Phase 2 — Implementation Plan (next)
|
||||
|
||||
### Story 1 (P1): Consistent, neutral tags on core inventory surfaces
|
||||
- Add a centralized tag badge catalog covering at least: policy type, policy category, platform, tenant environment, visibility/lifecycle, snapshot type.
|
||||
- Migrate Policies / Policy Versions / Inventory / Tenants to the tag badge catalog.
|
||||
- Enforce: domain-based colors and neutral palette allowlist.
|
||||
|
||||
### Story 2 (P2): Suite-wide consistency via discovery-driven migration
|
||||
- Produce a discovery report of existing tag badge surfaces and values.
|
||||
- Migrate additional tag-like badges found during discovery (dashboards, detail views, backup/snapshot metadata) while keeping status-like badges on the status catalog.
|
||||
|
||||
### Story 3 (P3): Guardrails keep consistency over time
|
||||
- Add mapping tests per tag domain, plus an invariant test enforcing the allowed palette.
|
||||
- Add a lightweight guard test that flags forbidden tag colors (`success|warning|danger`) on tag-like badge surfaces.
|
||||
26
specs/060-tag-badge-catalog/quickstart.md
Normal file
26
specs/060-tag-badge-catalog/quickstart.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Quickstart — Tag Badge Catalog (v1)
|
||||
|
||||
## Prereqs
|
||||
- Run everything via Sail.
|
||||
|
||||
## Setup
|
||||
- `vendor/bin/sail up -d`
|
||||
- `vendor/bin/sail composer install`
|
||||
|
||||
## Run tests (targeted)
|
||||
When implemented, run:
|
||||
- Tag badge mapping tests (new).
|
||||
- Tag badge palette invariant test (new).
|
||||
- Tag badge guard test (new, lightweight scan).
|
||||
|
||||
## Manual QA (tenant-scoped)
|
||||
- Policies list: policy type/category/platform chips are consistent and neutral (no health/status colors).
|
||||
- Policy versions + inventory: the same platform value renders the same label and neutral styling.
|
||||
- Tenants list: environment tag is neutral and consistent.
|
||||
- Unknown/new tag values: rendered as “Other: <value>” in a neutral color (and still readable).
|
||||
- Dark mode: tag chips remain readable and do not resemble health alerts.
|
||||
|
||||
## Frontend assets
|
||||
If UI changes don’t show:
|
||||
- `vendor/bin/sail npm run dev`
|
||||
- or `vendor/bin/sail npm run build`
|
||||
77
specs/060-tag-badge-catalog/research.md
Normal file
77
specs/060-tag-badge-catalog/research.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Research — Tag Badge Catalog (Neutral, Suite-wide) v1
|
||||
|
||||
## Goal
|
||||
Standardize non-status metadata badges (“tags/chips”) suite-wide so operators can reliably scan the admin UI without confusing metadata with health/status signals.
|
||||
|
||||
## Existing Code & Patterns (to reuse)
|
||||
|
||||
### Central badge semantics pattern (status-like)
|
||||
- Status-like badge semantics are centralized in:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeCatalog.php`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeRenderer.php`
|
||||
|
||||
This feature must not mix tag-like semantics into the status-like catalog; it should follow a similar centralization pattern, but remain neutral and separate.
|
||||
|
||||
### Filament badge surfaces (current)
|
||||
- Tables commonly use `TextColumn::badge()` to display chips.
|
||||
- Blade views sometimes use `<x-filament::badge>` directly for badge-like UI.
|
||||
|
||||
### Guard test pattern (current)
|
||||
- The repo uses Pest “guard” tests that scan the codebase for forbidden patterns, for example:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
|
||||
|
||||
## Key Decisions
|
||||
|
||||
### Decision: Centralize tag-like badge semantics behind a dedicated catalog
|
||||
- **Decision**: Introduce a dedicated tag badge catalog for metadata tags (separate from the status-like `BadgeCatalog`).
|
||||
- **Rationale**: Prevents per-page drift and makes tag presentation testable and enforceable without changing business semantics.
|
||||
- **Alternatives considered**:
|
||||
- **Inline mappings per resource/widget/view**: rejected (drifts quickly; hard to enforce).
|
||||
- **Reusing status-like domains**: rejected (tags must remain neutral and not inherit health semantics).
|
||||
|
||||
### Decision: Neutral palette is an explicit allowlist
|
||||
- **Decision**: Tag badges are restricted to an explicit allowlist: `gray`, `primary`, `info`.
|
||||
- **Rationale**: Keeps tag chips clearly “metadata” and prevents accidental use of health/status semantics.
|
||||
- **Alternatives considered**:
|
||||
- **“Neutral by convention” without an allowlist**: rejected (too ambiguous; hard to test and enforce).
|
||||
|
||||
### Decision: Tag badge colors are assigned per domain (domain → color)
|
||||
- **Decision**: The tag badge color is chosen by domain, not by individual tag value.
|
||||
- **Rationale**: Keeps tags scannable (domain is visually recognizable) while avoiding a “rainbow” palette that becomes hard to maintain.
|
||||
- **Alternatives considered**:
|
||||
- **One color for everything**: rejected (loses useful grouping at-a-glance).
|
||||
- **Per-tag-value colors**: rejected (too complex; tends to drift and become noisy).
|
||||
|
||||
### Decision: v1 migration coverage is discovery-driven but bounded
|
||||
- **Decision**: v1 migrates the minimum targeted surfaces, plus any additional tag-like badges found during the discovery sweep.
|
||||
- **Rationale**: Achieves meaningful suite-wide consistency without turning into an unbounded refactor.
|
||||
- **Alternatives considered**:
|
||||
- **Full sweep**: rejected (can sprawl and delay value).
|
||||
- **Minimum only**: rejected (leaves too many inconsistent surfaces behind).
|
||||
|
||||
### Decision: Unknown/unmapped values are shown as “Other: <value>”
|
||||
- **Decision**: Unknown/unmapped tag values render as a neutral badge with label “Other: <value>” (normalized).
|
||||
- **Rationale**: Preserves information for admins while clearly indicating it is not a curated/known tag.
|
||||
- **Alternatives considered**:
|
||||
- **Raw value only**: rejected (can look “official/curated”).
|
||||
- **“Unknown” without value**: rejected (hides useful info).
|
||||
- **Hide badge**: rejected (drops data).
|
||||
|
||||
### Decision: Canonical platform label casing is Title Case
|
||||
- **Decision**: Platform labels use Title Case: “Windows”, “Android”, “iOS”, “macOS”, “All”.
|
||||
- **Rationale**: Improves readability and matches common admin UI expectations.
|
||||
|
||||
## Initial Domain → Color Mapping (v1)
|
||||
|
||||
This mapping is intentionally small and neutral (allowlist only), and is applied consistently suite-wide:
|
||||
|
||||
- Policy type → `gray`
|
||||
- Policy category → `primary`
|
||||
- Platform → `info`
|
||||
- Tenant environment → `primary`
|
||||
- Visibility/lifecycle → `gray`
|
||||
- Snapshot type → `gray`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Discovery may surface additional tag domains/values. v1 will add them as part of the migration sweep while keeping the neutral palette and domain-based color rule.
|
||||
118
specs/060-tag-badge-catalog/spec.md
Normal file
118
specs/060-tag-badge-catalog/spec.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Feature Specification: Tag Badge Catalog (Neutral, Suite-wide) v1
|
||||
|
||||
**Feature Branch**: `060-tag-badge-catalog`
|
||||
**Created**: 2026-01-22
|
||||
**Status**: Draft
|
||||
**Input**: Standardize non-status metadata badges (“tags/chips”) across the admin suite with consistent labels and a neutral palette, without changing any status/severity/health semantics.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-01-23
|
||||
|
||||
- Q: Tag badge palette strategy → A: Neutral colors are assigned per tag domain (domain → color), consistent across the suite (not per individual tag value).
|
||||
- Q: v1 migration coverage → A: v1 migrates the minimum targeted surfaces plus any additional tag-like badges found in the discovery sweep.
|
||||
- Q: Unknown/unmapped tag value label → A: Show “Other: <value>” (normalized), so the value remains visible but clearly uncurated.
|
||||
- Q: Canonical platform label casing → A: Use Title Case labels (Windows, Android, iOS, macOS, All).
|
||||
- Q: Scope of “neutral palette” → A: Use an explicit allowlist of neutral colors only: gray, primary, info.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Read policy metadata clearly (Priority: P1)
|
||||
|
||||
An administrator browsing policy lists can quickly understand policy metadata (type, category, platform, visibility) via consistent tag badges that do not imply health or risk.
|
||||
|
||||
**Why this priority**: Policy inventory is a primary workflow; inconsistent tag styling causes repeated confusion and slows review.
|
||||
|
||||
**Independent Test**: On the Policies list, tag badges are consistent (label + neutral color) for the same values across multiple rows, and none appear as “success/warning/danger” style indicators.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a Policies list with mixed policy types, **When** the user views the list, **Then** each policy’s type/category/platform appears as a neutral tag badge with a canonical label.
|
||||
2. **Given** a Policies list that includes archived and active items, **When** the user views the list, **Then** visibility tags are clearly labeled and do not use health/status colors.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Consistent tags across key surfaces (Priority: P2)
|
||||
|
||||
An administrator sees the same tag values rendered the same way across the suite (lists, detail views, and dashboard snippets), so they can switch between areas without re-learning color meanings.
|
||||
|
||||
**Why this priority**: Inconsistency across surfaces makes the UI feel unreliable and increases misinterpretation of metadata.
|
||||
|
||||
**Independent Test**: On each targeted surface, the same tag value (e.g., “Windows” platform) renders with the same label and neutral styling.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an inventory item and a policy version that share a platform value, **When** the user views both pages, **Then** the platform tag badge matches in label and neutral styling.
|
||||
2. **Given** a tenant list that includes multiple environments, **When** the user views the list, **Then** environment tags are neutral and consistent across rows.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Prevent regressions (Priority: P3)
|
||||
|
||||
The product team can extend or refine tag display rules without reintroducing ad-hoc badge styling across individual pages.
|
||||
|
||||
**Why this priority**: Without guardrails, inconsistent tag colors tend to reappear over time as new pages and fields are added.
|
||||
|
||||
**Independent Test**: A change that introduces a non-neutral tag color is detected by automated validation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a newly introduced tag value, **When** it is displayed anywhere in the admin UI, **Then** it uses a neutral color and a canonical label (or a neutral fallback if unmapped).
|
||||
2. **Given** a change that attempts to render a tag badge using a reserved health/status color, **When** automated validation runs, **Then** it fails with a clear signal that the neutral palette rule was violated.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Unknown tag values (new/rare values): show a neutral badge labeled “Other: <value>” (normalized).
|
||||
- Missing metadata values: no misleading empty badge is shown; the UI handles absence gracefully.
|
||||
- Confusable values (e.g., “active” as lifecycle vs status): tag meaning is based on field context, not the label itself.
|
||||
- Mixed casing/source formatting (e.g., `windows`, `Windows`, `WINDOWS`): the displayed label is consistent.
|
||||
- Very long labels: tags remain readable without breaking layout or implying urgency.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment:** This feature changes UI presentation only. It must not add external API calls, background work, or any write behavior simply by viewing pages.
|
||||
|
||||
**Badge semantics alignment:** This feature standardizes tag-like (metadata) badges only. Status-like badges (status/outcome/severity/risk/availability/boolean) remain governed by the existing centralized status badge semantics.
|
||||
|
||||
### Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Standardize suite-wide display rules for non-status metadata tags (e.g., policy type/category, platform, tenant environment, visibility, snapshot type).
|
||||
- Apply consistent, neutral tag badges across key admin surfaces where metadata badges are shown, plus additional tag-like badges discovered during the discovery sweep.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Changing business meaning or introducing new tag values (beyond canonical label casing).
|
||||
- Re-standardizing status/severity/health/workflow badges (handled separately).
|
||||
- Any layout or information-architecture redesign beyond badge rendering consistency.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a single source of truth for tag badge display rules (label + neutral color + optional icon) grouped by tag domain (e.g., policy type, platform).
|
||||
- **FR-002**: System MUST render all tag-like badges via the centralized catalog on targeted surfaces (tables, detail views, and dashboard lists) where metadata badges are shown.
|
||||
- **FR-003**: Tag badges MUST use a neutral palette and MUST NOT use the suite’s reserved health/status colors (e.g., green/yellow/red semantics). The neutral palette MUST be an explicit allowlist: gray, primary, info.
|
||||
- **FR-003a**: Tag badge colors MUST be assigned per tag domain (domain → color) and remain consistent across the suite.
|
||||
- **FR-004**: System MUST keep a hard separation between tag-like badges (metadata) and status-like badges (health/workflow/severity). This feature MUST NOT change status-like badge meaning, color, or wording.
|
||||
- **FR-005**: System MUST provide canonical, consistent labels for mapped tag values (including consistent casing).
|
||||
- **FR-005a**: Platform tag labels MUST use Title Case: “Windows”, “Android”, “iOS”, “macOS”, and “All”.
|
||||
- **FR-006**: System MUST provide a neutral fallback for unknown/unmapped tag values that remains readable and does not imply health or risk. The fallback label MUST be “Other: <value>” (normalized).
|
||||
- **FR-007**: The initial rollout MUST cover, at minimum: Policies, Policy Versions, Inventory, Tenants, and any backup/snapshot metadata badges already displayed in the suite, plus any additional tag-like badges discovered during the discovery sweep.
|
||||
- **FR-008**: System MUST include automated guardrails that detect ad-hoc tag badge styling and enforce the neutral palette rule for tag-like badges.
|
||||
- **FR-009**: Viewing pages with tag badges MUST NOT trigger external requests or background work beyond the page’s normal data load.
|
||||
- **FR-010**: The centralized catalog MUST cover, at minimum, these tag domains: policy type, policy category, platform, tenant environment, visibility/lifecycle, and snapshot type.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Feature 059 (centralized status + severity badges) is available and remains the source of truth for all status-like badges.
|
||||
- Existing tag values are reused; this feature standardizes presentation and canonical label casing only.
|
||||
- The suite has a consistent understanding of “tag-like” metadata fields versus “status-like” fields; any ambiguous field is treated as status-like and excluded from this feature.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: On all targeted surfaces, 100% of tag badges use only the approved neutral palette (gray, primary, info), verified via automated validation and spot-check QA.
|
||||
- **SC-002**: In an internal review, at least 90% of administrators report that tag badges read as metadata and do not confuse them with health/status signals.
|
||||
- **SC-003**: For each canonical tag domain (policy type/category, platform, tenant environment, visibility, snapshot type), the same value is displayed with the same label across at least two different areas of the suite.
|
||||
- **SC-004**: Viewing pages containing tag badges does not introduce additional side effects (no unexpected external requests or background work triggered by badge rendering), verified via test/QA observation.
|
||||
174
specs/060-tag-badge-catalog/tasks.md
Normal file
174
specs/060-tag-badge-catalog/tasks.md
Normal file
@ -0,0 +1,174 @@
|
||||
---
|
||||
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: Tag Badge Catalog (Neutral, Suite-wide) v1
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/`
|
||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/plan.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/spec.md`
|
||||
|
||||
**Tests**: Required (Pest) because UI/runtime behavior changes are introduced.
|
||||
**Operations**: No new long-running/remote/queued/scheduled work introduced.
|
||||
**Badges**: Status-like badges remain governed by BADGE-001 (`BadgeCatalog` / `BadgeRenderer`). This feature covers tag-like (metadata) badges only.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `- [ ] T### [P?] [US?] Description with file path`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[US#]**: Which user story this task belongs to (US1/US2/US3). Setup + Foundational + Polish tasks have no story label.
|
||||
- Every task includes an exact file path (or command path).
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Ensure the local environment is ready for implementation and testing.
|
||||
|
||||
- [X] T001 Start local stack via `vendor/bin/sail` (run: `vendor/bin/sail up -d` and `vendor/bin/sail composer install`)
|
||||
- [X] T002 Confirm baseline test green via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Feature/Guards/NoAdHocStatusBadgesTest.php`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Central tag badge semantics layer required by all user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T003 [P] Add tag badge domain enum in `app/Support/Badges/TagBadgeDomain.php`
|
||||
- [X] T004 [P] Add tag badge spec value object (neutral allowlist only) in `app/Support/Badges/TagBadgeSpec.php`
|
||||
- [X] T005 Implement central catalog (domain → spec mapping + normalization + “Other: <value>” fallback) in `app/Support/Badges/TagBadgeCatalog.php`
|
||||
- [X] T006 Implement Filament helper closures (label/color/icon/iconColor) in `app/Support/Badges/TagBadgeRenderer.php`
|
||||
- [X] T007 [P] Add mapping tests for core domains (policy type/category/platform) in `tests/Unit/Badges/TagBadgeCatalogTest.php`
|
||||
- [X] T008 [P] Add palette invariant test (never returns `success|warning|danger`) in `tests/Unit/Badges/TagBadgePaletteInvariantTest.php`
|
||||
- [X] T009 Run foundational unit tests via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Unit/Badges/TagBadgeCatalogTest.php`)
|
||||
|
||||
**Checkpoint**: Tag badge catalog exists, is pure, and is unit-tested.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Read policy metadata clearly (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Policies list shows consistent, neutral tag badges for type/category/platform without implying health or risk.
|
||||
|
||||
**Independent Test**: In the Policies list, the same policy type/category/platform values render with the same label + neutral color, and no tag badge uses `success|warning|danger`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T010 [US1] Migrate policy type badges to TagBadgeRenderer in `app/Filament/Resources/PolicyResource.php`
|
||||
- [X] T011 [US1] Migrate policy category badges to TagBadgeRenderer in `app/Filament/Resources/PolicyResource.php`
|
||||
- [X] T012 [US1] Migrate platform badges to TagBadgeRenderer (Title Case labels) in `app/Filament/Resources/PolicyResource.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete and independently verifiable on the Policies list.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Consistent tags across key surfaces (Priority: P2)
|
||||
|
||||
**Goal**: The same tag values render consistently across lists, detail views, relation managers, and key tools.
|
||||
|
||||
**Independent Test**: Pick a platform value (e.g., “Windows”) and verify it renders with the same label + neutral styling in Policies, Policy Versions, Inventory, Backup policy picker, and any detail view that shows platform tags.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T013 [US2] Create discovery report of tag-like badges (file/line hits + observed values) in `specs/060-tag-badge-catalog/discovery.md`
|
||||
- [X] T014 [P] [US2] Migrate tenant environment badge to TagBadgeRenderer in `app/Filament/Resources/TenantResource.php`
|
||||
- [X] T015 [P] [US2] Migrate inventory list badges (type/category/platform) to TagBadgeRenderer in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [X] T016 [P] [US2] Migrate inventory detail badges (type/category/platform) to TagBadgeRenderer in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [X] T017 [P] [US2] Migrate policy version list badges (policy_type/platform) to TagBadgeRenderer in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T018 [P] [US2] Migrate policy version detail badges (policy_type/platform) to TagBadgeRenderer in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T019 [P] [US2] Migrate policy versions relation manager tag badges to TagBadgeRenderer in `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`
|
||||
- [X] T020 [P] [US2] Migrate backup item tag badges (policy_type/platform) to TagBadgeRenderer in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
|
||||
- [X] T021 [P] [US2] Migrate backup set policy picker tag badges (policy_type/platform) to TagBadgeRenderer in `app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- [X] T022 [P] [US2] Migrate backup schedule frequency badge to TagBadgeRenderer (neutral allowlist) in `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- [X] T023 [P] [US2] Migrate platform tag badges in policy detail view to TagBadgeCatalog in `resources/views/filament/infolists/entries/policy-general.blade.php`
|
||||
- [X] T024 [US2] Update and close discovery report with a “post-migration” check in `specs/060-tag-badge-catalog/discovery.md`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete and tag consistency holds across the targeted surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Prevent regressions (Priority: P3)
|
||||
|
||||
**Goal**: Prevent “random tag colors” and ad-hoc tag badge semantics from reappearing.
|
||||
|
||||
**Independent Test**: Automated validation fails when a tag-like badge uses `success|warning|danger` or bypasses the tag badge catalog on known tag surfaces.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Expand mapping tests to cover environment/visibility/snapshot type/frequency in `tests/Unit/Badges/TagBadgeCatalogTest.php`
|
||||
- [X] T026 [P] [US3] Add lightweight guard test for forbidden tag colors in `tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php`
|
||||
- [X] T027 [US3] Tighten guard exclusions to avoid status-like false positives in `tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php`
|
||||
- [X] T028 [US3] Run guard + unit tests via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php`)
|
||||
|
||||
**Checkpoint**: Guardrails and tests prevent regressions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final quality gates and verification before implementation work is considered done.
|
||||
|
||||
- [X] T029 Run formatting on changed files via `vendor/bin/sail` (run: `vendor/bin/sail php ./vendor/bin/pint --dirty`)
|
||||
- [X] T030 Run full tag-badge test subset via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Unit/Badges/TagBadgeCatalogTest.php`)
|
||||
- [X] T031 Re-run suite badge guards via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Feature/Guards/NoAdHocStatusBadgesTest.php`)
|
||||
- [x] T032 Validate manual QA steps in `specs/060-tag-badge-catalog/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)** → **Foundational (Phase 2)** → **US1 (Phase 3)** → **US2 (Phase 4)** → **US3 (Phase 5)** → **Polish (Phase 6)**
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Depends on Foundational; no dependency on US2/US3.
|
||||
- **US2 (P2)**: Depends on Foundational; can proceed after (or alongside) US1.
|
||||
- **US3 (P3)**: Depends on Foundational; can be implemented in parallel with US1/US2, but must be complete before final validation.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Examples
|
||||
|
||||
### Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
Task: "Migrate policy type badges to TagBadgeRenderer in app/Filament/Resources/PolicyResource.php"
|
||||
Task: "Migrate policy category badges to TagBadgeRenderer in app/Filament/Resources/PolicyResource.php"
|
||||
Task: "Migrate platform badges to TagBadgeRenderer (Title Case labels) in app/Filament/Resources/PolicyResource.php"
|
||||
```
|
||||
|
||||
### Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
Task: "Migrate tenant environment badge to TagBadgeRenderer in app/Filament/Resources/TenantResource.php"
|
||||
Task: "Migrate policy version list badges (policy_type/platform) to TagBadgeRenderer in app/Filament/Resources/PolicyVersionResource.php"
|
||||
Task: "Migrate inventory list badges (type/category/platform) to TagBadgeRenderer in app/Filament/Resources/InventoryItemResource.php"
|
||||
Task: "Migrate backup schedule frequency badge to TagBadgeRenderer (neutral allowlist) in app/Filament/Resources/BackupScheduleResource.php"
|
||||
```
|
||||
|
||||
### Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
Task: "Expand mapping tests to cover environment/visibility/snapshot type/frequency in tests/Unit/Badges/TagBadgeCatalogTest.php"
|
||||
Task: "Add lightweight guard test for forbidden tag colors in tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1 (Setup)
|
||||
2. Complete Phase 2 (Foundational)
|
||||
3. Complete Phase 3 (US1)
|
||||
4. Run Phase 6 tasks T029–T032 (at minimum: pint + targeted tests) before considering MVP done
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Foundation ready → deliver US1 (Policies list)
|
||||
2. Deliver US2 (cross-surface consistency + discovery-driven migration)
|
||||
3. Deliver US3 (guardrails) and re-run all targeted tests
|
||||
135
tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php
Normal file
135
tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php
Normal file
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
it('does not contain forbidden status-like colors for tag badges', function () {
|
||||
$root = base_path();
|
||||
$self = realpath(__FILE__);
|
||||
|
||||
$directories = [
|
||||
$root.'/app/Filament',
|
||||
$root.'/app/Livewire',
|
||||
];
|
||||
|
||||
$excludedPaths = [
|
||||
$root.'/vendor',
|
||||
$root.'/storage',
|
||||
$root.'/specs',
|
||||
$root.'/spechistory',
|
||||
$root.'/references',
|
||||
$root.'/public/build',
|
||||
];
|
||||
|
||||
$tagLikeFieldPattern = '/::make\\(\\s*[\'"](?:policy_type|category|platform|environment|visibility|frequency|snapshot_type)[\'"]/';
|
||||
$badgePattern = '/->badge\\s*\\(/';
|
||||
$colorCallStartPattern = '/->(?:color|iconColor)\\s*\\(/';
|
||||
$forbiddenColorLiteralPattern = '/[\'"](?:success|warning|danger)[\'"]/';
|
||||
|
||||
$lookaheadLines = 45;
|
||||
$elementEndPattern = '/\\),\\s*$/';
|
||||
|
||||
/** @var Collection<int, string> $files */
|
||||
$files = collect($directories)
|
||||
->filter(fn (string $dir): bool => is_dir($dir))
|
||||
->flatMap(function (string $dir): array {
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
$paths = [];
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
|
||||
if (! str_ends_with($path, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paths[] = $path;
|
||||
}
|
||||
|
||||
return $paths;
|
||||
})
|
||||
->filter(function (string $path) use ($excludedPaths, $self): bool {
|
||||
if ($self && realpath($path) === $self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($excludedPaths as $excluded) {
|
||||
if (str_starts_with($path, $excluded)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
->values();
|
||||
|
||||
$hits = [];
|
||||
|
||||
foreach ($files as $path) {
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/', $contents) ?: [];
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
if (! preg_match($tagLikeFieldPattern, $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$elementEndOffset = null;
|
||||
$windowLines = array_slice($lines, $index, $lookaheadLines);
|
||||
|
||||
foreach ($windowLines as $offset => $windowLine) {
|
||||
if (preg_match($elementEndPattern, (string) $windowLine)) {
|
||||
$elementEndOffset = $offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$elementLines = $elementEndOffset === null
|
||||
? $windowLines
|
||||
: array_slice($windowLines, 0, $elementEndOffset + 1);
|
||||
|
||||
$element = implode("\n", $elementLines);
|
||||
|
||||
if (! preg_match($badgePattern, $element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasForbiddenColor = false;
|
||||
|
||||
foreach ($elementLines as $offset => $windowLine) {
|
||||
if (! preg_match($colorCallStartPattern, (string) $windowLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$colorWindow = implode("\n", array_slice($elementLines, $offset));
|
||||
|
||||
if (! preg_match($forbiddenColorLiteralPattern, $colorWindow)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasForbiddenColor = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $hasForbiddenColor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace($root.'/', '', $path);
|
||||
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
|
||||
}
|
||||
}
|
||||
|
||||
expect($hits)->toBeEmpty("Forbidden tag badge colors found:\n".implode("\n", $hits));
|
||||
});
|
||||
27
tests/Unit/Badges/BooleanEnabledBadgesTest.php
Normal file
27
tests/Unit/Badges/BooleanEnabledBadgesTest.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps enabled/disabled values to canonical badge semantics', function (mixed $value, string $label, string $color, ?string $icon): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
|
||||
|
||||
expect($spec->label)->toBe($label);
|
||||
expect($spec->color)->toBe($color);
|
||||
expect($spec->icon)->toBe($icon);
|
||||
})->with([
|
||||
'bool true' => [true, 'Enabled', 'success', 'heroicon-m-check-circle'],
|
||||
'bool false' => [false, 'Disabled', 'gray', 'heroicon-m-minus-circle'],
|
||||
'int 1' => [1, 'Enabled', 'success', 'heroicon-m-check-circle'],
|
||||
'int 0' => [0, 'Disabled', 'gray', 'heroicon-m-minus-circle'],
|
||||
'string enabled' => ['enabled', 'Enabled', 'success', 'heroicon-m-check-circle'],
|
||||
'string disabled' => ['disabled', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
|
||||
'string yes' => ['yes', 'Enabled', 'success', 'heroicon-m-check-circle'],
|
||||
'string no' => ['no', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
|
||||
'string true' => ['true', 'Enabled', 'success', 'heroicon-m-check-circle'],
|
||||
'string false' => ['false', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
|
||||
'string 1' => ['1', 'Enabled', 'success', 'heroicon-m-check-circle'],
|
||||
'string 0' => ['0', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
|
||||
]);
|
||||
90
tests/Unit/Badges/TagBadgeCatalogTest.php
Normal file
90
tests/Unit/Badges/TagBadgeCatalogTest.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeSpec;
|
||||
|
||||
it('maps policy type tags to canonical label + domain color', function (): void {
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration');
|
||||
|
||||
expect($spec->label)->toBe('Device Configuration');
|
||||
expect($spec->color)->toBe('primary');
|
||||
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
|
||||
it('maps policy category tags to canonical label + domain color', function (): void {
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Configuration');
|
||||
|
||||
expect($spec->label)->toBe('Configuration');
|
||||
expect($spec->color)->toBe('gray');
|
||||
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
|
||||
it('falls back to Other: <value> for unknown policy category values', function (): void {
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Mystery Category');
|
||||
|
||||
expect($spec->label)->toBe('Other: Mystery Category');
|
||||
expect($spec->color)->toBe('gray');
|
||||
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
|
||||
it('maps platform tags to canonical Title Case labels', function (): void {
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'windows')->label)->toBe('Windows');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'Android')->label)->toBe('Android');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'iOS')->label)->toBe('iOS');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'macOS')->label)->toBe('macOS');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'all')->label)->toBe('All');
|
||||
});
|
||||
|
||||
it('falls back to Other: <value> for unknown platform values', function (): void {
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'plan9');
|
||||
|
||||
expect($spec->label)->toBe('Other: plan9');
|
||||
expect($spec->color)->toBe('info');
|
||||
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
|
||||
it('maps tenant environment tags to canonical labels + neutral colors', function (): void {
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'prod')->label)->toBe('Prod');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'staging')->label)->toBe('Staging');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'dev')->label)->toBe('Dev');
|
||||
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'production')->label)->toBe('Prod');
|
||||
});
|
||||
|
||||
it('maps visibility tags to canonical labels + neutral colors', function (): void {
|
||||
$active = TagBadgeCatalog::spec(TagBadgeDomain::Visibility, 'active');
|
||||
expect($active->label)->toBe('Active');
|
||||
expect($active->color)->toBe('gray');
|
||||
expect($active->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
|
||||
$archived = TagBadgeCatalog::spec(TagBadgeDomain::Visibility, 'archived');
|
||||
expect($archived->label)->toBe('Archived');
|
||||
expect($archived->color)->toBe('gray');
|
||||
expect($archived->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
|
||||
it('maps snapshot type tags to canonical labels + neutral colors', function (): void {
|
||||
$full = TagBadgeCatalog::spec(TagBadgeDomain::SnapshotType, 'full');
|
||||
expect($full->label)->toBe('Full');
|
||||
expect($full->color)->toBe('primary');
|
||||
expect($full->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
|
||||
$incremental = TagBadgeCatalog::spec(TagBadgeDomain::SnapshotType, 'incremental');
|
||||
expect($incremental->label)->toBe('Incremental');
|
||||
expect($incremental->color)->toBe('primary');
|
||||
expect($incremental->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
|
||||
it('maps backup schedule frequency tags to canonical labels + neutral colors', function (): void {
|
||||
$daily = TagBadgeCatalog::spec(TagBadgeDomain::BackupScheduleFrequency, 'daily');
|
||||
expect($daily->label)->toBe('Daily');
|
||||
expect($daily->color)->toBe('info');
|
||||
expect($daily->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
|
||||
$weekly = TagBadgeCatalog::spec(TagBadgeDomain::BackupScheduleFrequency, 'weekly');
|
||||
expect($weekly->label)->toBe('Weekly');
|
||||
expect($weekly->color)->toBe('info');
|
||||
expect($weekly->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
});
|
||||
41
tests/Unit/Badges/TagBadgePaletteInvariantTest.php
Normal file
41
tests/Unit/Badges/TagBadgePaletteInvariantTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeSpec;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
it('never returns status-like colors for tag badges', function (): void {
|
||||
$forbidden = ['success', 'warning', 'danger'];
|
||||
|
||||
foreach (TagBadgeDomain::cases() as $domain) {
|
||||
$known = TagBadgeCatalog::spec($domain, 'known');
|
||||
expect($known->color)->not->toBeIn($forbidden);
|
||||
expect($known->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
|
||||
$unknown = TagBadgeCatalog::spec($domain, 'unknown-value');
|
||||
expect($unknown->color)->not->toBeIn($forbidden);
|
||||
expect($unknown->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only neutral colors for all configured policy types', function (): void {
|
||||
$forbidden = ['success', 'warning', 'danger'];
|
||||
|
||||
foreach (InventoryPolicyTypeMeta::all() as $meta) {
|
||||
if (! is_array($meta)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $meta['type'] ?? null;
|
||||
if (! is_string($type) || trim($type) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, $type);
|
||||
expect($spec->color)->not->toBeIn($forbidden);
|
||||
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user