feat: enforce workspace and environment scope contract (Spec 338) #409
@ -7,6 +7,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubFilterStateResetter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
@ -29,6 +30,16 @@ public static function shouldRegisterNavigation(): bool
|
||||
return Filament::getCurrentPanel()?->getId() === 'admin';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring'));
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(parent::getNavigationUrl());
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(WorkspaceHubFilterStateResetter::class)->neutralizeEnvironmentLikeQueryState(request());
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -103,6 +104,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.governance'));
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -104,6 +105,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.governance'));
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeWorkspaceMembership();
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
@ -142,6 +143,16 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring'));
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::QueueReview)
|
||||
|
||||
@ -226,17 +226,19 @@ protected function getHeaderActions(): array
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_operations')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeEnvironment = $this->currentTenantFilterId() === null
|
||||
? $operateHubShell->activeEntitledTenant(request())
|
||||
: null;
|
||||
|
||||
$actions = [];
|
||||
|
||||
if ($activeEnvironment instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('operate_hub_scope_operations')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled();
|
||||
}
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_operations')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
@ -738,6 +740,17 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
}
|
||||
}
|
||||
|
||||
$requestedOperationType = request()->query('operation_type');
|
||||
|
||||
if (is_string($requestedOperationType) && trim($requestedOperationType) !== '') {
|
||||
$canonicalOperationType = OperationCatalog::canonicalCode($requestedOperationType);
|
||||
|
||||
if (OperationCatalog::rawValuesForCanonical($canonicalOperationType) !== []) {
|
||||
$this->tableFilters['type']['value'] = $canonicalOperationType;
|
||||
$this->tableDeferredFilters['type']['value'] = $canonicalOperationType;
|
||||
}
|
||||
}
|
||||
|
||||
$requestedProblemClass = request()->query('problemClass');
|
||||
|
||||
if (in_array($requestedProblemClass, self::problemClassTabs(), true)) {
|
||||
|
||||
@ -110,12 +110,14 @@ protected function getHeaderActions(): array
|
||||
$activeEnvironment = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->managed_environment_id ?? 0) : 0;
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
$actions = [];
|
||||
|
||||
if ($activeEnvironment instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make('operate_hub_scope_run_detail')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
->disabled();
|
||||
}
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -99,7 +100,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('localization.review.reporting');
|
||||
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.review.reporting'));
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
@ -107,6 +108,11 @@ public static function getNavigationLabel(): string
|
||||
return __('localization.review.customer_reviews');
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.review.customer_review_workspace');
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -79,6 +80,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.review.reporting'));
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseEnvironment;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -90,11 +89,6 @@ public function getTenants(): Collection
|
||||
->values();
|
||||
}
|
||||
|
||||
public function goToChooseEnvironment(): void
|
||||
{
|
||||
$this->redirect(route('admin.workspace.managed-environments.index', ['workspace' => $this->workspace]));
|
||||
}
|
||||
|
||||
public function openTenant(int $tenantId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -67,6 +68,11 @@ public static function shouldRegisterNavigation(): bool
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(parent::getNavigationUrl());
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
@ -84,6 +85,16 @@ public static function getNavigationParentItem(): ?string
|
||||
return 'Integrations';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::workspaceAdminGroup();
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(parent::getNavigationUrl());
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
|
||||
@ -53,14 +53,8 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
private function isEnvironmentScopedEvidencePath(string $previousPath): bool
|
||||
{
|
||||
if ($previousPath === '/admin/evidence') {
|
||||
return true;
|
||||
}
|
||||
$normalizedPath = '/'.ltrim($previousPath, '/');
|
||||
|
||||
if (! str_starts_with($previousPath, '/admin/evidence/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! str_starts_with($previousPath, '/admin/evidence/overview');
|
||||
return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/evidence(?:/|$)#', $normalizedPath) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Filament\Pages\ChooseEnvironment;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\CrossEnvironmentComparePage;
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Filament\Pages\EnvironmentRequiredPermissions;
|
||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||
@ -20,7 +21,6 @@
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
@ -32,6 +32,7 @@
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
@ -41,14 +42,16 @@
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Navigation\AdminSurfaceScope;
|
||||
use App\Support\Navigation\NavigationScope;
|
||||
use App\Support\Navigation\WorkspaceHubRegistry;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\FontProviders\LocalFontProvider;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
@ -86,8 +89,19 @@ public function panel(Panel $panel): Panel
|
||||
->colors([
|
||||
'primary' => Color::Indigo,
|
||||
])
|
||||
->navigationGroups([
|
||||
NavigationGroup::make('Inventory'),
|
||||
NavigationGroup::make(__('localization.navigation.monitoring')),
|
||||
NavigationGroup::make(__('localization.review.reporting')),
|
||||
NavigationGroup::make(__('localization.navigation.settings')),
|
||||
NavigationGroup::make(__('localization.navigation.governance')),
|
||||
NavigationGroup::make('Backups & Restore'),
|
||||
NavigationGroup::make('Directory'),
|
||||
NavigationGroup::make(__('localization.navigation.workspace_wide')),
|
||||
NavigationGroup::make(__('localization.navigation.workspace_admin')),
|
||||
])
|
||||
->navigationItems([
|
||||
WorkspaceOverview::navigationItem(),
|
||||
$this->overviewNavigationItem(),
|
||||
NavigationItem::make('Items')
|
||||
->url(fn (): string => InventoryCluster::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
@ -107,15 +121,15 @@ public function panel(Panel $panel): Panel
|
||||
->sort(10)
|
||||
->visible(fn (): bool => NavigationScope::shouldRegisterEnvironmentNavigation() && EntraGroupResource::canViewAny()),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
|
||||
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('filament.admin.resources.provider-connections.index')))
|
||||
->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(route('filament.admin.resources.provider-connections.index')))
|
||||
->icon('heroicon-o-link')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceAdminGroup())
|
||||
->sort(15)
|
||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.settings'))
|
||||
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(WorkspaceSettings::getUrl(panel: 'admin')))
|
||||
->url(fn (): string => WorkspaceHubNavigation::cleanUrl(WorkspaceSettings::getUrl(panel: 'admin')))
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceAdminGroup())
|
||||
->sort(20)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
@ -144,10 +158,10 @@ public function panel(Panel $panel): Panel
|
||||
}),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
|
||||
->url(function (): string {
|
||||
return WorkspaceHubRegistry::cleanUrl(route('filament.admin.resources.workspaces.index'));
|
||||
return WorkspaceHubNavigation::cleanUrl(route('filament.admin.resources.workspaces.index'));
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceAdminGroup())
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
@ -164,24 +178,24 @@ public function panel(Panel $panel): Panel
|
||||
->exists();
|
||||
}),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
||||
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(OperationRunLinks::index()))
|
||||
->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(OperationRunLinks::index()))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
|
||||
->sort(10),
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('filament.admin.alerts')))
|
||||
->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(route('filament.admin.alerts')))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
|
||||
->sort(23),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.evidence_overview'))
|
||||
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('admin.evidence.overview')))
|
||||
->url(fn (): string => $this->workspaceEvidenceOverviewNavigationUrl())
|
||||
->icon('heroicon-o-shield-check')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
|
||||
->sort(27),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
||||
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('admin.monitoring.audit-log')))
|
||||
->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(route('admin.monitoring.audit-log')))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
@ -192,6 +206,10 @@ public function panel(Panel $panel): Panel
|
||||
PanelsRenderHook::TOPBAR_START,
|
||||
fn () => view('filament.partials.context-bar')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::SIDEBAR_NAV_START,
|
||||
fn () => view('filament.partials.sidebar-scope-indicator')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::PAGE_START,
|
||||
fn (): string => AdminSurfaceScope::fromRequest(request()) === AdminSurfaceScope::OnboardingWorkflow
|
||||
@ -264,4 +282,39 @@ public function panel(Panel $panel): Panel
|
||||
|
||||
return $panel;
|
||||
}
|
||||
|
||||
private function workspaceEvidenceOverviewNavigationUrl(): string
|
||||
{
|
||||
return WorkspaceHubNavigation::environmentFilteredUrl(route('admin.evidence.overview'));
|
||||
}
|
||||
|
||||
private function overviewNavigationItem(): NavigationItem
|
||||
{
|
||||
return NavigationItem::make('Overview')
|
||||
->url(function (): string {
|
||||
$tenant = $this->environmentBoundNavigationTenant();
|
||||
|
||||
return $tenant instanceof ManagedEnvironment
|
||||
? EnvironmentDashboard::getUrl(panel: 'admin', tenant: $tenant)
|
||||
: route('admin.home');
|
||||
})
|
||||
->icon('heroicon-o-home')
|
||||
->sort(-100)
|
||||
->isActiveWhen(fn (): bool => request()->routeIs('admin.home', 'admin.workspace.environments.show'));
|
||||
}
|
||||
|
||||
private function environmentBoundNavigationTenant(): ?ManagedEnvironment
|
||||
{
|
||||
if (! AdminSurfaceScope::fromRequest(request())->requiresExplicitEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return app(WorkspaceContext::class)->rememberedEnvironment(request());
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ enum AdminSurfaceScope: string
|
||||
case WorkspaceScoped = 'workspace_scoped';
|
||||
case WorkspaceChooserException = 'workspace_chooser_exception';
|
||||
case EnvironmentBound = 'environment_bound';
|
||||
case EnvironmentScopedEvidence = 'environment_scoped_evidence';
|
||||
case OnboardingWorkflow = 'onboarding_workflow';
|
||||
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
|
||||
|
||||
@ -47,13 +46,6 @@ public static function fromPath(string $path): self
|
||||
return self::WorkspaceOwnedAnalysisSurface;
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($normalizedPath, '/admin/evidence/')
|
||||
&& ! str_starts_with($normalizedPath, '/admin/evidence/overview')
|
||||
) {
|
||||
return self::EnvironmentScopedEvidence;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
|
||||
return self::OnboardingWorkflow;
|
||||
}
|
||||
@ -108,7 +100,7 @@ public function forcesEnvironmentlessShellContext(): bool
|
||||
public function requiresExplicitEnvironment(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::EnvironmentBound, self::EnvironmentScopedEvidence => true,
|
||||
self::EnvironmentBound => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@ public static function isEnvironmentSurface(?Request $request = null): bool
|
||||
{
|
||||
return in_array(self::pageCategory($request), [
|
||||
AdminSurfaceScope::EnvironmentBound,
|
||||
AdminSurfaceScope::EnvironmentScopedEvidence,
|
||||
], true);
|
||||
}
|
||||
|
||||
|
||||
@ -1008,11 +1008,6 @@ private function contextForFinding(Finding $finding, string $surface): Canonical
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
filterPayload: $tenant instanceof ManagedEnvironment ? [
|
||||
'tableFilters' => [
|
||||
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@ -1030,11 +1025,6 @@ private function contextForPolicyVersion(PolicyVersion $version, string $surface
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
filterPayload: $tenant instanceof ManagedEnvironment ? [
|
||||
'tableFilters' => [
|
||||
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@ -1067,11 +1057,6 @@ private function contextForBackupSet(BackupSet $backupSet, string $surface): Can
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: $backLabel,
|
||||
backLinkUrl: $backUrl,
|
||||
filterPayload: $tenant instanceof ManagedEnvironment ? [
|
||||
'tableFilters' => [
|
||||
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@ -1085,11 +1070,6 @@ private function contextForOperationRun(OperationRun $run): CanonicalNavigationC
|
||||
tenantId: $tenant?->getKey(),
|
||||
backLinkLabel: 'Back to operations',
|
||||
backLinkUrl: OperationRunLinks::index($tenant),
|
||||
filterPayload: $tenant instanceof ManagedEnvironment ? [
|
||||
'tableFilters' => [
|
||||
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
||||
],
|
||||
] : [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
final class WorkspaceHubNavigation
|
||||
{
|
||||
public static function workspaceWideGroup(string $fallbackGroup): string
|
||||
{
|
||||
return NavigationScope::isEnvironmentSurface()
|
||||
? __('localization.navigation.workspace_wide')
|
||||
: $fallbackGroup;
|
||||
}
|
||||
|
||||
public static function workspaceAdminGroup(): string
|
||||
{
|
||||
return NavigationScope::isEnvironmentSurface()
|
||||
? __('localization.navigation.workspace_admin')
|
||||
: __('localization.navigation.settings');
|
||||
}
|
||||
|
||||
public static function environmentFilteredUrl(string $url): string
|
||||
{
|
||||
$url = WorkspaceHubRegistry::cleanUrl($url);
|
||||
|
||||
$environment = self::environmentContext();
|
||||
|
||||
if (! $environment instanceof ManagedEnvironment) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return url()->query($url, [
|
||||
'environment_id' => (int) $environment->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function cleanUrl(string $url): string
|
||||
{
|
||||
return WorkspaceHubRegistry::cleanUrl($url);
|
||||
}
|
||||
|
||||
public static function environmentContext(): ?ManagedEnvironment
|
||||
{
|
||||
if (! NavigationScope::isEnvironmentSurface()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return app(WorkspaceContext::class)->rememberedEnvironment(request());
|
||||
}
|
||||
}
|
||||
@ -64,12 +64,15 @@ public function headerActions(
|
||||
string $returnActionName = 'operate_hub_return',
|
||||
?Request $request = null,
|
||||
): array {
|
||||
$actions = [
|
||||
Action::make($scopeActionName)
|
||||
$actions = [];
|
||||
$activeEnvironment = $this->activeEntitledTenant($request);
|
||||
|
||||
if ($activeEnvironment instanceof ManagedEnvironment) {
|
||||
$actions[] = Action::make($scopeActionName)
|
||||
->label($this->scopeLabel($request))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
->disabled();
|
||||
}
|
||||
|
||||
$returnAffordance = $this->returnAffordance($request);
|
||||
|
||||
@ -249,12 +252,8 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
|
||||
state: 'missing_tenant',
|
||||
displayMode: 'recovery',
|
||||
workspaceSource: $workspaceSource,
|
||||
recoveryAction: $pageCategory === AdminSurfaceScope::EnvironmentScopedEvidence
|
||||
? 'redirect_evidence_overview'
|
||||
: 'abort_not_found',
|
||||
recoveryDestination: $pageCategory === AdminSurfaceScope::EnvironmentScopedEvidence
|
||||
? '/admin/evidence/overview'
|
||||
: null,
|
||||
recoveryAction: 'abort_not_found',
|
||||
recoveryDestination: null,
|
||||
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
||||
);
|
||||
}
|
||||
@ -320,7 +319,6 @@ private function resolveWorkspaceForPageCategory(
|
||||
?Request $request = null,
|
||||
): ?Workspace {
|
||||
return match ($pageCategory) {
|
||||
AdminSurfaceScope::EnvironmentScopedEvidence => $this->workspaceContext->currentWorkspace($request),
|
||||
AdminSurfaceScope::EnvironmentBound => $this->workspaceContext->currentWorkspaceOrEnvironmentWorkspace($tenant, $request),
|
||||
default => $this->workspaceContext->currentWorkspaceOrEnvironmentWorkspace($tenant, $request),
|
||||
};
|
||||
|
||||
@ -116,8 +116,8 @@ public static function index(
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($operationType) && $operationType !== '') {
|
||||
$parameters['tableFilters']['type']['value'] = OperationCatalog::canonicalCode($operationType);
|
||||
if (is_string($operationType) && trim($operationType) !== '') {
|
||||
$parameters['operation_type'] = OperationCatalog::canonicalCode($operationType);
|
||||
}
|
||||
|
||||
return route('admin.operations.index', $parameters);
|
||||
|
||||
@ -17,8 +17,7 @@ public static function fromSurfaceScope(AdminSurfaceScope $pageCategory): self
|
||||
{
|
||||
return match ($pageCategory) {
|
||||
AdminSurfaceScope::OnboardingWorkflow => self::OnboardingWorkflow,
|
||||
AdminSurfaceScope::EnvironmentBound,
|
||||
AdminSurfaceScope::EnvironmentScopedEvidence => self::AdministrativeManagement,
|
||||
AdminSurfaceScope::EnvironmentBound => self::AdministrativeManagement,
|
||||
AdminSurfaceScope::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
|
||||
AdminSurfaceScope::WorkspaceWideSurface,
|
||||
AdminSurfaceScope::WorkspaceOwnedAnalysisSurface,
|
||||
|
||||
@ -25,6 +25,13 @@
|
||||
'save_preference' => 'Einstellung speichern',
|
||||
'inherit_workspace' => 'Workspace-Standard verwenden',
|
||||
'workspace' => 'Workspace',
|
||||
'workspace_scope' => 'Workspace-Kontext',
|
||||
'workspace_scope_short' => 'Workspace',
|
||||
'environment_scope_short' => 'Umgebung',
|
||||
'workspace_context_label' => 'Workspace: :workspace',
|
||||
'workspace_scope_no_environment' => 'Keine Umgebung ausgewählt',
|
||||
'workspace_wide_scope' => 'Workspace-weit',
|
||||
'scope_indicator_action' => ':scope öffnen',
|
||||
'choose_workspace' => 'Workspace auswählen',
|
||||
'switch_workspace' => 'Workspace wechseln',
|
||||
'workspace_home' => 'Workspace-Start',
|
||||
@ -94,6 +101,8 @@
|
||||
'alerts' => 'Alerts',
|
||||
'governance' => 'Governance',
|
||||
'monitoring' => 'Monitoring',
|
||||
'workspace_wide' => 'Workspace-weit',
|
||||
'workspace_admin' => 'Workspace-Administration',
|
||||
'dashboard' => 'Dashboard',
|
||||
],
|
||||
'dashboard' => [
|
||||
|
||||
@ -25,6 +25,13 @@
|
||||
'save_preference' => 'Save preference',
|
||||
'inherit_workspace' => 'Use workspace default',
|
||||
'workspace' => 'Workspace',
|
||||
'workspace_scope' => 'Workspace scope',
|
||||
'workspace_scope_short' => 'Workspace',
|
||||
'environment_scope_short' => 'Environment',
|
||||
'workspace_context_label' => 'Workspace: :workspace',
|
||||
'workspace_scope_no_environment' => 'No environment selected',
|
||||
'workspace_wide_scope' => 'Workspace-wide',
|
||||
'scope_indicator_action' => 'Open :scope',
|
||||
'choose_workspace' => 'Choose workspace',
|
||||
'switch_workspace' => 'Switch workspace',
|
||||
'workspace_home' => 'Workspace Home',
|
||||
@ -94,6 +101,8 @@
|
||||
'alerts' => 'Alerts',
|
||||
'governance' => 'Governance',
|
||||
'monitoring' => 'Monitoring',
|
||||
'workspace_wide' => 'Workspace-wide',
|
||||
'workspace_admin' => 'Workspace admin',
|
||||
'dashboard' => 'Dashboard',
|
||||
],
|
||||
'dashboard' => [
|
||||
|
||||
@ -4,6 +4,24 @@
|
||||
$stepTestId = $stepTestId ?? 'product-process-flow-step';
|
||||
$connectorTestId = $connectorTestId ?? 'product-process-flow-connector';
|
||||
$badgeTestId = $badgeTestId ?? 'product-process-flow-badge';
|
||||
$layoutMode = $layoutMode ?? 'viewport';
|
||||
$usesContainerLayout = $layoutMode === 'container';
|
||||
$flowClasses = $usesContainerLayout ? '@container space-y-5' : 'space-y-5';
|
||||
$listClasses = $usesContainerLayout
|
||||
? 'flex flex-col gap-3 @5xl:flex-row @5xl:items-stretch @5xl:gap-1.5'
|
||||
: 'flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-1.5';
|
||||
$stepItemClasses = $usesContainerLayout
|
||||
? 'flex min-w-0 flex-1 flex-col gap-2 @5xl:flex-row @5xl:items-stretch'
|
||||
: 'flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch';
|
||||
$stepHeaderClasses = $usesContainerLayout
|
||||
? 'flex min-w-0 items-start gap-3 @5xl:flex-col @5xl:gap-2'
|
||||
: 'flex min-w-0 items-start gap-3 lg:flex-col lg:gap-2';
|
||||
$connectorClasses = $usesContainerLayout
|
||||
? 'flex shrink-0 items-center justify-center py-0.5 text-gray-400 dark:text-gray-500 @5xl:w-6 @5xl:py-0'
|
||||
: 'flex shrink-0 items-center justify-center py-0.5 text-gray-400 dark:text-gray-500 lg:w-6 lg:py-0';
|
||||
$connectorIconClasses = $usesContainerLayout
|
||||
? 'inline-flex h-7 min-w-7 rotate-90 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900 @5xl:rotate-0'
|
||||
: 'inline-flex h-7 min-w-7 rotate-90 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900 lg:rotate-0';
|
||||
$statusBadgeClasses = $statusBadgeClasses ?? static fn (?string $tone): string => 'inline-flex items-center rounded-md border px-2 py-0.5 text-left text-xs font-medium leading-5 whitespace-normal break-words '.match ($tone) {
|
||||
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-800 dark:bg-danger-500/10 dark:text-danger-300',
|
||||
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-800 dark:bg-success-500/10 dark:text-success-300',
|
||||
@ -14,7 +32,7 @@
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div data-testid="{{ $flowTestId }}" class="space-y-5">
|
||||
<div data-testid="{{ $flowTestId }}" class="{{ $flowClasses }}">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $title ?? 'Product process flow' }}
|
||||
@ -26,7 +44,7 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ol class="flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-1.5" aria-label="{{ $ariaLabel ?? 'Product process flow' }}">
|
||||
<ol class="{{ $listClasses }}" aria-label="{{ $ariaLabel ?? 'Product process flow' }}">
|
||||
@foreach ($steps as $step)
|
||||
@php
|
||||
$isCurrentBlocker = (bool) ($step['currentBlocker'] ?? false);
|
||||
@ -43,16 +61,16 @@
|
||||
data-step-label="{{ $step['label'] }}"
|
||||
data-step-state="{{ $step['state'] }}"
|
||||
data-step-current-blocker="{{ $isCurrentBlocker ? 'true' : 'false' }}"
|
||||
class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
|
||||
class="{{ $stepItemClasses }}"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col rounded-lg border px-3 py-2.5 {{ $stepCardClasses }}">
|
||||
<div class="flex min-w-0 items-start gap-3 lg:flex-col lg:gap-2">
|
||||
<div class="{{ $stepHeaderClasses }}">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none {{ $stepNumberClasses }}">
|
||||
{{ $loop->iteration }}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold leading-5 text-gray-950 break-normal dark:text-white">
|
||||
<div class="break-words text-sm font-semibold leading-5 text-gray-950 dark:text-white">
|
||||
{{ $step['label'] }}
|
||||
</div>
|
||||
|
||||
@ -64,7 +82,7 @@ class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
<p class="mt-2 break-words text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ $step['description'] }}
|
||||
</p>
|
||||
</div>
|
||||
@ -73,10 +91,10 @@ class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
|
||||
<div
|
||||
data-testid="{{ $connectorTestId }}"
|
||||
data-connector-label="{{ $step['label'] }} to {{ $steps[$loop->index + 1]['label'] ?? '' }}"
|
||||
class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-500 lg:w-6"
|
||||
class="{{ $connectorClasses }}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<span class="{{ $connectorIconClasses }}">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -49,8 +49,8 @@
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]" data-testid="evidence-disclosure-workbench">
|
||||
<main class="min-w-0 space-y-4" data-testid="evidence-proof-primary">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
|
||||
Primary proof path
|
||||
@ -60,6 +60,32 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if ($decisionCard['actionUrl'])
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="$decisionCard['actionUrl']"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
class="shrink-0 self-start whitespace-nowrap"
|
||||
data-testid="evidence-primary-proof-action"
|
||||
>
|
||||
{{ $decisionCard['actionLabel'] }}
|
||||
</x-filament::button>
|
||||
@else
|
||||
<div class="shrink-0 self-start sm:text-right">
|
||||
<x-filament::button color="gray" disabled data-testid="evidence-primary-proof-action">
|
||||
{{ $decisionCard['actionLabel'] }}
|
||||
</x-filament::button>
|
||||
|
||||
@if ($decisionCard['helperText'])
|
||||
<p class="mt-2 max-w-64 text-xs leading-5 text-gray-500 dark:text-gray-400 sm:ml-auto">
|
||||
{{ $decisionCard['helperText'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||
{{ $payload['primary_title'] }}
|
||||
@ -134,78 +160,8 @@
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if ($decisionCard['actionUrl'])
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="$decisionCard['actionUrl']"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
class="shrink-0 self-start whitespace-nowrap"
|
||||
data-testid="evidence-primary-proof-action"
|
||||
>
|
||||
{{ $decisionCard['actionLabel'] }}
|
||||
</x-filament::button>
|
||||
@else
|
||||
<div class="shrink-0 self-start">
|
||||
<x-filament::button color="gray" disabled data-testid="evidence-primary-proof-action">
|
||||
{{ $decisionCard['actionLabel'] }}
|
||||
</x-filament::button>
|
||||
|
||||
@if ($decisionCard['helperText'])
|
||||
<p class="mt-2 max-w-64 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $decisionCard['helperText'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! empty($payload['readiness_flow']))
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
@include('filament.components.product-process-flow-horizontal', [
|
||||
'title' => 'Evidence readiness flow',
|
||||
'subtitle' => 'Customer-safe evidence requires source data, evidence snapshot, stored report, review pack, and export readiness.',
|
||||
'ariaLabel' => 'Evidence readiness pipeline',
|
||||
'steps' => $payload['readiness_flow'],
|
||||
'flowTestId' => 'evidence-readiness-flow',
|
||||
'stepTestId' => 'evidence-readiness-step',
|
||||
'connectorTestId' => 'evidence-readiness-connector',
|
||||
'badgeTestId' => 'evidence-review-pack-status-badge',
|
||||
'statusBadgeClasses' => $statusBadgeClasses,
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900" data-testid="evidence-review-pack-coverage">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
Review pack contents / coverage
|
||||
</h3>
|
||||
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
Repo-backed values only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $payload['review_pack_coverage']['description'] }}
|
||||
</p>
|
||||
|
||||
@if (! empty($payload['review_pack_coverage']['items']))
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($payload['review_pack_coverage']['items'] as $coverageItem)
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{{ $coverageItem['label'] }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $coverageItem['value'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="min-w-0 space-y-4" data-testid="evidence-proof-aside">
|
||||
@ -261,6 +217,53 @@ class="rounded-xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:bor
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if (! empty($payload['readiness_flow']))
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
@include('filament.components.product-process-flow-horizontal', [
|
||||
'title' => 'Evidence readiness flow',
|
||||
'subtitle' => 'Customer-safe evidence requires source data, evidence snapshot, stored report, review pack, and export readiness.',
|
||||
'ariaLabel' => 'Evidence readiness pipeline',
|
||||
'steps' => $payload['readiness_flow'],
|
||||
'flowTestId' => 'evidence-readiness-flow',
|
||||
'stepTestId' => 'evidence-readiness-step',
|
||||
'connectorTestId' => 'evidence-readiness-connector',
|
||||
'badgeTestId' => 'evidence-review-pack-status-badge',
|
||||
'statusBadgeClasses' => $statusBadgeClasses,
|
||||
'layoutMode' => 'container',
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900" data-testid="evidence-review-pack-coverage">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
Review pack contents / coverage
|
||||
</h3>
|
||||
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
Repo-backed values only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $payload['review_pack_coverage']['description'] }}
|
||||
</p>
|
||||
|
||||
@if (! empty($payload['review_pack_coverage']['items']))
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($payload['review_pack_coverage']['items'] as $coverageItem)
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{{ $coverageItem['label'] }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $coverageItem['value'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
|
||||
@ -22,9 +22,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ $landingHierarchy['scope_label'] }}
|
||||
</span>
|
||||
@if ($landingHierarchy['scope_label'] !== __('localization.shell.all_environments'))
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ $landingHierarchy['scope_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ $landingHierarchy['scope_body'] }}
|
||||
</span>
|
||||
|
||||
@ -88,7 +88,9 @@
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
|
||||
@if ($monitoringDetail['scope_label'] !== __('localization.shell.all_environments'))
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
|
||||
@endif
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -60,15 +60,6 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
size="sm"
|
||||
wire:click="goToChooseEnvironment"
|
||||
icon="heroicon-m-arrow-right"
|
||||
icon-position="after"
|
||||
>
|
||||
{{ __('localization.shell.choose_environment') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
{{-- ManagedEnvironment cards --}}
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$environmentLabel = $currentEnvironmentName ?? __('localization.shell.no_environment_selected');
|
||||
$environmentLabel = $currentEnvironmentName;
|
||||
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
|
||||
$hasActiveEnvironment = $currentEnvironmentName !== null;
|
||||
$managedEnvironmentsUrl = $workspace
|
||||
@ -47,6 +47,9 @@
|
||||
? 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
|
||||
|
||||
@ -61,21 +64,25 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
|
||||
{{ $workspaceLabel }}
|
||||
</a>
|
||||
|
||||
@if ($workspace)
|
||||
@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 + chevron --}}
|
||||
{{-- 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="{{ $workspace ? __('localization.shell.environment_scope') : __('localization.shell.select_environment') }}"
|
||||
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"
|
||||
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' : '' }}"
|
||||
>
|
||||
<span class="{{ $workspace && $hasActiveEnvironment ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $environmentTriggerLabel }}
|
||||
</span>
|
||||
@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>
|
||||
@ -131,7 +138,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
||||
<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">
|
||||
{{ __('localization.shell.selected_environment') }}
|
||||
{{ $hasActiveEnvironment ? __('localization.shell.selected_environment') : __('localization.shell.choose_environment') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -168,12 +175,6 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
@if (! $hasActiveEnvironment)
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
|
||||
{{ __('localization.shell.workspace_wide_available_without_environment') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="fi-input fi-text-input w-full"
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
@php
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
$resolvedContext = app(OperateHubShell::class)->resolvedContext(request());
|
||||
$workspace = $resolvedContext->workspace;
|
||||
$environment = $resolvedContext->tenant;
|
||||
$isEnvironmentScope = $resolvedContext->pageCategory->requiresExplicitEnvironment()
|
||||
&& $environment instanceof ManagedEnvironment;
|
||||
|
||||
$environmentDisplayName = static function (ManagedEnvironment $environment): string {
|
||||
$displayName = trim((string) ($environment->display_name ?: $environment->name ?: $environment->external_id ?: ''));
|
||||
|
||||
return $displayName !== '' ? $displayName : 'Environment #'.$environment->getKey();
|
||||
};
|
||||
|
||||
$scopeUrl = $isEnvironmentScope
|
||||
? ManagedEnvironmentLinks::viewUrl($environment)
|
||||
: route('admin.home');
|
||||
|
||||
$scopeKind = $isEnvironmentScope
|
||||
? __('localization.shell.environment_scope_short')
|
||||
: __('localization.shell.workspace_scope_short');
|
||||
|
||||
$scopeName = $isEnvironmentScope ? $environmentDisplayName($environment) : $workspace?->name;
|
||||
|
||||
$workspaceEnvironmentCount = 0;
|
||||
$sidebarUser = auth()->user();
|
||||
if ($sidebarUser instanceof User && $workspace) {
|
||||
$workspaceEnvironmentCount = collect($sidebarUser->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(fn ($candidate): bool => $candidate instanceof ManagedEnvironment && (int) $candidate->workspace_id === (int) $workspace->getKey())
|
||||
->count();
|
||||
}
|
||||
|
||||
$scopeDescription = $isEnvironmentScope
|
||||
? __('localization.shell.workspace_context_label', ['workspace' => $workspace?->name])
|
||||
: trans_choice('localization.shell.environment_count', $workspaceEnvironmentCount, ['count' => $workspaceEnvironmentCount]);
|
||||
|
||||
$scopeActionLabel = __('localization.shell.scope_indicator_action', ['scope' => $scopeName]);
|
||||
@endphp
|
||||
|
||||
@if ($workspace)
|
||||
<a
|
||||
href="{{ $scopeUrl }}"
|
||||
wire:navigate
|
||||
data-testid="admin-sidebar-scope-indicator"
|
||||
aria-label="{{ $scopeKind }}: {{ $scopeName }}"
|
||||
title="{{ $scopeActionLabel }}"
|
||||
class="group mx-3 mb-4 flex min-w-0 items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-start shadow-xs transition hover:border-gray-300 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
|
||||
>
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border {{ $isEnvironmentScope ? 'border-primary-200 bg-primary-50 text-primary-600 dark:border-primary-800 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' }}">
|
||||
<x-filament::icon
|
||||
:icon="$isEnvironmentScope ? 'heroicon-o-building-office-2' : 'heroicon-o-squares-2x2'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[0.6875rem] font-semibold uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ $scopeKind }}
|
||||
</span>
|
||||
|
||||
<span class="mt-0.5 block truncate text-sm font-semibold text-gray-950 dark:text-white" title="{{ $scopeName }}">
|
||||
{{ $scopeName }}
|
||||
</span>
|
||||
|
||||
<span class="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $scopeDescription }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-chevron-right"
|
||||
class="h-4 w-4 shrink-0 text-gray-300 transition group-hover:text-gray-400 dark:text-gray-600 dark:group-hover:text-gray-400"
|
||||
/>
|
||||
</a>
|
||||
@endif
|
||||
@ -68,7 +68,7 @@
|
||||
|
||||
$landing = visit(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]))
|
||||
->waitForText('Managed environments')
|
||||
->assertSee('Choose environment')
|
||||
->assertDontSee('Choose environment')
|
||||
->assertSee('Spec 286 Production')
|
||||
->assertSee('Spec 286 Secondary')
|
||||
->assertDontSee('Managed tenants')
|
||||
@ -92,4 +92,4 @@
|
||||
->assertSee('Source: Microsoft Intune')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
});
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
$expectedPath = json_encode((string) parse_url($url, PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
||||
|
||||
$page
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Spec314 Browser Environment')
|
||||
->assertScript("window.location.pathname === {$expectedPath}", true)
|
||||
->assertScript('! window.location.search.includes("tenant=")', true)
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page->waitForText(__('localization.shell.no_environment_selected'));
|
||||
$page->waitForText('Search');
|
||||
$assertCleanWorkspaceHub($page, $url);
|
||||
}
|
||||
|
||||
@ -96,15 +96,15 @@
|
||||
$encodedUrl = json_encode($url, JSON_THROW_ON_ERROR);
|
||||
|
||||
$historyPage->script("window.location.assign({$encodedUrl});");
|
||||
$historyPage->waitForText(__('localization.shell.no_environment_selected'));
|
||||
$historyPage->waitForText('Search');
|
||||
$assertCleanWorkspaceHub($historyPage, $url);
|
||||
}
|
||||
|
||||
$historyPage->script('window.history.back();');
|
||||
$historyPage->waitForText(__('localization.shell.no_environment_selected'));
|
||||
$historyPage->waitForText('Search');
|
||||
$assertCleanWorkspaceHub($historyPage, $hubUrls[3]);
|
||||
|
||||
$historyPage->script('window.history.forward();');
|
||||
$historyPage->waitForText(__('localization.shell.no_environment_selected'));
|
||||
$historyPage->waitForText('Search');
|
||||
$assertCleanWorkspaceHub($historyPage, $hubUrls[4]);
|
||||
});
|
||||
|
||||
@ -107,7 +107,7 @@
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
spec316BrowserClearEnvironmentFilter($page)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($hub['wide_text'])
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($hub['wide_text'])
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -121,7 +121,7 @@
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($hub['wide_text'])
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($hub['wide_text'])
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -160,7 +160,7 @@
|
||||
->assertDontSee($environmentB->name);
|
||||
|
||||
spec316BrowserClearEnvironmentFilter($page)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($environmentB->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name);
|
||||
|
||||
@ -175,7 +175,7 @@
|
||||
$page->script('window.history.forward();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($environmentB->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript('! window.location.search.includes("environment_id=")', true)
|
||||
@ -233,7 +233,7 @@
|
||||
]);
|
||||
|
||||
visit($case['url'])
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($case['wide_text'])
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($case['wide_text'])
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
|
||||
foreach ($configurationUrls as $url) {
|
||||
visit($url)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText('Alerts')
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
@ -114,7 +114,7 @@
|
||||
|
||||
foreach ($legacyUrls as $url) {
|
||||
visit($url)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($fixture['environmentB']->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($fixture['environmentB']->name)
|
||||
->assertNoJavaScriptErrors()
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
visit(CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
||||
->waitForText('Customer-safe review packages')
|
||||
->assertSee('No environment selected')
|
||||
->assertDontSee('No environment selected')
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Is this review ready to share?')
|
||||
->assertSee('Evidence path')
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
$page
|
||||
->click('[data-testid="workspace-hub-environment-filter-clear"]')
|
||||
->waitForText('No environment selected')
|
||||
->waitForText($environmentB->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -98,7 +98,7 @@
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText('No environment selected')
|
||||
->waitForText($environmentB->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Governance Inbox')
|
||||
->assertSee('Prioritized governance decisions, owners, evidence, and follow-up actions across entitled environments.')
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('What decision clears the highest-priority item?')
|
||||
->assertSee('Decision workbench')
|
||||
@ -103,7 +103,7 @@
|
||||
|
||||
$page
|
||||
->click('[data-testid="workspace-hub-environment-filter-clear"]')
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($environmentB->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -117,7 +117,7 @@
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($environmentB->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
$page = visit(OperationRunLinks::index(workspace: $environmentA->workspace))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Operations Hub')
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Execution follow-up workbench')
|
||||
->assertSee('Which operation needs attention now?')
|
||||
@ -157,7 +157,7 @@
|
||||
spec328CopyBrowserScreenshot('operations-hub--filtered');
|
||||
|
||||
spec328ClearEnvironmentFilter($page)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText('Policy sync')
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Policy sync')
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -171,7 +171,7 @@
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText('Policy sync')
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Policy sync')
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
$page = visit(route('admin.evidence.overview'))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('What proof is available for this scope?')
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Evidence proof workbench')
|
||||
->assertSee('Primary proof path')
|
||||
@ -138,7 +138,7 @@
|
||||
spec329CopyDisclosureScreenshot('evidence-overview--filtered');
|
||||
|
||||
spec329ClearDisclosureEnvironmentFilter($page)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($fixture['environmentB']->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->waitForText($fixture['environmentB']->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -152,7 +152,7 @@
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText($fixture['environmentB']->name)
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($fixture['environmentB']->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -175,7 +175,7 @@
|
||||
]))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Which event proves what happened?')
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Audit proof workbench')
|
||||
->assertSee('Selected event proof')
|
||||
@ -248,7 +248,7 @@
|
||||
spec329CopyDisclosureScreenshot('audit-log--filtered');
|
||||
|
||||
spec329ClearDisclosureEnvironmentFilter($page)
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText('Workspace selected by browser proof B')
|
||||
->assertDontSee('Environment filter:')
|
||||
->waitForText('Workspace selected by browser proof B')
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
@ -262,7 +262,7 @@
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->waitForText('Workspace selected by browser proof B')
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Workspace selected by browser proof B')
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
|
||||
@ -129,12 +129,14 @@ function spec337BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnviro
|
||||
$page = visit(route('admin.evidence.overview', [
|
||||
'environment_id' => (int) $missingEnvironment->getKey(),
|
||||
]))
|
||||
->resize(1440, 1100)
|
||||
->resize(1236, 862)
|
||||
->waitForText('Evidence snapshot required')
|
||||
->assertSee('Is this evidence package ready for customer or auditor consumption?')
|
||||
->assertSee('Generate evidence snapshot')
|
||||
->assertSee('Evidence readiness flow')
|
||||
->assertScript('document.querySelectorAll("[data-testid=\"evidence-readiness-step\"]").length === 6', true)
|
||||
->assertScript('(() => { const list = document.querySelector("[data-testid=\"evidence-readiness-flow\"] ol"); return list !== null && getComputedStyle(list).flexDirection === "column"; })()', true)
|
||||
->assertScript('(() => { const steps = Array.from(document.querySelectorAll("[data-testid=\"evidence-readiness-step\"]")); return steps.length === 6 && steps.every((step) => step.getBoundingClientRect().width >= 300); })()', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepState === "Missing"', true)
|
||||
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepCurrentBlocker === "true"', true)
|
||||
->assertScript('document.querySelector("[data-testid=\"evidence-disclosure-diagnostics\"]")?.open === false', true)
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use Tests\Browser\Support\Spec322WorkspaceEnvironmentBrowserHarness as Spec322Harness;
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
it('Spec338 smokes environment origin operations link contract uses environment_id and avoids Filament internals', function (): void {
|
||||
$fixture = Spec322Harness::fixture();
|
||||
|
||||
Spec322Harness::authenticate($this, $fixture['user'], $fixture['workspace'], $fixture['environmentA']);
|
||||
|
||||
$page = visit(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $fixture['environmentA']))
|
||||
->waitForText($fixture['environmentA']->name)
|
||||
->assertScript('Array.from(document.querySelectorAll("a[href*=\"/operations\"]")).some((element) => element.href.includes("environment_id=") && !element.href.includes("tableFilters"))', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
$page->script('(() => {
|
||||
const link = Array.from(document.querySelectorAll("a[href*=\\"/operations\\"]"))
|
||||
.find((element) => element.href.includes("environment_id=") && ! element.href.includes("tableFilters"));
|
||||
|
||||
if (! link) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(link.href);
|
||||
})()');
|
||||
|
||||
Spec322Harness::assertFilteredWorkspaceHub($page, $fixture['environmentA']);
|
||||
});
|
||||
|
||||
it('Spec338 smokes clearing environment context from environment evidence surface redirects to Evidence Overview hub', function (): void {
|
||||
$fixture = Spec322Harness::fixture();
|
||||
|
||||
Spec322Harness::authenticate($this, $fixture['user'], $fixture['workspace'], $fixture['environmentA']);
|
||||
|
||||
$overviewPath = json_encode((string) parse_url(route('admin.evidence.overview'), PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
||||
|
||||
$page = visit(EvidenceSnapshotResource::getUrl('index', tenant: $fixture['environmentA'], panel: 'admin'))
|
||||
->waitForText('Evidence')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
$page
|
||||
->assertScript('document.querySelector(\'form[action*="/admin/clear-environment-context"]\') instanceof HTMLFormElement', true)
|
||||
->script('document.querySelector(\'form[action*="/admin/clear-environment-context"]\').submit();');
|
||||
|
||||
$page
|
||||
->waitForText('Evidence Overview')
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertScript("window.location.pathname === {$overviewPath}", true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
@ -159,8 +159,14 @@ public static function authenticate(object $testCase, User $user, Workspace $wor
|
||||
|
||||
public static function assertWorkspaceOnly(mixed $page, ?string $wideText = null, ?string $environmentName = null): mixed
|
||||
{
|
||||
if ($wideText !== null) {
|
||||
$page->waitForText($wideText);
|
||||
} else {
|
||||
$page->assertScript('document.querySelector("nav.fi-topbar") instanceof HTMLElement', true);
|
||||
}
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
@ -215,7 +221,7 @@ public static function clearWorkspaceHubEnvironmentFilter(mixed $page): mixed
|
||||
$page->assertScript('document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\') instanceof HTMLAnchorElement', true);
|
||||
$page->script('window.location.assign(document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\').href);');
|
||||
|
||||
return $page->waitForText(__('localization.shell.no_environment_selected'));
|
||||
return $page;
|
||||
}
|
||||
|
||||
private static function findingException(
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
->assertDontSee('Clear tenant scope');
|
||||
});
|
||||
|
||||
it('renders all-environments shell wording on tenantless monitoring pages', function (): void {
|
||||
it('does not render generic all-environments shell wording on tenantless monitoring pages', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
@ -30,6 +30,6 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||
->get(route('admin.operations.index', ['workspace' => $environment->workspace]))
|
||||
->assertOk()
|
||||
->assertSee('All environments')
|
||||
->assertDontSee('All environments')
|
||||
->assertDontSee('All tenants');
|
||||
});
|
||||
|
||||
@ -244,6 +244,26 @@ function seedWorkspaceSidebarVisibleDecision(ManagedEnvironment $tenant, User $a
|
||||
->and(NavigationScope::isWorkspaceSurface())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the evidence overview navigation link explicitly filtered on environment surfaces', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get(ManagedEnvironmentLinks::viewUrl($tenant))
|
||||
->assertOk();
|
||||
|
||||
preg_match_all('#/admin/evidence/overview[^"\']*#', $response->getContent(), $matches);
|
||||
|
||||
expect($matches[0] ?? [])->toContain('/admin/evidence/overview?environment_id='.(int) $tenant->getKey());
|
||||
});
|
||||
|
||||
it('registers environment-owned surfaces only on environment surfaces', function (string $class): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
spec337AssertFlowStep($content, 'Export / delivery', 'Unavailable', false);
|
||||
|
||||
expect(substr_count($content, 'data-testid="evidence-readiness-step"'))->toBe(6)
|
||||
->and($content)->toContain('data-testid="evidence-readiness-flow" class="@container')
|
||||
->and($content)->not->toContain('data-testid="evidence-disclosure-diagnostics" open');
|
||||
});
|
||||
|
||||
|
||||
@ -35,7 +35,8 @@
|
||||
])
|
||||
->get(route('admin.operations.index', ['workspace' => $validTenant->workspace, 'tenant' => $foreignTenant->external_id]))
|
||||
->assertOk()
|
||||
->assertSee('Context unavailable')
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee('Context unavailable')
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$foreignTenant->name);
|
||||
});
|
||||
|
||||
@ -95,8 +95,9 @@
|
||||
])
|
||||
->get(route('admin.operations.index', ['workspace' => $workspaceId, 'environment_id' => (int) $hintedTenant->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Hinted Topbar ManagedEnvironment')
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Remembered Topbar ManagedEnvironment');
|
||||
});
|
||||
|
||||
@ -1707,7 +1707,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('operate_hub_scope_run_detail')
|
||||
->assertActionDoesNotExist('operate_hub_scope_run_detail')
|
||||
->assertActionVisible('operate_hub_back_to_operations')
|
||||
->assertActionVisible('refresh');
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
->get(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Managed environments')
|
||||
->assertSee('Choose environment')
|
||||
->assertDontSee('Choose environment')
|
||||
->assertSee('Add environment')
|
||||
->assertDontSee('Managed tenants')
|
||||
->assertDontSee('Choose tenant')
|
||||
|
||||
@ -72,7 +72,8 @@ function spec314FindingException(ManagedEnvironment $environment, User $user, st
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Queue Environment A');
|
||||
});
|
||||
|
||||
@ -101,7 +102,8 @@ function spec314FindingException(ManagedEnvironment $environment, User $user, st
|
||||
|
||||
$this->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'));
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'));
|
||||
|
||||
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
|
||||
|
||||
|
||||
@ -293,7 +293,7 @@
|
||||
])
|
||||
->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertSee('Canonical workspace view')
|
||||
->assertSee('No environment context is currently selected.');
|
||||
});
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
->assertOk()
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name);
|
||||
});
|
||||
|
||||
@ -105,7 +105,7 @@
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name);
|
||||
});
|
||||
|
||||
|
||||
@ -31,8 +31,9 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get($url)
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.all_environments'));
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.all_environments'));
|
||||
});
|
||||
|
||||
it('Spec314 operations clean workspace entry sees runs across entitled environments', function (): void {
|
||||
|
||||
@ -162,7 +162,8 @@ function spec321QueryKeys(string $url): array
|
||||
->assertOk()
|
||||
->assertSee('Environment filter:')
|
||||
->assertSee('Spec321 Environment A')
|
||||
->assertSee('Clear filter');
|
||||
->assertSee('Clear filter')
|
||||
->assertDontSee(__('localization.shell.all_environments'));
|
||||
|
||||
$values = spec321AlertsKpiValues(
|
||||
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
|
||||
@ -198,6 +199,7 @@ function spec321QueryKeys(string $url): array
|
||||
->assertSee('Environment filter:')
|
||||
->assertSee('Spec321 Environment A')
|
||||
->assertSee('Clear filter')
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
|
||||
->assertCanSeeTableRecords([$records['deliveryA']])
|
||||
->assertCanNotSeeTableRecords([$records['deliveryB']]);
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, group: string|null, url: string|null, parent: string|null}>
|
||||
*/
|
||||
function spec338EnvironmentNavigationRows(array $items): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! $item instanceof NavigationItem || ! $item->isVisible()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $item->getGroup();
|
||||
|
||||
$rows[] = [
|
||||
'label' => $item->getLabel(),
|
||||
'group' => $group instanceof UnitEnum ? $group->name : (is_string($group) ? $group : null),
|
||||
'url' => $item->getUrl(),
|
||||
'parent' => $item->getParentItem(),
|
||||
];
|
||||
|
||||
$childItems = $item->getChildItems();
|
||||
|
||||
if ($childItems instanceof Traversable) {
|
||||
$childItems = iterator_to_array($childItems);
|
||||
}
|
||||
|
||||
if (is_array($childItems) && $childItems !== []) {
|
||||
$rows = [
|
||||
...$rows,
|
||||
...spec338EnvironmentNavigationRows($childItems),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function spec338FindNavigationRow(string $label, ?string $group = null): ?array
|
||||
{
|
||||
foreach (spec338EnvironmentNavigationRows(Filament::getCurrentOrDefaultPanel()->getNavigationItems()) as $row) {
|
||||
if ($row['label'] !== $label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($group !== null && $row['group'] !== $group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('separates workspace-wide and workspace-admin links from environment navigation', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 IA Environment']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$workspace = $environment->workspace()->firstOrFail();
|
||||
|
||||
$workspaceWideGroup = __('localization.navigation.workspace_wide');
|
||||
$workspaceAdminGroup = __('localization.navigation.workspace_admin');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee($workspaceWideGroup)
|
||||
->assertSee($workspaceAdminGroup);
|
||||
|
||||
$overview = spec338FindNavigationRow('Overview');
|
||||
$operations = spec338FindNavigationRow(__('localization.navigation.operations'), $workspaceWideGroup);
|
||||
$manageWorkspaces = spec338FindNavigationRow(__('localization.navigation.manage_workspaces'), $workspaceAdminGroup);
|
||||
$integrations = spec338FindNavigationRow(__('localization.navigation.integrations'), $workspaceAdminGroup);
|
||||
|
||||
expect($overview)->not->toBeNull()
|
||||
->and($overview['url'])->toBe(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment))
|
||||
->and($operations)->not->toBeNull()
|
||||
->and($operations['url'])->toContain('environment_id='.(int) $environment->getKey())
|
||||
->and($operations['url'])->not->toContain('tableFilters')
|
||||
->and($operations['url'])->not->toContain('managed_environment_id=')
|
||||
->and($manageWorkspaces)->not->toBeNull()
|
||||
->and($integrations)->not->toBeNull()
|
||||
->and($integrations['url'])->toContain('environment_id='.(int) $environment->getKey());
|
||||
});
|
||||
|
||||
it('keeps workspace-owned sidebar groups normal on workspace pages', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 Workspace IA Environment']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$workspace = $environment->workspace()->firstOrFail();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.workspace.home', ['workspace' => $workspace]))
|
||||
->assertOk();
|
||||
|
||||
$operations = spec338FindNavigationRow(__('localization.navigation.operations'));
|
||||
|
||||
expect($operations)->not->toBeNull()
|
||||
->and($operations['url'])->not->toContain('environment_id=')
|
||||
->and($operations['url'])->not->toContain('tableFilters');
|
||||
});
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('Spec338 OperationRunLinks operation type deep links use operation_type and never tableFilters', function (): void {
|
||||
[$workspace] = localizationWorkspaceMember();
|
||||
|
||||
$url = OperationRunLinks::index(operationType: 'inventory_sync', workspace: $workspace);
|
||||
|
||||
expect($url)
|
||||
->toContain('/admin/workspaces/'.$workspace->getRouteKey().'/operations')
|
||||
->toContain('operation_type=inventory.sync')
|
||||
->not->toContain('tableFilters')
|
||||
->not->toContain('inventory_sync');
|
||||
});
|
||||
|
||||
it('Spec338 operations hub maps operation_type query into the type table filter', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec338 Environment A',
|
||||
'external_id' => 'spec338-environment-a',
|
||||
]);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
|
||||
$workspace = $environment->workspace()->firstOrFail();
|
||||
|
||||
$runInventory = OperationRun::factory()
|
||||
->forTenant($environment)
|
||||
->create(['type' => 'inventory_sync']);
|
||||
|
||||
$runPolicy = OperationRun::factory()
|
||||
->forTenant($environment)
|
||||
->create(['type' => 'policy.sync']);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
Livewire::withQueryParams(['operation_type' => 'inventory.sync'])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSet('tableFilters.type.value', 'inventory.sync')
|
||||
->assertCanSeeTableRecords([$runInventory])
|
||||
->assertCanNotSeeTableRecords([$runPolicy]);
|
||||
});
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows an explicit workspace scope indicator in the sidebar on workspace-owned pages', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 Sidebar Workspace Environment']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$workspace = $environment->workspace()->firstOrFail();
|
||||
|
||||
ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec338 Sidebar Inaccessible Environment',
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$accessibleEnvironmentCount = 1;
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.workspace.home', ['workspace' => $workspace]))
|
||||
->assertOk()
|
||||
->assertSee('data-testid="admin-sidebar-scope-indicator"', false)
|
||||
->assertSee(__('localization.shell.workspace_scope_short'))
|
||||
->assertSee($workspace->name)
|
||||
->assertSee(trans_choice('localization.shell.environment_count', $accessibleEnvironmentCount, ['count' => $accessibleEnvironmentCount]))
|
||||
->assertDontSee(trans_choice('localization.shell.environment_count', 2, ['count' => 2]));
|
||||
});
|
||||
|
||||
it('shows an explicit environment scope indicator in the sidebar on environment-owned pages', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 Sidebar Environment']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$workspace = $environment->workspace()->firstOrFail();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment))
|
||||
->assertOk()
|
||||
->assertSee('data-testid="admin-sidebar-scope-indicator"', false)
|
||||
->assertSee(__('localization.shell.environment_scope_short'))
|
||||
->assertSee($environment->name)
|
||||
->assertSee(__('localization.shell.workspace_context_label', ['workspace' => $workspace->name]));
|
||||
});
|
||||
@ -381,7 +381,7 @@
|
||||
])
|
||||
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
|
||||
->assertSuccessful()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertSee('Canonical workspace view')
|
||||
->assertSee('No environment context is currently selected.');
|
||||
});
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
$this->withSession($session)
|
||||
->get(route('admin.operations.index', ['workspace' => (int) $run->workspace_id]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee('Scope: ManagedEnvironment')
|
||||
->assertDontSee('Scope: Workspace');
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
'run' => (int) $run->getKey(),
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee('Scope: ManagedEnvironment')
|
||||
->assertDontSee('Scope: Workspace');
|
||||
|
||||
@ -63,14 +63,14 @@
|
||||
->followingRedirects()
|
||||
->get(AlertsCluster::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee('Scope: ManagedEnvironment')
|
||||
->assertDontSee('Scope: Workspace');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee('Scope: ManagedEnvironment')
|
||||
->assertDontSee('Scope: Workspace');
|
||||
});
|
||||
@ -494,7 +494,7 @@
|
||||
expect($resolved?->is($routedTenant))->toBeTrue();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows all-environments shell label on workspace operations even when tenant context is active', function (): void {
|
||||
it('keeps workspace operations tenantless without showing a generic all-environments label', function (): void {
|
||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -504,7 +504,7 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.index', ['workspace' => (int) $tenant->workspace_id]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee('Scope: ManagedEnvironment')
|
||||
->assertDontSee('Scope: Workspace')
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name);
|
||||
@ -641,7 +641,7 @@
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertSee('Canonical workspace view')
|
||||
->assertSee('No environment context is currently selected.');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -52,7 +52,8 @@
|
||||
->assertOk()
|
||||
->assertSee('Spec314 Provider A')
|
||||
->assertSee('Spec314 Provider B')
|
||||
->assertSee(__('localization.shell.no_environment_selected'));
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'));
|
||||
});
|
||||
|
||||
it('Spec314 provider connections keeps explicit environment CTA filters explicit', function (): void {
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
expect(Filament::getTenant())->toBe($tenant);
|
||||
});
|
||||
|
||||
it('renders workspace scope label when no tenant context is active', function (): void {
|
||||
it('does not render a generic all-environments badge when no tenant context is active', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'));
|
||||
->assertDontSee(__('localization.shell.all_environments'));
|
||||
|
||||
expect(Filament::getTenant())->toBeNull();
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('renders workspace scope label when tenant context is active on the workspace operations route', function (): void {
|
||||
it('keeps the workspace operations route tenantless without a generic all-environments badge', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
@ -24,8 +24,9 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name)
|
||||
->assertDontSee('Back to '.$tenant->name)
|
||||
->assertDontSee(__('localization.shell.show_all_environments'));
|
||||
@ -45,7 +46,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
|
||||
->get(route('admin.operations.index', ['workspace' => $entitledTenant->workspace]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee('Back to '.$staleTenant->name)
|
||||
->assertDontSee($staleTenant->name)
|
||||
->assertDontSee(__('localization.shell.show_all_environments'));
|
||||
@ -80,7 +81,7 @@
|
||||
])
|
||||
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name);
|
||||
});
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['workspace' => $entitledTenant->workspace, 'run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.all_environments'))
|
||||
->assertSee('Back to Operations')
|
||||
->assertDontSee('← Back to '.$staleTenant->name)
|
||||
->assertDontSee($staleTenant->name)
|
||||
|
||||
@ -148,5 +148,6 @@
|
||||
->assertSee('Header Active ManagedEnvironment')
|
||||
->assertDontSee('Header Onboarding ManagedEnvironment')
|
||||
->assertDontSee('Header Archived ManagedEnvironment')
|
||||
->assertSee(__('localization.shell.no_environment_selected'));
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'));
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -119,13 +120,14 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee(__('localization.shell.all_environments'));
|
||||
->assertDontSee(__('localization.shell.all_environments'));
|
||||
});
|
||||
|
||||
it('redirects clear selected tenant from the evidence index to the workspace-safe evidence overview', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
$evidenceIndexPath = (string) parse_url(EvidenceSnapshotResource::getUrl('index', tenant: $tenant, panel: 'admin'), PHP_URL_PATH);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
@ -134,7 +136,7 @@
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->from('/admin/evidence')
|
||||
->from($evidenceIndexPath)
|
||||
->post(route('admin.clear-environment-context'))
|
||||
->assertRedirect(route('admin.evidence.overview'));
|
||||
|
||||
|
||||
@ -43,7 +43,8 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||
->get(route('admin.operations.index', ['workspace' => $workspaceTenant->workspace, 'tenant' => $foreignTenant->external_id]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Rejected Foreign ManagedEnvironment');
|
||||
});
|
||||
|
||||
@ -77,7 +78,8 @@
|
||||
->followingRedirects()
|
||||
->get($url)
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Hinted ManagedEnvironment')
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Remembered ManagedEnvironment')
|
||||
->assertDontSee('Back to Hinted ManagedEnvironment')
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\ChooseEnvironment;
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Filament\Pages\Workspaces\ManagedEnvironmentsLanding;
|
||||
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||
@ -44,7 +43,7 @@
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'));
|
||||
});
|
||||
|
||||
it('routes the managed-tenants landing back into the chooser flow and open-tenant flow', function (): void {
|
||||
it('routes the managed-tenants landing card into the open-environment flow', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-routing']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -70,14 +69,6 @@
|
||||
->test(ManagedEnvironmentsLanding::class, ['workspace' => $workspace]);
|
||||
|
||||
$component
|
||||
->call('goToChooseEnvironment')
|
||||
->assertRedirect(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]));
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedEnvironmentsLanding::class, ['workspace' => $workspace])
|
||||
->call('openTenant', $tenant->getKey())
|
||||
->assertRedirect(EnvironmentDashboard::getUrl(tenant: $tenant));
|
||||
});
|
||||
|
||||
@ -38,7 +38,8 @@
|
||||
->get($url)
|
||||
->assertOk()
|
||||
->assertSee($workspace->name)
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Active Environment Context');
|
||||
})->with([
|
||||
'provider connections' => ['provider_connections', fn ($workspace): string => ProviderConnectionResource::getUrl('index', panel: 'admin')],
|
||||
@ -72,6 +73,7 @@
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.choose_environment'))
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Remembered Environment Boundary');
|
||||
});
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
'findings intake' => ['/admin/findings/intake', AdminSurfaceScope::WorkspaceOwnedAnalysisSurface],
|
||||
'findings hygiene' => ['/admin/findings/hygiene', AdminSurfaceScope::WorkspaceOwnedAnalysisSurface],
|
||||
'cross-environment compare' => ['/admin/cross-environment-compare', AdminSurfaceScope::WorkspaceOwnedAnalysisSurface],
|
||||
'tenant scoped evidence detail' => ['/admin/evidence/123', AdminSurfaceScope::EnvironmentScopedEvidence],
|
||||
'environment evidence detail' => ['/admin/workspaces/acme/environments/tenant-123/evidence/123', AdminSurfaceScope::EnvironmentBound],
|
||||
'evidence overview' => ['/admin/evidence/overview', AdminSurfaceScope::WorkspaceWideSurface],
|
||||
'customer review workspace' => ['/admin/reviews/workspace', AdminSurfaceScope::WorkspaceWideSurface],
|
||||
'review register' => ['/admin/reviews', AdminSurfaceScope::WorkspaceWideSurface],
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
# Specification Quality Checklist: Spec 338 - Workspace / Environment Resource Scope Contract
|
||||
|
||||
**Purpose**: Validate specification completeness, preparation quality, and readiness before implementation.
|
||||
**Created**: 2026-05-30
|
||||
**Feature**: `specs/338-workspace-environment-resource-scope-contract/spec.md`
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] Spec 338 was directly provided/promoted by the user as the preparation target.
|
||||
- [x] Completed-spec guardrail checked that no existing `specs/338-*` package existed before creation.
|
||||
- [x] Branch guardrail checked that no existing `338-*` branch existed locally before creation.
|
||||
- [x] `docs/product/spec-candidates.md` was inspected; it states the active auto-prep queue is empty, so this spec proceeds only because the user directly supplied/promoted it.
|
||||
- [x] Related completed/historical specs were treated as context only and remain unchanged:
|
||||
- `specs/311-workspace-environment-surface-scope-contract/` (implemented + validated)
|
||||
- `specs/320-workspace-owned-analysis-surface-registration-shell-cutover/` (completed)
|
||||
- `specs/322-browser-no-drift-regression-guard/` (guard posture)
|
||||
|
||||
## Close Alternatives Deferred
|
||||
|
||||
- [x] Provider Connection Scope Hardening (already a promoted candidate) is deferred; this spec focuses on link/query and evidence scope seams.
|
||||
- [x] Canonical Link / Query Cleanup remains related and partially overlaps; Spec 338 scope is kept tight around confirmed helper outputs and evidence special casing.
|
||||
- [x] Environment Resource Context Follow-through remains separate (resource internals); this spec focuses on contract seams and helper outputs.
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] Problem statement is operator-visible and framed as “scope drift + non-canonical deep links”, not internal refactor desire.
|
||||
- [x] Scope is bounded to confirmed seams: `OperationRunLinks` query output contract and evidence special casing; baseline navigation is regression-only.
|
||||
- [x] Explicit non-goals prevent reopening Spec 311/320 scope work or starting a navigation redesign.
|
||||
- [x] Mandatory Spec Candidate Check is complete (score + decision included).
|
||||
- [x] No unresolved placeholder markers remain.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] Scope taxonomy and link/query contract are documented inside `spec.md`.
|
||||
- [x] Required runtime decisions are explicit for:
|
||||
- Operation type deep links (no `tableFilters` in helper output)
|
||||
- Evidence `/admin/evidence/*` special casing (remove if stale, otherwise document + test)
|
||||
- baseline ownership/navigation (regression-only)
|
||||
- [x] Acceptance criteria are concrete and testable.
|
||||
|
||||
## Plan Quality
|
||||
|
||||
- [x] Plan records stack context (Laravel/Filament/Livewire/Pest/PostgreSQL) and the no-migration/no-route-rewrite constraint.
|
||||
- [x] Plan includes a “failing tests first” phase for contract changes.
|
||||
- [x] OperationRun UX Impact is limited to link semantics; no lifecycle changes are planned.
|
||||
|
||||
## Task Quality
|
||||
|
||||
- [x] Tasks are ordered from repo-truth → failing tests → implementation → validation.
|
||||
- [x] Task IDs follow the required checkbox format and are verifiable.
|
||||
- [x] Tasks include explicit non-goals to prevent scope creep.
|
||||
|
||||
## Constitution / Repo Alignment
|
||||
|
||||
- [x] No new persisted entity, table, or artifact is introduced by this spec.
|
||||
- [x] No new taxonomy framework is proposed; the spec reuses existing navigation/scope seams (`AdminSurfaceScope`, hub registry, navigation context).
|
||||
- [x] Provider boundary is respected: platform-core scope keys (`environment_id`) remain separate from provider “tenant” identity semantics.
|
||||
- [x] Filament v5 / Livewire v4 compliance is assumed by project baseline; this spec does not introduce version drift.
|
||||
|
||||
## Preparation Analysis Outcome
|
||||
|
||||
- [x] Preparation artifacts (`spec.md`, `plan.md`, `tasks.md`) are internally consistent after manual `/speckit.analyze`-style review.
|
||||
- [x] Every acceptance criterion maps to one or more tasks.
|
||||
- [x] No preparation issue requires application implementation to resolve.
|
||||
- [x] Candidate Selection Gate result: PASS.
|
||||
- [x] Spec Readiness Gate result: PASS for later implementation.
|
||||
|
||||
## Notes
|
||||
|
||||
- Repository has prompt/agent definitions for `speckit.tasks` and `speckit.analyze`, but no local executable Bash command for those phases. Tasks and analysis were therefore produced repo-conformantly from templates and checked manually in this checklist.
|
||||
123
specs/338-workspace-environment-resource-scope-contract/plan.md
Normal file
123
specs/338-workspace-environment-resource-scope-contract/plan.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Implementation Plan: Spec 338 - Workspace / Environment Resource Scope Contract
|
||||
|
||||
- Branch: `338-workspace-environment-resource-scope-contract`
|
||||
- Date: 2026-05-30
|
||||
- Spec: `specs/338-workspace-environment-resource-scope-contract/spec.md`
|
||||
- Input: User-provided Spec 338 draft + repo inspection for link/query seams.
|
||||
|
||||
## Summary
|
||||
|
||||
Harden TenantPilot’s resource scope contract by tightening the canonical deep-link and query contract for workspace hubs and by eliminating first-party helper outputs that encode Filament internals (`tableFilters[...]`) as a product-level URL contract.
|
||||
|
||||
This is contract-first and targeted:
|
||||
|
||||
- `OperationRunLinks::index(..., operationType: ...)` must stop emitting `tableFilters[type][value]`.
|
||||
- Evidence scope special casing under `/admin/evidence/*` must be either proven real and contractual, or removed as stale ambiguity.
|
||||
- Environment-owned sidebar navigation must keep environment-owned entries primary and move workspace-wide/admin links into explicitly labeled cross-scope groups.
|
||||
- Baseline ownership/navigation is regression-only (Spec 320 already completed; do not reopen).
|
||||
|
||||
## Technical Context
|
||||
|
||||
- Language/Version: PHP 8.4.15, Laravel 12.52.x.
|
||||
- Primary Dependencies: Filament 5.2.x, Livewire 4.1.x, Pest 4.x, Tailwind CSS 4.x.
|
||||
- Storage: PostgreSQL; no schema change expected.
|
||||
- Testing: Pest Feature tests + minimal browser smoke only if navigation presentation is materially affected.
|
||||
- Validation Lanes: fast-feedback (Feature) + browser (smoke, scoped).
|
||||
- Target Platform: Sail locally; Dokploy/container deployment posture unchanged.
|
||||
- Project Type: Laravel monolith under `apps/platform`.
|
||||
- Constraints: No new persisted truth, migrations, packages, env vars, queue/scheduler changes, or route architecture rewrite.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed existing operator-facing scope/link behavior (navigation + deep links).
|
||||
- **Affected surfaces**:
|
||||
- Workspace hub links to Operations (`OperationRunLinks` and any `CanonicalNavigationContext` filter payload usage).
|
||||
- Evidence Overview hub + “clear environment context” redirect behavior.
|
||||
- Environment → workspace hub “filtered” links (`environment_id` must remain canonical).
|
||||
- Environment sidebar grouping for workspace-wide/admin links.
|
||||
- **Native vs custom**: native Filament + existing project navigation helpers; no custom UI framework.
|
||||
- **Shared-family relevance**: navigation entry points, scope presentation, deep links, hub filtering, OperationRun “view in collection” links.
|
||||
- **State layers in scope**: shell scope (route-owned), URL query contract, local table filter state (internal translation only).
|
||||
- **Handling modes**: review-mandatory.
|
||||
- **Required tests / smoke**:
|
||||
- Feature tests for URL contract + helper output.
|
||||
- Optional minimal browser smoke when sidebar/scope presentation changes are user-visible.
|
||||
- **UI/Productization coverage**: no new routes/pages expected; capture screenshots only when needed to prove a scope regression fix.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched (expected)**:
|
||||
- `apps/platform/app/Support/OperationRunLinks.php`
|
||||
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
|
||||
- `apps/platform/app/Support/Navigation/AdminSurfaceScope.php`
|
||||
- `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php`
|
||||
- `apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php`
|
||||
- `apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php`
|
||||
- `apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php`
|
||||
- **New abstraction introduced?**: `WorkspaceHubNavigation`, a narrow helper for environment-surface hub grouping and explicit `environment_id` URL carry.
|
||||
- **Shared abstractions reused**: existing `AdminSurfaceScope` + hub registry + navigation context; do not create a second taxonomy framework.
|
||||
- **Bounded deviation**: if Filament requires `tableFilters` internally, keep it internal (page-level translation) and keep first-party helper output contract stable.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
Link semantics only (no new OperationRun types, no lifecycle changes):
|
||||
|
||||
- Stop emitting Filament internals as deep-link contract for operation type filtering.
|
||||
- Decide between:
|
||||
1) `operation_type=<canonical-code>` accepted by Operations page and mapped to internal table state, or
|
||||
2) removing operation-type deep-linking entirely if safe mapping is not feasible without bloat.
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1 — Repo truth + failing tests first
|
||||
|
||||
- Inventory current first-party helper outputs and navigation contexts that emit:
|
||||
- `tableFilters[...]` (confirmed in `OperationRunLinks`; re-check `CanonicalNavigationContext` usage and call sites)
|
||||
- legacy `/admin/evidence/*` special casing branches (`AdminSurfaceScope`, `ClearEnvironmentContextController`)
|
||||
- Add failing tests that lock the desired contract:
|
||||
- `OperationRunLinks::index(..., operationType: ...)` must not contain `tableFilters`.
|
||||
- Evidence Overview is workspace hub; any `/admin/evidence/*` environment-scope handling is either intentional + tested or removed.
|
||||
|
||||
### Phase 2 — OperationRunLinks query contract
|
||||
|
||||
- Change `OperationRunLinks::index`:
|
||||
- replace `tableFilters[type][value]` emission with a stable query key (`operation_type`) or remove operation-type deep linking.
|
||||
- Update the Operations page boundary to translate `operation_type` into internal table state where needed (keep `environment_id` canonical).
|
||||
|
||||
### Phase 3 — Navigation context payload hygiene
|
||||
|
||||
- Re-check `CanonicalNavigationContext::toQuery()` usage:
|
||||
- prefer keeping navigation metadata under `nav[...]` only,
|
||||
- avoid emitting additional top-level filter payload that encodes `tableFilters` for hub filtering when `environment_id` is sufficient.
|
||||
- Adjust the specific call sites (e.g. RelatedNavigationResolver contexts) that currently inject `tableFilters[managed_environment_id]` into query strings when linking to Operations.
|
||||
|
||||
### Phase 4 — Evidence scope special casing
|
||||
|
||||
- Verify actual route inventory for `/admin/evidence/*` beyond overview.
|
||||
- Remove stale classification or redirect rules only when route inventory proves they are not real, or explicitly document + test the remaining route family if it is still reachable.
|
||||
|
||||
### Phase 5 — Validation and regression posture
|
||||
|
||||
- Split Environment sidebar IA:
|
||||
- keep environment-owned resources in their domain groups,
|
||||
- move workspace hub entries into “Workspace-wide” on environment pages,
|
||||
- move workspace configuration/admin entries into “Workspace admin” on environment pages,
|
||||
- preserve explicit `environment_id` only for workspace hubs that already accept that filter.
|
||||
|
||||
Run narrow tests first:
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <new/updated Spec 338 tests>`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
Run minimal browser smoke only if link/scope changes are user-visible in navigation:
|
||||
|
||||
- `cd apps/platform && php vendor/bin/pest tests/Browser --filter=Spec338 --compact`
|
||||
|
||||
## Deployment / Ops Impact
|
||||
|
||||
- Migrations: none expected.
|
||||
- Env vars: none expected.
|
||||
- Queues/scheduler: none expected.
|
||||
- Filament assets: no new registered assets expected; `filament:assets` posture unchanged.
|
||||
259
specs/338-workspace-environment-resource-scope-contract/spec.md
Normal file
259
specs/338-workspace-environment-resource-scope-contract/spec.md
Normal file
@ -0,0 +1,259 @@
|
||||
# Feature Specification: Spec 338 - Workspace / Environment Resource Scope Contract
|
||||
|
||||
**Feature Branch**: `338-workspace-environment-resource-scope-contract`
|
||||
**Created**: 2026-05-30
|
||||
**Status**: Draft
|
||||
**Input**: User-provided Spec 338 draft (“Contract-/Guard-Spec” for workspace/environment resource ownership + link/query hygiene)
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot’s workspace/environment scope foundations exist (Specs 311/319/320/321), but remaining link/query seams and navigation registration can still encode “mixed ownership” (workspace-owned surfaces appearing environment-owned, or workspace hub filters encoded as hidden context / framework internals).
|
||||
- **Today's failure**:
|
||||
- First-party deep links can still emit Filament internal query keys (notably `tableFilters[...]`) instead of a stable product-level contract.
|
||||
- Environment → workspace hub links can drift between “route scope” and “filter scope” depending on the helper used.
|
||||
- Evidence has legacy `/admin/evidence/*` classification/special casing that must be either proven real and intentional, or removed as stale to reduce ambiguity.
|
||||
- **User-visible improvement**: Operators can trust that:
|
||||
- route scope determines shell/sidebar;
|
||||
- workspace hubs filter by a stable, explicit query contract (`environment_id`, and where needed `operation_type`);
|
||||
- environment navigation does not claim workspace-owned portfolio surfaces as environment-owned;
|
||||
- the sidebar exposes a direct scope signal so workspace-level and environment-level pages are distinguishable without reading the URL.
|
||||
- workspace-wide pages do not render a generic “All environments” header/scope badge when the page is already tenantless; explicit environment filters remain visible through filter banners and table chips.
|
||||
- **Smallest enterprise-capable version**: Document the canonical ownership taxonomy and enforce only the highest-risk seams with tests:
|
||||
- stop first-party helpers from emitting `tableFilters[...]` for hub deep links (especially Operations),
|
||||
- ensure Evidence scope is explicit (workspace hub vs environment-owned resources),
|
||||
- keep baseline ownership/navigation contract regression-proof (no reopen of Spec 320; fix only if regression is proven).
|
||||
- **Explicit non-goals**:
|
||||
- no broad UI redesign of the admin shell, sidebars, or page layouts,
|
||||
- no route restructuring (keep canonical route families as-is),
|
||||
- no workspace/environment data model or schema changes,
|
||||
- no Provider Connections “scope split” feature (defer to the already-listed candidate),
|
||||
- no rewrite of Spec 311/320 behavior unless a regression is proven by tests.
|
||||
- **Permanent complexity imported**: narrow link/query contract mapping (`operation_type` deep-link), a small set of guard tests, and clarified operator-copy expectations (only where proven misleading).
|
||||
- **Why now**: Current productization and audit lanes depend on stable, explicit scope and deep links; leaving internal query keys in first-party helpers makes future specs copy the wrong contract.
|
||||
- **Why not local**: Fixing one page’s deep link without a contract + guard tests leaves the next helper or navigation entry free to reintroduce the same ambiguity.
|
||||
- **Approval class**: Cleanup / Consolidation (contract + guard hardening over existing foundations).
|
||||
- **Red flags triggered**: Cross-surface contract + shared link helper changes. **Defense**: scope is bounded to confirmed seams; no new taxonomy framework or persisted truth; tests enforce stability.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Summary
|
||||
|
||||
TenantPilot already has strong workspace/environment scope foundations. This spec locks down a **resource ownership + link/query contract** so that:
|
||||
|
||||
1) workspace-owned surfaces stay workspace-owned (even when entered from environment context),
|
||||
2) workspace hubs are filtered only via explicit, product-level query keys (`environment_id`, and optionally `operation_type`),
|
||||
3) environment-owned detail surfaces remain environment-route-owned,
|
||||
4) first-party helpers stop emitting Filament table internals (`tableFilters[...]`) as canonical deep link contract,
|
||||
5) the sidebar presents explicit workspace vs environment scope identity.
|
||||
|
||||
This is a contract-first spec with targeted runtime fixes only.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view (navigation + link/query contract)
|
||||
- **Primary Routes (representative)**:
|
||||
- Workspace hubs: `/admin/workspaces/{workspace}/operations`, `/admin/evidence/overview`, `/admin/alerts`, `/admin/audit-log`
|
||||
- Workspace-owned portfolio surfaces: `/admin/baseline-profiles`, `/admin/baseline-snapshots`
|
||||
- Environment-owned detail surfaces: `/admin/workspaces/{workspace}/environments/{environment}/...`
|
||||
- **Data Ownership**: no ownership model change. This spec is about UI scope signals + link/query contracts, not table ownership.
|
||||
- **RBAC**: no new capabilities. Existing workspace membership and tenant/environment membership continue to gate visibility and access.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**:
|
||||
- Workspace hubs MUST NOT silently infer environment filtering from remembered environment/topbar selection.
|
||||
- If a workspace hub is filtered, it MUST be via explicit query (`environment_id`) or explicit visible UI filter state.
|
||||
- Environment-owned routes MUST include the environment in the route (no query-derived environment ownership).
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**:
|
||||
- `environment_id` MUST be validated as “belongs to current workspace” AND “actor is entitled”; otherwise ignore/deny safely.
|
||||
- Canonical deep links must not widen scope through implicit session context.
|
||||
|
||||
## Canonical Scope Taxonomy (product contract)
|
||||
|
||||
Every reachable surface is classified as exactly one:
|
||||
|
||||
### A. Workspace-owned source of truth
|
||||
|
||||
Workspace-owned; may aggregate across environments; does not require an environment route.
|
||||
|
||||
### B. Workspace hub with optional local environment filter
|
||||
|
||||
Workspace-owned monitoring/governance hubs that may filter by environment via explicit query/UI.
|
||||
|
||||
Rules:
|
||||
- Route determines shell (workspace shell).
|
||||
- Public filter query key is `environment_id`.
|
||||
- Hubs must not infer filters from topbar “remembered environment”.
|
||||
|
||||
### C. Environment-owned detail surface
|
||||
|
||||
Belongs to exactly one Managed Environment.
|
||||
|
||||
Rules:
|
||||
- Route includes workspace + environment: `/admin/workspaces/{workspace}/environments/{environment}/...`
|
||||
- Environment is not optional or query-derived.
|
||||
|
||||
### D. Cross-environment / portfolio aggregation
|
||||
|
||||
Compares/aggregates across multiple environments; must not pretend to be “current environment owned”.
|
||||
|
||||
### E. Platform / system / utility
|
||||
|
||||
System pages (`/system`, auth callbacks, choosers). Must not create hidden environment filters.
|
||||
|
||||
### F. Invalid / needs split
|
||||
|
||||
Any surface that mixes route scope, navigation scope, and data scope such that the operator cannot tell “what owns this”.
|
||||
|
||||
## Routing / Link Contract
|
||||
|
||||
### WorkspaceLink
|
||||
|
||||
Workspace-owned surface without environment filter.
|
||||
|
||||
### EnvironmentLink
|
||||
|
||||
Environment-owned surface with environment in the route.
|
||||
|
||||
### WorkspaceFilteredLink
|
||||
|
||||
Workspace-owned hub filtered to one environment via explicit query.
|
||||
|
||||
Allowed public filter keys:
|
||||
|
||||
- `environment_id` (canonical)
|
||||
- `operation_type` (Operations-only, optional; see required decision D1)
|
||||
|
||||
Forbidden as **first-party helper output** for hub scope (canonical deep-link contract):
|
||||
|
||||
- `tenant`
|
||||
- `tenant_id`
|
||||
- `managed_environment_id`
|
||||
- `tenant_scope`
|
||||
- `tableFilters`
|
||||
|
||||
Note: Filament may still persist table state in the URL after user interactions. This spec’s restriction is about **first-party helper outputs** and **canonical deep links**, not about banning every possible `tableFilters` appearance after manual operator filtering.
|
||||
|
||||
## Required Runtime Decisions
|
||||
|
||||
### D1 — OperationRunLinks operation type filter (confirmed repo seam)
|
||||
|
||||
Repo evidence: `apps/platform/app/Support/OperationRunLinks.php` currently emits `tableFilters[type][value]` when `operationType` is provided.
|
||||
|
||||
Decision:
|
||||
- `tableFilters[...]` must not be emitted by first-party helpers for operation-type deep links.
|
||||
- If operation-type deep-linking is needed, use a stable query key:
|
||||
- `operation_type=<canonical-code>`
|
||||
|
||||
Acceptance:
|
||||
- `OperationRunLinks::index(..., operationType: ...)` does not emit `tableFilters`.
|
||||
- Operations page accepts `operation_type` and translates it into local table state, **or** operation-type deep links are removed (prefer correctness over leaking internals).
|
||||
|
||||
### D2 — Evidence route special casing (confirmed repo seam)
|
||||
|
||||
Repo evidence:
|
||||
- `/admin/evidence/overview` is a workspace hub route (`admin.evidence.overview`).
|
||||
- `apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php` and `apps/platform/app/Support/Navigation/AdminSurfaceScope.php` contain legacy handling/classification for `/admin/evidence/*` paths.
|
||||
|
||||
Decision:
|
||||
- Keep Evidence Overview as workspace hub, with optional explicit `environment_id` filter.
|
||||
- Confirm whether any `/admin/evidence/*` non-overview paths are still real and intended:
|
||||
- If not real, remove/neutralize stale classification branches.
|
||||
- If real, document the intended contract and stop treating it as “mystery scope”.
|
||||
|
||||
Acceptance:
|
||||
- No ambiguous third “environment-scoped evidence under `/admin/evidence/*`” remains without explicit contract + test.
|
||||
|
||||
### D3 — Baseline ownership & navigation (regression-only)
|
||||
|
||||
Repo evidence: Spec 320 completed classification for baseline library surfaces as workspace-owned analysis.
|
||||
|
||||
Decision:
|
||||
- Do not reopen baseline ownership decisions in Spec 338.
|
||||
- Only change baseline navigation registration if a regression is proven by tests or UI contract failures on current branch.
|
||||
|
||||
Acceptance:
|
||||
- Baseline Profiles/Snapshots remain workspace-owned surfaces; environment navigation must not claim them as environment-owned.
|
||||
|
||||
## UI Surface Impact *(mandatory — UI-COV-001)*
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [x] Navigation changed
|
||||
- [x] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [ ] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [ ] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## Scope Badge Contract Addendum
|
||||
|
||||
- Tenantless workspace-wide pages MUST NOT render a generic “All environments” header action or workbench badge as the primary context signal.
|
||||
- When `environment_id` is present on a workspace hub, the explicit filter banner/chip is the source of truth for the narrowed dataset.
|
||||
- Environment-scoped shell labels remain valid only when the route truly resolves to an environment-owned context.
|
||||
|
||||
## UI/Productization Coverage *(UI-COV-001)*
|
||||
|
||||
- **Route/page/surface**: Operations hub deep links; Evidence Overview hub; environment sidebar vs workspace sidebar entries and scope identity, including separated workspace-wide/admin groups on environment-owned pages (baseline library surfaces, regression-only)
|
||||
- **Current page archetype**: Monitoring hub (Operations/Evidence); navigation shell contract
|
||||
- **Design depth**: Domain Pattern Surface (contract hardening, minimal visual work)
|
||||
- **Repo-truth level**: repo-verified (Spec 311/320/322 + current helper code)
|
||||
- **Existing pattern reused**: `AdminSurfaceScope`, `WorkspaceHubRegistry`, Filament `SIDEBAR_NAV_START` render hook, Filament navigation groups, `CanonicalNavigationContext`, `OperationRunLinks`
|
||||
- **New pattern required**: small scope-aware workspace hub navigation helper, limited to grouping and environment filter URL carry for existing hub entries
|
||||
- **Screenshot required**: yes, only for scope-regression proof in the implementation PR (light/dark where relevant)
|
||||
- **Page audit required**: no (existing archetypes; update coverage artifacts only if new navigation entries are introduced)
|
||||
- **Dangerous-action review required**: no (no destructive action changes)
|
||||
- **Coverage files to update (in implementation PR)**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` (only if navigation entries/routes change)
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` (only if new surface created; expected `no`)
|
||||
- [x] `N/A - no new reachable UI surface added; contract hardening only`
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: navigation entry points, scope presentation, deep links, hub filtering
|
||||
- **Systems touched**: `AdminSurfaceScope`, `WorkspaceHubRegistry`, `WorkspaceHubNavigation`, Filament sidebar render hook/navigation groups, `CanonicalNavigationContext`, `OperationRunLinks`, `ClearEnvironmentContextController`
|
||||
- **Existing pattern(s) to extend**: canonical workspace/environment scope contract (Specs 311/320/322)
|
||||
- **Allowed deviation and why**: none (prefer tightening existing helpers over new frameworking)
|
||||
- **Consistency impact**: “Route determines shell; query determines filter; helpers emit canonical keys.”
|
||||
- **Review focus**: no new scope magic; no helper outputs that encode Filament internals as canonical contract.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes (link semantics only)
|
||||
- **Shared OperationRun UX contract/layer reused**: `App\\Support\\OperationRunLinks`
|
||||
- **Delegated behaviors**: operation collection URL generation; environment filter key; operation type deep link key
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes (terminology + query keys must not reintroduce legacy “tenant” meaning at platform scope)
|
||||
- **Boundary classification**: platform-core (workspace/environment scope) + provider-owned (Entra “tenant” identity) must remain separated
|
||||
- **Seams affected**: query keys and helper naming only
|
||||
- **Why this does not deepen provider coupling accidentally**: enforce `environment_id`/`operation_type` at platform scope; keep `tenant` terminology provider-boundary-only.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature (contract tests) + Browser (minimal smoke for sidebar/scope)
|
||||
- **Validation lane(s)**: fast-feedback (Feature) + browser (smoke), no heavy-governance required for this slice
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC1**: First-party deep links use canonical query keys (`environment_id`, and where needed `operation_type`), not `tableFilters[...]`.
|
||||
- **AC2**: Evidence scope is explicit: Evidence Overview is a workspace hub; any remaining `/admin/evidence/*` special casing is either removed as stale or documented + tested as intentional.
|
||||
- **AC3**: Baseline library ownership remains workspace-owned and does not regress (no baseline ownership reopen).
|
||||
- **AC4**: Targeted tests are green (feature contract tests + minimal browser smoke if UI navigation is involved).
|
||||
- **AC5**: Workspace-owned and environment-owned pages show an explicit sidebar scope indicator that names the active workspace or environment, while tenantless workspace topbars and environment pickers do not render a negative “No environment selected” status.
|
||||
- **AC6**: Environment-owned sidebars separate workspace-wide/admin links into clearly labeled groups and carry `environment_id` only to workspace hubs that support explicit environment filtering.
|
||||
- **AC7**: Managed Environments registry pages do not duplicate the `/admin/choose-environment` flow with a redundant “Choose environment” CTA; environment cards remain the entry point, with Add Environment and Switch Workspace as the supporting actions.
|
||||
|
||||
## Follow-up spec candidates
|
||||
|
||||
- Provider Connection Scope Hardening (credential-adjacent authority semantics)
|
||||
- Canonical Link / Query Cleanup (broader inventory + replacement beyond Operations/Evidence)
|
||||
- Environment Resource Context Follow-through (reduce hidden context reliance inside environment-owned resources)
|
||||
121
specs/338-workspace-environment-resource-scope-contract/tasks.md
Normal file
121
specs/338-workspace-environment-resource-scope-contract/tasks.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Tasks: Spec 338 - Workspace / Environment Resource Scope Contract
|
||||
|
||||
- Input: `specs/338-workspace-environment-resource-scope-contract/spec.md`, `specs/338-workspace-environment-resource-scope-contract/plan.md`
|
||||
- Preparation status: implemented + validated.
|
||||
|
||||
**Tests**: Required. This spec changes canonical link/query contract semantics for operator-facing hubs.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment remains explicit and narrowest sufficient (Feature + optional Browser smoke).
|
||||
- [x] No new default-heavy helpers/factories/seeds are introduced.
|
||||
- [x] Contract changes are guarded by deterministic tests before refactors.
|
||||
- [x] Any exception resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||
|
||||
## Phase 1: Preparation And Repo Truth (blocks runtime changes)
|
||||
|
||||
**Purpose**: Confirm repo truth and lock the current contract seams before changing runtime behavior.
|
||||
|
||||
- [x] T001 Re-read `spec.md` + `plan.md` + this `tasks.md`.
|
||||
- [x] T002 Confirm working tree intent and record baseline commit (`git status`, `git log -1`).
|
||||
- [x] T003 Re-verify dependency specs as context only (do not reopen them):
|
||||
- `specs/311-workspace-environment-surface-scope-contract/` (implemented)
|
||||
- `specs/320-workspace-owned-analysis-surface-registration-shell-cutover/` (completed)
|
||||
- `specs/322-browser-no-drift-regression-guard/` (guard posture)
|
||||
- [x] T004 Inspect the confirmed helper seams:
|
||||
- `apps/platform/app/Support/OperationRunLinks.php` (currently emits `tableFilters[type][value]` for `operationType`)
|
||||
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` (`toQuery()` behavior)
|
||||
- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` (filter payload injection)
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` (query parsing + filter handling)
|
||||
- `apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php` (`/admin/evidence/*` special casing)
|
||||
- `apps/platform/app/Support/Navigation/AdminSurfaceScope.php` (evidence path classification)
|
||||
- `apps/platform/routes/web.php` (`/admin/evidence/overview`, operations routes)
|
||||
- [x] T005 Inspect existing guard tests that already encode parts of the contract:
|
||||
- `apps/platform/tests/Feature/Navigation/Spec322LegacyQueryAliasGuardTest.php`
|
||||
- `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php`
|
||||
- `apps/platform/tests/Unit/Tenants/AdminSurfaceScopeTest.php`
|
||||
|
||||
## Phase 2: Add failing contract tests first
|
||||
|
||||
**Purpose**: Make contract changes reviewable and regression-proof.
|
||||
|
||||
- [x] T006 Add a new Spec 338 contract test for `OperationRunLinks::index(..., operationType: ...)`:
|
||||
- asserts the generated URL does not contain `tableFilters`
|
||||
- asserts operation type deep-linking uses `operation_type` **or** is intentionally not supported
|
||||
- [x] T007 Add/extend tests ensuring environment filtering remains canonical:
|
||||
- `environment_id` works for Operations hub filtering
|
||||
- legacy aliases remain ignored (no regression vs Spec 322)
|
||||
- [x] T008 Add/extend Evidence scope tests:
|
||||
- Evidence Overview is workspace hub (`/admin/evidence/overview`)
|
||||
- `/admin/evidence/*` special casing is either removed as stale or explicitly covered by a route-inventory-backed test
|
||||
|
||||
## Phase 3: Implement OperationRunLinks query contract
|
||||
|
||||
**Purpose**: Remove Filament internals from first-party helper outputs.
|
||||
|
||||
- [x] T009 Update `apps/platform/app/Support/OperationRunLinks.php` so `operationType` does not emit `tableFilters[type][value]`.
|
||||
- [x] T010 Decide and implement one of:
|
||||
- Map `operation_type` query to internal table state in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, or
|
||||
- Remove operation type deep-linking and keep only environment filtering + tabs/problem classes.
|
||||
- [x] T011 Update any call sites that depended on helper-emitted `tableFilters` and make them use the new canonical key (or drop the feature).
|
||||
|
||||
## Phase 4: Navigation context payload hygiene
|
||||
|
||||
**Purpose**: Stop emitting legacy alias filters in navigation contexts.
|
||||
|
||||
- [x] T012 Audit `CanonicalNavigationContext` usage where `filterPayload` includes `tableFilters[managed_environment_id]` and confirm whether it is still needed.
|
||||
- [x] T013 Update call sites (e.g. `RelatedNavigationResolver`) to avoid injecting legacy alias filters when linking to workspace hubs; use `environment_id` where filter intent exists.
|
||||
- [x] T014 Ensure no first-party helper emits legacy query aliases for hub filtering (`tenant`, `tenant_id`, `managed_environment_id`, `tenant_scope`, `tableFilters`) as canonical contract.
|
||||
|
||||
## Phase 5: Evidence scope special casing cleanup
|
||||
|
||||
**Purpose**: Reduce ambiguity and stale branching.
|
||||
|
||||
- [x] T015 Confirm whether any `/admin/evidence/*` non-overview route family is real and reachable on current branch:
|
||||
- if not real: remove/neutralize stale handling in `ClearEnvironmentContextController` and `AdminSurfaceScope`
|
||||
- if real: document intent in Spec 338 and add explicit tests proving the contract
|
||||
- [x] T016 Ensure Evidence Overview remains a workspace hub and accepts only explicit `environment_id` filtering.
|
||||
|
||||
## Phase 6: Regression posture (baseline ownership)
|
||||
|
||||
**Purpose**: Ensure Spec 320 baseline ownership/navigation remains stable.
|
||||
|
||||
- [x] T017 Confirm existing baseline ownership tests remain green (no new baseline work unless regression is proven):
|
||||
- `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`
|
||||
- any Spec 320/322 smoke coverage already in repo
|
||||
|
||||
## Phase 7: Optional browser smoke (only if navigation presentation changes)
|
||||
|
||||
- [x] T018 Add a minimal browser smoke test `apps/platform/tests/Browser/Spec338ScopeContractSmokeTest.php` covering:
|
||||
- environment → operations filtered link uses `environment_id`
|
||||
- clearing environment context from an evidence page returns to Evidence Overview hub without ambiguous redirects
|
||||
|
||||
## Phase 8: Validation
|
||||
|
||||
- [x] T021 Add the sidebar scope identity indicator requested during browser review:
|
||||
- workspace-owned pages show Workspace scope + active workspace without a negative “no environment selected” topbar or picker status
|
||||
- environment-owned pages show Environment scope + active environment + containing workspace
|
||||
- use Filament render hooks rather than publishing internal sidebar views
|
||||
- [x] T022 Split environment sidebar IA for workspace-owned links:
|
||||
- workspace-wide hub entries move into a separate `Workspace-wide` group on environment pages
|
||||
- workspace configuration/admin entries move into `Workspace admin`
|
||||
- supported hub links carry explicit `environment_id`; clean workspace/admin links remain unfiltered
|
||||
- [x] T023 Remove the redundant “Choose environment” CTA from the Managed Environments registry:
|
||||
- environment cards remain the entry/open affordance
|
||||
- supporting actions stay limited to Add Environment and Switch Workspace
|
||||
- `/admin/choose-environment` remains the dedicated fast context-switch surface
|
||||
- [x] T024 Remove generic tenantless “All environments” badges from workspace-wide pages:
|
||||
- header scope actions are omitted when no concrete environment context exists
|
||||
- explicit `environment_id` filters remain visible through filter banners/table chips
|
||||
- [x] T019 Run narrow tests first:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact <new/updated Spec 338 tests>`
|
||||
- [x] T020 Run formatting and patch checks:
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- [x] NT001 Do not add migrations, new tables, or persisted truth.
|
||||
- [x] NT002 Do not restructure route families; keep canonical workspace/environment routes.
|
||||
- [x] NT003 Do not introduce a new navigation taxonomy framework; reuse existing `AdminSurfaceScope` / hub registry seams.
|
||||
- [x] NT004 Do not change destructive action behavior (confirmation/authorization/audit).
|
||||
Loading…
Reference in New Issue
Block a user