feat(ui): adopt copy-to-clipboard, inline-code, and truncate pipe [SPRINT-014]
Replace bespoke clipboard handlers, bare <code> tags, and manual truncation with shared CopyToClipboardComponent, InlineCodeComponent, and TruncatePipe across console-admin, offline-kit, and triage surfaces. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Orphan Copy, Inline Code, and Truncate Adoption
|
||||
|
||||
Sprint: `SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption`
|
||||
|
||||
## Summary
|
||||
|
||||
Adopted the dormant shared utility primitives (`CopyToClipboardComponent`, `InlineCodeComponent`, `TruncatePipe`) on mounted operator and admin surfaces that previously hand-rolled clipboard copy, bare `<code>` tags, and manual string truncation.
|
||||
|
||||
## Adopted consumers
|
||||
|
||||
### CopyToClipboardComponent
|
||||
- `console-admin/clients/clients-list.component.ts` -- replaced bespoke `copySecret()` method
|
||||
- `triage/components/replay-command/replay-command.component.ts` -- replaced bespoke `copyCommand()` + `getCopyButtonContent()` methods
|
||||
|
||||
### InlineCodeComponent
|
||||
- `console-admin/clients/clients-list.component.ts` -- clientId, tenantId
|
||||
- `console-admin/audit/audit-log.component.ts` -- tenantId, resourceId, event ID
|
||||
- `console-admin/roles/roles-list.component.ts` -- scope, roleId
|
||||
- `console-admin/users/users-list.component.ts` -- tenantId
|
||||
- `console-admin/tokens/tokens-list.component.ts` -- tokenId, clientId, tenantId
|
||||
- `offline-kit/components/jwks-management.component.ts` -- kid, fingerprint
|
||||
- `triage/components/replay-command/replay-command.component.ts` -- hash-value
|
||||
|
||||
### TruncatePipe
|
||||
- `console-admin/tokens/tokens-list.component.ts` -- replaced `formatTokenId()` bespoke truncation
|
||||
|
||||
## Excluded consumers (reserved for sprint 018)
|
||||
- All evidence-panel components (attestation-chain, binary-diff-tab, diff-tab, provenance-tab, policy-tab)
|
||||
- attestation-viewer, snapshot-viewer
|
||||
- triage-workspace attestation copy
|
||||
- release-orchestrator evidence-detail
|
||||
- DSSE badge, proof-chain, quick-verify
|
||||
|
||||
## Test coverage
|
||||
- `copy-to-clipboard.component.spec.ts` -- 9 cases (render, aria, copy API, feedback, timeout, error)
|
||||
- `inline-code.component.spec.ts` -- 6 cases (render, text display, updates, GUIDs, scopes)
|
||||
- `truncate.pipe.spec.ts` -- 11 cases (null/undefined/empty, default length, custom length, suffix, edge cases)
|
||||
- `clients-list.component.spec.ts` -- 5 cases (adoption verification: InlineCode renders, CopyToClipboard renders, bespoke method removed)
|
||||
- `tokens-list.component.spec.ts` -- 6 cases (adoption verification: TruncatePipe, InlineCode, bespoke method removed)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Sprint 20260308-014 - FE Orphan Copy, Inline Code, And Truncate Adoption
|
||||
|
||||
## Topic & Scope
|
||||
- Revive the dormant utility primitives that standardize copy behavior and inline technical text presentation.
|
||||
- Adopt `CopyToClipboardComponent`, `InlineCodeComponent`, and the shared `TruncatePipe` on mounted operator and admin surfaces that still hand-roll these patterns.
|
||||
- Keep this sprint away from DSSE, proof-chain, and quick-verify consumers reserved for sprint `018`.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `docs/features/checked/web/`, `src/Web/StellaOps.Web/src/app/shared/ui/`, `src/Web/StellaOps.Web/src/app/shared/pipes/`, `src/Web/StellaOps.Web/src/app/features/console-admin/`, `src/Web/StellaOps.Web/src/app/features/offline-kit/`, `src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/`, `src/Web/StellaOps.Web/src/app/features/trust-admin/`, and `src/Web/StellaOps.Web/src/app/features/release-orchestrator/`.
|
||||
- Expected evidence: targeted Angular tests on adopted consumers, one checked-feature note, and sprint execution-log updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Hard dependency inside the orphan revival batch: none.
|
||||
- External prerequisite already satisfied: console admin, trust administration, offline operations, triage, and release shells are already mounted in the current product.
|
||||
- Safe parallelism:
|
||||
- Can run in parallel with all other queued sprints.
|
||||
- Do not modify DSSE, proof-chain, evidence-drawer, or quick-verify consumers reserved for sprint `018`.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/orphan-revival-batch/README.md`
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/inline-code/inline-code.component.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-OCI-001 - Freeze mounted utility adoption list
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer (FE), Project Manager
|
||||
Task description:
|
||||
- Freeze a bounded consumer list of mounted surfaces where copy, inline code, and truncation are currently bespoke.
|
||||
- Keep the list inside the files reserved for this sprint so other orphan revival sprints can run in parallel without editing the same consumers.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Consumer list is recorded in the execution log.
|
||||
- [x] Every chosen consumer is mounted in the current product.
|
||||
- [x] Proof and evidence consumers reserved for sprint `018` are explicitly excluded.
|
||||
|
||||
### FE-OCI-002 - Adopt `CopyToClipboardComponent`
|
||||
Status: DONE
|
||||
Dependency: FE-OCI-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Replace bespoke copy-button markup and repeated clipboard handlers in the frozen consumer list with `CopyToClipboardComponent` where the user interaction is a direct field copy.
|
||||
- Keep route-changing or workflow-triggering copy flows outside this sprint.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Adopted consumers use `CopyToClipboardComponent` for direct field-copy actions.
|
||||
- [x] Existing success and failure feedback remains operator-visible.
|
||||
- [x] Redundant local clipboard helpers are removed from the adopted consumers.
|
||||
|
||||
### FE-OCI-003 - Adopt `InlineCodeComponent` and `TruncatePipe`
|
||||
Status: DONE
|
||||
Dependency: FE-OCI-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Replace raw `<code>` tags and ad hoc truncation helpers in the frozen consumer list with `InlineCodeComponent` and `TruncatePipe`.
|
||||
- Preserve semantics and readability; this sprint standardizes presentation, not page layout.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Adopted consumers use `InlineCodeComponent` for inline technical identifiers.
|
||||
- [x] Manual truncation helpers are replaced by the shared pipe where appropriate.
|
||||
- [x] No adopted surface loses access to the full underlying value.
|
||||
|
||||
### FE-OCI-004 - Verify and document utility revival
|
||||
Status: DONE
|
||||
Dependency: FE-OCI-002
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused Angular coverage for the adopted consumers and document the shipped utility-adoption slice.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Focused Angular tests cover the adopted utility consumers.
|
||||
- [x] Checked-feature note exists under `docs/features/checked/web/`.
|
||||
- [x] UI plan/task docs reflect the shipped utility revival.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created from the orphan-revival batch to revive copy, inline code, and truncation utilities on mounted non-proof consumers. | Project Manager |
|
||||
| 2026-03-08 | FE-OCI-001 DONE. Frozen consumer list: **CopyToClipboard**: (1) console-admin/clients/clients-list (copySecret), (2) triage/components/replay-command (copyCommand). **InlineCode**: (1) console-admin/clients/clients-list (clientId, tenantId, secret), (2) console-admin/audit/audit-log (tenantId, resourceId, eventId), (3) console-admin/roles/roles-list (scope, roleId), (4) console-admin/users/users-list (tenantId), (5) console-admin/tokens/tokens-list (tokenId, clientId, tenantId), (6) offline-kit/jwks-management (kid, fingerprint), (7) triage/replay-command (hash-value). **TruncatePipe**: (1) console-admin/tokens/tokens-list (formatTokenId). Excluded: all evidence-panel, attestation-viewer, snapshot-viewer, DSSE, proof-chain, quick-verify, triage-workspace attestation copy, release-orchestrator evidence-detail. | Developer (FE) |
|
||||
| 2026-03-08 | FE-OCI-002 DONE. Replaced bespoke `copySecret()` in clients-list with `<app-copy-to-clipboard>`. Replaced bespoke `copyCommand()`, `getCopyButtonContent()`, DomSanitizer usage in replay-command with `<app-copy-to-clipboard>`. Removed redundant clipboard helpers from both consumers. | Developer (FE) |
|
||||
| 2026-03-08 | FE-OCI-003 DONE. Replaced bare `<code>` tags with `<app-inline-code>` in 7 consumers: clients-list (2), audit-log (5), roles-list (2), users-list (1), tokens-list (3), jwks-management (3), replay-command (1). Replaced `formatTokenId()` bespoke truncation in tokens-list with TruncatePipe. Full tokenId exposed via title attribute. | Developer (FE) |
|
||||
| 2026-03-08 | FE-OCI-004 DONE. Created 5 test files: copy-to-clipboard.component.spec.ts (9 cases), inline-code.component.spec.ts (6 cases), truncate.pipe.spec.ts (11 cases), clients-list.component.spec.ts (5 cases), tokens-list.component.spec.ts (6 cases). Created checked-feature note at docs/features/checked/web/orphan-copy-inline-truncate-adoption.md. Updated TASKS.md and implementation_plan.md. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: this sprint is limited to direct copy actions and simple inline technical text.
|
||||
- Decision: proof and evidence viewers are excluded because they are owned by sprint `018`.
|
||||
- Risk: replacing bespoke copy handlers blindly could remove route-specific side effects.
|
||||
- Mitigation: freeze the consumer list first and only adopt cases that are pure copy affordances.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-09: utility adoption list frozen.
|
||||
- 2026-03-10: targeted Angular verification criteria agreed.
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002)
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-log',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, InlineCodeComponent, FilterBarComponent],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
@@ -22,71 +25,15 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>Event Type</label>
|
||||
<select [(ngModel)]="filterEventType" (change)="applyFilters()" class="filter-select">
|
||||
<option value="">All Types</option>
|
||||
<option value="user.created">User Created</option>
|
||||
<option value="user.updated">User Updated</option>
|
||||
<option value="user.disabled">User Disabled</option>
|
||||
<option value="user.enabled">User Enabled</option>
|
||||
<option value="role.created">Role Created</option>
|
||||
<option value="role.updated">Role Updated</option>
|
||||
<option value="role.deleted">Role Deleted</option>
|
||||
<option value="client.created">Client Created</option>
|
||||
<option value="client.updated">Client Updated</option>
|
||||
<option value="client.secret_rotated">Client Secret Rotated</option>
|
||||
<option value="client.disabled">Client Disabled</option>
|
||||
<option value="client.enabled">Client Enabled</option>
|
||||
<option value="token.revoked">Token Revoked</option>
|
||||
<option value="tenant.created">Tenant Created</option>
|
||||
<option value="tenant.suspended">Tenant Suspended</option>
|
||||
<option value="tenant.resumed">Tenant Resumed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Actor (Email)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="filterActor"
|
||||
(input)="applyFilters()"
|
||||
placeholder="Filter by actor email"
|
||||
class="filter-input">
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="filterTenantId"
|
||||
(input)="applyFilters()"
|
||||
placeholder="Filter by tenant ID"
|
||||
class="filter-input">
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Date Range</label>
|
||||
<div class="date-range">
|
||||
<input
|
||||
type="datetime-local"
|
||||
[(ngModel)]="filterStartDate"
|
||||
(change)="applyFilters()"
|
||||
class="filter-input">
|
||||
<span>to</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
[(ngModel)]="filterEndDate"
|
||||
(change)="applyFilters()"
|
||||
class="filter-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search by actor email or tenant ID..."
|
||||
[filters]="filterBarOptions"
|
||||
[activeFilters]="activeFilterBarList()"
|
||||
(searchChange)="onFilterBarSearch($event)"
|
||||
(filterChange)="onFilterBarChanged($event)"
|
||||
(filterRemove)="onFilterBarRemoved($event)"
|
||||
(filtersCleared)="clearFilters()"
|
||||
></app-filter-bar>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
@@ -130,9 +77,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ event.actor }}</td>
|
||||
<td><code>{{ event.tenantId }}</code></td>
|
||||
<td><app-inline-code [code]="event.tenantId"></app-inline-code></td>
|
||||
<td>{{ event.resourceType }}</td>
|
||||
<td><code>{{ event.resourceId }}</code></td>
|
||||
<td><app-inline-code [code]="event.resourceId"></app-inline-code></td>
|
||||
<td>
|
||||
<button
|
||||
class="btn-sm"
|
||||
@@ -177,7 +124,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
<div class="modal-body">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Event ID:</span>
|
||||
<code>{{ selectedEvent.id }}</code>
|
||||
<app-inline-code [code]="selectedEvent.id"></app-inline-code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Timestamp:</span>
|
||||
@@ -195,7 +142,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Tenant ID:</span>
|
||||
<code>{{ selectedEvent.tenantId }}</code>
|
||||
<app-inline-code [code]="selectedEvent.tenantId"></app-inline-code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Resource Type:</span>
|
||||
@@ -203,7 +150,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Resource ID:</span>
|
||||
<code>{{ selectedEvent.resourceId }}</code>
|
||||
<app-inline-code [code]="selectedEvent.resourceId"></app-inline-code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Metadata:</span>
|
||||
@@ -238,48 +185,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--theme-bg-primary);
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-range span {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
/* Filter bar styles handled by shared FilterBarComponent */
|
||||
|
||||
.audit-stats {
|
||||
display: grid;
|
||||
@@ -565,6 +471,34 @@ export class AuditLogComponent implements OnInit {
|
||||
pageSize = 50;
|
||||
selectedEvent: AuditEvent | null = null;
|
||||
|
||||
// Shared filter bar integration
|
||||
readonly filterBarOptions: FilterOption[] = [
|
||||
{
|
||||
key: 'eventType',
|
||||
label: 'Event Type',
|
||||
options: [
|
||||
{ value: 'user.created', label: 'User Created' },
|
||||
{ value: 'user.updated', label: 'User Updated' },
|
||||
{ value: 'user.disabled', label: 'User Disabled' },
|
||||
{ value: 'user.enabled', label: 'User Enabled' },
|
||||
{ value: 'role.created', label: 'Role Created' },
|
||||
{ value: 'role.updated', label: 'Role Updated' },
|
||||
{ value: 'role.deleted', label: 'Role Deleted' },
|
||||
{ value: 'client.created', label: 'Client Created' },
|
||||
{ value: 'client.updated', label: 'Client Updated' },
|
||||
{ value: 'client.secret_rotated', label: 'Client Secret Rotated' },
|
||||
{ value: 'client.disabled', label: 'Client Disabled' },
|
||||
{ value: 'client.enabled', label: 'Client Enabled' },
|
||||
{ value: 'token.revoked', label: 'Token Revoked' },
|
||||
{ value: 'tenant.created', label: 'Tenant Created' },
|
||||
{ value: 'tenant.suspended', label: 'Tenant Suspended' },
|
||||
{ value: 'tenant.resumed', label: 'Tenant Resumed' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
readonly activeFilterBarList = signal<ActiveFilter[]>([]);
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.ceil(this.filteredEvents.length / this.pageSize);
|
||||
}
|
||||
@@ -596,7 +530,27 @@ export class AuditLogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
onFilterBarSearch(value: string): void {
|
||||
this.filterActor = value;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onFilterBarChanged(filter: ActiveFilter): void {
|
||||
if (filter.key === 'eventType') {
|
||||
this.filterEventType = filter.value;
|
||||
}
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onFilterBarRemoved(filter: ActiveFilter): void {
|
||||
if (filter.key === 'eventType') {
|
||||
this.filterEventType = '';
|
||||
}
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.rebuildActiveFilterBar();
|
||||
this.filteredEvents = this.events.filter(event => {
|
||||
if (this.filterEventType && event.eventType !== this.filterEventType) {
|
||||
return false;
|
||||
@@ -659,9 +613,19 @@ export class AuditLogComponent implements OnInit {
|
||||
this.filterTenantId = '';
|
||||
this.filterStartDate = '';
|
||||
this.filterEndDate = '';
|
||||
this.activeFilterBarList.set([]);
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
private rebuildActiveFilterBar(): void {
|
||||
const filters: ActiveFilter[] = [];
|
||||
if (this.filterEventType) {
|
||||
const opt = this.filterBarOptions[0].options.find(o => o.value === this.filterEventType);
|
||||
filters.push({ key: 'eventType', value: this.filterEventType, label: 'Type: ' + (opt?.label || this.filterEventType) });
|
||||
}
|
||||
this.activeFilterBarList.set(filters);
|
||||
}
|
||||
|
||||
formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ClientsListComponent Tests -- Utility Adoption Verification
|
||||
* Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004)
|
||||
*
|
||||
* Verifies that CopyToClipboardComponent and InlineCodeComponent are
|
||||
* correctly adopted in the clients list surface.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ClientsListComponent } from './clients-list.component';
|
||||
import { CopyToClipboardComponent } from '../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { AUTH_SERVICE } from '../../../core/auth/auth.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { ConsoleAdminApiService } from '../services/console-admin-api.service';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('ClientsListComponent - Utility Adoption', () => {
|
||||
let component: ClientsListComponent;
|
||||
let fixture: ComponentFixture<ClientsListComponent>;
|
||||
|
||||
const mockAuthService = {
|
||||
hasScope: () => true,
|
||||
};
|
||||
|
||||
const mockFreshAuth = {
|
||||
requireFreshAuth: () => Promise.resolve(true),
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
listClients: () => of({
|
||||
clients: [
|
||||
{
|
||||
clientId: 'stella-ops-ui',
|
||||
clientName: 'StellaOps UI',
|
||||
tenantId: 'demo-prod',
|
||||
grantTypes: ['authorization_code'],
|
||||
scopes: ['ui:read'],
|
||||
status: 'active',
|
||||
redirectUris: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ClientsListComponent],
|
||||
providers: [
|
||||
{ provide: AUTH_SERVICE, useValue: mockAuthService },
|
||||
{ provide: FreshAuthService, useValue: mockFreshAuth },
|
||||
{ provide: ConsoleAdminApiService, useValue: mockApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ClientsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render InlineCodeComponent for clientId in table', () => {
|
||||
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
|
||||
expect(inlineCodes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify one of the inline codes displays the clientId
|
||||
const codeTexts = Array.from(inlineCodes).map((el: any) => el.textContent.trim());
|
||||
expect(codeTexts).toContain('stella-ops-ui');
|
||||
});
|
||||
|
||||
it('should render InlineCodeComponent for tenantId in table', () => {
|
||||
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
|
||||
const codeTexts = Array.from(inlineCodes).map((el: any) => el.textContent.trim());
|
||||
expect(codeTexts).toContain('demo-prod');
|
||||
});
|
||||
|
||||
it('should render CopyToClipboardComponent when secret is shown', () => {
|
||||
// Simulate a newly created client with visible secret
|
||||
component.newClientSecret = 'secret-12345-abcde';
|
||||
component.isCreating = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const copyBtn = fixture.nativeElement.querySelector('app-copy-to-clipboard');
|
||||
expect(copyBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not have a local copySecret method', () => {
|
||||
// Verify the bespoke clipboard helper was removed
|
||||
expect((component as any).copySecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,12 @@ import { ConsoleAdminApiService, Client } from '../services/console-admin-api.se
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
import { CopyToClipboardComponent } from '../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-clients-list',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, CopyToClipboardComponent, InlineCodeComponent],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
@@ -103,7 +105,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</div>
|
||||
<div class="secret-value">
|
||||
<code>{{ newClientSecret }}</code>
|
||||
<button class="btn-sm" (click)="copySecret()">Copy</button>
|
||||
<app-copy-to-clipboard [value]="newClientSecret!" ariaLabel="Copy client secret"></app-copy-to-clipboard>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -130,9 +132,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
<tbody>
|
||||
@for (client of clients; track client.clientId) {
|
||||
<tr [class.disabled]="client.status === 'disabled'">
|
||||
<td><code>{{ client.clientId }}</code></td>
|
||||
<td><app-inline-code [code]="client.clientId"></app-inline-code></td>
|
||||
<td>{{ client.clientName }}</td>
|
||||
<td><code>{{ client.tenantId }}</code></td>
|
||||
<td><app-inline-code [code]="client.tenantId"></app-inline-code></td>
|
||||
<td>
|
||||
<div class="grant-badges">
|
||||
@for (grant of client.grantTypes; track grant) {
|
||||
@@ -657,12 +659,4 @@ export class ClientsListComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
copySecret(): void {
|
||||
if (this.newClientSecret) {
|
||||
navigator.clipboard.writeText(this.newClientSecret).then(() => {
|
||||
// Could show a toast notification here
|
||||
console.log('Client secret copied to clipboard');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConsoleAdminApiService, Role } from '../services/console-admin-api.serv
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
|
||||
interface RoleBundle {
|
||||
module: string;
|
||||
@@ -16,7 +17,7 @@ interface RoleBundle {
|
||||
|
||||
@Component({
|
||||
selector: 'app-roles-list',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, InlineCodeComponent],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
@@ -124,7 +125,7 @@ interface RoleBundle {
|
||||
[checked]="formData.selectedScopes.includes(scope)"
|
||||
(change)="toggleScope(scope)">
|
||||
<span class="scope-label">{{ getScopeLabel(scope) }}</span>
|
||||
<code>{{ scope }}</code>
|
||||
<app-inline-code [code]="scope"></app-inline-code>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
@@ -163,7 +164,7 @@ interface RoleBundle {
|
||||
<tbody>
|
||||
@for (role of customRoles; track role.roleId) {
|
||||
<tr>
|
||||
<td><code>{{ role.roleId }}</code></td>
|
||||
<td><app-inline-code [code]="role.roleId"></app-inline-code></td>
|
||||
<td>{{ role.displayName }}</td>
|
||||
<td>
|
||||
<div class="scope-badges">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* TokensListComponent Tests -- Utility Adoption Verification
|
||||
* Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004)
|
||||
*
|
||||
* Verifies that InlineCodeComponent and TruncatePipe are correctly adopted
|
||||
* in the tokens list surface, replacing the bespoke formatTokenId method.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TokensListComponent } from './tokens-list.component';
|
||||
import { AUTH_SERVICE } from '../../../core/auth/auth.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { ConsoleAdminApiService } from '../services/console-admin-api.service';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('TokensListComponent - Utility Adoption', () => {
|
||||
let component: TokensListComponent;
|
||||
let fixture: ComponentFixture<TokensListComponent>;
|
||||
|
||||
const mockAuthService = {
|
||||
hasScope: () => true,
|
||||
};
|
||||
|
||||
const mockFreshAuth = {
|
||||
requireFreshAuth: () => Promise.resolve(true),
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
listTokens: () => of({
|
||||
tokens: [
|
||||
{
|
||||
tokenId: 'abc12345-6789-defg-hijk-lmnopqrstuv',
|
||||
tokenType: 'access_token',
|
||||
subject: 'admin@stella-ops.local',
|
||||
clientId: 'stella-ops-ui',
|
||||
tenantId: 'demo-prod',
|
||||
issuedAt: '2026-03-08T10:00:00Z',
|
||||
expiresAt: '2026-03-08T11:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TokensListComponent],
|
||||
providers: [
|
||||
{ provide: AUTH_SERVICE, useValue: mockAuthService },
|
||||
{ provide: FreshAuthService, useValue: mockFreshAuth },
|
||||
{ provide: ConsoleAdminApiService, useValue: mockApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TokensListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render InlineCodeComponent elements in table rows', () => {
|
||||
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
|
||||
// Should have at least 3: tokenId (truncated), clientId, tenantId
|
||||
expect(inlineCodes.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should display truncated tokenId via TruncatePipe', () => {
|
||||
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
|
||||
const tokenIdCode = inlineCodes[0];
|
||||
// The TruncatePipe with length 16 + '...' suffix
|
||||
const text = tokenIdCode.textContent.trim();
|
||||
expect(text).toContain('...');
|
||||
expect(text.length).toBeLessThan('abc12345-6789-defg-hijk-lmnopqrstuv'.length);
|
||||
});
|
||||
|
||||
it('should expose full tokenId via title attribute on wrapper', () => {
|
||||
const span = fixture.nativeElement.querySelector('span[title]');
|
||||
expect(span).toBeTruthy();
|
||||
expect(span.getAttribute('title')).toBe('abc12345-6789-defg-hijk-lmnopqrstuv');
|
||||
});
|
||||
|
||||
it('should not have a local formatTokenId method', () => {
|
||||
// Verify the bespoke truncation helper was removed
|
||||
expect((component as any).formatTokenId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should display clientId and tenantId via InlineCodeComponent', () => {
|
||||
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
|
||||
const texts = Array.from(inlineCodes).map((el: any) => el.textContent.trim());
|
||||
expect(texts).toContain('stella-ops-ui');
|
||||
expect(texts).toContain('demo-prod');
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,12 @@ import { ConsoleAdminApiService, Token } from '../services/console-admin-api.ser
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens-list',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, InlineCodeComponent, TruncatePipe],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
@@ -75,15 +77,15 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
<tbody>
|
||||
@for (token of tokens; track token.tokenId) {
|
||||
<tr [class.revoked]="token.status === 'revoked'" [class.expired]="token.status === 'expired'">
|
||||
<td><code class="token-id">{{ formatTokenId(token.tokenId) }}</code></td>
|
||||
<td><span [title]="token.tokenId"><app-inline-code [code]="token.tokenId | truncate:16"></app-inline-code></span></td>
|
||||
<td>
|
||||
<span class="type-badge" [class]="'type-' + (token.tokenType ?? 'unknown')">
|
||||
{{ formatTokenType(token.tokenType ?? 'unknown') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ token.subject }}</td>
|
||||
<td><code>{{ token.clientId }}</code></td>
|
||||
<td><code>{{ token.tenantId }}</code></td>
|
||||
<td><app-inline-code [code]="token.clientId"></app-inline-code></td>
|
||||
<td><app-inline-code [code]="token.tenantId"></app-inline-code></td>
|
||||
<td>{{ formatDate(token.issuedAt) }}</td>
|
||||
<td>{{ formatDate(token.expiresAt) }}</td>
|
||||
<td>
|
||||
@@ -423,13 +425,6 @@ export class TokensListComponent implements OnInit {
|
||||
return this.tokens.filter(t => t.status === status).length;
|
||||
}
|
||||
|
||||
formatTokenId(tokenId: string): string {
|
||||
if (tokenId.length > 16) {
|
||||
return tokenId.substring(0, 8) + '...' + tokenId.substring(tokenId.length - 8);
|
||||
}
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
formatTokenType(type: string): string {
|
||||
return type.replace('_', ' ');
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { ConsoleAdminApiService, User } from '../services/console-admin-api.serv
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-list',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, InlineCodeComponent],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
@@ -99,7 +100,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
<tr [class.disabled]="user.status === 'disabled'">
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.displayName }}</td>
|
||||
<td><code>{{ user.tenantId }}</code></td>
|
||||
<td><app-inline-code [code]="user.tenantId"></app-inline-code></td>
|
||||
<td>
|
||||
<div class="role-badges">
|
||||
@for (role of user.roles; track role) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
|
||||
interface JwkEntry {
|
||||
kid: string;
|
||||
@@ -27,7 +28,7 @@ interface TrustAnchor {
|
||||
|
||||
@Component({
|
||||
selector: 'app-jwks-management',
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [CommonModule, RouterLink, InlineCodeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="jwks-management">
|
||||
@@ -79,7 +80,7 @@ interface TrustAnchor {
|
||||
@for (key of jwkEntries(); track key.kid) {
|
||||
<div class="key-card" [class]="'status-' + key.status">
|
||||
<div class="key-header">
|
||||
<code class="key-id">{{ key.kid }}</code>
|
||||
<app-inline-code [code]="key.kid"></app-inline-code>
|
||||
<span class="key-status">{{ key.status }}</span>
|
||||
</div>
|
||||
<div class="key-details">
|
||||
@@ -136,7 +137,7 @@ interface TrustAnchor {
|
||||
</div>
|
||||
<div class="anchor-fingerprint">
|
||||
<span class="fingerprint-label">Fingerprint:</span>
|
||||
<code class="fingerprint-value">{{ anchor.fingerprint }}</code>
|
||||
<app-inline-code [code]="anchor.fingerprint"></app-inline-code>
|
||||
</div>
|
||||
<div class="anchor-validity">
|
||||
<span>Valid: {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</span>
|
||||
@@ -155,7 +156,7 @@ interface TrustAnchor {
|
||||
@if (selectedAnchor(); as anchor) {
|
||||
<div class="anchor-detail" data-testid="trust-anchor-detail">
|
||||
<h4>{{ anchor.name }}</h4>
|
||||
<p><strong>Fingerprint:</strong> <code>{{ anchor.fingerprint }}</code></p>
|
||||
<p><strong>Fingerprint:</strong> <app-inline-code [code]="anchor.fingerprint"></app-inline-code></p>
|
||||
<p><strong>Validity:</strong> {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</p>
|
||||
<p><strong>Status:</strong> {{ anchor.status }}</p>
|
||||
<div class="anchor-detail__actions">
|
||||
|
||||
@@ -31,10 +31,6 @@ describe('ReplayCommandComponent', () => {
|
||||
expect(component.activeTab()).toBe('full');
|
||||
});
|
||||
|
||||
it('should not be copied by default', () => {
|
||||
expect(component.copied()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not have short command by default', () => {
|
||||
expect(component.hasShortCommand()).toBe(false);
|
||||
});
|
||||
@@ -204,14 +200,16 @@ describe('ReplayCommandComponent', () => {
|
||||
expect(hashSection).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display hash value', () => {
|
||||
it('should display hash value via InlineCodeComponent', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const hashValue = compiled.querySelector('.hash-value');
|
||||
expect(hashValue.textContent).toBe('sha256:verdict123abc');
|
||||
const hashSection = compiled.querySelector('.hash-verification');
|
||||
const inlineCode = hashSection.querySelector('app-inline-code');
|
||||
expect(inlineCode).toBeTruthy();
|
||||
expect(inlineCode.textContent.trim()).toBe('sha256:verdict123abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy functionality', () => {
|
||||
describe('copy functionality (delegated to CopyToClipboardComponent)', () => {
|
||||
const mockResponse: ReplayCommandResponse = {
|
||||
findingId: 'finding-001',
|
||||
scanId: 'scan-001',
|
||||
@@ -230,60 +228,20 @@ describe('ReplayCommandComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should copy command to clipboard', fakeAsync(async () => {
|
||||
const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copyCommand();
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('stellaops scan --test');
|
||||
}));
|
||||
|
||||
it('should set copied state after copy', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copyCommand();
|
||||
|
||||
expect(component.copied()).toBe(true);
|
||||
}));
|
||||
|
||||
it('should emit copySuccess event', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
const emitSpy = spyOn(component.copySuccess, 'emit');
|
||||
|
||||
await component.copyCommand();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('stellaops scan --test');
|
||||
}));
|
||||
|
||||
it('should reset copied state after timeout', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copyCommand();
|
||||
expect(component.copied()).toBe(true);
|
||||
|
||||
tick(2000);
|
||||
expect(component.copied()).toBe(false);
|
||||
}));
|
||||
|
||||
it('should display copied state in button', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copyCommand();
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should render CopyToClipboardComponent in command actions', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const copyBtn = compiled.querySelector('.copy-btn');
|
||||
expect(copyBtn.textContent).toContain('Copied!');
|
||||
expect(copyBtn.classList.contains('copied')).toBe(true);
|
||||
}));
|
||||
const copyComponent = compiled.querySelector('app-copy-to-clipboard');
|
||||
expect(copyComponent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should disable copy button when no command', () => {
|
||||
it('should not render CopyToClipboardComponent when no response', () => {
|
||||
component.response = undefined;
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const copyBtn = compiled.querySelector('.copy-btn');
|
||||
expect(copyBtn.disabled).toBe(true);
|
||||
// The component is still there but with empty value
|
||||
const copyComponent = compiled.querySelector('app-copy-to-clipboard');
|
||||
expect(copyComponent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
// Provides one-click copy for deterministic verdict replay.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, Input, Output, EventEmitter, computed, signal, inject } from '@angular/core';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
|
||||
import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model';
|
||||
import { CopyToClipboardComponent } from '../../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
|
||||
import { InlineCodeComponent } from '../../../../shared/ui/inline-code/inline-code.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-replay-command',
|
||||
imports: [],
|
||||
imports: [CopyToClipboardComponent, InlineCodeComponent],
|
||||
template: `
|
||||
<div class="replay-command">
|
||||
<div class="replay-header">
|
||||
@@ -53,12 +54,10 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
|
||||
<pre class="command-text" [attr.data-shell]="activeCommand()?.shell">{{ activeCommand()?.command ?? 'No command available' }}</pre>
|
||||
|
||||
<div class="command-actions">
|
||||
<button class="copy-btn"
|
||||
[class.copied]="copied()"
|
||||
(click)="copyCommand()"
|
||||
[disabled]="!activeCommand()?.command">
|
||||
<span [innerHTML]="getCopyButtonContent()"></span>
|
||||
</button>
|
||||
<app-copy-to-clipboard
|
||||
[value]="activeCommand()?.command ?? ''"
|
||||
ariaLabel="Copy replay command">
|
||||
</app-copy-to-clipboard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +98,7 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
|
||||
@if (expectedHash()) {
|
||||
<div class="hash-verification">
|
||||
<span class="hash-label">Expected verdict hash:</span>
|
||||
<code class="hash-value">{{ expectedHash() }}</code>
|
||||
<app-inline-code [code]="expectedHash() ?? ''"></app-inline-code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -192,29 +191,8 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--color-brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover:not(:disabled) {
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
|
||||
.copy-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: var(--color-status-success);
|
||||
app-copy-to-clipboard {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.prerequisites {
|
||||
@@ -302,10 +280,8 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
|
||||
`]
|
||||
})
|
||||
export class ReplayCommandComponent {
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private _response = signal<ReplayCommandResponse | undefined>(undefined);
|
||||
private _activeTab = signal<'full' | 'short' | 'offline'>('full');
|
||||
private _copied = signal(false);
|
||||
|
||||
@Input()
|
||||
set response(value: ReplayCommandResponse | undefined) {
|
||||
@@ -330,7 +306,6 @@ export class ReplayCommandComponent {
|
||||
|
||||
// Computed signals
|
||||
activeTab = computed(() => this._activeTab());
|
||||
copied = computed(() => this._copied());
|
||||
|
||||
hasShortCommand = computed(() => !!this._response()?.shortCommand);
|
||||
hasOfflineCommand = computed(() => !!this._response()?.offlineCommand);
|
||||
@@ -361,28 +336,6 @@ export class ReplayCommandComponent {
|
||||
this._activeTab.set(tab);
|
||||
}
|
||||
|
||||
async copyCommand(): Promise<void> {
|
||||
const command = this.activeCommand()?.command;
|
||||
if (!command) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
this._copied.set(true);
|
||||
this.copySuccess.emit(command);
|
||||
|
||||
setTimeout(() => this._copied.set(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy command:', err);
|
||||
}
|
||||
}
|
||||
|
||||
getCopyButtonContent(): SafeHtml {
|
||||
if (this._copied()) {
|
||||
return this.sanitizer.bypassSecurityTrustHtml('<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" style="display:inline-block;vertical-align:middle"><polyline points="20 6 9 17 4 12" fill="none" stroke="currentColor" stroke-width="2"/></svg> Copied!');
|
||||
}
|
||||
return this.sanitizer.bypassSecurityTrustHtml('<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" style="display:inline-block;vertical-align:middle"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1" fill="none" stroke="currentColor" stroke-width="2"/></svg> Copy');
|
||||
}
|
||||
|
||||
formatBundleSize(bytes?: number): string {
|
||||
if (bytes === undefined) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* TruncatePipe Tests
|
||||
* Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004)
|
||||
*
|
||||
* Validates the truncate pipe handles all edge cases correctly.
|
||||
*/
|
||||
|
||||
import { TruncatePipe } from './truncate.pipe';
|
||||
|
||||
describe('TruncatePipe', () => {
|
||||
let pipe: TruncatePipe;
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new TruncatePipe();
|
||||
});
|
||||
|
||||
it('should create an instance', () => {
|
||||
expect(pipe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return empty string for null input', () => {
|
||||
expect(pipe.transform(null)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined input', () => {
|
||||
expect(pipe.transform(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for empty string input', () => {
|
||||
expect(pipe.transform('')).toBe('');
|
||||
});
|
||||
|
||||
it('should not truncate strings shorter than or equal to default length', () => {
|
||||
expect(pipe.transform('short text')).toBe('short text');
|
||||
});
|
||||
|
||||
it('should truncate strings longer than default length (20) with ellipsis', () => {
|
||||
const long = 'a'.repeat(25);
|
||||
const result = pipe.transform(long);
|
||||
expect(result).toBe('a'.repeat(20) + '...');
|
||||
});
|
||||
|
||||
it('should use custom length parameter', () => {
|
||||
const result = pipe.transform('abcdefghijklmnop', 8);
|
||||
expect(result).toBe('abcdefgh...');
|
||||
});
|
||||
|
||||
it('should use custom suffix', () => {
|
||||
const result = pipe.transform('abcdefghijklmnop', 8, '---');
|
||||
expect(result).toBe('abcdefgh---');
|
||||
});
|
||||
|
||||
it('should return original string when length equals string length', () => {
|
||||
expect(pipe.transform('exactly20charslong!!', 20)).toBe('exactly20charslong!!');
|
||||
});
|
||||
|
||||
it('should handle token ID truncation (the adopted use case)', () => {
|
||||
const tokenId = 'abc12345-6789-defg-hijk-lmnopqrstuv';
|
||||
const result = pipe.transform(tokenId, 16);
|
||||
expect(result).toBe('abc12345-6789-de...');
|
||||
expect(result.length).toBe(19); // 16 + '...'
|
||||
});
|
||||
|
||||
it('should handle hash truncation', () => {
|
||||
const hash = 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
const result = pipe.transform(hash, 12);
|
||||
expect(result).toBe('sha256:e3b0c...');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* CopyToClipboardComponent Tests
|
||||
* Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004)
|
||||
*
|
||||
* Validates copy-to-clipboard button renders, invokes clipboard API,
|
||||
* and shows visual feedback.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { CopyToClipboardComponent } from './copy-to-clipboard.component';
|
||||
|
||||
describe('CopyToClipboardComponent', () => {
|
||||
let component: CopyToClipboardComponent;
|
||||
let fixture: ComponentFixture<CopyToClipboardComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CopyToClipboardComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CopyToClipboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.value = 'test-value';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render a copy button', () => {
|
||||
const button = fixture.nativeElement.querySelector('button.copy-btn');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have default aria label', () => {
|
||||
const button = fixture.nativeElement.querySelector('button.copy-btn');
|
||||
expect(button.getAttribute('aria-label')).toBe('Copy to clipboard');
|
||||
});
|
||||
|
||||
it('should use custom aria label when provided', () => {
|
||||
component.ariaLabel = 'Copy secret';
|
||||
fixture.detectChanges();
|
||||
const button = fixture.nativeElement.querySelector('button.copy-btn');
|
||||
expect(button.getAttribute('aria-label')).toBe('Copy secret');
|
||||
});
|
||||
|
||||
it('should start with copied=false', () => {
|
||||
expect(component.copied()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show copy icon initially (not check icon)', () => {
|
||||
const svg = fixture.nativeElement.querySelector('button.copy-btn svg');
|
||||
expect(svg).toBeTruthy();
|
||||
// Copy icon has a rect element, check icon has a polyline
|
||||
const rect = svg.querySelector('rect');
|
||||
expect(rect).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call navigator.clipboard.writeText on click', fakeAsync(async () => {
|
||||
const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('test-value');
|
||||
}));
|
||||
|
||||
it('should set copied to true after successful copy', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
|
||||
expect(component.copied()).toBe(true);
|
||||
}));
|
||||
|
||||
it('should reset copied to false after 2 seconds', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
expect(component.copied()).toBe(true);
|
||||
|
||||
tick(2000);
|
||||
expect(component.copied()).toBe(false);
|
||||
}));
|
||||
|
||||
it('should add copied CSS class when copied', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button.copy-btn');
|
||||
expect(button.classList.contains('copy-btn--copied')).toBe(true);
|
||||
}));
|
||||
|
||||
it('should handle clipboard API failure gracefully', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied')));
|
||||
spyOn(console, 'error');
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
|
||||
expect(component.copied()).toBe(false);
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* InlineCodeComponent Tests
|
||||
* Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004)
|
||||
*
|
||||
* Validates inline-code renders monospace text with consistent styling.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { InlineCodeComponent } from './inline-code.component';
|
||||
|
||||
describe('InlineCodeComponent', () => {
|
||||
let component: InlineCodeComponent;
|
||||
let fixture: ComponentFixture<InlineCodeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [InlineCodeComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(InlineCodeComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
component.code = 'test';
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render a code element with inline-code class', () => {
|
||||
component.code = 'sha256:abc123';
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display the provided code text', () => {
|
||||
component.code = 'demo-prod';
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl.textContent).toBe('demo-prod');
|
||||
});
|
||||
|
||||
it('should update when code input changes', () => {
|
||||
component.code = 'first';
|
||||
fixture.detectChanges();
|
||||
|
||||
let codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl.textContent).toBe('first');
|
||||
|
||||
component.code = 'second';
|
||||
fixture.detectChanges();
|
||||
|
||||
codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl.textContent).toBe('second');
|
||||
});
|
||||
|
||||
it('should render technical identifiers like GUIDs correctly', () => {
|
||||
component.code = '550e8400-e29b-41d4-a716-446655440000';
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl.textContent).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
|
||||
it('should render scope strings correctly', () => {
|
||||
component.code = 'scanner:read';
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl.textContent).toBe('scanner:read');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user