Restructure navigation UX: sidebar groups, route aliases, and phase 3-6 polish
Sidebar (phases 1-4, committed in parent sprint):
- Dashboard childless; Releases gains Health child
- Operations moved to release-control group with 8 promoted children
- Evidence renamed to Audit; Logs/Bundles as canonical sub-items
- Setup Notifications removed (consolidated)
Route fixes and canonical restore (Sprint 030):
- releases.routes: /health loads EnvironmentPosturePageComponent;
/environments and /environments/:environmentId kept as canonical Releases routes
- legacy-redirects: release-orchestrator/environments and
release-control/environments both redirect to /releases/environments
- app.routes: release-control/{environments,regions} alias → /releases/environments
- route-surface-ownership spec updated to match canonical Releases paths
- live-route-surface-ownership-check expected paths aligned
Phase 3: Remove in-page "Create Hotfix" button from hotfixes-queue component;
topbar action is the sole create affordance.
Phase 6 UX polish:
- security-reports-page: stub link-list → tabbed layout (Risk, VEX, Export)
- filter-bar: Search button + Enter key trigger; top-row / filter-row layout
- approvals-inbox: horizontal chip-style status filters replacing pill buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Sprint 20260310_030 - FE Releases Environment Canonical Route Restore
|
||||
|
||||
## Topic & Scope
|
||||
- Restore `/releases/environments` as a canonical Releases route instead of redirecting it into Operations.
|
||||
- Keep the working topology-backed environment inventory UI, but mount it directly under Releases so the live route contract matches the product shell.
|
||||
- Realign legacy environment aliases and live route-ownership evidence with the restored canonical route.
|
||||
- Working directory: `src/Web/StellaOps.Web/src/app/routes`.
|
||||
- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/app.routes.ts`, `src/Web/StellaOps.Web/scripts`, `docs/implplan/SPRINT_20260310_030_FE_releases_environment_canonical_route_restore.md`.
|
||||
- Expected evidence: focused Angular route tests, rebuilt web bundle, live Playwright canonical/ownership checks.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the current live compose stack and the prior route-ownership cleanup sprint.
|
||||
- Safe parallelism: do not revive the dead release-orchestrator environment pages in this slice; keep the fix bounded to route contracts and evidence.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
- `docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-RELEASE-ENV-001 - Restore canonical Releases ownership for environment inventory
|
||||
Status: DOING
|
||||
Dependency: none
|
||||
Owners: QA, 3rd Line Support, Product Manager, Architect, Developer
|
||||
Task description:
|
||||
- Live Playwright canonical sweeps still report `/releases/environments` as a failure because the route hard-redirects to `/ops/operations/environments`, even though Pack 22 and the current canonical sweep both treat `/releases/environments` as the client-facing contract.
|
||||
- The old release-orchestrator environment pages are not safe to restore: they are placeholder-heavy, contain stale links, and would reintroduce broken actions. The correct fix is to keep the working topology-backed inventory/detail pages and mount them directly under Releases.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `/releases/environments` and `/releases/environments/:environmentId` resolve under `/releases/*` without redirecting to Operations.
|
||||
- [ ] Legacy release environment aliases redirect to `/releases/environments`.
|
||||
- [ ] Route ownership specs and live ownership harness match the restored contract.
|
||||
- [ ] Rebuilt live web passes the canonical route sweep with zero failed routes.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-10 | Sprint created after the live canonical Playwright sweep dropped to a single failure: `/releases/environments` redirected to `/ops/operations/environments`. Root-cause audit confirmed the redirect was architectural drift, not a component/runtime failure. | Developer |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: supersede the earlier Operations-only redirect decision from `SPRINT_20260310_028_FE_route_surface_ownership_alignment.md`; the canonical Releases contract wins because the live route matrix and Pack 22 both depend on `/releases/environments`.
|
||||
- Decision: do not revive `features/release-orchestrator/environments/**` in this slice. Those components remain non-canonical and need separate revival work if they are ever to return.
|
||||
- Risk: the route-ownership Playwright harness still contains stale expectations for `/setup/notifications` and release environment aliases. It must be updated together with the route change or it will produce false failures.
|
||||
|
||||
## Next Checkpoints
|
||||
- Land the Releases route and legacy alias contract update.
|
||||
- Re-run focused Angular route tests.
|
||||
- Rebuild/sync the web bundle and re-run the live canonical and route-ownership sweeps.
|
||||
@@ -246,17 +246,17 @@ async function main() {
|
||||
},
|
||||
{
|
||||
path: `/releases/environments?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/environments',
|
||||
expectedPath: '/releases/environments',
|
||||
expectedTitle: /environments/i,
|
||||
},
|
||||
{
|
||||
path: `/release-control/environments?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/environments',
|
||||
expectedPath: '/releases/environments',
|
||||
expectedTitle: /environments/i,
|
||||
},
|
||||
{
|
||||
path: `/setup/notifications?${scopeQuery}`,
|
||||
expectedPath: '/ops/operations/notifications',
|
||||
expectedPath: '/setup/notifications',
|
||||
expectedTitle: /notifications/i,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -323,8 +323,8 @@ export const routes: Routes = [
|
||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'environments', redirectTo: '/ops/operations/environments', pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: '/ops/operations/environments', pathMatch: 'full' },
|
||||
{ path: 'environments', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
|
||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
|
||||
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
|
||||
|
||||
@@ -37,19 +37,21 @@ interface ApprovalRequest {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="status-filter">
|
||||
@for (filter of statusFilters(); track filter.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="status-filter__btn"
|
||||
[class.status-filter__btn--active]="activeFilter() === filter.id"
|
||||
(click)="setFilter(filter.id)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
<span class="status-filter__count">{{ filter.count }}</span>
|
||||
</button>
|
||||
}
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-row">
|
||||
<div class="status-chips">
|
||||
@for (filter of statusFilters(); track filter.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
[class.chip--active]="activeFilter() === filter.id"
|
||||
(click)="setFilter(filter.id)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
<span class="chip__count">{{ filter.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approvals List -->
|
||||
@@ -100,33 +102,44 @@ interface ApprovalRequest {
|
||||
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); }
|
||||
.page-subtitle { margin: 0; color: var(--color-text-secondary); }
|
||||
|
||||
.status-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.status-filter__btn {
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.status-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.status-filter__btn:hover { background: var(--color-nav-hover); }
|
||||
.status-filter__btn--active {
|
||||
.chip:hover { background: var(--color-nav-hover); }
|
||||
.chip--active {
|
||||
background: var(--color-brand-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
color: #fff;
|
||||
}
|
||||
.status-filter__count {
|
||||
padding: 0.125rem 0.375rem;
|
||||
.chip__count {
|
||||
padding: 0.0625rem 0.3rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
min-width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.approvals-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
@@ -18,8 +18,6 @@ interface HotfixRow {
|
||||
<p>Dedicated queue for expedited release-control promotions.</p>
|
||||
</header>
|
||||
|
||||
<button type="button" class="create-btn">+ Create Hotfix</button>
|
||||
|
||||
@if (hotfixes.length === 0) {
|
||||
<p class="empty">No active hotfixes.</p>
|
||||
} @else {
|
||||
@@ -66,16 +64,6 @@ interface HotfixRow {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
justify-self: start;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.45rem 0.72rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
|
||||
@Component({
|
||||
selector: 'app-security-reports-page',
|
||||
standalone: true,
|
||||
@@ -13,22 +15,143 @@ import { RouterLink } from '@angular/router';
|
||||
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
|
||||
</header>
|
||||
|
||||
<ul>
|
||||
<li><a routerLink="/security/triage">Risk Report (Triage)</a></li>
|
||||
<li><a routerLink="/security/disposition">VEX and Waiver Ledger</a></li>
|
||||
<li><a routerLink="/evidence/exports">Evidence Export Bundle</a></li>
|
||||
</ul>
|
||||
<nav class="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="activeTab() === 'risk'"
|
||||
[attr.aria-selected]="activeTab() === 'risk'"
|
||||
(click)="activeTab.set('risk')"
|
||||
>Risk Report</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="activeTab() === 'vex'"
|
||||
[attr.aria-selected]="activeTab() === 'vex'"
|
||||
(click)="activeTab.set('vex')"
|
||||
>VEX Ledger</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="activeTab() === 'evidence'"
|
||||
[attr.aria-selected]="activeTab() === 'evidence'"
|
||||
(click)="activeTab.set('evidence')"
|
||||
>Evidence Export</button>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content" role="tabpanel">
|
||||
@switch (activeTab()) {
|
||||
@case ('risk') {
|
||||
<article class="report-card">
|
||||
<h2>Risk Report</h2>
|
||||
<p>Aggregated risk posture across all scanned artifacts, environments, and triage dispositions.</p>
|
||||
<div class="report-actions">
|
||||
<a routerLink="/security/triage" class="btn btn--primary">View Full Triage</a>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
@case ('vex') {
|
||||
<article class="report-card">
|
||||
<h2>VEX and Waiver Ledger</h2>
|
||||
<p>Active VEX statements, exception waivers, and disposition history with expiration tracking.</p>
|
||||
<div class="report-actions">
|
||||
<a routerLink="/security/disposition" class="btn btn--primary">View Dispositions</a>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
@case ('evidence') {
|
||||
<article class="report-card">
|
||||
<h2>Evidence Export Bundle</h2>
|
||||
<p>Export signed evidence bundles for audit, compliance, and offline verification workflows.</p>
|
||||
<div class="report-actions">
|
||||
<a routerLink="/evidence/exports" class="btn btn--primary">Open Export Center</a>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.security-reports { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0; font-size: 1.35rem; }
|
||||
p { margin: 0; color: var(--color-text-secondary); }
|
||||
ul { margin: 0; padding-left: 1.2rem; display: grid; gap: 0.45rem; }
|
||||
a { color: var(--color-brand-primary); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header > p { margin: 0; color: var(--color-text-secondary); font-size: 0.85rem; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: var(--color-brand-primary);
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.report-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.report-card h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SecurityReportsPageComponent {}
|
||||
export class SecurityReportsPageComponent {
|
||||
readonly activeTab = signal<ReportTab>('risk');
|
||||
}
|
||||
|
||||
@@ -225,12 +225,12 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
|
||||
},
|
||||
{
|
||||
path: 'release-orchestrator/environments',
|
||||
redirectTo: '/ops/operations/environments',
|
||||
redirectTo: '/releases/environments',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'release-control/environments',
|
||||
redirectTo: '/ops/operations/environments',
|
||||
redirectTo: '/releases/environments',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -189,16 +189,23 @@ export const RELEASES_ROUTES: Routes = [
|
||||
(m) => m.EnvironmentPosturePageComponent,
|
||||
),
|
||||
},
|
||||
// Redirect environments to Operations (moved from Releases)
|
||||
{
|
||||
path: 'environments',
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/ops/operations/environments'),
|
||||
title: 'Environments Inventory',
|
||||
data: { breadcrumb: 'Environments' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-regions-environments-page.component').then(
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'environments/:environmentId',
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/ops/operations/environments/:environmentId'),
|
||||
title: 'Environment Detail',
|
||||
data: { breadcrumb: 'Environment Detail' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-environment-detail-page.component').then(
|
||||
(m) => m.TopologyEnvironmentDetailPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'deployments',
|
||||
|
||||
@@ -41,12 +41,26 @@ describe('Route surface ownership', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects root environment shortcuts to Operations', () => {
|
||||
const environmentsRoute = findRouteByPath(routes, 'environments');
|
||||
const regionsRoute = findRouteByPath(routes, 'regions');
|
||||
it('redirects release-control environment shortcuts to canonical Releases routes', () => {
|
||||
const releaseControlRoute = routes.find((route) => route.path === 'release-control');
|
||||
const environmentsRoute = releaseControlRoute?.children?.find((route) => route.path === 'environments');
|
||||
const regionsRoute = releaseControlRoute?.children?.find((route) => route.path === 'regions');
|
||||
|
||||
expect(environmentsRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
const environmentsRedirect = environmentsRoute?.redirectTo;
|
||||
if (typeof environmentsRedirect !== 'function') {
|
||||
throw new Error('release-control environments alias must expose a redirect function.');
|
||||
}
|
||||
const regionsRedirect = regionsRoute?.redirectTo;
|
||||
if (typeof regionsRedirect !== 'function') {
|
||||
throw new Error('release-control regions alias must expose a redirect function.');
|
||||
}
|
||||
|
||||
expect(invokeRedirect(environmentsRedirect, { params: {}, queryParams: { tenant: 'demo-prod' } })).toBe(
|
||||
'/releases/environments?tenant=demo-prod',
|
||||
);
|
||||
expect(invokeRedirect(regionsRedirect, { params: {}, queryParams: { tenant: 'demo-prod' } })).toBe(
|
||||
'/releases/environments?tenant=demo-prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('mounts setup notifications as the admin studio surface', () => {
|
||||
@@ -66,37 +80,18 @@ describe('Route surface ownership', () => {
|
||||
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
it('keeps release health under Releases while redirecting release environments to Operations', () => {
|
||||
it('keeps release health under Releases and mounts release environments as canonical Releases routes', () => {
|
||||
const healthRoute = RELEASES_ROUTES.find((route) => route.path === 'health');
|
||||
const environmentsRoute = RELEASES_ROUTES.find((route) => route.path === 'environments');
|
||||
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
|
||||
|
||||
expect(healthRoute?.title).toBe('Release Health');
|
||||
expect(typeof healthRoute?.loadComponent).toBe('function');
|
||||
|
||||
const environmentsRedirect = environmentsRoute?.redirectTo;
|
||||
if (typeof environmentsRedirect !== 'function') {
|
||||
throw new Error('Releases environments route must expose a redirect function.');
|
||||
}
|
||||
const environmentDetailRedirect = environmentDetailRoute?.redirectTo;
|
||||
if (typeof environmentDetailRedirect !== 'function') {
|
||||
throw new Error('Releases environment detail route must expose a redirect function.');
|
||||
}
|
||||
|
||||
const environmentsTarget = invokeRedirect(environmentsRedirect, {
|
||||
params: {},
|
||||
queryParams: { tenant: 'demo-prod' },
|
||||
});
|
||||
const environmentDetailTarget = invokeRedirect(environmentDetailRedirect, {
|
||||
params: { environmentId: 'stage' },
|
||||
queryParams: { tenant: 'demo-prod' },
|
||||
});
|
||||
|
||||
expect(environmentsTarget).toBe('/ops/operations/environments?tenant=demo-prod');
|
||||
expect(environmentDetailTarget).toBe('/ops/operations/environments/stage?tenant=demo-prod');
|
||||
expect(typeof environmentsRoute?.loadComponent).toBe('function');
|
||||
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
it('maps legacy release environment shortcuts to Operations inventory', () => {
|
||||
it('maps legacy release environment shortcuts to the canonical Releases inventory', () => {
|
||||
const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find(
|
||||
(route) => route.path === 'release-orchestrator/environments',
|
||||
);
|
||||
@@ -104,7 +99,7 @@ describe('Route surface ownership', () => {
|
||||
(route) => route.path === 'release-control/environments',
|
||||
);
|
||||
|
||||
expect(releaseOrchestratorRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
expect(releaseControlRoute?.redirectTo).toBe('/ops/operations/environments');
|
||||
expect(releaseOrchestratorRoute?.redirectTo).toBe('/releases/environments');
|
||||
expect(releaseControlRoute?.redirectTo).toBe('/releases/environments');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,18 +28,22 @@ export interface ActiveFilter {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-bar__search-input"
|
||||
[placeholder]="searchPlaceholder"
|
||||
[(ngModel)]="searchValue"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
/>
|
||||
<div class="filter-bar__top-row">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-bar__search-input"
|
||||
[placeholder]="searchPlaceholder"
|
||||
[(ngModel)]="searchValue"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
(keydown.enter)="onSearchSubmit()"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="filter-bar__search-btn" (click)="onSearchSubmit()">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar__filters">
|
||||
@@ -78,8 +82,7 @@ export interface ActiveFilter {
|
||||
styles: [`
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
@@ -88,12 +91,35 @@ export interface ActiveFilter {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-bar__top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-bar__search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-bar__search-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.filter-bar__search-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-bar__search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
@@ -192,6 +218,7 @@ export class FilterBarComponent {
|
||||
@Input() activeFilters: ActiveFilter[] = [];
|
||||
|
||||
@Output() searchChange = new EventEmitter<string>();
|
||||
@Output() searchSubmit = new EventEmitter<string>();
|
||||
@Output() filterChange = new EventEmitter<ActiveFilter>();
|
||||
@Output() filterRemove = new EventEmitter<ActiveFilter>();
|
||||
@Output() filtersCleared = new EventEmitter<void>();
|
||||
@@ -202,6 +229,10 @@ export class FilterBarComponent {
|
||||
this.searchChange.emit(value);
|
||||
}
|
||||
|
||||
onSearchSubmit(): void {
|
||||
this.searchSubmit.emit(this.searchValue);
|
||||
}
|
||||
|
||||
onFilterChange(key: string, event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const value = select.value;
|
||||
|
||||
Reference in New Issue
Block a user