Implemented the consolidated operator guidance panel for the environment dashboard. Updated EnvironmentDashboardSummaryBuilder to prioritize and select guidance based on the operator guidance contract. Added comprehensive unit, feature, and browser tests to verify the guidance selection logic and UI rendering. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #423
365 lines
25 KiB
PHP
365 lines
25 KiB
PHP
@php
|
|
$tenantDashboardStatusBadgeClasses = 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',
|
|
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-800 dark:bg-warning-500/10 dark:text-warning-300',
|
|
'info' => 'border-info-200 bg-info-50 text-info-700 dark:border-info-800 dark:bg-info-500/10 dark:text-info-300',
|
|
'primary' => 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-800 dark:bg-primary-500/10 dark:text-primary-300',
|
|
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-200',
|
|
};
|
|
$secondaryRecommendedActions = array_values(array_filter(
|
|
$recommendedActions,
|
|
static function (array $action) use ($operatorGuidance): bool {
|
|
$guidanceKey = $operatorGuidance['key'] ?? null;
|
|
$guidanceLabel = $operatorGuidance['actionLabel'] ?? null;
|
|
$guidanceUrl = $operatorGuidance['actionUrl'] ?? null;
|
|
|
|
if (($action['key'] ?? null) === $guidanceKey) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
($action['actionLabel'] ?? null) === $guidanceLabel
|
|
&& ($action['actionUrl'] ?? null) === $guidanceUrl
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
));
|
|
$operatorGuidanceKey = is_string($operatorGuidance['key'] ?? null) ? (string) $operatorGuidance['key'] : null;
|
|
$useCompactRecommendedActions = $secondaryRecommendedActions !== [] && (
|
|
($operatorGuidanceKey !== null && str_starts_with($operatorGuidanceKey, 'provider_readiness.'))
|
|
|| ($operatorGuidanceKey !== null && str_starts_with($operatorGuidanceKey, 'review_output.'))
|
|
);
|
|
$recommendedActionsHeading = $useCompactRecommendedActions
|
|
? __('localization.dashboard.overview.section_additional_follow_ups')
|
|
: __('localization.dashboard.overview.section_recommended_actions');
|
|
$recommendedActionsDescription = $useCompactRecommendedActions
|
|
? __('localization.dashboard.overview.section_additional_follow_ups_summary')
|
|
: 'Recommended next actions are derived from repo-backed blockers and proof gaps.';
|
|
@endphp
|
|
|
|
<div
|
|
@if ($pollingInterval)
|
|
wire:poll.{{ $pollingInterval }}
|
|
@endif
|
|
data-testid="tenant-dashboard-overview"
|
|
class="grid w-full min-w-0 gap-6 xl:grid-cols-12"
|
|
style="grid-column: 1 / -1;"
|
|
>
|
|
<!-- Left Column (Main) -->
|
|
<div data-testid="tenant-dashboard-overview-main" class="flex w-full min-w-0 flex-col gap-6 xl:col-span-8">
|
|
<x-filament::section>
|
|
<div data-testid="tenant-dashboard-readiness-decision" class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem] xl:items-start">
|
|
<div class="min-w-0 space-y-4">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
|
{{ $readinessDecision['question'] ?? 'Is this environment ready, blocked, stale, or requiring review?' }}
|
|
</h2>
|
|
<span data-testid="tenant-dashboard-status-badge" class="{{ $tenantDashboardStatusBadgeClasses($readinessDecision['tone'] ?? 'gray') }}">
|
|
{{ $readinessDecision['status'] ?? ($posture['status'] ?? __('localization.dashboard.overview.status_unavailable')) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div data-testid="tenant-dashboard-operator-guidance-title" class="max-w-3xl">
|
|
<p class="text-lg font-semibold leading-7 text-gray-950 dark:text-white">
|
|
{{ $readinessDecision['title'] ?? ($operatorGuidance['title'] ?? __('localization.dashboard.overview.environment_context_unavailable_headline')) }}
|
|
</p>
|
|
</div>
|
|
|
|
<dl class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/80 dark:border-white/10 dark:bg-white/5">
|
|
<div class="grid gap-1 px-4 py-3 md:grid-cols-[9rem_minmax(0,1fr)] md:gap-4">
|
|
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $readinessDecision['statusLabel'] ?? 'Status' }}</dt>
|
|
<dd class="text-sm font-semibold text-gray-950 dark:text-white">{{ $readinessDecision['status'] ?? ($posture['status'] ?? __('localization.dashboard.overview.status_unavailable')) }}</dd>
|
|
</div>
|
|
|
|
<div class="grid gap-1 border-t border-gray-200 px-4 py-3 md:grid-cols-[9rem_minmax(0,1fr)] md:gap-4 dark:border-white/10">
|
|
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $readinessDecision['reasonLabel'] ?? 'Reason' }}</dt>
|
|
<dd class="text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $readinessDecision['reason'] ?? ($posture['headline'] ?? __('localization.dashboard.overview.tenant_context_unavailable_headline')) }}</dd>
|
|
</div>
|
|
|
|
<div class="grid gap-1 border-t border-gray-200 px-4 py-3 md:grid-cols-[9rem_minmax(0,1fr)] md:gap-4 dark:border-white/10">
|
|
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $readinessDecision['impactLabel'] ?? 'Why this matters' }}</dt>
|
|
<dd class="text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $readinessDecision['impact'] ?? ($posture['summary'] ?? __('localization.dashboard.overview.tenant_context_unavailable_summary')) }}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<div data-testid="tenant-dashboard-primary-next-action" class="flex min-w-0 flex-col gap-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5 xl:w-72 xl:self-start">
|
|
<div class="space-y-2">
|
|
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $readinessDecision['nextActionLabel'] ?? __('localization.dashboard.overview.label_recommended_next_action') }}</div>
|
|
<div class="text-sm font-semibold leading-6 text-gray-950 dark:text-white">{{ $readinessDecision['actionLabel'] ?? 'Review readiness proof' }}</div>
|
|
</div>
|
|
|
|
@if (filled($readinessDecision['actionUrl'] ?? null))
|
|
<x-filament::button class="w-full justify-center" tag="a" :href="$readinessDecision['actionUrl']" size="sm">
|
|
{{ $readinessDecision['actionLabel'] ?? 'Review readiness proof' }}
|
|
</x-filament::button>
|
|
@else
|
|
<x-filament::button class="w-full justify-center" size="sm" color="gray" disabled>
|
|
{{ $readinessDecision['actionLabel'] ?? 'Review readiness proof' }}
|
|
</x-filament::button>
|
|
@endif
|
|
|
|
@if (filled($readinessDecision['helperText'] ?? null) || ($readinessDecision['secondaryActions'] ?? []) !== [])
|
|
<div class="space-y-3 border-t border-gray-100 pt-3 dark:border-white/10">
|
|
@if (filled($readinessDecision['helperText'] ?? null))
|
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $readinessDecision['helperText'] }}</p>
|
|
@endif
|
|
|
|
@if (($readinessDecision['secondaryActions'] ?? []) !== [])
|
|
<div data-testid="tenant-dashboard-operator-guidance-secondary-actions" class="flex flex-wrap gap-x-3 gap-y-2">
|
|
@foreach ($readinessDecision['secondaryActions'] as $secondaryAction)
|
|
@if (filled($secondaryAction['actionUrl'] ?? null))
|
|
<a
|
|
data-testid="tenant-dashboard-operator-guidance-secondary-action"
|
|
href="{{ $secondaryAction['actionUrl'] }}"
|
|
class="text-xs font-medium text-primary-600 underline-offset-2 hover:underline dark:text-primary-400"
|
|
>
|
|
{{ $secondaryAction['actionLabel'] ?? __('localization.dashboard.overview.action_review_environment') }}
|
|
</a>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<x-filament::section heading="Readiness dimensions">
|
|
<div data-testid="tenant-dashboard-readiness-dimensions" class="grid gap-3 md:grid-cols-2">
|
|
@forelse ($readinessDimensions as $dimension)
|
|
<div data-testid="tenant-dashboard-readiness-dimension" data-readiness-key="{{ $dimension['key'] ?? '' }}" class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
<div class="flex flex-col gap-2">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $dimension['title'] ?? 'Readiness signal' }}</div>
|
|
</div>
|
|
<div>
|
|
<span data-testid="tenant-dashboard-status-badge" class="{{ $tenantDashboardStatusBadgeClasses($dimension['tone'] ?? 'gray') }}">
|
|
{{ $dimension['status'] ?? __('localization.dashboard.overview.status_unavailable') }}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $dimension['description'] ?? '' }}</p>
|
|
</div>
|
|
</div>
|
|
@empty
|
|
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
|
Readiness dimensions are unavailable until an environment context is selected.
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<!-- Recommended Actions -->
|
|
<x-filament::section :heading="$recommendedActionsHeading">
|
|
<x-slot name="description">
|
|
{{ $recommendedActionsDescription }}
|
|
</x-slot>
|
|
|
|
@if ($secondaryRecommendedActions === [])
|
|
<div data-testid="tenant-dashboard-recommended-actions-empty" class="rounded-xl border border-success-200 bg-success-50/80 p-5 dark:border-success-800 dark:bg-success-500/10">
|
|
<div class="text-sm font-semibold text-success-700 dark:text-success-300">{{ __('localization.dashboard.overview.empty_recommended_actions_headline') }}</div>
|
|
<p class="mt-2 text-sm leading-6 text-success-700/90 dark:text-success-200/90">
|
|
{{ __('localization.dashboard.overview.empty_recommended_actions_summary') }}
|
|
</p>
|
|
</div>
|
|
@elseif ($useCompactRecommendedActions)
|
|
<div data-testid="tenant-dashboard-recommended-actions" data-recommended-actions-style="compact" class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
@foreach (array_slice($secondaryRecommendedActions, 0, 2) as $index => $action)
|
|
<div
|
|
data-testid="tenant-dashboard-recommended-action"
|
|
data-action-key="{{ $action['key'] }}"
|
|
class="flex min-w-0 flex-col gap-3 px-4 py-4 sm:flex-row sm:items-start sm:justify-between {{ $index > 0 ? 'border-t border-gray-100 dark:border-white/10' : '' }}"
|
|
>
|
|
<div class="min-w-0 space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
@if (filled($action['icon'] ?? null))
|
|
<x-filament::icon
|
|
data-testid="tenant-dashboard-recommended-action-icon"
|
|
data-action-key="{{ $action['key'] }}"
|
|
data-icon="{{ $action['icon'] }}"
|
|
:icon="$action['icon']"
|
|
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
|
/>
|
|
@endif
|
|
|
|
<h3 class="min-w-0 text-sm font-semibold text-gray-950 dark:text-white">{{ $action['title'] }}</h3>
|
|
</div>
|
|
|
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
|
<span class="font-medium text-gray-700 dark:text-gray-300">Reason:</span> {{ $action['reason'] }}
|
|
</p>
|
|
</div>
|
|
|
|
@if (filled($action['actionUrl'] ?? null))
|
|
<a
|
|
data-testid="tenant-dashboard-secondary-action"
|
|
href="{{ $action['actionUrl'] }}"
|
|
class="shrink-0 text-xs font-semibold text-primary-600 underline-offset-2 hover:underline dark:text-primary-400"
|
|
>
|
|
{{ $action['actionLabel'] ?? 'Review' }}
|
|
</a>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div data-testid="tenant-dashboard-recommended-actions" class="grid min-w-0 gap-4">
|
|
@foreach (array_slice($secondaryRecommendedActions, 0, 3) as $index => $action)
|
|
<div data-testid="tenant-dashboard-recommended-action" data-action-key="{{ $action['key'] }}" class="flex min-w-0 items-start gap-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 max-sm:flex-col max-sm:items-stretch">
|
|
<div data-testid="tenant-dashboard-recommended-action-priority" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
|
|
{{ $index + 1 }}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
@if (filled($action['icon'] ?? null))
|
|
<x-filament::icon
|
|
data-testid="tenant-dashboard-recommended-action-icon"
|
|
data-action-key="{{ $action['key'] }}"
|
|
data-icon="{{ $action['icon'] }}"
|
|
:icon="$action['icon']"
|
|
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
|
/>
|
|
@endif
|
|
|
|
<h3 class="min-w-0 text-sm font-semibold text-gray-950 dark:text-white">{{ $action['title'] }}</h3>
|
|
</div>
|
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
<span class="font-medium text-gray-700 dark:text-gray-300">Reason:</span> {{ $action['reason'] }}
|
|
</p>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-500">
|
|
<span class="font-medium text-gray-700 dark:text-gray-300">Impact:</span> {{ $action['impact'] }}
|
|
</p>
|
|
</div>
|
|
@if (filled($action['actionUrl'] ?? null))
|
|
<div class="shrink-0 max-sm:ml-0 sm:ml-4">
|
|
<x-filament::button data-testid="tenant-dashboard-secondary-action" :href="$action['actionUrl']" tag="a" color="gray" size="sm">
|
|
{{ $action['actionLabel'] ?? 'Review' }}
|
|
</x-filament::button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</x-filament::section>
|
|
|
|
<x-filament::section heading="Supporting signals">
|
|
<x-slot name="description">
|
|
Additional readiness signals used to explain the current recommendation.
|
|
</x-slot>
|
|
|
|
<div data-testid="tenant-dashboard-supporting-signals" class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
@if ($supportingSignals === [])
|
|
<div class="p-4 text-sm text-gray-600 dark:text-gray-300">
|
|
Supporting signals are unavailable until an environment context is selected.
|
|
</div>
|
|
@else
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-100 text-sm dark:divide-white/10">
|
|
<thead class="bg-gray-50/80 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-white/5 dark:text-gray-400">
|
|
<tr>
|
|
<th scope="col" class="px-4 py-2 text-left">Signal</th>
|
|
<th scope="col" class="px-4 py-2 text-left">State</th>
|
|
<th scope="col" class="px-4 py-2 text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-white/10">
|
|
@foreach ($supportingSignals as $signal)
|
|
<tr data-testid="tenant-dashboard-supporting-signal" data-signal-key="{{ $signal['key'] ?? '' }}">
|
|
<th scope="row" class="px-4 py-3 text-left align-middle font-semibold text-gray-950 dark:text-white">
|
|
{{ $signal['label'] ?? 'Readiness signal' }}
|
|
</th>
|
|
<td class="px-4 py-3 align-middle">
|
|
<span data-testid="tenant-dashboard-status-badge" class="{{ $tenantDashboardStatusBadgeClasses($signal['tone'] ?? 'gray') }}">
|
|
{{ $signal['value'] ?? __('localization.dashboard.overview.status_unavailable') }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-right align-middle">
|
|
@if (filled($signal['actionLabel'] ?? null))
|
|
@if (filled($signal['actionUrl'] ?? null) && ! ($signal['actionDisabled'] ?? false))
|
|
<x-filament::button
|
|
data-testid="tenant-dashboard-supporting-signal-action"
|
|
tag="a"
|
|
:href="$signal['actionUrl']"
|
|
size="sm"
|
|
color="gray"
|
|
>
|
|
{{ $signal['actionLabel'] }}
|
|
</x-filament::button>
|
|
@else
|
|
<x-filament::button
|
|
data-testid="tenant-dashboard-supporting-signal-action"
|
|
size="sm"
|
|
color="gray"
|
|
disabled
|
|
>
|
|
{{ $signal['actionLabel'] }}
|
|
</x-filament::button>
|
|
@endif
|
|
@endif
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</x-filament::section>
|
|
|
|
<x-filament::section>
|
|
<details data-testid="tenant-dashboard-diagnostics" class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
<summary class="cursor-pointer text-sm font-semibold text-gray-950 dark:text-white">
|
|
{{ $diagnosticsDisclosure['label'] ?? 'Diagnostics - Collapsed' }}
|
|
</summary>
|
|
<p class="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
{{ $diagnosticsDisclosure['summary'] ?? 'Support diagnostics stay closed by default and require the existing diagnostics capability.' }}
|
|
</p>
|
|
</details>
|
|
</x-filament::section>
|
|
</div>
|
|
|
|
<!-- Right Column (Aside) -->
|
|
<div data-testid="tenant-dashboard-overview-aside" class="flex w-full min-w-0 flex-col gap-6 xl:col-span-4">
|
|
<x-filament::section heading="Readiness proof">
|
|
<div data-testid="tenant-dashboard-readiness-proof-panel" class="flex flex-col gap-3">
|
|
@foreach ($readinessProofPanel as $proof)
|
|
<div data-testid="tenant-dashboard-readiness-proof-item" data-proof-key="{{ $proof['key'] ?? '' }}" class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
<div class="flex flex-col gap-2">
|
|
<div>
|
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $proof['label'] ?? 'Proof path' }}</div>
|
|
</div>
|
|
<div>
|
|
<span data-testid="tenant-dashboard-status-badge" class="{{ $tenantDashboardStatusBadgeClasses($proof['tone'] ?? 'gray') }}">
|
|
{{ $proof['value'] ?? __('localization.dashboard.overview.status_unavailable') }}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $proof['description'] ?? '' }}</p>
|
|
</div>
|
|
|
|
@if (filled($proof['actionLabel'] ?? null))
|
|
<div class="mt-3">
|
|
@if (filled($proof['actionUrl'] ?? null))
|
|
<x-filament::button tag="a" :href="$proof['actionUrl']" size="sm" color="gray">
|
|
{{ $proof['actionLabel'] }}
|
|
</x-filament::button>
|
|
@else
|
|
<x-filament::button size="sm" color="gray" disabled>
|
|
{{ $proof['actionLabel'] }}
|
|
</x-filament::button>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
|
|
</div>
|
|
</x-filament::section>
|
|
</div>
|
|
</div>
|