Update project files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,67 @@
|
||||
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<div class="db-switcher">
|
||||
<button class="db-trigger" (click)="toggleMenu()" title="Switch database">
|
||||
<span class="db-icon">DB</span>
|
||||
<span class="db-name">{{ activeDbName }}</span>
|
||||
</button>
|
||||
@if (showDbMenu) {
|
||||
<div class="db-menu">
|
||||
<div class="db-menu-header">Databases</div>
|
||||
@for (db of databases; track db.path) {
|
||||
<button
|
||||
class="db-item"
|
||||
[class.active]="db.active"
|
||||
(click)="switchDb(db)"
|
||||
>
|
||||
<span class="db-item-name">{{ db.name }}</span>
|
||||
<span class="db-item-size">{{ formatSize(db.size_bytes) }}</span>
|
||||
@if (db.active) {
|
||||
<span class="db-item-badge">active</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div class="db-menu-divider"></div>
|
||||
<!-- Upload a .db file from your computer -->
|
||||
<label class="db-item db-action">
|
||||
<input
|
||||
type="file"
|
||||
accept=".db"
|
||||
hidden
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
@if (uploading) {
|
||||
Uploading...
|
||||
} @else {
|
||||
Upload .db file...
|
||||
}
|
||||
</label>
|
||||
<!-- Download current active DB -->
|
||||
<button class="db-item db-action" (click)="downloadDb()">
|
||||
Download current DB
|
||||
</button>
|
||||
<div class="db-menu-divider"></div>
|
||||
<!-- Create a new empty DB -->
|
||||
@if (showNewDbInput) {
|
||||
<div class="db-new-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newDbName"
|
||||
placeholder="new-database-name"
|
||||
(keydown.enter)="createDb()"
|
||||
(keydown.escape)="showNewDbInput = false"
|
||||
/>
|
||||
<button class="db-new-btn" (click)="createDb()">Create</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button class="db-item db-action" (click)="showNewDbInput = true">
|
||||
+ New database
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
<router-outlet />
|
||||
|
||||
@@ -74,3 +74,164 @@
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ─── Database Switcher ─────────────────────────────────────────
|
||||
.db-switcher {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.db-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted, var(--text-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
.db-icon {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.db-name {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.db-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 280px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.db-menu-header {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, var(--text-secondary));
|
||||
}
|
||||
|
||||
.db-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.db-item-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.db-item-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, var(--text-secondary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.db-item-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.db-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.db-new,
|
||||
.db-action {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.db-new-form {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.db-new-btn {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,104 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { RequirementService } from './core/services/requirement.service';
|
||||
import { DatabaseInfo } from './core/models/requirement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
imports: [CommonModule, FormsModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {}
|
||||
export class AppComponent implements OnInit {
|
||||
private svc = inject(RequirementService);
|
||||
|
||||
databases: DatabaseInfo[] = [];
|
||||
activeDbPath = '';
|
||||
showDbMenu = false;
|
||||
newDbName = '';
|
||||
showNewDbInput = false;
|
||||
uploading = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.loadDatabases();
|
||||
}
|
||||
|
||||
loadDatabases() {
|
||||
this.svc.getDatabases().subscribe(dbs => {
|
||||
this.databases = dbs;
|
||||
const active = dbs.find(d => d.active);
|
||||
if (active) this.activeDbPath = active.path;
|
||||
});
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
this.showDbMenu = !this.showDbMenu;
|
||||
if (this.showDbMenu) {
|
||||
this.loadDatabases();
|
||||
}
|
||||
}
|
||||
|
||||
switchDb(db: DatabaseInfo) {
|
||||
if (db.active) {
|
||||
this.showDbMenu = false;
|
||||
return;
|
||||
}
|
||||
this.svc.switchDatabase(db.path).subscribe(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.name.endsWith('.db')) {
|
||||
alert('Please select a .db file');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
this.uploading = true;
|
||||
this.svc.uploadDatabase(file).subscribe({
|
||||
next: () => window.location.reload(),
|
||||
error: (err) => {
|
||||
this.uploading = false;
|
||||
alert(err.error?.detail ?? 'Failed to upload database');
|
||||
input.value = '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
downloadDb() {
|
||||
this.svc.downloadDatabase().subscribe(blob => {
|
||||
const active = this.databases.find(d => d.active);
|
||||
const name = active?.name ?? 'planner';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name}.db`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
createDb() {
|
||||
const name = this.newDbName.trim();
|
||||
if (!name) return;
|
||||
this.svc.createDatabase(name).subscribe({
|
||||
next: () => window.location.reload(),
|
||||
error: (err) => alert(err.error?.detail ?? 'Failed to create database'),
|
||||
});
|
||||
}
|
||||
|
||||
formatSize(bytes: number): string {
|
||||
return (bytes / 1024).toFixed(0) + ' KB';
|
||||
}
|
||||
|
||||
get activeDbName(): string {
|
||||
const active = this.databases.find(d => d.active);
|
||||
return active?.name ?? 'planner';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface Stats {
|
||||
requirements: number;
|
||||
sub_requirements: number;
|
||||
leaves: number;
|
||||
orphaned: number;
|
||||
by_status: Record<string, number>;
|
||||
by_priority: Record<string, number>;
|
||||
quality_passing: number;
|
||||
@@ -146,6 +147,14 @@ export interface GraphLayoutCreate {
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
// ─── Database Switching ──────────────────────────────────────────────
|
||||
export interface DatabaseInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size_bytes: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<Priority, string> = {
|
||||
critical: '#da3633',
|
||||
high: '#d29922',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
GraphLayoutResponse,
|
||||
GraphLayoutDetailResponse,
|
||||
GraphLayoutCreate,
|
||||
DatabaseInfo,
|
||||
} from '../models/requirement.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -49,8 +50,10 @@ export class RequirementService {
|
||||
return this.http.put<RequirementNode>(`${this.baseUrl}/requirements/${id}`, data);
|
||||
}
|
||||
|
||||
deleteRequirement(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/requirements/${id}`);
|
||||
deleteRequirement(id: string, deleteIds: string[] = []): Observable<void> {
|
||||
return this.http.request<void>('DELETE', `${this.baseUrl}/requirements/${id}`, {
|
||||
body: deleteIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -116,10 +119,49 @@ export class RequirementService {
|
||||
return this.http.delete<void>(`${this.baseUrl}/layouts/${id}`);
|
||||
}
|
||||
|
||||
getAutosaveLayout(): Observable<GraphLayoutDetailResponse> {
|
||||
return this.http.get<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/autosave`);
|
||||
}
|
||||
|
||||
saveAutosaveLayout(data: { node_positions?: Record<string, { x: number; y: number }>; viewport?: Record<string, any> }): Observable<GraphLayoutDetailResponse> {
|
||||
return this.http.put<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/autosave`, data);
|
||||
}
|
||||
|
||||
// ─── Stats ────────────────────────────────────────────────────
|
||||
getStats(projectId?: number): Observable<Stats> {
|
||||
const params: Record<string, string> = {};
|
||||
if (projectId != null) params['project_id'] = String(projectId);
|
||||
return this.http.get<Stats>(`${this.baseUrl}/stats`, { params });
|
||||
}
|
||||
|
||||
// ─── Databases ──────────────────────────────────────────────
|
||||
getDatabases(): Observable<DatabaseInfo[]> {
|
||||
return this.http.get<DatabaseInfo[]>(`${this.baseUrl}/databases`);
|
||||
}
|
||||
|
||||
getActiveDatabase(): Observable<DatabaseInfo> {
|
||||
return this.http.get<DatabaseInfo>(`${this.baseUrl}/databases/active`);
|
||||
}
|
||||
|
||||
switchDatabase(path: string): Observable<DatabaseInfo> {
|
||||
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/switch`, { path });
|
||||
}
|
||||
|
||||
createDatabase(name: string): Observable<DatabaseInfo> {
|
||||
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/create`, { name });
|
||||
}
|
||||
|
||||
copyDatabase(name: string): Observable<DatabaseInfo> {
|
||||
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/copy`, { name });
|
||||
}
|
||||
|
||||
uploadDatabase(file: File): Observable<DatabaseInfo> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/upload`, formData);
|
||||
}
|
||||
|
||||
downloadDatabase(): Observable<Blob> {
|
||||
return this.http.get(`${this.baseUrl}/databases/download`, { responseType: 'blob' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
<div class="stat-value">{{ stats.coverage_percent }}%</div>
|
||||
<div class="stat-label">Test Coverage</div>
|
||||
</div>
|
||||
@if (stats.orphaned > 0) {
|
||||
<div class="stat-card warn">
|
||||
<div class="stat-value">{{ stats.orphaned }}</div>
|
||||
<div class="stat-label">Orphaned</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="charts-row">
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
border-color: var(--accent);
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
border-color: var(--yellow, #d29922);
|
||||
background: rgba(210, 153, 34, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
|
||||
@@ -151,10 +151,43 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Orphan Warning + Re-parent -->
|
||||
@if (!node.parent_id && node.node_type !== 'pillar') {
|
||||
<div class="section orphan-banner">
|
||||
<div class="orphan-label">Orphaned — assign a parent to restore traceability</div>
|
||||
<app-node-picker
|
||||
placeholder="Parent node ID..."
|
||||
submitLabel="Assign"
|
||||
[excludeIds]="[node.id]"
|
||||
(picked)="onReparentPicked($event)"
|
||||
/>
|
||||
@if (reparentError) {
|
||||
<div class="reparent-error">{{ reparentError }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Parent Requirement -->
|
||||
@if (parentNode) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">Parent</h3>
|
||||
<h3 class="section-title">
|
||||
Parent
|
||||
@if (editing) {
|
||||
<button class="section-action" (click)="showReparent = !showReparent">change</button>
|
||||
<button class="section-action danger" (click)="unlinkParent()">unlink</button>
|
||||
}
|
||||
</h3>
|
||||
@if (editing && showReparent) {
|
||||
<app-node-picker
|
||||
placeholder="New parent node ID..."
|
||||
submitLabel="Move"
|
||||
[excludeIds]="[node.id]"
|
||||
(picked)="onReparentPicked($event)"
|
||||
/>
|
||||
@if (reparentError) {
|
||||
<div class="reparent-error">{{ reparentError }}</div>
|
||||
}
|
||||
}
|
||||
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
|
||||
<div class="child-header">
|
||||
<span class="child-id">{{ parentNode.id }}</span>
|
||||
@@ -253,38 +286,16 @@
|
||||
</div>
|
||||
@if (showAddLink) {
|
||||
<div class="add-link-form">
|
||||
<div class="typeahead-container">
|
||||
<input
|
||||
class="edit-input"
|
||||
[(ngModel)]="newLinkTargetId"
|
||||
placeholder="Search by ID or title..."
|
||||
(input)="onLinkSearchInput()"
|
||||
(focus)="onLinkSearchFocus()"
|
||||
(keydown)="onLinkSearchKeydown($event)"
|
||||
(keydown.enter)="highlightedIndex < 0 && createLink()"
|
||||
(blur)="showLinkDropdown = false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (showLinkDropdown) {
|
||||
<div class="typeahead-dropdown">
|
||||
@for (req of filteredRequirements; track req.id; let i = $index) {
|
||||
<div
|
||||
class="typeahead-option"
|
||||
[class.highlighted]="i === highlightedIndex"
|
||||
(mousedown)="selectLinkTarget(req)"
|
||||
>
|
||||
<span class="typeahead-id">{{ req.id }}</span>
|
||||
<span class="typeahead-title">{{ req.title }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-node-picker
|
||||
placeholder="Search by ID or title..."
|
||||
submitLabel="Link"
|
||||
[excludeIds]="linkExcludeIds"
|
||||
(picked)="onLinkPicked($event)"
|
||||
/>
|
||||
@if (linkError) {
|
||||
<div class="create-error">{{ linkError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button class="action-btn primary small" (click)="createLink()">Link</button>
|
||||
<button class="action-btn small" (click)="toggleAddLink()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,45 +361,27 @@
|
||||
<div class="history-detail">
|
||||
<h4 class="history-detail-title">Version {{ selectedVersion.version }} Details</h4>
|
||||
<div class="history-fields">
|
||||
<div class="history-field" [class.changed]="isFieldChanged('title')">
|
||||
<span class="history-field-label">Title</span>
|
||||
<span class="history-field-value">{{ selectedVersion.title }}</span>
|
||||
</div>
|
||||
<div class="history-field" [class.changed]="isFieldChanged('description')">
|
||||
<span class="history-field-label">Description</span>
|
||||
<span class="history-field-value">{{ selectedVersion.description || '(empty)' }}</span>
|
||||
</div>
|
||||
<div class="history-field" [class.changed]="isFieldChanged('priority')">
|
||||
<span class="history-field-label">Priority</span>
|
||||
<span class="history-field-value">{{ selectedVersion.priority }}</span>
|
||||
</div>
|
||||
<div class="history-field" [class.changed]="isFieldChanged('status')">
|
||||
<span class="history-field-label">Status</span>
|
||||
<span class="history-field-value">{{ selectedVersion.status }}</span>
|
||||
</div>
|
||||
@if (isFieldChanged('acceptance_criteria')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Acceptance Criteria</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.acceptance_criteria || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldChanged('test_spec')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Test Spec</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.test_spec || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldChanged('test_code')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Test Code</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.test_code || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldChanged('impl_code')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Impl Code</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.impl_code || '(empty)' }}</pre>
|
||||
</div>
|
||||
@for (field of [
|
||||
{key: 'title', label: 'Title'},
|
||||
{key: 'description', label: 'Description'},
|
||||
{key: 'priority', label: 'Priority'},
|
||||
{key: 'status', label: 'Status'},
|
||||
{key: 'node_type', label: 'Node Type'},
|
||||
{key: 'acceptance_criteria', label: 'Acceptance Criteria'},
|
||||
{key: 'test_spec', label: 'Test Spec'},
|
||||
{key: 'test_code', label: 'Test Code'},
|
||||
{key: 'impl_code', label: 'Impl Code'}
|
||||
]; track field.key) {
|
||||
@if (isFieldChanged(field.key)) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">{{ field.label }}</span>
|
||||
<div class="history-delta">
|
||||
<div class="delta-old">{{ getFieldOldValue(field.key) }}</div>
|
||||
<span class="delta-arrow">→</span>
|
||||
<div class="delta-new">{{ getFieldNewValue(field.key) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
|
||||
@@ -409,3 +402,50 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showDeleteDialog) {
|
||||
<div class="delete-overlay" (click)="showDeleteDialog = false">
|
||||
<div class="delete-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="delete-header">
|
||||
<span>Delete "{{ node.title }}"</span>
|
||||
<button class="delete-close" (click)="showDeleteDialog = false">x</button>
|
||||
</div>
|
||||
<div class="delete-body">
|
||||
<p class="delete-desc">This node has {{ deleteChildren.length }} direct {{ deleteChildren.length === 1 ? 'child' : 'children' }}. Choose what happens to each:</p>
|
||||
<div class="delete-bulk-actions">
|
||||
<button class="bulk-btn" (click)="setAllDeleteActions('orphan')">Orphan All</button>
|
||||
<button class="bulk-btn danger" (click)="setAllDeleteActions('delete')">Delete All</button>
|
||||
</div>
|
||||
<div class="delete-children-list">
|
||||
@for (entry of deleteChildren; track entry.node.id) {
|
||||
<div class="delete-child-row">
|
||||
<div class="delete-child-info">
|
||||
<span class="delete-child-id">{{ entry.node.id }}</span>
|
||||
<span class="delete-child-title">{{ entry.node.title }}</span>
|
||||
@if (entry.descendantCount > 0) {
|
||||
<span class="delete-child-desc">+{{ entry.descendantCount }} nested</span>
|
||||
}
|
||||
</div>
|
||||
<div class="delete-child-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="entry.action === 'orphan'"
|
||||
(click)="entry.action = 'orphan'"
|
||||
>Orphan</button>
|
||||
<button
|
||||
class="toggle-btn danger"
|
||||
[class.active]="entry.action === 'delete'"
|
||||
(click)="entry.action = 'delete'"
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="delete-footer">
|
||||
<button class="action-btn danger" (click)="confirmDelete()">Confirm Delete</button>
|
||||
<button class="action-btn" (click)="showDeleteDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -496,57 +496,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// Typeahead dropdown
|
||||
.typeahead-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.typeahead-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.typeahead-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover, &.highlighted {
|
||||
background: rgba(188, 140, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.typeahead-id {
|
||||
color: #bc8cff;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeahead-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// History styles
|
||||
.clickable-title {
|
||||
@@ -666,3 +615,221 @@
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-delta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.delta-old {
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.delta-arrow {
|
||||
color: var(--yellow);
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.delta-new {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// ─── Orphan / Re-parent ──────────────────────────────────────────
|
||||
.orphan-banner {
|
||||
background: rgba(210, 153, 34, 0.08);
|
||||
border: 1px dashed #d29922;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.orphan-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d29922;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
.reparent-error {
|
||||
font-size: 12px;
|
||||
color: var(--red);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
||||
.section-action {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
&:hover { text-decoration: underline; }
|
||||
&.danger { color: var(--red, #f85149); }
|
||||
}
|
||||
|
||||
// ─── Delete Dialog ────────────────────────────────────────────────
|
||||
.delete-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.delete-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
width: 480px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.delete-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.delete-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
&:hover { color: var(--text-primary); }
|
||||
}
|
||||
|
||||
.delete-body {
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.delete-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.delete-bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
&:hover { color: var(--text-primary); border-color: var(--text-muted); }
|
||||
&.danger { color: var(--red); &:hover { border-color: var(--red); } }
|
||||
}
|
||||
|
||||
.delete-children-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.delete-child-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.delete-child-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.delete-child-id {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.delete-child-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-child-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.delete-child-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&.active {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
&.danger.active {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.delete-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RequirementService } from '../../core/services/requirement.service';
|
||||
import { NodePickerComponent } from '../../shared/node-picker.component';
|
||||
import {
|
||||
RequirementNode,
|
||||
CrossPillarLink,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
@Component({
|
||||
selector: 'app-detail-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, NodePickerComponent],
|
||||
templateUrl: './detail-panel.component.html',
|
||||
styleUrl: './detail-panel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -57,17 +58,21 @@ export class DetailPanelComponent implements OnChanges {
|
||||
historyList: RequirementHistorySummary[] = [];
|
||||
showHistory = false;
|
||||
selectedVersion: RequirementHistoryDetail | null = null;
|
||||
previousVersion: RequirementHistoryDetail | null = null;
|
||||
|
||||
// Delete dialog
|
||||
showDeleteDialog = false;
|
||||
deleteChildren: { node: RequirementNode; action: 'orphan' | 'delete'; descendantCount: number }[] = [];
|
||||
|
||||
// Re-parent
|
||||
showReparent = false;
|
||||
reparentError: string | null = null;
|
||||
|
||||
// Link management
|
||||
outgoingLinks: CrossPillarLink[] = []; // this node "also satisfies" these
|
||||
incomingLinks: CrossPillarLink[] = []; // these nodes satisfy this one
|
||||
outgoingLinks: CrossPillarLink[] = [];
|
||||
incomingLinks: CrossPillarLink[] = [];
|
||||
showAddLink = false;
|
||||
newLinkTargetId = '';
|
||||
linkError: string | null = null;
|
||||
allRequirements: { id: string; title: string }[] = [];
|
||||
filteredRequirements: { id: string; title: string }[] = [];
|
||||
showLinkDropdown = false;
|
||||
highlightedIndex = -1;
|
||||
|
||||
get effectiveMeasurable(): boolean {
|
||||
return this.validationReport ? this.validationReport.measurable : this.node.measurable;
|
||||
@@ -86,9 +91,14 @@ export class DetailPanelComponent implements OnChanges {
|
||||
this.editing = false;
|
||||
this.showAddChild = false;
|
||||
this.showAddLink = false;
|
||||
this.showReparent = false;
|
||||
this.showReparent = false;
|
||||
this.reparentError = null;
|
||||
this.showDeleteDialog = false;
|
||||
this.showHistory = false;
|
||||
this.historyList = [];
|
||||
this.selectedVersion = null;
|
||||
this.previousVersion = null;
|
||||
this.validationReport = this.node.quality || null;
|
||||
this.loadParent();
|
||||
this.loadChildren();
|
||||
@@ -205,9 +215,81 @@ export class DetailPanelComponent implements OnChanges {
|
||||
}
|
||||
|
||||
deleteNode(): void {
|
||||
if (!confirm(`Delete "${this.node.title}" and all its children?`)) return;
|
||||
this.reqService.deleteRequirement(this.node.id).subscribe({
|
||||
next: () => this.nodeDeleted.emit(this.node.id),
|
||||
if (this.children.length === 0) {
|
||||
// No children — delete immediately with simple confirm
|
||||
if (!confirm(`Delete "${this.node.title}"?`)) return;
|
||||
this.reqService.deleteRequirement(this.node.id).subscribe({
|
||||
next: () => this.nodeDeleted.emit(this.node.id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Has children — show the delete dialog
|
||||
this.deleteChildren = this.children.map(c => ({
|
||||
node: c,
|
||||
action: 'orphan' as const,
|
||||
descendantCount: this.countDescendants(c),
|
||||
}));
|
||||
this.showDeleteDialog = true;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private countDescendants(node: RequirementNode): number {
|
||||
let count = 0;
|
||||
if (node.children) {
|
||||
count = node.children.length;
|
||||
for (const child of node.children) {
|
||||
count += this.countDescendants(child);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
setAllDeleteActions(action: 'orphan' | 'delete'): void {
|
||||
this.deleteChildren.forEach(c => c.action = action);
|
||||
}
|
||||
|
||||
confirmDelete(): void {
|
||||
const deleteIds = this.deleteChildren
|
||||
.filter(c => c.action === 'delete')
|
||||
.map(c => c.node.id);
|
||||
this.reqService.deleteRequirement(this.node.id, deleteIds).subscribe({
|
||||
next: () => {
|
||||
this.showDeleteDialog = false;
|
||||
this.nodeDeleted.emit(this.node.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Re-parent ──────────────────────────────────────────────
|
||||
|
||||
unlinkParent(): void {
|
||||
this.reqService.updateRequirement(this.node.id, { parent_id: null } as any).subscribe({
|
||||
next: (updated) => {
|
||||
Object.assign(this.node, updated);
|
||||
this.validationReport = updated.quality || null;
|
||||
this.parentNode = null;
|
||||
this.nodeUpdated.emit(updated);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onReparentPicked(newParentId: string): void {
|
||||
this.reparentError = null;
|
||||
this.reqService.updateRequirement(this.node.id, { parent_id: newParentId } as any).subscribe({
|
||||
next: (updated) => {
|
||||
this.showReparent = false;
|
||||
Object.assign(this.node, updated);
|
||||
this.validationReport = updated.quality || null;
|
||||
this.loadParent();
|
||||
this.loadChildren();
|
||||
this.nodeUpdated.emit(updated);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (err) => {
|
||||
this.reparentError = err?.error?.detail || 'Failed to re-parent';
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,72 +307,14 @@ export class DetailPanelComponent implements OnChanges {
|
||||
|
||||
toggleAddLink(): void {
|
||||
this.showAddLink = !this.showAddLink;
|
||||
this.newLinkTargetId = '';
|
||||
this.linkError = null;
|
||||
this.showLinkDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
if (this.showAddLink) {
|
||||
this.reqService.getRequirements().subscribe({
|
||||
next: (reqs) => {
|
||||
// Exclude self and already-linked targets
|
||||
const linkedIds = new Set(this.outgoingLinks.map(l => l.target_id));
|
||||
linkedIds.add(this.node.id);
|
||||
this.allRequirements = reqs
|
||||
.filter(r => !linkedIds.has(r.id))
|
||||
.map(r => ({ id: r.id, title: r.title }));
|
||||
this.filteredRequirements = [];
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onLinkSearchInput(): void {
|
||||
this.filterLinkOptions();
|
||||
get linkExcludeIds(): string[] {
|
||||
return [this.node.id, ...this.outgoingLinks.map(l => l.target_id)];
|
||||
}
|
||||
|
||||
onLinkSearchFocus(): void {
|
||||
this.filterLinkOptions();
|
||||
}
|
||||
|
||||
private filterLinkOptions(): void {
|
||||
const q = this.newLinkTargetId.toLowerCase().trim();
|
||||
if (!q) {
|
||||
this.filteredRequirements = this.allRequirements.slice(0, 10);
|
||||
} else {
|
||||
this.filteredRequirements = this.allRequirements
|
||||
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
|
||||
.slice(0, 10);
|
||||
}
|
||||
this.showLinkDropdown = this.filteredRequirements.length > 0;
|
||||
this.highlightedIndex = -1;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
onLinkSearchKeydown(event: KeyboardEvent): void {
|
||||
if (!this.showLinkDropdown) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filteredRequirements.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
|
||||
} else if (event.key === 'Enter' && this.highlightedIndex >= 0) {
|
||||
event.preventDefault();
|
||||
this.selectLinkTarget(this.filteredRequirements[this.highlightedIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
selectLinkTarget(req: { id: string; title: string }): void {
|
||||
this.newLinkTargetId = req.id;
|
||||
this.showLinkDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
createLink(): void {
|
||||
const targetId = this.newLinkTargetId.trim();
|
||||
if (!targetId) return;
|
||||
onLinkPicked(targetId: string): void {
|
||||
this.reqService.createLink({
|
||||
source_id: this.node.id,
|
||||
target_id: targetId,
|
||||
@@ -298,13 +322,13 @@ export class DetailPanelComponent implements OnChanges {
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.showAddLink = false;
|
||||
this.newLinkTargetId = '';
|
||||
this.linkError = null;
|
||||
this.loadLinks();
|
||||
this.linksChanged.emit();
|
||||
},
|
||||
error: (err) => {
|
||||
this.linkError = err?.error?.detail || 'Failed to create link';
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -335,6 +359,7 @@ export class DetailPanelComponent implements OnChanges {
|
||||
toggleHistory(): void {
|
||||
this.showHistory = !this.showHistory;
|
||||
this.selectedVersion = null;
|
||||
this.previousVersion = null;
|
||||
if (this.showHistory) {
|
||||
this.loadHistory();
|
||||
}
|
||||
@@ -352,12 +377,23 @@ export class DetailPanelComponent implements OnChanges {
|
||||
viewVersion(version: number): void {
|
||||
if (this.selectedVersion?.version === version) {
|
||||
this.selectedVersion = null;
|
||||
this.previousVersion = null;
|
||||
return;
|
||||
}
|
||||
this.reqService.getHistoryVersion(this.node.id, version).subscribe({
|
||||
next: (detail) => {
|
||||
this.selectedVersion = detail;
|
||||
this.cdr.markForCheck();
|
||||
if (version > 1) {
|
||||
this.reqService.getHistoryVersion(this.node.id, version - 1).subscribe({
|
||||
next: (prev) => {
|
||||
this.previousVersion = prev;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.previousVersion = null;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -370,6 +406,7 @@ export class DetailPanelComponent implements OnChanges {
|
||||
this.validationReport = restored.quality || null;
|
||||
this.nodeUpdated.emit(restored);
|
||||
this.selectedVersion = null;
|
||||
this.previousVersion = null;
|
||||
this.loadHistory();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
@@ -378,7 +415,21 @@ export class DetailPanelComponent implements OnChanges {
|
||||
|
||||
isFieldChanged(field: string): boolean {
|
||||
if (!this.selectedVersion) return false;
|
||||
return (this.selectedVersion as any)[field] !== (this.node as any)[field];
|
||||
if (this.previousVersion) {
|
||||
return (this.selectedVersion as any)[field] !== (this.previousVersion as any)[field];
|
||||
}
|
||||
// Version 1 — everything is "new"
|
||||
return true;
|
||||
}
|
||||
|
||||
getFieldOldValue(field: string): string {
|
||||
if (!this.previousVersion) return '(none)';
|
||||
return (this.previousVersion as any)[field] || '(empty)';
|
||||
}
|
||||
|
||||
getFieldNewValue(field: string): string {
|
||||
if (!this.selectedVersion) return '';
|
||||
return (this.selectedVersion as any)[field] || '(empty)';
|
||||
}
|
||||
|
||||
goToNode(nodeId: string): void {
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<button class="toolbar-btn" (click)="zoomOut()" title="Zoom Out">-</button>
|
||||
<button class="toolbar-btn" (click)="fitGraph()" title="Fit to Screen">Fit</button>
|
||||
<button class="toolbar-btn" (click)="relayout()" title="Re-layout">Layout</button>
|
||||
<button class="toolbar-btn warn" (click)="groupOrphans()" title="Group orphaned nodes together">Orphans</button>
|
||||
<div class="layout-controls">
|
||||
<button class="toolbar-btn" (click)="showSaveLayout = true" title="Save Layout">Save</button>
|
||||
@if (savedLayouts.length > 0) {
|
||||
@@ -55,16 +56,17 @@
|
||||
<button class="toolbar-btn" (click)="showManageLayouts = true" title="Manage Layouts">Layouts</button>
|
||||
}
|
||||
</div>
|
||||
<button class="toolbar-btn help-btn" (click)="showShortcuts = !showShortcuts" title="Keyboard & Mouse Shortcuts">⌨</button>
|
||||
<button class="toolbar-btn help-btn" (click)="showHelp = !showHelp" title="Help">?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showHelp) {
|
||||
<div class="help-overlay" (click)="showHelp = false">
|
||||
@if (showShortcuts) {
|
||||
<div class="help-overlay" (click)="showShortcuts = false">
|
||||
<div class="help-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="help-header">
|
||||
<span>Keyboard & Mouse Shortcuts</span>
|
||||
<button class="help-close" (click)="showHelp = false">x</button>
|
||||
<button class="help-close" (click)="showShortcuts = false">x</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<div class="help-section">Mouse</div>
|
||||
@@ -87,6 +89,7 @@
|
||||
<div class="help-section">Graph</div>
|
||||
<div class="help-row"><span class="help-key">Solid lines</span><span>Parent-child relationships</span></div>
|
||||
<div class="help-row"><span class="help-key">Dashed purple</span><span>"Also satisfies" links</span></div>
|
||||
<div class="help-row"><span class="help-key">Dashed yellow</span><span>Orphaned node (parent was deleted)</span></div>
|
||||
<div class="help-row"><span class="help-key">Red dot</span><span>Fewer than 2 quality gates pass</span></div>
|
||||
<div class="help-row"><span class="help-key">Orange dot</span><span>2 of 3 quality gates pass</span></div>
|
||||
<div class="help-row"><span class="help-key">Green dot</span><span>All quality gates pass</span></div>
|
||||
@@ -95,6 +98,54 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showHelp) {
|
||||
<div class="help-overlay" (click)="showHelp = false">
|
||||
<div class="help-dialog wide" (click)="$event.stopPropagation()">
|
||||
<div class="help-header">
|
||||
<span>How It Works</span>
|
||||
<button class="help-close" (click)="showHelp = false">x</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<div class="help-section">Databases</div>
|
||||
<div class="help-text">
|
||||
Each project lives in its own SQLite <code>.db</code> file. Use the database selector
|
||||
in the navbar to switch between them.
|
||||
</div>
|
||||
<div class="help-row"><span class="help-key">New</span><span>Creates an empty database on the server</span></div>
|
||||
<div class="help-row"><span class="help-key">Copy</span><span>Clones the active database under a new name</span></div>
|
||||
<div class="help-row"><span class="help-key">Upload</span><span>Uploads a <code>.db</code> file from your computer to the server and switches to it</span></div>
|
||||
<div class="help-row"><span class="help-key">Download</span><span>Downloads the active database as a local backup file</span></div>
|
||||
|
||||
<div class="help-section">Layout Persistence</div>
|
||||
<div class="help-text">
|
||||
Node positions, viewport (zoom/pan), selected node, and panel state are automatically
|
||||
saved to the active database every time you make a change. This means your layout
|
||||
travels with the <code>.db</code> file — download it, upload to another machine or
|
||||
browser, and everything is exactly where you left it.
|
||||
</div>
|
||||
<div class="help-row"><span class="help-key">Auto-save</span><span>Working state writes to the DB after 1 second of inactivity</span></div>
|
||||
<div class="help-row"><span class="help-key">Named layouts</span><span>Save/Load snapshots of node positions via the toolbar</span></div>
|
||||
<div class="help-row"><span class="help-key">Layout button</span><span>Resets the tree to an automatic dagre layout; orphans are grouped below</span></div>
|
||||
|
||||
<div class="help-section">Deleting Nodes</div>
|
||||
<div class="help-text">
|
||||
Deleting a requirement does <strong>not</strong> delete its children. Children become
|
||||
<strong>orphans</strong> — they lose their parent link and show a dashed yellow border.
|
||||
Orphaned nodes fail the Traceable (T) quality gate until re-parented.
|
||||
</div>
|
||||
<div class="help-row"><span class="help-key">Orphans button</span><span>Groups orphaned nodes together without moving the rest of the layout</span></div>
|
||||
<div class="help-row"><span class="help-key">Re-parent</span><span>Edit an orphan's parent ID in the detail panel to restore traceability</span></div>
|
||||
|
||||
<div class="help-section">Quality Gates (TMCS)</div>
|
||||
<div class="help-row"><span class="help-key">T — Traceable</span><span>Auto-calculated: non-pillar nodes must have a parent</span></div>
|
||||
<div class="help-row"><span class="help-key">M — Measurable</span><span>Requires a test specification (GIVEN/WHEN/THEN)</span></div>
|
||||
<div class="help-row"><span class="help-key">C — Consistent</span><span>Manual toggle — you verify it doesn't contradict other requirements</span></div>
|
||||
<div class="help-row"><span class="help-key">S — Single-purpose</span><span>Manual toggle — you verify it serves exactly one purpose</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showCreatePillar) {
|
||||
<div class="create-pillar-bar">
|
||||
<input
|
||||
|
||||
@@ -260,6 +260,10 @@
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
|
||||
&.wide {
|
||||
width: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
.help-header {
|
||||
@@ -304,6 +308,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
|
||||
code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.help-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -440,3 +460,12 @@
|
||||
border-color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn.warn {
|
||||
color: #d29922;
|
||||
border-color: rgba(210, 153, 34, 0.3);
|
||||
&:hover {
|
||||
background: rgba(210, 153, 34, 0.15);
|
||||
border-color: #d29922;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import cytoscape, { Core, NodeSingular, Collection } from 'cytoscape';
|
||||
import dagre from 'cytoscape-dagre';
|
||||
import { RequirementService } from '../../core/services/requirement.service';
|
||||
import { DetailPanelComponent } from '../detail/detail-panel.component';
|
||||
import { RequirementNode, RequirementTreeNode, CrossPillarLink, PRIORITY_COLORS, Priority, GraphLayoutResponse } from '../../core/models/requirement.model';
|
||||
import { Subject, takeUntil, forkJoin } from 'rxjs';
|
||||
import { RequirementNode, RequirementTreeNode, CrossPillarLink, PRIORITY_COLORS, Priority, GraphLayoutResponse, DatabaseInfo } from '../../core/models/requirement.model';
|
||||
import { Subject, takeUntil, forkJoin, debounceTime } from 'rxjs';
|
||||
|
||||
cytoscape.use(dagre);
|
||||
|
||||
@@ -67,6 +67,7 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
treeData: RequirementTreeNode[] = [];
|
||||
loading = true;
|
||||
showCreatePillar = false;
|
||||
showShortcuts = false;
|
||||
showHelp = false;
|
||||
isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
newPillar = { id: '', title: '', description: '' };
|
||||
@@ -85,6 +86,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
newLayoutDescription = '';
|
||||
newLayoutDefault = false;
|
||||
|
||||
// Database-scoped localStorage prefix
|
||||
private dbName = 'planner';
|
||||
|
||||
// Debounced autosave to DB
|
||||
private autosave$ = new Subject<void>();
|
||||
|
||||
// Canvas state history (Ctrl+Z to undo)
|
||||
private canvasHistory: CanvasSnapshot[] = [];
|
||||
private readonly MAX_HISTORY = 20;
|
||||
@@ -99,8 +106,42 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initCytoscape();
|
||||
this.loadTree();
|
||||
this.loadSavedLayouts();
|
||||
this.initAutosavePipeline();
|
||||
this.reqService.getActiveDatabase().subscribe({
|
||||
next: (db) => { this.dbName = db.name; },
|
||||
complete: () => {
|
||||
this.loadTree();
|
||||
this.loadSavedLayouts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private initAutosavePipeline(): void {
|
||||
this.autosave$.pipe(
|
||||
debounceTime(1000),
|
||||
takeUntil(this.destroy$),
|
||||
).subscribe(() => this.flushAutosaveToDb());
|
||||
}
|
||||
|
||||
private flushAutosaveToDb(): void {
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
this.cy.nodes().forEach((node: NodeSingular) => {
|
||||
const pos = node.position();
|
||||
positions[node.id()] = { x: pos.x, y: pos.y };
|
||||
});
|
||||
const viewport: Record<string, any> = {
|
||||
zoom: this.cy.zoom(),
|
||||
pan: this.cy.pan(),
|
||||
selected_node_id: this.selectedNode?.id ?? null,
|
||||
panel_open: this.detailOpen,
|
||||
};
|
||||
this.reqService.saveAutosaveLayout({ node_positions: positions, viewport }).pipe(
|
||||
takeUntil(this.destroy$),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
private dbKey(key: string): string {
|
||||
return `req-planner-${this.dbName}-${key}`;
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
@@ -223,11 +264,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
zoom: this.cy.zoom(),
|
||||
pan: this.cy.pan(),
|
||||
};
|
||||
localStorage.setItem('req-planner-viewport', JSON.stringify(viewport));
|
||||
localStorage.setItem(this.dbKey('viewport'), JSON.stringify(viewport));
|
||||
this.autosave$.next();
|
||||
}
|
||||
|
||||
private restoreViewport(): boolean {
|
||||
const saved = localStorage.getItem('req-planner-viewport');
|
||||
const saved = localStorage.getItem(this.dbKey('viewport'));
|
||||
if (!saved) return false;
|
||||
try {
|
||||
const { zoom, pan } = JSON.parse(saved);
|
||||
@@ -247,11 +289,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
const pos = node.position();
|
||||
positions[node.id()] = { x: pos.x, y: pos.y };
|
||||
});
|
||||
localStorage.setItem('req-planner-node-positions', JSON.stringify(positions));
|
||||
localStorage.setItem(this.dbKey('node-positions'), JSON.stringify(positions));
|
||||
this.autosave$.next();
|
||||
}
|
||||
|
||||
private restoreNodePositions(): boolean {
|
||||
const saved = localStorage.getItem('req-planner-node-positions');
|
||||
const saved = localStorage.getItem(this.dbKey('node-positions'));
|
||||
if (!saved) return false;
|
||||
try {
|
||||
const positions: Record<string, { x: number; y: number }> = JSON.parse(saved);
|
||||
@@ -403,6 +446,14 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
'border-color': '#d29922',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node.orphan',
|
||||
style: {
|
||||
'border-style': 'dashed',
|
||||
'border-color': '#d29922',
|
||||
'border-width': 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -436,6 +487,7 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
const borderColor = this.getPriorityColor(node.priority);
|
||||
const dims = this.getNodeDimensions(node.node_type);
|
||||
const qualityLabel = this.getQualityLabel(node);
|
||||
const isOrphan = !node.parent_id && node.node_type !== 'pillar';
|
||||
elements.push({
|
||||
data: {
|
||||
id: node.id,
|
||||
@@ -453,9 +505,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
measurable: node.measurable,
|
||||
consistent: node.consistent,
|
||||
singlePurpose: node.single_purpose,
|
||||
parent_id: node.parent_id || null,
|
||||
hasGiteaLink: !!node.gitea_issue_number,
|
||||
giteaBadge: this.getGiteaBadgeDataUri(!!node.gitea_issue_number),
|
||||
orphan: isOrphan,
|
||||
},
|
||||
classes: isOrphan ? 'orphan' : undefined,
|
||||
});
|
||||
|
||||
if (node.parent_id) {
|
||||
@@ -490,7 +545,42 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
this.cy.elements().remove();
|
||||
this.cy.add(elements);
|
||||
|
||||
// Try restoring saved positions; fall back to dagre layout
|
||||
// Try restoring from DB autosave first, then localStorage, then dagre
|
||||
this.reqService.getAutosaveLayout().pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (autosave) => {
|
||||
const vp = autosave.viewport as Record<string, any>;
|
||||
if (this.restoreFromLayout(autosave.node_positions, vp)) {
|
||||
this.updateNodeAppearance();
|
||||
this.restoreSelectedNode(vp);
|
||||
} else {
|
||||
this.restoreWithLocalStorageOrDagre();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No autosave in DB — fall back to localStorage / dagre
|
||||
this.restoreWithLocalStorageOrDagre();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private restoreFromLayout(
|
||||
positions: Record<string, { x: number; y: number }>,
|
||||
viewport: Record<string, any>,
|
||||
): boolean {
|
||||
let restored = 0;
|
||||
const total = this.cy.nodes().length;
|
||||
this.cy.nodes().forEach((node: NodeSingular) => {
|
||||
const pos = positions[node.id()];
|
||||
if (pos) { node.position(pos); restored++; }
|
||||
});
|
||||
if (restored !== total) return false;
|
||||
this.restoringViewport = true;
|
||||
this.cy.viewport({ zoom: viewport['zoom'], pan: viewport['pan'] });
|
||||
this.restoringViewport = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private restoreWithLocalStorageOrDagre(): void {
|
||||
if (this.restoreNodePositions()) {
|
||||
if (!this.restoreViewport()) {
|
||||
this.cy.fit(undefined, 40);
|
||||
@@ -499,7 +589,6 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
this.restoreSelectedNode();
|
||||
} else {
|
||||
this.restoringViewport = true;
|
||||
// Exclude satisfaction links from layout so they don't pull nodes out of tree order
|
||||
const layoutEles = this.cy.elements().filter((ele: any) => {
|
||||
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
|
||||
return true;
|
||||
@@ -524,12 +613,13 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private restoreSelectedNode(): void {
|
||||
const savedId = localStorage.getItem('req-planner-selected-node');
|
||||
private restoreSelectedNode(viewport?: Record<string, any>): void {
|
||||
// Try DB autosave state first, then localStorage
|
||||
const savedId = viewport?.['selected_node_id'] ?? localStorage.getItem(this.dbKey('selected-node'));
|
||||
if (!savedId) return;
|
||||
const cyNode = this.cy.getElementById(savedId);
|
||||
if (cyNode.length) {
|
||||
const panelWasOpen = localStorage.getItem('req-planner-panel-open') === 'true';
|
||||
const panelWasOpen = viewport?.['panel_open'] ?? localStorage.getItem(this.dbKey('panel-open')) === 'true';
|
||||
this.loadNodeDetail(savedId, panelWasOpen);
|
||||
}
|
||||
}
|
||||
@@ -691,8 +781,9 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
}, { duration: 300 });
|
||||
}
|
||||
}
|
||||
localStorage.setItem('req-planner-selected-node', nodeId);
|
||||
localStorage.setItem('req-planner-panel-open', String(this.detailOpen));
|
||||
localStorage.setItem(this.dbKey('selected-node'), nodeId);
|
||||
localStorage.setItem(this.dbKey('panel-open'), String(this.detailOpen));
|
||||
this.autosave$.next();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
@@ -1095,7 +1186,8 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
|
||||
closeDetail(): void {
|
||||
this.detailOpen = false;
|
||||
localStorage.setItem('req-planner-panel-open', 'false');
|
||||
localStorage.setItem(this.dbKey('panel-open'), 'false');
|
||||
this.autosave$.next();
|
||||
const zoom = this.cy.zoom();
|
||||
const pan = { ...this.cy.pan() };
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1107,8 +1199,9 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onNodeUpdated(updated: RequirementNode): void {
|
||||
// Refresh the node in the graph
|
||||
const cyNode = this.cy.getElementById(updated.id);
|
||||
const oldParentId = cyNode.length ? cyNode.data('parent_id') : null;
|
||||
|
||||
if (cyNode.length) {
|
||||
cyNode.data('label', this.getNodeLabel(updated as any));
|
||||
cyNode.data('priority', updated.priority);
|
||||
@@ -1119,7 +1212,13 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
cyNode.data('hasGiteaLink', !!updated.gitea_issue_number);
|
||||
cyNode.data('giteaBadge', this.getGiteaBadgeDataUri(!!updated.gitea_issue_number));
|
||||
}
|
||||
this.updateNodeAppearance();
|
||||
|
||||
// If parent changed, reload tree to rebuild edges
|
||||
if (updated.parent_id !== oldParentId) {
|
||||
this.loadTree();
|
||||
} else {
|
||||
this.updateNodeAppearance();
|
||||
}
|
||||
}
|
||||
|
||||
onNodeCreated(): void {
|
||||
@@ -1149,13 +1248,15 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
relayout(): void {
|
||||
localStorage.removeItem('req-planner-node-positions');
|
||||
// Exclude satisfaction links from layout so they don't pull nodes out of tree order
|
||||
const layoutEles = this.cy.elements().filter((ele: any) => {
|
||||
localStorage.removeItem(this.dbKey('node-positions'));
|
||||
const orphanNodes = this.cy.nodes('[?orphan]');
|
||||
// Layout the tree (non-orphan) elements
|
||||
const treeEles = this.cy.elements().filter((ele: any) => {
|
||||
if (ele.isNode() && ele.data('orphan')) return false;
|
||||
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
|
||||
return true;
|
||||
});
|
||||
layoutEles.layout({
|
||||
treeEles.layout({
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 60,
|
||||
@@ -1164,11 +1265,44 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
||||
animate: true,
|
||||
animationDuration: 300,
|
||||
stop: () => {
|
||||
this.positionOrphansBelow(orphanNodes);
|
||||
this.saveNodePositions();
|
||||
},
|
||||
} as any).run();
|
||||
}
|
||||
|
||||
groupOrphans(): void {
|
||||
const orphanNodes = this.cy.nodes('[?orphan]');
|
||||
if (orphanNodes.length === 0) return;
|
||||
this.positionOrphansBelow(orphanNodes);
|
||||
this.saveNodePositions();
|
||||
}
|
||||
|
||||
private positionOrphansBelow(orphanNodes: any): void {
|
||||
if (orphanNodes.length === 0) return;
|
||||
// Find the bounding box of all non-orphan nodes
|
||||
const nonOrphans = this.cy.nodes().filter((n: NodeSingular) => !n.data('orphan'));
|
||||
let bottomY = 0;
|
||||
let centerX = 0;
|
||||
if (nonOrphans.length > 0) {
|
||||
const bb = nonOrphans.boundingBox();
|
||||
bottomY = bb.y2 + 120;
|
||||
centerX = (bb.x1 + bb.x2) / 2;
|
||||
}
|
||||
// Grid layout for orphans
|
||||
const cols = Math.ceil(Math.sqrt(orphanNodes.length));
|
||||
const spacingX = 200;
|
||||
const spacingY = 100;
|
||||
const startX = centerX - ((cols - 1) * spacingX) / 2;
|
||||
orphanNodes.forEach((node: NodeSingular, i: number) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
node.animate({
|
||||
position: { x: startX + col * spacingX, y: bottomY + row * spacingY },
|
||||
}, { duration: 300 });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Search Typeahead ──────────────────────────────────────────
|
||||
|
||||
private buildSearchIndex(tree: RequirementTreeNode[]): void {
|
||||
|
||||
235
frontend/src/app/shared/node-picker.component.ts
Normal file
235
frontend/src/app/shared/node-picker.component.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
inject,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RequirementService } from '../core/services/requirement.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-node-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="typeahead-container">
|
||||
<div class="typeahead-input-row">
|
||||
<input
|
||||
class="typeahead-input"
|
||||
[(ngModel)]="query"
|
||||
[placeholder]="placeholder"
|
||||
(input)="onInput()"
|
||||
(focus)="onFocus()"
|
||||
(keydown)="onKeydown($event)"
|
||||
(blur)="onBlur()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
class="typeahead-submit"
|
||||
[class.primary]="submitStyle === 'primary'"
|
||||
[disabled]="!query.trim()"
|
||||
(click)="submit()"
|
||||
>{{ submitLabel }}</button>
|
||||
</div>
|
||||
@if (showDropdown && filtered.length > 0) {
|
||||
<div class="typeahead-dropdown">
|
||||
@for (item of filtered; track item.id; let i = $index) {
|
||||
<div
|
||||
class="typeahead-option"
|
||||
[class.highlighted]="i === highlightedIndex"
|
||||
(mousedown)="selectItem(item)"
|
||||
>
|
||||
<span class="typeahead-id">{{ item.id }}</span>
|
||||
<span class="typeahead-title">{{ item.title }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.typeahead-container {
|
||||
position: relative;
|
||||
}
|
||||
.typeahead-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.typeahead-input {
|
||||
flex: 1;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
&:focus { border-color: var(--accent); }
|
||||
}
|
||||
.typeahead-submit {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
&:hover { color: var(--text-primary); border-color: var(--text-muted); }
|
||||
&:disabled { opacity: 0.4; cursor: default; }
|
||||
&.primary {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
&:hover { background: rgba(88, 166, 255, 0.1); }
|
||||
}
|
||||
}
|
||||
.typeahead-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
margin-top: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
.typeahead-option {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
&:hover, &.highlighted { background: rgba(88, 166, 255, 0.1); }
|
||||
}
|
||||
.typeahead-id {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.typeahead-title {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NodePickerComponent implements OnInit, OnDestroy {
|
||||
@Input() placeholder = 'Search by ID or title...';
|
||||
@Input() submitLabel = 'Select';
|
||||
@Input() submitStyle: 'primary' | 'default' = 'primary';
|
||||
@Input() excludeIds: string[] = [];
|
||||
@Output() picked = new EventEmitter<string>();
|
||||
|
||||
private reqService = inject(RequirementService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
query = '';
|
||||
allItems: { id: string; title: string }[] = [];
|
||||
filtered: { id: string; title: string }[] = [];
|
||||
showDropdown = false;
|
||||
highlightedIndex = -1;
|
||||
private loaded = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadItems();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private loadItems(): void {
|
||||
this.reqService.getRequirements().pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (reqs) => {
|
||||
const excluded = new Set(this.excludeIds);
|
||||
this.allItems = reqs
|
||||
.filter(r => !excluded.has(r.id))
|
||||
.map(r => ({ id: r.id, title: r.title }));
|
||||
this.loaded = true;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onInput(): void {
|
||||
this.filter();
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
if (!this.loaded) this.loadItems();
|
||||
this.filter();
|
||||
}
|
||||
|
||||
onBlur(): void {
|
||||
// Delay to allow mousedown on dropdown items
|
||||
setTimeout(() => {
|
||||
this.showDropdown = false;
|
||||
this.cdr.markForCheck();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (!this.showDropdown) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filtered.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
|
||||
} else if (event.key === 'Enter') {
|
||||
if (this.highlightedIndex >= 0) {
|
||||
event.preventDefault();
|
||||
this.selectItem(this.filtered[this.highlightedIndex]);
|
||||
} else {
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(item: { id: string; title: string }): void {
|
||||
this.query = item.id;
|
||||
this.showDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const id = this.query.trim();
|
||||
if (!id) return;
|
||||
this.picked.emit(id);
|
||||
this.query = '';
|
||||
this.showDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private filter(): void {
|
||||
const q = this.query.toLowerCase().trim();
|
||||
if (!q) {
|
||||
this.filtered = this.allItems.slice(0, 10);
|
||||
} else {
|
||||
this.filtered = this.allItems
|
||||
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
|
||||
.slice(0, 10);
|
||||
}
|
||||
this.showDropdown = this.filtered.length > 0;
|
||||
this.highlightedIndex = -1;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user