Fix pending lane animation, approvals table, and Pipeline-only visibility

Pending actions lane:
- Only visible on Pipeline tab (not Approvals), uses showPendingLane computed
- Exit animation now works: pendingLaneHidden signal delays @if removal until
  the 250ms CSS collapse animation completes
- Re-shows when switching back to Pipeline tab

Approvals tab:
- Replaced horizontal card scroll with a proper sortable table
- Columns: Release, Promotion (source → target), Status, Urgency, Gates, Requested, Actions
- Shows requestedAt date + requestedBy in the Requested column
- Approve/Reject/View buttons inline in the Actions column
- Empty state message when no approvals match gate filters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-27 18:46:48 +02:00
parent 27690ed9a6
commit b6862190d0

View File

@@ -70,8 +70,8 @@ function deriveOutcomeIcon(status: string): string {
<p>Deployment runs, approvals, and promotion activity.</p>
</header>
<!-- Pending approvals inline lane (dashboard-style action cards) -->
@if (pendingApprovals().length > 0 && viewMode() !== 'approvals') {
<!-- Pending approvals inline lane (only visible on Pipeline tab) -->
@if (showPendingLane()) {
<div class="pending-lane" [class.pending-lane--exiting]="pendingLaneExiting()">
<div class="pending-lane__header">
<h2 class="pending-lane__title">
@@ -197,49 +197,47 @@ function deriveOutcomeIcon(status: string): string {
} @else if (filteredApprovals().length === 0) {
<div class="empty-state">No approvals match the active gate filters.</div>
} @else {
<div class="apc-lane-wrapper" [class.can-scroll-left]="showApcLeft()" [class.can-scroll-right]="showApcRight()">
@if (showApcLeft()) {
<button class="apc-scroll apc-scroll--left" (click)="scrollApc('left')" type="button" aria-label="Scroll left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
}
<div class="apc-lane" #apcScroll (scroll)="onApcScroll()">
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead>
<tr>
<th>Release</th>
<th>Promotion</th>
<th>Status</th>
<th>Urgency</th>
<th>Gates</th>
<th>Requested</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (apr of filteredApprovals(); track apr.id) {
<div class="apc" [class.apc--prod]="isProductionEnv(apr.targetEnvironment)" [class.apc--expiring]="isExpiringSoon(apr.expiresAt)">
<div class="apc__head">
<div class="apc__id"><span class="apc__name">{{ apr.releaseName }}</span>@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {<span class="apc__ver">{{ apr.releaseVersion }}</span>}</div>
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
</div>
<div class="apc__envs">
<span class="apc__env">{{ apr.sourceEnvironment }}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="apc__arr"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
<span class="apc__env" [class.apc__env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
</div>
<div class="apc__meta">
<span class="apc__who">by {{ apr.requestedBy }}</span>
<span class="apc__gate" [class.apc__gate--pass]="apr.gatesPassed" [class.apc__gate--fail]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}</span>
<span class="apc__exp" [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</span>
</div>
<div class="apc__status-row">
<span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span>
<span class="gate-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span>
</div>
<div class="apc__btns">
@if (apr.status === 'pending') {
<button class="apc__btn apc__btn--approve" (click)="onApprove(apr)" type="button">Approve</button>
<button class="apc__btn apc__btn--reject" (click)="onReject(apr)" type="button">Reject</button>
<tr [class.row--prod]="isProductionEnv(apr.targetEnvironment)">
<td>
<strong>{{ apr.releaseName }}</strong>
@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {
<br><span class="muted mono">{{ apr.releaseVersion }}</span>
}
<button class="apc__btn apc__btn--view" (click)="onView(apr)" type="button">View</button>
</div>
</div>
</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span></td>
<td><span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span></td>
<td><span [class.text-success]="apr.gatesPassed" [class.text-danger]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Passed' : 'Failed' }}</span></td>
<td class="muted">{{ formatDate(apr.requestedAt) }}<br>by {{ apr.requestedBy }}</td>
<td>
<div class="row-actions">
@if (apr.status === 'pending') {
<button class="apc__btn apc__btn--approve" (click)="onApprove(apr)" type="button">Approve</button>
<button class="apc__btn apc__btn--reject" (click)="onReject(apr)" type="button">Reject</button>
}
<button class="apc__btn apc__btn--view" (click)="onView(apr)" type="button">View</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="7" style="text-align:center;padding:1.5rem;color:var(--color-text-muted)">No approvals match the active gate filters.</td></tr>
}
</div>
@if (showApcRight()) {
<button class="apc-scroll apc-scroll--right" (click)="scrollApc('right')" type="button" aria-label="Scroll right">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
}
</div>
</tbody>
</table>
}
} @else {
<!-- Existing deployment views: filters + timeline/table/correlations -->
@@ -733,12 +731,17 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
}
readonly pendingLaneExiting = signal(false);
readonly showPendingLane = computed(() =>
this.pendingApprovals().length > 0 && this.viewMode() === 'timeline' && !this.pendingLaneHidden()
);
private readonly pendingLaneHidden = signal(false);
onTabChange(tab: string): void {
// Animate pending lane out before switching to approvals
if (tab === 'approvals' && this.pendingApprovals().length > 0 && this.viewMode() !== 'approvals') {
if (tab === 'approvals' && this.pendingApprovals().length > 0 && this.viewMode() === 'timeline') {
this.pendingLaneExiting.set(true);
setTimeout(() => {
this.pendingLaneHidden.set(true);
this.pendingLaneExiting.set(false);
this.viewMode.set('approvals');
this.loadApprovals();
@@ -746,6 +749,10 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
}, 250);
return;
}
// When switching back to pipeline, show pending lane again
if (tab === 'timeline') {
this.pendingLaneHidden.set(false);
}
this.viewMode.set(tab as 'timeline' | 'approvals');
if (tab === 'approvals') this.loadApprovals();
this.applyFilters();