TenantAtlas/app/Livewire/BaselineCompareEvidenceGapTable.php
ahmido 20b6aa6a32 refactor: reduce operation run detail density (#194)
## Summary
- collapse secondary and diagnostic operation-run sections by default to reduce page density
- visually emphasize the primary next step while keeping counts readable but secondary
- keep failures and other actionable detail available without dominating the default reading path

## Testing
- vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #194
2026-03-26 13:23:52 +00:00

255 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class BaselineCompareEvidenceGapTable extends TableComponent
{
/**
* @var list<array<string, mixed>>
*/
public array $gapRows = [];
public string $context = 'default';
/**
* @param list<array<string, mixed>> $buckets
*/
public function mount(array $buckets = [], string $context = 'default'): void
{
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
$this->context = $context;
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
->defaultSort('reason_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->searchable()
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->filterRows(
rows: collect($this->gapRows),
search: $search,
filters: $filters,
);
$rows = $this->sortRows(
rows: $rows,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
);
return $this->paginateRows(
rows: $rows,
page: $page,
recordsPerPage: $recordsPerPage,
);
})
->filters([
SelectFilter::make('reason_code')
->label(__('baseline-compare.evidence_gap_reason'))
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
SelectFilter::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
SelectFilter::make('subject_class')
->label(__('baseline-compare.evidence_gap_subject_class'))
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
SelectFilter::make('operator_action_category')
->label(__('baseline-compare.evidence_gap_next_action'))
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
])
->striped()
->deferLoading(! app()->runningUnitTests())
->columns([
TextColumn::make('reason_label')
->label(__('baseline-compare.evidence_gap_reason'))
->searchable()
->sortable()
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_class_label')
->label(__('baseline-compare.evidence_gap_subject_class'))
->badge()
->searchable()
->sortable()
->wrap(),
TextColumn::make('resolution_outcome_label')
->label(__('baseline-compare.evidence_gap_outcome'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('operator_action_category_label')
->label(__('baseline-compare.evidence_gap_next_action'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_key')
->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable()
->sortable()
->wrap(),
])
->actions([])
->bulkActions([])
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
}
public function render(): View
{
return view('livewire.baseline-compare-evidence-gap-table');
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @param array<string, mixed> $filters
* @return Collection<int, array<string, mixed>>
*/
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$reasonCode = $filters['reason_code']['value'] ?? null;
$policyType = $filters['policy_type']['value'] ?? null;
$subjectClass = $filters['subject_class']['value'] ?? null;
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
return $rows
->when(
$normalizedSearch !== '',
function (Collection $rows) use ($normalizedSearch): Collection {
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
});
}
)
->when(
filled($reasonCode),
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
)
->when(
filled($policyType),
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
)
->when(
filled($subjectClass),
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
)
->when(
filled($operatorActionCategory),
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @return Collection<int, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
if (! filled($sortColumn)) {
return $rows;
}
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
return $rows->sortBy(
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
SORT_NATURAL | SORT_FLAG_CASE,
$direction === 'desc'
)->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
$perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page);
$total = $rows->count();
$items = $rows->forPage($currentPage, $perPage)
->values()
->map(fn (array $row, int $index): Model => $this->toTableRecord(
row: $row,
index: (($currentPage - 1) * $perPage) + $index,
));
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
);
}
/**
* @param array<string, mixed> $row
*/
private function toTableRecord(array $row, int $index): Model
{
$record = new class extends Model
{
public $timestamps = false;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $table = 'baseline_compare_evidence_gap_rows';
};
$record->forceFill([
'id' => implode(':', array_filter([
(string) ($row['reason_code'] ?? 'reason'),
(string) ($row['policy_type'] ?? 'policy'),
(string) ($row['subject_key'] ?? 'subject'),
(string) $index,
])),
...$row,
]);
$record->exists = true;
return $record;
}
}