Promote button logic, delete confirmation, tooltips, and AGENTS.md conventions
U2 — Promote button three-state model: - showPromote: visible only when a next promotion target exists - canPromote: enabled only when deployed + no blocking gates - promoteDisabledReason: tooltip explaining why promotion is disabled - Added .rdp__btn--disabled style (opacity + pointer-events) W1 — Script delete confirmation: - Replace window.confirm() with app-confirm-dialog variant="danger" - Names the script being deleted in the confirmation message W2 — Script description tooltip: - Add [title] binding to truncated description text in scripts table V1 — Remove duplicate "Profile" h2 in User Preferences tab panel X1 — Breadcrumb root "Ops" → "Operations" to match sidebar group label AGENTS.md — Three new mandatory conventions: - Destructive Action Convention: all deletes/revokes must use app-confirm-dialog - Truncated Text Convention: all text-overflow:ellipsis elements must have [title] - Promote Button Convention: three-state (hidden/disabled/enabled) model Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,49 @@ Design and build the StellaOps web user experience that surfaces backend capabil
|
|||||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||||
|
|
||||||
|
## Destructive Action Convention (MANDATORY)
|
||||||
|
|
||||||
|
All destructive actions (delete, revoke, purge, reset) **must** use `<app-confirm-dialog>` — never `window.confirm()` or unguarded inline handlers.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Every destructive button must open a styled `<app-confirm-dialog>` with `variant="danger"` before executing
|
||||||
|
- The confirm dialog message must name the resource being destroyed (e.g., "Delete script 'Pre-deploy Health Check'?")
|
||||||
|
- Include "This cannot be undone." or equivalent irreversibility warning when applicable
|
||||||
|
- Never perform destructive API calls directly from a `(click)` handler without confirmation
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```html
|
||||||
|
<app-confirm-dialog #deleteConfirm
|
||||||
|
title="Delete Script"
|
||||||
|
[message]="'Delete script \\'' + item.name + '\\'? This cannot be undone.'"
|
||||||
|
confirmLabel="Delete" cancelLabel="Cancel" variant="danger"
|
||||||
|
(confirmed)="executeDelete()" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Truncated Text Convention (MANDATORY)
|
||||||
|
|
||||||
|
All text that may be truncated by CSS (`text-overflow: ellipsis`, table cell overflow, or max-width constraints) **must** have a `[title]` attribute binding to the full untruncated text.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Table cells with descriptions, names, digests, or IDs that truncate must include `[title]="fullValue"`
|
||||||
|
- Metric card labels already have `[title]="label"` (built into `stella-metric-card`) — do not add a second one
|
||||||
|
- For dynamically computed truncation, prefer `[title]` over custom tooltip directives for simplicity and offline compatibility
|
||||||
|
- Ensure tooltips are also applied to `<span>` and `<code>` elements inside table cells when they use `text-overflow: ellipsis`
|
||||||
|
|
||||||
|
## Promote Button Convention (MANDATORY)
|
||||||
|
|
||||||
|
The Promote button on release detail pages must follow a three-state model:
|
||||||
|
|
||||||
|
1. **Hidden** — no further promotion path exists (single-environment release, or already at the final environment in the promotion graph)
|
||||||
|
2. **Disabled** — promotion path exists but preconditions are not met:
|
||||||
|
- Release is not yet deployed on the current environment (status = `draft`)
|
||||||
|
- Blocking gates are unresolved
|
||||||
|
- Tooltip must explain why promotion is disabled
|
||||||
|
3. **Enabled** — release is deployed, gates are clear, and a next environment exists
|
||||||
|
|
||||||
|
Use `showPromote` (computed, boolean) for visibility and `canPromote` (computed, boolean) for the enabled/disabled state.
|
||||||
|
Use `promoteDisabledReason` (computed, string | null) for the disabled tooltip.
|
||||||
|
|
||||||
## Metric / KPI Cards Convention (MANDATORY)
|
## Metric / KPI Cards Convention (MANDATORY)
|
||||||
|
|
||||||
All metric badges, stat cards, KPI tiles, and summary indicators **must** use `<stella-metric-card>`.
|
All metric badges, stat cards, KPI tiles, and summary indicators **must** use `<stella-metric-card>`.
|
||||||
|
|||||||
@@ -163,9 +163,9 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ops',
|
path: 'ops',
|
||||||
title: 'Ops',
|
title: 'Operations',
|
||||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireOpsGuard],
|
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireOpsGuard],
|
||||||
data: { breadcrumb: 'Ops' },
|
data: { breadcrumb: 'Operations' },
|
||||||
loadChildren: () => import('./routes/ops.routes').then((m) => m.OPS_ROUTES),
|
loadChildren: () => import('./routes/ops.routes').then((m) => m.OPS_ROUTES),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,8 +95,14 @@ const TABS: StellaPageTab[] = [
|
|||||||
@if (store.canDeploy()) {
|
@if (store.canDeploy()) {
|
||||||
<button type="button" class="rdp__btn rdp__btn--deploy" (click)="onDeploy()">Deploy</button>
|
<button type="button" class="rdp__btn rdp__btn--deploy" (click)="onDeploy()">Deploy</button>
|
||||||
}
|
}
|
||||||
@if (store.canPromote()) {
|
@if (store.showPromote()) {
|
||||||
<a class="rdp__btn rdp__btn--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: releaseId() }">Promote</a>
|
<a class="rdp__btn rdp__btn--promote"
|
||||||
|
[class.rdp__btn--disabled]="!store.canPromote()"
|
||||||
|
[routerLink]="store.canPromote() ? ['/releases/promotions'] : null"
|
||||||
|
[queryParams]="store.canPromote() ? { releaseId: releaseId() } : {}"
|
||||||
|
[title]="store.promoteDisabledReason() ?? 'Promote to next environment'"
|
||||||
|
[attr.aria-disabled]="!store.canPromote()"
|
||||||
|
role="link">Promote</a>
|
||||||
}
|
}
|
||||||
@if (store.canRollback()) {
|
@if (store.canRollback()) {
|
||||||
<button type="button" class="rdp__btn rdp__btn--rollback" (click)="onRollback()">Rollback</button>
|
<button type="button" class="rdp__btn rdp__btn--rollback" (click)="onRollback()">Rollback</button>
|
||||||
@@ -370,6 +376,7 @@ const TABS: StellaPageTab[] = [
|
|||||||
.rdp__btn--deploy:hover { opacity: 0.9; }
|
.rdp__btn--deploy:hover { opacity: 0.9; }
|
||||||
.rdp__btn--promote { background: #00695C; color: #fff; }
|
.rdp__btn--promote { background: #00695C; color: #fff; }
|
||||||
.rdp__btn--promote:hover { opacity: 0.9; }
|
.rdp__btn--promote:hover { opacity: 0.9; }
|
||||||
|
.rdp__btn--disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
|
||||||
.rdp__btn--rollback { background: var(--color-status-warning, #C89820); color: #fff; }
|
.rdp__btn--rollback { background: var(--color-status-warning, #C89820); color: #fff; }
|
||||||
.rdp__btn--rollback:hover { opacity: 0.9; }
|
.rdp__btn--rollback:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,30 @@ export class ReleaseDetailStore {
|
|||||||
return summary?.gates.some(g => g.status === 'BLOCK') ?? false;
|
return summary?.gates.some(g => g.status === 'BLOCK') ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Whether the Promote button should be visible (has a promotion path at all). */
|
||||||
|
readonly showPromote = computed(() => {
|
||||||
|
return this.release() !== null && this.nextPromotionTarget() !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether the Promote action is enabled (deployed on current env, no blocking gates). */
|
||||||
readonly canPromote = computed(() => {
|
readonly canPromote = computed(() => {
|
||||||
return this.release() !== null && !this.hasBlockingGates();
|
if (!this.release()) return false;
|
||||||
|
if (this.hasBlockingGates()) return false;
|
||||||
|
if (!this.nextPromotionTarget()) return false;
|
||||||
|
// Must be deployed on current environment before promoting further
|
||||||
|
const r = this.release()!;
|
||||||
|
if (r.status === 'draft') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Reason text when Promote is disabled but visible. */
|
||||||
|
readonly promoteDisabledReason = computed<string | null>(() => {
|
||||||
|
if (!this.showPromote()) return null;
|
||||||
|
if (this.canPromote()) return null;
|
||||||
|
if (this.hasBlockingGates()) return 'Resolve blocking gates before promoting';
|
||||||
|
const r = this.release();
|
||||||
|
if (r?.status === 'draft') return 'Deploy to the current environment first';
|
||||||
|
return 'Not eligible for promotion';
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly currentEnvironments = computed(() => {
|
readonly currentEnvironments = computed(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, ViewChild, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { take } from 'rxjs';
|
import { take } from 'rxjs';
|
||||||
|
|
||||||
@@ -8,12 +8,13 @@ import { PageActionService } from '../../core/services/page-action.service';
|
|||||||
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
|
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
|
||||||
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-scripts-list',
|
selector: 'app-scripts-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent],
|
imports: [RouterLink, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent],
|
||||||
template: `
|
template: `
|
||||||
<section class="scripts">
|
<section class="scripts">
|
||||||
<header class="scripts__header">
|
<header class="scripts__header">
|
||||||
@@ -89,7 +90,7 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
|||||||
<td>
|
<td>
|
||||||
<a class="script-name" [routerLink]="['/ops/scripts', s.id]">{{ s.name }}</a>
|
<a class="script-name" [routerLink]="['/ops/scripts', s.id]">{{ s.name }}</a>
|
||||||
@if (s.description) {
|
@if (s.description) {
|
||||||
<span class="script-desc">{{ s.description }}</span>
|
<span class="script-desc" [title]="s.description">{{ s.description }}</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td><span class="lang-badge" [attr.data-lang]="s.language">{{ langLabel(s.language) }}</span></td>
|
<td><span class="lang-badge" [attr.data-lang]="s.language">{{ langLabel(s.language) }}</span></td>
|
||||||
@@ -107,6 +108,12 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<app-confirm-dialog #deleteConfirm
|
||||||
|
title="Delete Script"
|
||||||
|
[message]="'Delete script \\'' + (pendingDeleteScript()?.name ?? '') + '\\'? This cannot be undone.'"
|
||||||
|
confirmLabel="Delete" cancelLabel="Cancel" variant="danger"
|
||||||
|
(confirmed)="executeDelete()" />
|
||||||
|
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
<app-pagination
|
<app-pagination
|
||||||
[total]="filteredScripts().length"
|
[total]="filteredScripts().length"
|
||||||
@@ -273,11 +280,21 @@ export class ScriptsListComponent implements OnInit, OnDestroy {
|
|||||||
this.pageSize.set(event.pageSize);
|
this.pageSize.set(event.pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly pendingDeleteScript = signal<Script | null>(null);
|
||||||
|
@ViewChild('deleteConfirm') deleteConfirm!: ConfirmDialogComponent;
|
||||||
|
|
||||||
confirmDelete(script: Script): void {
|
confirmDelete(script: Script): void {
|
||||||
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
|
this.pendingDeleteScript.set(script);
|
||||||
|
this.deleteConfirm.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
executeDelete(): void {
|
||||||
|
const script = this.pendingDeleteScript();
|
||||||
|
if (!script) return;
|
||||||
this.api.deleteScript(script.id).pipe(take(1)).subscribe({
|
this.api.deleteScript(script.id).pipe(take(1)).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.scripts.update((list) => list.filter((s) => s.id !== script.id));
|
this.scripts.update((list) => list.filter((s) => s.id !== script.id));
|
||||||
|
this.pendingDeleteScript.set(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
|
|||||||
<div class="prefs-card__intro">
|
<div class="prefs-card__intro">
|
||||||
<div class="prefs-card__avatar" aria-hidden="true">{{ userInitials() }}</div>
|
<div class="prefs-card__avatar" aria-hidden="true">{{ userInitials() }}</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="prefs-card__title">Profile</h2>
|
|
||||||
<p class="prefs-card__desc">Manage your account identity and contact details</p>
|
<p class="prefs-card__desc">Manage your account identity and contact details</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user