TenantAtlas/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
ahmido 4699f13a72 Spec 196: restore native Filament table contracts (#236)
## Summary
- replace the inventory dependency GET/apply flow with an embedded native Filament `TableComponent`
- convert tenant required permissions and evidence overview to native page-owned Filament tables with mount-only query seeding and preserved scope authority
- extend focused Pest, Livewire, RBAC, and guard coverage, and update the Spec 196 artifacts and release close-out notes

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php tests/Feature/Filament/TenantRequiredPermissionsPageTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Unit/TenantRequiredPermissionsFilteringTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` (`45` tests, `177` assertions)
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated-browser smoke on localhost for inventory detail dependencies, tenant required permissions, and evidence overview

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #236
2026-04-14 23:30:53 +00:00

264 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament;
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\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class InventoryItemDependencyEdgesTable extends TableComponent
{
public int $inventoryItemId;
private ?InventoryItem $cachedInventoryItem = null;
public function mount(int $inventoryItemId): void
{
$this->inventoryItemId = $inventoryItemId;
$this->resolveInventoryItem();
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId))
->defaultSort('relationship_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->striped()
->deferLoading(! app()->runningUnitTests())
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->dependencyRows(
direction: (string) ($filters['direction']['value'] ?? 'all'),
relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null),
);
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
return $this->paginateRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('direction')
->label('Direction')
->default('all')
->options([
'all' => 'All',
'inbound' => 'Inbound',
'outbound' => 'Outbound',
]),
SelectFilter::make('relationship_type')
->label('Relationship')
->options([
'all' => 'All',
...RelationshipType::options(),
])
->default('all')
->searchable(),
])
->columns([
TextColumn::make('relationship_label')
->label('Relationship')
->badge()
->sortable(),
TextColumn::make('target_label')
->label('Target')
->badge()
->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null)
->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null)
->wrap(),
TextColumn::make('missing_state')
->label('Status')
->badge()
->placeholder('—')
->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray')
->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null)
->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null)
->wrap(),
])
->actions([])
->bulkActions([])
->emptyStateHeading('No dependencies found')
->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.');
}
public function render(): View
{
return view('livewire.inventory-item-dependency-edges-table');
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function dependencyRows(string $direction, ?string $relationshipType): Collection
{
$inventoryItem = $this->resolveInventoryItem();
$tenant = $this->resolveCurrentTenant();
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType));
}
return $resolver->attachRenderedTargets($edges->take(100), $tenant)
->map(function (array $edge): array {
$targetId = $edge['target_id'] ?? null;
$renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : [];
$badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null;
$linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null;
$lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null;
$isMissing = ($edge['target_type'] ?? null) === 'missing';
$missingHint = null;
if ($isMissing) {
$missingHint = 'Missing target';
if (filled($lastKnownName)) {
$missingHint .= ". Last known: {$lastKnownName}";
}
$rawRef = data_get($edge, 'metadata.raw_ref');
$encodedRef = $rawRef !== null ? json_encode($rawRef) : null;
if (is_string($encodedRef) && $encodedRef !== '') {
$missingHint .= '. Ref: '.Str::limit($encodedRef, 200);
}
}
$fallbackLabel = null;
if (filled($lastKnownName)) {
$fallbackLabel = $lastKnownName;
} elseif (is_string($targetId) && $targetId !== '') {
$fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…';
} else {
$fallbackLabel = 'External reference';
}
$relationshipType = (string) ($edge['relationship_type'] ?? 'unknown');
return [
'id' => (string) ($edge['id'] ?? Str::uuid()->toString()),
'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType),
'target_label' => $badgeText ?? $fallbackLabel,
'target_url' => $linkUrl,
'target_tooltip' => is_string($targetId) ? $targetId : null,
'missing_state' => $isMissing ? 'Missing' : null,
'missing_hint' => $missingHint,
];
})
->mapWithKeys(fn (array $row): array => [$row['id'] => $row]);
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true)
? $sortColumn
: 'relationship_label';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
if ($comparison === 0) {
$comparison = strnatcasecmp(
(string) ($left['target_label'] ?? ''),
(string) ($right['target_label'] ?? ''),
);
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
private function resolveInventoryItem(): InventoryItem
{
if ($this->cachedInventoryItem instanceof InventoryItem) {
return $this->cachedInventoryItem;
}
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
$tenant = $this->resolveCurrentTenant();
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
throw new NotFoundHttpException;
}
return $this->cachedInventoryItem = $inventoryItem;
}
private function resolveCurrentTenant(): Tenant
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
return $tenant;
}
private function normalizeRelationshipType(mixed $value): ?string
{
if (! is_string($value) || $value === '' || $value === 'all') {
return null;
}
return RelationshipType::tryFrom($value)?->value;
}
}