Implements platform feature branch `feat/043-cross-tenant-compare-and-promotion`. Target branch: `platform-dev`. Follow-up integration path after merge: `platform-dev` → `dev`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #307
674 lines
22 KiB
PHP
674 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
|
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
|
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Grid;
|
|
use Filament\Schemas\Schema;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use UnitEnum;
|
|
|
|
class CrossTenantComparePage extends Page implements HasForms
|
|
{
|
|
use InteractsWithForms;
|
|
|
|
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
|
|
|
|
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
|
|
|
|
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $title = 'Cross-Tenant Compare';
|
|
|
|
protected static ?string $slug = 'cross-tenant-compare';
|
|
|
|
protected string $view = 'filament.pages.cross-tenant-compare';
|
|
|
|
public ?string $sourceTenantId = null;
|
|
|
|
public ?string $targetTenantId = null;
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
public array $selectedPolicyTypes = [];
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $navigationContextPayload = null;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $preview = null;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $preflight = null;
|
|
|
|
public ?string $selectionMessage = null;
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
|
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->authorizePageAccess();
|
|
|
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
|
|
|
$this->hydrateSelectionFromRequest();
|
|
$this->refreshPreview();
|
|
|
|
$this->form->fill($this->formState());
|
|
}
|
|
|
|
public function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Grid::make([
|
|
'default' => 1,
|
|
'xl' => 3,
|
|
])
|
|
->schema([
|
|
Select::make('sourceTenantId')
|
|
->label('Source tenant')
|
|
->options(fn (): array => $this->tenantOptions())
|
|
->searchable()
|
|
->preload()
|
|
->native(false)
|
|
->placeholder('Select a source tenant')
|
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
|
|
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
|
|
Select::make('targetTenantId')
|
|
->label('Target tenant')
|
|
->options(fn (): array => $this->tenantOptions())
|
|
->searchable()
|
|
->preload()
|
|
->native(false)
|
|
->placeholder('Select a target tenant')
|
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
|
|
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
|
|
Select::make('selectedPolicyTypes')
|
|
->label('Governed subjects')
|
|
->options(fn (): array => $this->policyTypeOptions())
|
|
->multiple()
|
|
->searchable()
|
|
->preload()
|
|
->native(false)
|
|
->placeholder('All governed subjects')
|
|
->helperText(fn (): ?string => $this->policyTypeOptions() === []
|
|
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
|
|
: null)
|
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
|
|
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = [];
|
|
|
|
$navigationContext = $this->navigationContext();
|
|
|
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
|
$actions[] = Action::make('return_to_origin')
|
|
->label($navigationContext->backLinkLabel)
|
|
->icon('heroicon-o-arrow-left')
|
|
->color('gray')
|
|
->url($navigationContext->backLinkUrl);
|
|
}
|
|
|
|
$sourceTenant = $this->selectedSourceTenant();
|
|
|
|
if ($sourceTenant instanceof Tenant) {
|
|
$actions[] = Action::make('open_source_tenant')
|
|
->label('Open source tenant')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
|
|
}
|
|
|
|
$targetTenant = $this->selectedTargetTenant();
|
|
|
|
if ($targetTenant instanceof Tenant) {
|
|
$actions[] = Action::make('open_target_tenant')
|
|
->label('Open target tenant')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
|
|
}
|
|
|
|
$preflightAction = Action::make('generatePromotionPreflight')
|
|
->label('Generate promotion preflight')
|
|
->icon('heroicon-o-sparkles')
|
|
->color('primary')
|
|
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
|
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
|
->action(fn (): mixed => $this->generatePromotionPreflight());
|
|
|
|
$preflightAction = WorkspaceUiEnforcement::forAction(
|
|
$preflightAction,
|
|
fn (): ?Workspace => $this->workspace(),
|
|
)
|
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
->preserveDisabled()
|
|
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
|
->apply()
|
|
->tooltip(function (): ?string {
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if ($user instanceof User && $workspace instanceof Workspace) {
|
|
/** @var WorkspaceCapabilityResolver $resolver */
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
if ($resolver->isMember($user, $workspace)
|
|
&& ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
|
return 'You need workspace baseline manage access to generate a promotion preflight.';
|
|
}
|
|
}
|
|
|
|
return $this->preflightDisabledReason();
|
|
});
|
|
|
|
$actions[] = $preflightAction;
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function applySelection(): void
|
|
{
|
|
$this->selectionMessage = null;
|
|
$this->preflight = null;
|
|
|
|
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
|
|
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
|
|
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
|
|
|
|
if ($this->sourceTenantId !== null
|
|
&& $this->targetTenantId !== null
|
|
&& $this->sourceTenantId === $this->targetTenantId) {
|
|
$this->selectionMessage = 'Choose two different tenants.';
|
|
$this->addError('targetTenantId', $this->selectionMessage);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->redirect($this->selectionUrl(), navigate: true);
|
|
}
|
|
|
|
public function generatePromotionPreflight(): void
|
|
{
|
|
$this->authorizePageAccess();
|
|
$this->authorizePreflightExecution();
|
|
|
|
if ($this->preview === null) {
|
|
$this->refreshPreview();
|
|
}
|
|
|
|
if ($this->preview === null) {
|
|
return;
|
|
}
|
|
|
|
$selection = $this->compareSelection();
|
|
|
|
if (! $selection instanceof CrossTenantCompareSelection) {
|
|
return;
|
|
}
|
|
|
|
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
|
|
|
|
$workspace = $this->workspace();
|
|
$user = auth()->user();
|
|
|
|
if ($workspace instanceof Workspace && $user instanceof User) {
|
|
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
|
|
workspace: $workspace,
|
|
sourceTenant: $selection->sourceTenant,
|
|
targetTenant: $selection->targetTenant,
|
|
preflight: $this->preflight,
|
|
actor: $user,
|
|
);
|
|
}
|
|
}
|
|
|
|
public function clearSelectionUrl(): string
|
|
{
|
|
return static::getUrl($this->routeParameters([
|
|
self::SOURCE_TENANT_QUERY_KEY => null,
|
|
self::TARGET_TENANT_QUERY_KEY => null,
|
|
self::POLICY_TYPE_QUERY_KEY => null,
|
|
]), panel: 'admin');
|
|
}
|
|
|
|
public function selectionUrl(): string
|
|
{
|
|
return static::getUrl($this->routeParameters(), panel: 'admin');
|
|
}
|
|
|
|
public static function launchUrl(
|
|
?Tenant $sourceTenant = null,
|
|
?Tenant $targetTenant = null,
|
|
?CanonicalNavigationContext $navigationContext = null,
|
|
): string {
|
|
$parameters = [];
|
|
|
|
if ($sourceTenant instanceof Tenant) {
|
|
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
|
|
}
|
|
|
|
if ($targetTenant instanceof Tenant) {
|
|
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
|
|
}
|
|
|
|
if ($navigationContext instanceof CanonicalNavigationContext) {
|
|
$parameters = array_replace($parameters, $navigationContext->toQuery());
|
|
}
|
|
|
|
return static::getUrl($parameters, panel: 'admin');
|
|
}
|
|
|
|
public function hasActiveSelection(): bool
|
|
{
|
|
return $this->sourceTenantId !== null
|
|
|| $this->targetTenantId !== null
|
|
|| $this->selectedPolicyTypes !== [];
|
|
}
|
|
|
|
public function stateColor(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'match', 'ready' => 'success',
|
|
'different', 'manual_mapping_required' => 'warning',
|
|
'missing' => 'info',
|
|
'ambiguous' => 'gray',
|
|
'blocked' => 'danger',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
public function stateLabel(string $value): string
|
|
{
|
|
return Str::headline(str_replace('_', ' ', $value));
|
|
}
|
|
|
|
public function reasonLabel(string $reasonCode): string
|
|
{
|
|
return Str::headline(str_replace('_', ' ', $reasonCode));
|
|
}
|
|
|
|
public function sourceTenantUrl(): ?string
|
|
{
|
|
$tenant = $this->selectedSourceTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
|
}
|
|
|
|
public function targetTenantUrl(): ?string
|
|
{
|
|
$tenant = $this->selectedTargetTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function formState(): array
|
|
{
|
|
return [
|
|
'sourceTenantId' => $this->sourceTenantId,
|
|
'targetTenantId' => $this->targetTenantId,
|
|
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
|
];
|
|
}
|
|
|
|
private function hydrateSelectionFromRequest(): void
|
|
{
|
|
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
|
|
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
|
|
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
|
|
}
|
|
|
|
private function refreshPreview(): void
|
|
{
|
|
$this->selectionMessage = null;
|
|
$this->preview = null;
|
|
$this->preflight = null;
|
|
|
|
$selection = $this->compareSelection();
|
|
|
|
if (! $selection instanceof CrossTenantCompareSelection) {
|
|
return;
|
|
}
|
|
|
|
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
|
}
|
|
|
|
private function authorizePageAccess(): void
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
abort(404);
|
|
}
|
|
|
|
/** @var WorkspaceCapabilityResolver $resolver */
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
if (! $resolver->isMember($user, $workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function authorizePreflightExecution(): void
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
abort(404);
|
|
}
|
|
|
|
/** @var WorkspaceCapabilityResolver $resolver */
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
if (! $resolver->isMember($user, $workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function compareSelection(): ?CrossTenantCompareSelection
|
|
{
|
|
$sourceTenant = $this->selectedSourceTenant();
|
|
$targetTenant = $this->selectedTargetTenant();
|
|
|
|
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
|
|
$this->selectionMessage = 'Choose two different tenants.';
|
|
|
|
return null;
|
|
}
|
|
|
|
return new CrossTenantCompareSelection(
|
|
sourceTenant: $sourceTenant,
|
|
targetTenant: $targetTenant,
|
|
policyTypes: $this->selectedPolicyTypes,
|
|
);
|
|
}
|
|
|
|
private function selectedSourceTenant(): ?Tenant
|
|
{
|
|
if ($this->sourceTenantId === null) {
|
|
return null;
|
|
}
|
|
|
|
return $this->resolveAuthorizedTenant($this->sourceTenantId);
|
|
}
|
|
|
|
private function selectedTargetTenant(): ?Tenant
|
|
{
|
|
if ($this->targetTenantId === null) {
|
|
return null;
|
|
}
|
|
|
|
return $this->resolveAuthorizedTenant($this->targetTenantId);
|
|
}
|
|
|
|
private function resolveAuthorizedTenant(string $tenantId): Tenant
|
|
{
|
|
$workspace = $this->workspace();
|
|
$user = auth()->user();
|
|
|
|
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
|
abort(404);
|
|
}
|
|
|
|
$tenant = Tenant::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->whereKey((int) $tenantId)
|
|
->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
|
abort(404);
|
|
}
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function tenantOptions(): array
|
|
{
|
|
$workspace = $this->workspace();
|
|
$user = auth()->user();
|
|
|
|
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
|
return [];
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
$tenants = $user->tenants()
|
|
->where('tenants.workspace_id', (int) $workspace->getKey())
|
|
->select('tenants.*')
|
|
->orderBy('tenants.name')
|
|
->get();
|
|
|
|
$resolver->primeMemberships($user, $tenants->modelKeys());
|
|
|
|
return $tenants
|
|
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
|
->mapWithKeys(fn (Tenant $tenant): array => [
|
|
(string) $tenant->getKey() => (string) $tenant->name,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function policyTypeOptions(): array
|
|
{
|
|
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
|
|
|
|
if ($tenantIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return InventoryItem::query()
|
|
->whereIn('tenant_id', $tenantIds)
|
|
->whereNotNull('policy_type')
|
|
->where('policy_type', '!=', '')
|
|
->distinct()
|
|
->orderBy('policy_type')
|
|
->pluck('policy_type')
|
|
->mapWithKeys(fn (string $policyType): array => [
|
|
$policyType => Str::headline($policyType),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function preflightDisabledReason(): ?string
|
|
{
|
|
if ($this->selectionMessage !== null) {
|
|
return $this->selectionMessage;
|
|
}
|
|
|
|
if (! is_array($this->preview)) {
|
|
return 'Select an authorized source and target tenant to generate a promotion preflight.';
|
|
}
|
|
|
|
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
|
|
return 'No governed subjects are available for this compare selection yet.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*/
|
|
private function normalizeTenantIdentifier(mixed $value): ?string
|
|
{
|
|
if (! is_string($value) && ! is_int($value)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = trim((string) $value);
|
|
|
|
return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return list<string>
|
|
*/
|
|
private function normalizePolicyTypes(mixed $value): array
|
|
{
|
|
$allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true);
|
|
|
|
$values = match (true) {
|
|
is_string($value) && $value !== '' => [$value],
|
|
is_array($value) => $value,
|
|
default => [],
|
|
};
|
|
|
|
return array_values(array_filter(array_unique(array_map(
|
|
static fn (mixed $item): string => is_string($item) ? trim($item) : '',
|
|
$values,
|
|
)), static fn (string $item): bool => $item !== '' && isset($allowed[$item])));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function routeParameters(array $overrides = []): array
|
|
{
|
|
$parameters = [
|
|
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
|
|
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
|
|
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
|
|
];
|
|
|
|
if (is_array($this->navigationContextPayload)) {
|
|
$parameters['nav'] = $this->navigationContextPayload;
|
|
}
|
|
|
|
foreach ($overrides as $key => $value) {
|
|
$parameters[$key] = $value;
|
|
}
|
|
|
|
return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
|
}
|
|
|
|
private function navigationContext(): ?CanonicalNavigationContext
|
|
{
|
|
if (! is_array($this->navigationContextPayload)) {
|
|
return CanonicalNavigationContext::fromRequest(request());
|
|
}
|
|
|
|
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
|
}
|
|
|
|
private function workspace(): ?Workspace
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if (! is_int($workspaceId)) {
|
|
return null;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
return $workspace instanceof Workspace ? $workspace : null;
|
|
}
|
|
} |