TenantAtlas/apps/platform/resources/views/filament/partials/context-bar.blade.php
ahmido e0c2cdb1f4 feat: enforce workspace and environment scope contract (Spec 338) (#409)
## Summary
- enforce the canonical workspace/environment scope contract for workspace hubs and environment-owned surfaces
- replace first-party Operations deep links that leaked Filament `tableFilters[...]` internals with stable product-level query behavior
- add the sidebar scope indicator and split environment-page navigation into explicit `Workspace-wide` and `Workspace admin` groups
- remove redundant tenantless `All environments` scope badges from workspace-wide pages while preserving explicit environment filter affordances
- include the Spec 338 artifacts, guard tests, and browser smoke coverage for the new contract

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php tests/Feature/Filament/PanelNavigationSegregationTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec338ScopeContractSmokeTest.php --compact`

## Notes
- Livewire v4 compliance unchanged
- Filament provider registration remains in `bootstrap/providers.php`
- no destructive action behavior changed
- no migrations, env var changes, or new Filament asset registration

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #409
2026-05-31 01:36:08 +00:00

236 lines
14 KiB
PHP

@php
use App\Filament\Pages\ChooseEnvironment;
use App\Filament\Pages\ChooseWorkspace;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class);
$resolvedContext = app(OperateHubShell::class)->resolvedContext(request());
$workspace = $resolvedContext->workspace;
$user = auth()->user();
$environments = collect();
if ($user instanceof User && $workspace) {
$environments = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->filter(fn ($environment): bool => $environment instanceof ManagedEnvironment && (int) $environment->workspace_id === (int) $workspace->getKey())
->values();
}
$environmentDisplayName = static function (ManagedEnvironment $environment): string {
$displayName = trim((string) ($environment->display_name ?: $environment->name ?: $environment->external_id ?: ''));
return $displayName !== '' ? $displayName : 'Environment #'.$environment->getKey();
};
$currentEnvironment = $resolvedContext->tenant;
$currentEnvironmentId = $currentEnvironment instanceof ManagedEnvironment ? (int) $currentEnvironment->getKey() : null;
$currentEnvironmentName = $currentEnvironment instanceof ManagedEnvironment ? $environmentDisplayName($currentEnvironment) : null;
$lastEnvironmentId = $workspaceContext->lastEnvironmentId(request());
$canClearEnvironmentContext = $currentEnvironment instanceof ManagedEnvironment || $lastEnvironmentId !== null;
@endphp
@php
$environmentLabel = $currentEnvironmentName;
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
$hasActiveEnvironment = $currentEnvironmentName !== null;
$managedEnvironmentsUrl = $workspace
? route('admin.workspace.managed-environments.index', ['workspace' => $workspace])
: route('admin.onboarding');
$workspaceUrl = $workspace
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$environmentTriggerLabel = $workspace ? $environmentLabel : __('localization.shell.choose_workspace');
$environmentTriggerAriaLabel = $workspace && $hasActiveEnvironment
? __('localization.shell.environment_scope')
: __('localization.shell.select_environment');
$localePlane = 'admin';
@endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
{{-- Workspace label: standalone link --}}
<a
href="{{ $workspaceUrl }}"
wire:navigate
class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium text-gray-700 transition hover:bg-gray-50 hover:text-primary-600 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-primary-400"
>
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
{{ $workspaceLabel }}
</a>
@if ($workspace && $hasActiveEnvironment)
<x-filament::icon icon="heroicon-m-chevron-right" class="h-3 w-3 shrink-0 text-gray-300 dark:text-gray-600" />
@endif
{{-- Dropdown trigger: environment label or compact picker + chevron --}}
<x-filament::dropdown placement="bottom-start" teleport width="xs">
<x-slot name="trigger">
<button
type="button"
aria-label="{{ $environmentTriggerAriaLabel }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10 {{ $workspace && ! $hasActiveEnvironment ? 'border-l border-gray-200 dark:border-white/10' : '' }}"
>
@if ($environmentTriggerLabel !== null)
<span class="{{ $workspace && $hasActiveEnvironment ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $environmentTriggerLabel }}
</span>
@else
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@endif
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
</button>
</x-slot>
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
@if ($resolvedContext->showsRecoveryNotice())
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<div class="font-semibold">{{ __('localization.shell.context_unavailable') }}</div>
@if ($workspace)
<div>{{ __('localization.shell.context_unavailable_workspace') }}</div>
@else
<div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div>
@endif
</div>
@endif
{{-- Workspace section --}}
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.workspace') }}
</div>
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div class="flex items-center gap-2">
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-4 w-4 text-gray-500 dark:text-gray-400" />
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $workspaceLabel }}</span>
</div>
<a
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ __('localization.shell.switch_workspace') }}
</a>
</div>
<a
href="{{ route('admin.home') }}"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
>
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
{{ __('localization.shell.workspace_home') }}
</a>
</div>
@if ($workspace)
<div class="border-t border-gray-200 dark:border-white/10"></div>
{{-- Managed Environment section --}}
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ $hasActiveEnvironment ? __('localization.shell.selected_environment') : __('localization.shell.choose_environment') }}
</div>
</div>
@if ($resolvedContext->pageCategory->requiresExplicitEnvironment() && $hasActiveEnvironment)
<div class="space-y-2">
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentEnvironmentName }}</span>
<a
href="{{ ChooseEnvironment::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ __('localization.shell.switch_environment') }}
</a>
</div>
@if ($canClearEnvironmentContext)
<form method="POST" action="{{ route('admin.clear-environment-context') }}">
@csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_environment_scope') }}
</button>
</form>
@endif
</div>
@else
@if ($environments->isEmpty())
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
<div>{{ __('localization.shell.no_active_environments') }}</div>
<a href="{{ $managedEnvironmentsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
{{ __('localization.shell.view_managed_environments') }}
</a>
</div>
@else
<input
type="text"
class="fi-input fi-text-input w-full"
placeholder="{{ __('localization.shell.search_environments') }}"
x-model="query"
/>
<div class="max-h-48 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
@foreach ($environments as $environment)
@php
$isActive = $currentEnvironmentId !== null && (int) $environment->getKey() === $currentEnvironmentId;
@endphp
<form method="POST" action="{{ route('admin.select-environment') }}">
@csrf
<input type="hidden" name="managed_environment_id" value="{{ (int) $environment->getKey() }}" />
<button
type="submit"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{ $isActive ? 'bg-primary-50 font-medium text-primary-700 dark:bg-primary-950/30 dark:text-primary-300' : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5' }}"
data-search="{{ (string) str($environmentDisplayName($environment))->lower() }}"
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
>
@if ($isActive)
<x-filament::icon icon="heroicon-s-check-circle" class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400" />
@else
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
@endif
{{ $environmentDisplayName($environment) }}
</button>
</form>
@endforeach
</div>
@if ($canClearEnvironmentContext)
<form method="POST" action="{{ route('admin.clear-environment-context') }}">
@csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_environment_scope') }}
</button>
</form>
@endif
@endif
@endif
</div>
@else
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
{{ __('localization.shell.choose_workspace_first') }}
</div>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
@include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
</div>