## 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
236 lines
14 KiB
PHP
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>
|