init commit
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.angular/
|
||||
|
||||
# Build outputs
|
||||
frontend/dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
281
README.md
Normal file
281
README.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# req-planner
|
||||
|
||||
A standalone requirements management tool with interactive graph visualization, hierarchical decomposition, quality gates, and code traceability. Designed as a general-purpose planning tool that can connect to any repository.
|
||||
|
||||
## What It Does
|
||||
|
||||
req-planner lets you decompose high-level project goals into traceable, testable requirements organized as a directed acyclic graph (DAG). Each requirement is a card that can be drilled down from abstract pillars to code-level leaf nodes, with quality validation at every level.
|
||||
|
||||
### Core Concepts
|
||||
|
||||
- **Pillars** — Top-level project goals (e.g., "MIDI Signal Path")
|
||||
- **Requirements** — Functional requirements under a pillar
|
||||
- **Sub-requirements** — Decomposed pieces of a requirement
|
||||
- **Leaves** — Code-level items with test specs and implementation
|
||||
- **Planning** — Planning-phase nodes (larger green-tinted cards)
|
||||
|
||||
Each node carries:
|
||||
- Acceptance criteria (what it must do)
|
||||
- Test specification (GIVEN/WHEN/THEN format)
|
||||
- Test and implementation code snippets
|
||||
- Target file paths for code export
|
||||
- Quality gate status
|
||||
- Gitea issue integration fields
|
||||
|
||||
### Quality Gates (TMCS)
|
||||
|
||||
Every requirement is validated against four gates:
|
||||
|
||||
| Gate | Type | Rule |
|
||||
|------|------|------|
|
||||
| **T**raceable | Auto | Has a parent requirement and description |
|
||||
| **M**easurable | Toggle | Forced false without test specification; user can toggle when test spec exists |
|
||||
| **C**onsistent | Toggle | User asserts no contradictions with sibling requirements |
|
||||
| **S**ingle-purpose | Toggle | User asserts requirement serves exactly one concern |
|
||||
|
||||
**T** is auto-calculated from the requirement's structure. **M**, **C**, and **S** are user-set toggles — click the badge in the detail panel to flip them. **M** is enforced: if a node has no test specification, measurable is always false regardless of the toggle.
|
||||
|
||||
Visual indicators on graph nodes:
|
||||
- **Red dot** — Fewer than 2 quality gates pass
|
||||
- **Orange dot** — 2 of 3 gates pass
|
||||
- **Green dot** — All quality gates pass
|
||||
|
||||
### Requirement Status Flow
|
||||
|
||||
Requirements follow a lifecycle: `idea` → `draft` → `todo` → `in_progress` → `done`
|
||||
|
||||
- **idea** (purple) — ideas backlog; things you're thinking about but haven't committed to building
|
||||
- **draft** (gray) — being defined, not yet ready for work
|
||||
- **todo** (blue) — defined and ready to implement
|
||||
- **in_progress** (yellow) — actively being worked on
|
||||
- **done** (green) — completed
|
||||
|
||||
### Satisfaction Links
|
||||
|
||||
Requirements can declare they "also satisfy" other requirements beyond their parent. This creates cross-cutting traceability:
|
||||
|
||||
- **Also Satisfies** — outgoing links: "this requirement also contributes to requirement X"
|
||||
- **Satisfied By** — incoming links: "requirement Y also contributes to this one"
|
||||
|
||||
These appear as purple dashed edges in the graph.
|
||||
|
||||
## Features
|
||||
|
||||
### Interactive Graph View (`/graph`)
|
||||
|
||||
The main view renders the requirement tree as a Cytoscape.js DAG with dagre layout.
|
||||
|
||||
**Semantic zoom** — node detail changes continuously with zoom level:
|
||||
|
||||
| Zoom Range | What You See |
|
||||
|------------|--------------|
|
||||
| 0 - 0.3 | Dots with ID only |
|
||||
| 0.3 - 0.6 | Cards with ID, title, child count |
|
||||
| 0.6 - 1.0 | Cards with TMCS quality badges |
|
||||
| 1.0+ | Expanded cards with AC count and status |
|
||||
|
||||
**Mouse interactions:**
|
||||
|
||||
| Action | Behavior |
|
||||
|--------|----------|
|
||||
| Click node | Select node, open detail panel, highlight paths to root (parent-child + satisfaction links) |
|
||||
| Shift+Click | Highlight parent-child path to root only (no satisfaction links) |
|
||||
| Alt+Click (Win) / Option+Click (Mac) | Highlight only "also satisfies" links for a node |
|
||||
| Ctrl+Click (Win) / Cmd+Click (Mac) | Focus mode: show node, direct children, and paths to roots |
|
||||
| Ctrl+Shift+Click (Win) / Cmd+Shift+Click (Mac) | Deep focus: show node, all descendants to leaves, and paths to roots |
|
||||
| Right-click | Highlight entire subtree downward |
|
||||
| Click background | Exit focus mode or close detail panel |
|
||||
| Drag node | Reposition (saved across sessions) |
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `+` / `=` | Zoom in |
|
||||
| `-` | Zoom out |
|
||||
| `0` | Fit graph to screen |
|
||||
| Ctrl+Z (Win) / Cmd+Z (Mac) | Undo last canvas action |
|
||||
| Esc | Exit focus mode |
|
||||
|
||||
**Canvas undo** tracks all interactions: node clicks, focus/exit, right-click highlights, and node drags. Up to 20 states are kept in the undo stack.
|
||||
|
||||
**Persistence:**
|
||||
- Node positions saved to localStorage — drag nodes to rearrange and they stay put across reloads
|
||||
- Viewport (zoom + pan) persisted across sessions
|
||||
- Selected node and panel state restored on reload
|
||||
- Re-layout button resets to auto-calculated dagre positions
|
||||
|
||||
**Named layouts:**
|
||||
- Save the current graph layout (node positions + viewport) with a name and description
|
||||
- Load any saved layout from the toolbar dropdown
|
||||
- Set a default layout that auto-loads on startup
|
||||
- Manage layouts: load, set default, or delete
|
||||
|
||||
**Edge rendering:**
|
||||
- Parent-child edges: solid gray lines, width scales up when zoomed out
|
||||
- Satisfaction links: purple dashed lines with directional arrows
|
||||
- Path highlight: blue for parent-child, purple for satisfaction links
|
||||
|
||||
### Detail Panel
|
||||
|
||||
A 420px side panel for viewing and editing a requirement:
|
||||
|
||||
- Inline editing for title, description, priority, status, phase, node type
|
||||
- Acceptance criteria and test spec (GIVEN/WHEN/THEN) editors
|
||||
- Test code and implementation code blocks (monospace)
|
||||
- Quality gate badges — click M/C/S to toggle, T is auto-calculated
|
||||
- Quality validation with error/warning display
|
||||
- Child requirements list with add-child form (auto-generates IDs)
|
||||
- Parent card link — click to navigate to parent
|
||||
- **Also Satisfies** section — manage outgoing satisfaction links with typeahead search
|
||||
- **Satisfied By** section — view incoming links, click to navigate
|
||||
- **Edit history** — collapsible section showing all versions with timestamps and change summaries
|
||||
- Click a version to see field-level diffs (changed fields highlighted)
|
||||
- Restore any previous version (creates a new version with "Restored from version N" note)
|
||||
- Gitea issue badge with link (when issue number is set)
|
||||
- Delete with cascade confirmation
|
||||
|
||||
### Dashboard (`/dashboard`)
|
||||
|
||||
Statistics overview with:
|
||||
- Node counts by type (pillars, requirements, sub-requirements, leaves, planning)
|
||||
- Test coverage percentage
|
||||
- Breakdown by status and priority
|
||||
- Quality gates pass/fail ratio
|
||||
|
||||
### Authentication
|
||||
|
||||
- Password-based login with JWT tokens (24-hour expiry)
|
||||
- Protected routes with auth guard
|
||||
- Token auto-injected into API requests via HTTP interceptor
|
||||
- Configurable password via `REQ_PLANNER_PASSWORD` environment variable
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
req-planner/
|
||||
├── backend/ FastAPI + SQLAlchemy + SQLite
|
||||
│ └── app/
|
||||
│ ├── main.py App entry, CORS config, router registration
|
||||
│ ├── auth.py JWT authentication
|
||||
│ ├── database.py SQLite connection (data/planner.db)
|
||||
│ ├── models.py ORM models (RequirementNode, CrossPillarLink,
|
||||
│ │ Project, RequirementHistory, GraphLayout)
|
||||
│ ├── schemas.py Pydantic request/response schemas
|
||||
│ ├── quality.py TMCS validation logic
|
||||
│ └── routers/
|
||||
│ ├── requirements.py CRUD + tree + validation + history endpoints
|
||||
│ ├── links.py Cross-pillar link management
|
||||
│ ├── projects.py Project CRUD
|
||||
│ ├── stats.py Aggregated statistics
|
||||
│ └── layouts.py Named graph layout CRUD
|
||||
├── frontend/ Angular 17 + Cytoscape.js
|
||||
│ └── src/app/
|
||||
│ ├── core/
|
||||
│ │ ├── guards/ Auth route guard
|
||||
│ │ ├── interceptors/ JWT token interceptor
|
||||
│ │ ├── models/ TypeScript interfaces
|
||||
│ │ └── services/ HTTP API client (requirements, auth)
|
||||
│ └── features/
|
||||
│ ├── graph/ Interactive DAG visualization
|
||||
│ ├── detail/ Side panel editor with history
|
||||
│ ├── dashboard/ Statistics overview
|
||||
│ └── login/ Password login
|
||||
├── data/
|
||||
│ └── planner.db SQLite database (auto-created)
|
||||
└── docs/
|
||||
└── MARKET-RESEARCH.md Competitive analysis
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Requirements
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/requirements` | List requirements (filter by project_id, parent_id) |
|
||||
| GET | `/api/requirements/tree` | Tree structure for graph rendering |
|
||||
| GET | `/api/requirements/{id}` | Single requirement with children |
|
||||
| POST | `/api/requirements` | Create requirement |
|
||||
| PUT | `/api/requirements/{id}` | Update requirement |
|
||||
| DELETE | `/api/requirements/{id}` | Delete requirement (cascades to children) |
|
||||
| PATCH | `/api/requirements/{id}/status` | Update status only |
|
||||
| POST | `/api/requirements/{id}/validate` | Run TMCS quality gates |
|
||||
| GET | `/api/requirements/{id}/children` | List direct children |
|
||||
|
||||
### Edit History
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/requirements/{id}/history` | List all versions (newest first) |
|
||||
| GET | `/api/requirements/{id}/history/{version}` | Get specific version snapshot |
|
||||
| POST | `/api/requirements/{id}/history/{version}/restore` | Restore to previous version |
|
||||
|
||||
### Satisfaction Links
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/links` | List links (optional `node_id` filter) |
|
||||
| POST | `/api/links` | Create satisfaction link |
|
||||
| DELETE | `/api/links/{source_id}/{target_id}` | Delete link |
|
||||
|
||||
### Graph Layouts
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/layouts` | List saved layouts (optional `project_id` filter) |
|
||||
| GET | `/api/layouts/{id}` | Get layout with positions and viewport |
|
||||
| POST | `/api/layouts` | Save current layout |
|
||||
| PUT | `/api/layouts/{id}` | Update layout |
|
||||
| DELETE | `/api/layouts/{id}` | Delete layout |
|
||||
|
||||
### Other
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Health check |
|
||||
| POST | `/api/auth/login` | Login, returns JWT token |
|
||||
| GET/POST/DELETE | `/api/projects` | Project management |
|
||||
| GET | `/api/stats` | Aggregated statistics |
|
||||
|
||||
Interactive API docs available at `http://localhost:8100/docs` (Swagger UI).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.12+, FastAPI, SQLAlchemy 2.0, SQLite
|
||||
- Pydantic 2.10+ for validation
|
||||
- python-jose for JWT tokens
|
||||
- Uvicorn ASGI server
|
||||
|
||||
**Frontend:**
|
||||
- Angular 17 (standalone components, new control flow syntax)
|
||||
- Cytoscape.js + cytoscape-dagre for graph rendering
|
||||
- RxJS for reactive data flow
|
||||
- SCSS with dark theme (GitHub-inspired palette)
|
||||
- OnPush change detection, lazy-loaded routes
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv sync # or: pip install -e ".[dev]"
|
||||
uvicorn app.main:app --reload --port 8100
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
ng serve # runs on http://localhost:4200
|
||||
```
|
||||
|
||||
The frontend proxies API requests to `http://localhost:8100/api` via `proxy.conf.json`.
|
||||
|
||||
## Database
|
||||
|
||||
SQLite database at `data/planner.db`, auto-created on first backend startup. Five tables:
|
||||
|
||||
- **requirement_nodes** — hierarchical requirements with quality flags, code metadata, and timestamps
|
||||
- **cross_pillar_links** — many-to-many satisfaction links between requirements
|
||||
- **projects** — project containers with optional repo path/URL
|
||||
- **requirement_history** — versioned snapshots of requirement edits
|
||||
- **graph_layouts** — named layout snapshots with node positions and viewport state
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
49
backend/app/auth.py
Normal file
49
backend/app/auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
PASSWORD = os.environ.get("REQ_PLANNER_PASSWORD", "what_is_this_2")
|
||||
SECRET_KEY = os.environ.get("REQ_PLANNER_SECRET", os.urandom(32).hex())
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
security = HTTPBearer()
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
def create_token() -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS)
|
||||
return jwt.encode({"exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> bool:
|
||||
try:
|
||||
jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return True
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: LoginRequest):
|
||||
if data.password != PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid password",
|
||||
)
|
||||
return TokenResponse(token=create_token())
|
||||
22
backend/app/database.py
Normal file
22
backend/app/database.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
DB_PATH = DATA_DIR / "planner.db"
|
||||
|
||||
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
|
||||
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
34
backend/app/main.py
Normal file
34
backend/app/main.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .database import Base, engine
|
||||
from .routers import layouts, links, projects, requirements, stats
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Requirements Planner",
|
||||
description="General-purpose requirements management with quality gates",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(requirements.router)
|
||||
app.include_router(links.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(layouts.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
221
backend/app/models.py
Normal file
221
backend/app/models.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, Index, Integer, String, Text, Boolean, DateTime, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Priority(str, enum.Enum):
|
||||
critical = "critical"
|
||||
high = "high"
|
||||
medium = "medium"
|
||||
low = "low"
|
||||
|
||||
|
||||
class Status(str, enum.Enum):
|
||||
idea = "idea"
|
||||
draft = "draft"
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
done = "done"
|
||||
|
||||
|
||||
class NodeType(str, enum.Enum):
|
||||
pillar = "pillar"
|
||||
requirement = "requirement"
|
||||
sub_requirement = "sub_requirement"
|
||||
leaf = "leaf"
|
||||
planning = "planning"
|
||||
|
||||
|
||||
class CodeLanguage(str, enum.Enum):
|
||||
python = "python"
|
||||
cpp = "cpp"
|
||||
typescript = "typescript"
|
||||
|
||||
|
||||
class TestStatus(str, enum.Enum):
|
||||
not_written = "not_written"
|
||||
written = "written"
|
||||
passing = "passing"
|
||||
failing = "failing"
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class RequirementNode(Base):
|
||||
__tablename__ = "requirement_nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[Priority] = mapped_column(Enum(Priority), default=Priority.medium)
|
||||
phase: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[Status] = mapped_column(Enum(Status), default=Status.draft)
|
||||
node_type: Mapped[NodeType] = mapped_column(Enum(NodeType), default=NodeType.requirement)
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
String(64), ForeignKey("requirement_nodes.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
|
||||
# Quality gate flags
|
||||
traceable: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
measurable: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
consistent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
single_purpose: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Issue tracker integration
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gitea_repo: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# Acceptance criteria and code (all node types)
|
||||
acceptance_criteria: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
test_spec: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
test_code: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
impl_code: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
code_language: Mapped[CodeLanguage | None] = mapped_column(
|
||||
Enum(CodeLanguage), nullable=True
|
||||
)
|
||||
target_test_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
target_impl_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
test_status: Mapped[TestStatus] = mapped_column(
|
||||
Enum(TestStatus), default=TestStatus.not_written
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
# Relationships
|
||||
parent: Mapped["RequirementNode | None"] = relationship(
|
||||
"RequirementNode", remote_side="RequirementNode.id", back_populates="children"
|
||||
)
|
||||
children: Mapped[list["RequirementNode"]] = relationship(
|
||||
"RequirementNode", back_populates="parent", cascade="all, delete-orphan",
|
||||
order_by="RequirementNode.sort_order"
|
||||
)
|
||||
project: Mapped["Project | None"] = relationship("Project", back_populates="requirements")
|
||||
|
||||
# Cross-pillar links (outgoing)
|
||||
outgoing_links: Mapped[list["CrossPillarLink"]] = relationship(
|
||||
"CrossPillarLink", foreign_keys="CrossPillarLink.source_id",
|
||||
back_populates="source", cascade="all, delete-orphan"
|
||||
)
|
||||
incoming_links: Mapped[list["CrossPillarLink"]] = relationship(
|
||||
"CrossPillarLink", foreign_keys="CrossPillarLink.target_id",
|
||||
back_populates="target", cascade="all, delete-orphan"
|
||||
)
|
||||
history: Mapped[list["RequirementHistory"]] = relationship(
|
||||
"RequirementHistory", back_populates="requirement",
|
||||
cascade="all, delete-orphan", order_by="RequirementHistory.version"
|
||||
)
|
||||
|
||||
|
||||
class CrossPillarLink(Base):
|
||||
__tablename__ = "cross_pillar_links"
|
||||
|
||||
source_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("requirement_nodes.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
target_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("requirement_nodes.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
relationship_type: Mapped[str] = mapped_column(
|
||||
String(64), default="shared_parent"
|
||||
)
|
||||
|
||||
source: Mapped[RequirementNode] = relationship(
|
||||
"RequirementNode", foreign_keys=[source_id], back_populates="outgoing_links"
|
||||
)
|
||||
target: Mapped[RequirementNode] = relationship(
|
||||
"RequirementNode", foreign_keys=[target_id], back_populates="incoming_links"
|
||||
)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
repo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
requirements_yaml_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
requirements: Mapped[list[RequirementNode]] = relationship(
|
||||
"RequirementNode", back_populates="project", cascade="all, delete-orphan"
|
||||
)
|
||||
layouts: Mapped[list["GraphLayout"]] = relationship(
|
||||
"GraphLayout", back_populates="project", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class RequirementHistory(Base):
|
||||
__tablename__ = "requirement_history"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("requirement_id", "version", name="uq_requirement_version"),
|
||||
Index("idx_req_history_req_version", "requirement_id", "version"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
requirement_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("requirement_nodes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# Snapshot of editable fields
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
phase: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
node_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
acceptance_criteria: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
test_spec: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
test_code: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
impl_code: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
code_language: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
target_test_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
target_impl_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
measurable: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
consistent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
single_purpose: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gitea_repo: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
change_summary: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
requirement: Mapped[RequirementNode] = relationship(
|
||||
"RequirementNode", back_populates="history"
|
||||
)
|
||||
|
||||
|
||||
class GraphLayout(Base):
|
||||
__tablename__ = "graph_layouts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
node_positions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
viewport: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
project: Mapped["Project | None"] = relationship("Project", back_populates="layouts")
|
||||
54
backend/app/quality.py
Normal file
54
backend/app/quality.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from .models import RequirementNode, NodeType
|
||||
from .schemas import QualityReport
|
||||
|
||||
|
||||
def validate_requirement(node: RequirementNode, children: list[RequirementNode]) -> QualityReport:
|
||||
"""Run quality gates on a requirement node.
|
||||
|
||||
Quality gates:
|
||||
1. Traceable — auto-calculated: has parent (non-pillar) + description
|
||||
2. Measurable — user toggle (manually set by the user)
|
||||
3. Consistent — user toggle (manually set by the user)
|
||||
4. Single-purpose — user toggle (manually set by the user)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
# 1. Traceable — auto-calculated
|
||||
traceable = True
|
||||
if node.node_type != NodeType.pillar:
|
||||
if not node.parent_id:
|
||||
errors.append("Non-pillar node must have a parent (traceability)")
|
||||
traceable = False
|
||||
if not node.description:
|
||||
warnings.append("Missing description — hard to trace how this serves parent")
|
||||
else:
|
||||
if not node.description:
|
||||
warnings.append("Pillar should have a description explaining its purpose")
|
||||
|
||||
# 2. Measurable — user toggle, but forced false without test_spec
|
||||
measurable = bool(node.measurable)
|
||||
if not node.acceptance_criteria:
|
||||
warnings.append("Missing acceptance criteria")
|
||||
if not node.test_spec:
|
||||
errors.append("Missing test specification (GIVEN/WHEN/THEN) — cannot be measurable")
|
||||
measurable = False
|
||||
|
||||
# 3. Consistent — user toggle
|
||||
consistent = bool(node.consistent)
|
||||
|
||||
# 4. Single-purpose — user toggle, but warn on heuristic
|
||||
single_purpose = bool(node.single_purpose)
|
||||
title_lower = node.title.lower()
|
||||
if " and " in title_lower and " and " not in title_lower.split("(")[0]:
|
||||
warnings.append("Title contains 'and' — may serve multiple purposes")
|
||||
|
||||
return QualityReport(
|
||||
traceable=traceable,
|
||||
measurable=measurable,
|
||||
consistent=consistent,
|
||||
single_purpose=single_purpose,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
valid=len(errors) == 0,
|
||||
)
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
102
backend/app/routers/layouts.py
Normal file
102
backend/app/routers/layouts.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..database import get_db
|
||||
from ..models import GraphLayout
|
||||
from ..schemas import (
|
||||
GraphLayoutCreate,
|
||||
GraphLayoutDetailResponse,
|
||||
GraphLayoutResponse,
|
||||
GraphLayoutUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/layouts", tags=["layouts"], dependencies=[Depends(verify_token)])
|
||||
|
||||
|
||||
def _to_response(layout: GraphLayout) -> GraphLayoutResponse:
|
||||
return GraphLayoutResponse.model_validate(layout)
|
||||
|
||||
|
||||
def _to_detail_response(layout: GraphLayout) -> GraphLayoutDetailResponse:
|
||||
base = GraphLayoutResponse.model_validate(layout)
|
||||
return GraphLayoutDetailResponse(
|
||||
**base.model_dump(),
|
||||
node_positions=json.loads(layout.node_positions),
|
||||
viewport=json.loads(layout.viewport),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GraphLayoutResponse])
|
||||
def list_layouts(project_id: int | None = None, db: Session = Depends(get_db)):
|
||||
query = db.query(GraphLayout)
|
||||
if project_id is not None:
|
||||
query = query.filter(GraphLayout.project_id == project_id)
|
||||
return [_to_response(l) for l in query.order_by(GraphLayout.updated_at.desc()).all()]
|
||||
|
||||
|
||||
@router.get("/{layout_id}", response_model=GraphLayoutDetailResponse)
|
||||
def get_layout(layout_id: int, db: Session = Depends(get_db)):
|
||||
layout = db.get(GraphLayout, layout_id)
|
||||
if not layout:
|
||||
raise HTTPException(404, "Layout not found")
|
||||
return _to_detail_response(layout)
|
||||
|
||||
|
||||
@router.post("", response_model=GraphLayoutDetailResponse, status_code=201)
|
||||
def create_layout(data: GraphLayoutCreate, db: Session = Depends(get_db)):
|
||||
if data.is_default:
|
||||
db.query(GraphLayout).filter(
|
||||
GraphLayout.project_id == data.project_id
|
||||
).update({"is_default": False})
|
||||
|
||||
layout = GraphLayout(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
project_id=data.project_id,
|
||||
node_positions=json.dumps(data.node_positions),
|
||||
viewport=json.dumps(data.viewport),
|
||||
is_default=data.is_default,
|
||||
)
|
||||
db.add(layout)
|
||||
db.commit()
|
||||
db.refresh(layout)
|
||||
return _to_detail_response(layout)
|
||||
|
||||
|
||||
@router.put("/{layout_id}", response_model=GraphLayoutDetailResponse)
|
||||
def update_layout(layout_id: int, data: GraphLayoutUpdate, db: Session = Depends(get_db)):
|
||||
layout = db.get(GraphLayout, layout_id)
|
||||
if not layout:
|
||||
raise HTTPException(404, "Layout not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
if update_data.get("is_default"):
|
||||
db.query(GraphLayout).filter(
|
||||
GraphLayout.project_id == layout.project_id,
|
||||
GraphLayout.id != layout_id,
|
||||
).update({"is_default": False})
|
||||
|
||||
if "node_positions" in update_data:
|
||||
update_data["node_positions"] = json.dumps(update_data["node_positions"])
|
||||
if "viewport" in update_data:
|
||||
update_data["viewport"] = json.dumps(update_data["viewport"])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(layout, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(layout)
|
||||
return _to_detail_response(layout)
|
||||
|
||||
|
||||
@router.delete("/{layout_id}", status_code=204)
|
||||
def delete_layout(layout_id: int, db: Session = Depends(get_db)):
|
||||
layout = db.get(GraphLayout, layout_id)
|
||||
if not layout:
|
||||
raise HTTPException(404, "Layout not found")
|
||||
db.delete(layout)
|
||||
db.commit()
|
||||
55
backend/app/routers/links.py
Normal file
55
backend/app/routers/links.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..database import get_db
|
||||
from ..models import CrossPillarLink, RequirementNode
|
||||
from ..schemas import LinkCreate, LinkResponse
|
||||
|
||||
router = APIRouter(prefix="/api/links", tags=["links"], dependencies=[Depends(verify_token)])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LinkResponse])
|
||||
def list_links(
|
||||
node_id: Optional[str] = Query(None, description="Filter links involving this node"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(CrossPillarLink)
|
||||
if node_id:
|
||||
q = q.filter(
|
||||
or_(
|
||||
CrossPillarLink.source_id == node_id,
|
||||
CrossPillarLink.target_id == node_id,
|
||||
)
|
||||
)
|
||||
return q.all()
|
||||
|
||||
|
||||
@router.post("", response_model=LinkResponse, status_code=201)
|
||||
def create_link(data: LinkCreate, db: Session = Depends(get_db)):
|
||||
if not db.get(RequirementNode, data.source_id):
|
||||
raise HTTPException(404, f"Source {data.source_id} not found")
|
||||
if not db.get(RequirementNode, data.target_id):
|
||||
raise HTTPException(404, f"Target {data.target_id} not found")
|
||||
|
||||
existing = db.get(CrossPillarLink, (data.source_id, data.target_id))
|
||||
if existing:
|
||||
raise HTTPException(409, "Link already exists")
|
||||
|
||||
link = CrossPillarLink(**data.model_dump())
|
||||
db.add(link)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
@router.delete("/{source_id}/{target_id}", status_code=204)
|
||||
def delete_link(source_id: str, target_id: str, db: Session = Depends(get_db)):
|
||||
link = db.get(CrossPillarLink, (source_id, target_id))
|
||||
if not link:
|
||||
raise HTTPException(404, "Link not found")
|
||||
db.delete(link)
|
||||
db.commit()
|
||||
40
backend/app/routers/projects.py
Normal file
40
backend/app/routers/projects.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..database import get_db
|
||||
from ..models import Project
|
||||
from ..schemas import ProjectCreate, ProjectResponse
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"], dependencies=[Depends(verify_token)])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectResponse])
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
return db.query(Project).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=201)
|
||||
def create_project(data: ProjectCreate, db: Session = Depends(get_db)):
|
||||
project = Project(**data.model_dump())
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
||||
project = db.get(Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(404, f"Project {project_id} not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
||||
project = db.get(Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(404, f"Project {project_id} not found")
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
252
backend/app/routers/requirements.py
Normal file
252
backend/app/routers/requirements.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..database import get_db
|
||||
from ..models import RequirementNode, RequirementHistory
|
||||
from ..quality import validate_requirement
|
||||
from ..schemas import (
|
||||
RequirementCreate,
|
||||
RequirementResponse,
|
||||
RequirementTreeNode,
|
||||
RequirementUpdate,
|
||||
QualityReport,
|
||||
RequirementHistorySummary,
|
||||
RequirementHistoryDetail,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/requirements", tags=["requirements"], dependencies=[Depends(verify_token)])
|
||||
|
||||
# ─── Snapshot fields to copy between RequirementNode and RequirementHistory ───
|
||||
_SNAPSHOT_FIELDS = [
|
||||
"title", "description", "priority", "phase", "status", "node_type",
|
||||
"acceptance_criteria", "test_spec", "test_code", "impl_code",
|
||||
"code_language", "target_test_file", "target_impl_file",
|
||||
"measurable", "consistent", "single_purpose",
|
||||
"gitea_issue_number", "gitea_repo",
|
||||
]
|
||||
|
||||
|
||||
def _create_history_entry(
|
||||
db: Session, node: RequirementNode, version: int, summary: str | None = None
|
||||
) -> RequirementHistory:
|
||||
data = {field: getattr(node, field) for field in _SNAPSHOT_FIELDS}
|
||||
# Convert enums to their string values for plain String columns
|
||||
for key in ("priority", "status", "node_type", "code_language"):
|
||||
val = data.get(key)
|
||||
if val is not None and hasattr(val, "value"):
|
||||
data[key] = val.value
|
||||
entry = RequirementHistory(
|
||||
requirement_id=node.id,
|
||||
version=version,
|
||||
change_summary=summary,
|
||||
**data,
|
||||
)
|
||||
db.add(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _next_version(db: Session, req_id: str) -> int:
|
||||
max_ver = db.query(func.max(RequirementHistory.version)).filter(
|
||||
RequirementHistory.requirement_id == req_id
|
||||
).scalar()
|
||||
return (max_ver or 0) + 1
|
||||
|
||||
|
||||
def _auto_summary(update_data: dict) -> str:
|
||||
fields = [k for k in update_data if k in _SNAPSHOT_FIELDS]
|
||||
if not fields:
|
||||
return "Update"
|
||||
return "Updated " + ", ".join(fields)
|
||||
|
||||
|
||||
def _to_response(node: RequirementNode, include_children: bool = False) -> RequirementResponse:
|
||||
quality = validate_requirement(node, node.children)
|
||||
resp = RequirementResponse.model_validate(node)
|
||||
resp.quality = quality
|
||||
if include_children:
|
||||
resp.children = [_to_response(c) for c in node.children]
|
||||
return resp
|
||||
|
||||
|
||||
def _to_tree_node(node: RequirementNode) -> RequirementTreeNode:
|
||||
quality = validate_requirement(node, node.children)
|
||||
return RequirementTreeNode(
|
||||
id=node.id,
|
||||
title=node.title,
|
||||
priority=node.priority,
|
||||
phase=node.phase,
|
||||
status=node.status,
|
||||
node_type=node.node_type,
|
||||
parent_id=node.parent_id,
|
||||
child_count=len(node.children),
|
||||
traceable=quality.traceable,
|
||||
measurable=quality.measurable,
|
||||
consistent=node.consistent,
|
||||
single_purpose=node.single_purpose,
|
||||
gitea_issue_number=node.gitea_issue_number,
|
||||
children=[_to_tree_node(c) for c in node.children],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RequirementResponse])
|
||||
def list_requirements(
|
||||
project_id: int | None = None,
|
||||
parent_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(RequirementNode)
|
||||
if project_id is not None:
|
||||
query = query.filter(RequirementNode.project_id == project_id)
|
||||
if parent_id is not None:
|
||||
query = query.filter(RequirementNode.parent_id == parent_id)
|
||||
nodes = query.order_by(RequirementNode.sort_order).all()
|
||||
return [_to_response(n) for n in nodes]
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[RequirementTreeNode])
|
||||
def get_tree(project_id: int | None = None, db: Session = Depends(get_db)):
|
||||
query = db.query(RequirementNode).filter(RequirementNode.parent_id.is_(None))
|
||||
if project_id is not None:
|
||||
query = query.filter(RequirementNode.project_id == project_id)
|
||||
roots = query.order_by(RequirementNode.sort_order).all()
|
||||
return [_to_tree_node(r) for r in roots]
|
||||
|
||||
|
||||
@router.get("/{req_id}", response_model=RequirementResponse)
|
||||
def get_requirement(req_id: str, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
return _to_response(node, include_children=True)
|
||||
|
||||
|
||||
@router.post("", response_model=RequirementResponse, status_code=201)
|
||||
def create_requirement(data: RequirementCreate, db: Session = Depends(get_db)):
|
||||
if db.get(RequirementNode, data.id):
|
||||
raise HTTPException(409, f"Requirement {data.id} already exists")
|
||||
if data.parent_id and not db.get(RequirementNode, data.parent_id):
|
||||
raise HTTPException(404, f"Parent {data.parent_id} not found")
|
||||
|
||||
node = RequirementNode(**data.model_dump())
|
||||
|
||||
# Auto-compute traceable flag only
|
||||
quality = validate_requirement(node, [])
|
||||
node.traceable = quality.traceable
|
||||
|
||||
db.add(node)
|
||||
db.flush()
|
||||
_create_history_entry(db, node, version=1, summary="Initial version")
|
||||
db.commit()
|
||||
db.refresh(node)
|
||||
return _to_response(node)
|
||||
|
||||
|
||||
@router.put("/{req_id}", response_model=RequirementResponse)
|
||||
def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(node, key, value)
|
||||
|
||||
# Auto-compute traceable flag only (M, C, S are user-set toggles)
|
||||
quality = validate_requirement(node, node.children)
|
||||
node.traceable = quality.traceable
|
||||
|
||||
version = _next_version(db, req_id)
|
||||
_create_history_entry(db, node, version, _auto_summary(update_data))
|
||||
|
||||
db.commit()
|
||||
db.refresh(node)
|
||||
return _to_response(node, include_children=True)
|
||||
|
||||
|
||||
@router.delete("/{req_id}", status_code=204)
|
||||
def delete_requirement(req_id: str, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
db.delete(node)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.patch("/{req_id}/status")
|
||||
def update_status(req_id: str, status: str, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
node.status = status
|
||||
db.commit()
|
||||
return {"id": req_id, "status": status}
|
||||
|
||||
|
||||
@router.post("/{req_id}/validate", response_model=QualityReport)
|
||||
def validate_node(req_id: str, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
return validate_requirement(node, node.children)
|
||||
|
||||
|
||||
@router.get("/{req_id}/children", response_model=list[RequirementResponse])
|
||||
def get_children(req_id: str, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
return [_to_response(c) for c in node.children]
|
||||
|
||||
|
||||
# ─── History Endpoints ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{req_id}/history", response_model=list[RequirementHistorySummary])
|
||||
def get_history(req_id: str, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
entries = db.query(RequirementHistory).filter(
|
||||
RequirementHistory.requirement_id == req_id
|
||||
).order_by(RequirementHistory.version.desc()).all()
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/{req_id}/history/{version}", response_model=RequirementHistoryDetail)
|
||||
def get_history_version(req_id: str, version: int, db: Session = Depends(get_db)):
|
||||
entry = db.query(RequirementHistory).filter(
|
||||
RequirementHistory.requirement_id == req_id,
|
||||
RequirementHistory.version == version,
|
||||
).first()
|
||||
if not entry:
|
||||
raise HTTPException(404, f"Version {version} not found for {req_id}")
|
||||
return entry
|
||||
|
||||
|
||||
@router.post("/{req_id}/history/{version}/restore", response_model=RequirementResponse)
|
||||
def restore_version(req_id: str, version: int, db: Session = Depends(get_db)):
|
||||
node = db.get(RequirementNode, req_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||
|
||||
entry = db.query(RequirementHistory).filter(
|
||||
RequirementHistory.requirement_id == req_id,
|
||||
RequirementHistory.version == version,
|
||||
).first()
|
||||
if not entry:
|
||||
raise HTTPException(404, f"Version {version} not found for {req_id}")
|
||||
|
||||
# Restore fields from history snapshot
|
||||
for field in _SNAPSHOT_FIELDS:
|
||||
setattr(node, field, getattr(entry, field))
|
||||
|
||||
quality = validate_requirement(node, node.children)
|
||||
node.traceable = quality.traceable
|
||||
|
||||
new_version = _next_version(db, req_id)
|
||||
_create_history_entry(db, node, new_version, f"Restored from version {version}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(node)
|
||||
return _to_response(node, include_children=True)
|
||||
55
backend/app/routers/stats.py
Normal file
55
backend/app/routers/stats.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..database import get_db
|
||||
from ..models import NodeType, RequirementNode
|
||||
from ..schemas import StatsResponse
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["stats"], dependencies=[Depends(verify_token)])
|
||||
|
||||
|
||||
@router.get("", response_model=StatsResponse)
|
||||
def get_stats(project_id: int | None = None, db: Session = Depends(get_db)):
|
||||
query = db.query(RequirementNode)
|
||||
if project_id is not None:
|
||||
query = query.filter(RequirementNode.project_id == project_id)
|
||||
|
||||
nodes = query.all()
|
||||
total = len(nodes)
|
||||
|
||||
by_type = {}
|
||||
by_status: dict[str, int] = {}
|
||||
by_priority: dict[str, int] = {}
|
||||
quality_passing = 0
|
||||
leaves_with_tests = 0
|
||||
total_leaves = 0
|
||||
|
||||
for n in nodes:
|
||||
by_type[n.node_type.value] = by_type.get(n.node_type.value, 0) + 1
|
||||
by_status[n.status.value] = by_status.get(n.status.value, 0) + 1
|
||||
by_priority[n.priority.value] = by_priority.get(n.priority.value, 0) + 1
|
||||
|
||||
if n.traceable and n.measurable and n.consistent and n.single_purpose:
|
||||
quality_passing += 1
|
||||
|
||||
if n.node_type == NodeType.leaf:
|
||||
total_leaves += 1
|
||||
if n.test_spec:
|
||||
leaves_with_tests += 1
|
||||
|
||||
coverage = (leaves_with_tests / total_leaves * 100) if total_leaves > 0 else 0.0
|
||||
|
||||
return StatsResponse(
|
||||
total_nodes=total,
|
||||
pillars=by_type.get("pillar", 0),
|
||||
requirements=by_type.get("requirement", 0),
|
||||
sub_requirements=by_type.get("sub_requirement", 0),
|
||||
leaves=by_type.get("leaf", 0),
|
||||
by_status=by_status,
|
||||
by_priority=by_priority,
|
||||
quality_passing=quality_passing,
|
||||
quality_failing=total - quality_passing,
|
||||
coverage_percent=round(coverage, 1),
|
||||
)
|
||||
229
backend/app/schemas.py
Normal file
229
backend/app/schemas.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .models import CodeLanguage, NodeType, Priority, Status, TestStatus
|
||||
|
||||
|
||||
# ─── Requirement ─────────────────────────────────────────────────────────
|
||||
class RequirementCreate(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
title: str = Field(..., max_length=256)
|
||||
description: str | None = None
|
||||
priority: Priority = Priority.medium
|
||||
phase: int | None = None
|
||||
status: Status = Status.draft
|
||||
node_type: NodeType = NodeType.requirement
|
||||
parent_id: str | None = None
|
||||
sort_order: int = 0
|
||||
project_id: int | None = None
|
||||
gitea_issue_number: int | None = None
|
||||
gitea_repo: str | None = None
|
||||
acceptance_criteria: str | None = None
|
||||
test_spec: str | None = None
|
||||
test_code: str | None = None
|
||||
impl_code: str | None = None
|
||||
code_language: CodeLanguage | None = None
|
||||
target_test_file: str | None = None
|
||||
target_impl_file: str | None = None
|
||||
|
||||
|
||||
class RequirementUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
priority: Priority | None = None
|
||||
phase: int | None = None
|
||||
status: Status | None = None
|
||||
node_type: NodeType | None = None
|
||||
parent_id: str | None = None
|
||||
sort_order: int | None = None
|
||||
gitea_issue_number: int | None = None
|
||||
gitea_repo: str | None = None
|
||||
acceptance_criteria: str | None = None
|
||||
test_spec: str | None = None
|
||||
test_code: str | None = None
|
||||
impl_code: str | None = None
|
||||
code_language: CodeLanguage | None = None
|
||||
target_test_file: str | None = None
|
||||
target_impl_file: str | None = None
|
||||
measurable: bool | None = None
|
||||
consistent: bool | None = None
|
||||
single_purpose: bool | None = None
|
||||
|
||||
|
||||
class QualityReport(BaseModel):
|
||||
traceable: bool
|
||||
measurable: bool
|
||||
consistent: bool
|
||||
single_purpose: bool
|
||||
errors: list[str]
|
||||
warnings: list[str]
|
||||
valid: bool
|
||||
|
||||
|
||||
class RequirementResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str | None
|
||||
priority: Priority
|
||||
phase: int | None
|
||||
status: Status
|
||||
node_type: NodeType
|
||||
parent_id: str | None
|
||||
sort_order: int
|
||||
project_id: int | None
|
||||
gitea_issue_number: int | None
|
||||
gitea_repo: str | None
|
||||
traceable: bool
|
||||
measurable: bool
|
||||
consistent: bool
|
||||
single_purpose: bool
|
||||
acceptance_criteria: str | None
|
||||
test_spec: str | None
|
||||
test_code: str | None
|
||||
impl_code: str | None
|
||||
code_language: CodeLanguage | None
|
||||
target_test_file: str | None
|
||||
target_impl_file: str | None
|
||||
test_status: TestStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
quality: QualityReport | None = None
|
||||
children: list["RequirementResponse"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RequirementTreeNode(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
priority: Priority
|
||||
phase: int | None
|
||||
status: Status
|
||||
node_type: NodeType
|
||||
parent_id: str | None
|
||||
child_count: int = 0
|
||||
traceable: bool
|
||||
measurable: bool
|
||||
consistent: bool
|
||||
single_purpose: bool
|
||||
gitea_issue_number: int | None = None
|
||||
children: list["RequirementTreeNode"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Requirement History ────────────────────────────────────────────────
|
||||
class RequirementHistorySummary(BaseModel):
|
||||
id: int
|
||||
requirement_id: str
|
||||
version: int
|
||||
title: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
change_summary: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RequirementHistoryDetail(RequirementHistorySummary):
|
||||
description: str | None
|
||||
priority: str
|
||||
phase: int | None
|
||||
node_type: str
|
||||
acceptance_criteria: str | None
|
||||
test_spec: str | None
|
||||
test_code: str | None
|
||||
impl_code: str | None
|
||||
code_language: str | None
|
||||
target_test_file: str | None
|
||||
target_impl_file: str | None
|
||||
measurable: bool
|
||||
consistent: bool
|
||||
single_purpose: bool
|
||||
gitea_issue_number: int | None
|
||||
gitea_repo: str | None
|
||||
|
||||
|
||||
# ─── Graph Layouts ──────────────────────────────────────────────────────
|
||||
class GraphLayoutCreate(BaseModel):
|
||||
name: str = Field(..., max_length=128)
|
||||
description: str | None = None
|
||||
project_id: int | None = None
|
||||
node_positions: dict[str, dict[str, float]]
|
||||
viewport: dict[str, object]
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class GraphLayoutUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
node_positions: dict[str, dict[str, float]] | None = None
|
||||
viewport: dict[str, object] | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class GraphLayoutResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
project_id: int | None
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GraphLayoutDetailResponse(GraphLayoutResponse):
|
||||
node_positions: dict[str, dict[str, float]]
|
||||
viewport: dict[str, object]
|
||||
|
||||
|
||||
# ─── Cross-Pillar Links ─────────────────────────────────────────────────
|
||||
class LinkCreate(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
relationship_type: str = "shared_parent"
|
||||
|
||||
|
||||
class LinkResponse(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
relationship_type: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Project ────────────────────────────────────────────────────────────
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str = Field(..., max_length=256)
|
||||
description: str | None = None
|
||||
repo_path: str | None = None
|
||||
repo_url: str | None = None
|
||||
requirements_yaml_path: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
repo_path: str | None
|
||||
repo_url: str | None
|
||||
requirements_yaml_path: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Stats ───────────────────────────────────────────────────────────────
|
||||
class StatsResponse(BaseModel):
|
||||
total_nodes: int
|
||||
pillars: int
|
||||
requirements: int
|
||||
sub_requirements: int
|
||||
leaves: int
|
||||
by_status: dict[str, int]
|
||||
by_priority: dict[str, int]
|
||||
quality_passing: int
|
||||
quality_failing: int
|
||||
coverage_percent: float
|
||||
32
backend/pyproject.toml
Normal file
32
backend/pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[project]
|
||||
name = "req-planner"
|
||||
version = "0.1.0"
|
||||
description = "General-purpose requirements management app"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"sqlalchemy>=2.0.36",
|
||||
"alembic>=1.14.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pyyaml>=6.0.2",
|
||||
"aiosqlite>=0.20.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
16
frontend/.editorconfig
Normal file
16
frontend/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
frontend/.gitignore
vendored
Normal file
42
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
27
frontend/README.md
Normal file
27
frontend/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Frontend
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.7.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
105
frontend/angular.json
Normal file
105
frontend/angular.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": ["cytoscape-dagre"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "8kb",
|
||||
"maximumError": "16kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"allowedHosts": ["to-the-n8nth-degree.andrewawesomo.net"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "frontend:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12310
frontend/package-lock.json
generated
Normal file
12310
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.0",
|
||||
"@angular/common": "^17.3.0",
|
||||
"@angular/compiler": "^17.3.0",
|
||||
"@angular/core": "^17.3.0",
|
||||
"@angular/forms": "^17.3.0",
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"@types/cytoscape": "^3.31.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-dagre": "^2.5.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.7",
|
||||
"@angular/cli": "^17.3.7",
|
||||
"@angular/compiler-cli": "^17.3.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.4.2"
|
||||
}
|
||||
}
|
||||
6
frontend/proxy.conf.json
Normal file
6
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8100",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
16
frontend/src/app/app.component.html
Normal file
16
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="app-shell">
|
||||
<nav class="top-nav">
|
||||
<div class="nav-brand">
|
||||
<span class="brand-icon">R</span>
|
||||
<span class="brand-text">ReqPlanner</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a routerLink="/graph" routerLinkActive="active">Graph</a>
|
||||
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
76
frontend/src/app/app.component.scss
Normal file
76
frontend/src/app/app.component.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
a {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
29
frontend/src/app/app.component.spec.ts
Normal file
29
frontend/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'frontend' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('frontend');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
11
frontend/src/app/app.component.ts
Normal file
11
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {}
|
||||
13
frontend/src/app/app.config.ts
Normal file
13
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
]
|
||||
};
|
||||
23
frontend/src/app/app.routes.ts
Normal file
23
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'graph', pathMatch: 'full' },
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then(m => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'graph',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/graph/graph-view.component').then(m => m.GraphViewComponent),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
|
||||
},
|
||||
];
|
||||
15
frontend/src/app/core/guards/auth.guard.ts
Normal file
15
frontend/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isLoggedIn()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
};
|
||||
29
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
29
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
|
||||
// Don't add token to login requests
|
||||
if (req.url.includes('/auth/login')) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const token = auth.getToken();
|
||||
if (token) {
|
||||
req = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
auth.logout();
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
154
frontend/src/app/core/models/requirement.model.ts
Normal file
154
frontend/src/app/core/models/requirement.model.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
export type Priority = 'critical' | 'high' | 'medium' | 'low';
|
||||
export type Status = 'idea' | 'draft' | 'todo' | 'in_progress' | 'done';
|
||||
export type NodeType = 'pillar' | 'requirement' | 'sub_requirement' | 'leaf' | 'planning';
|
||||
export type CodeLanguage = 'python' | 'cpp' | 'typescript';
|
||||
export type TestStatus = 'not_written' | 'written' | 'passing' | 'failing';
|
||||
|
||||
export interface QualityReport {
|
||||
traceable: boolean;
|
||||
measurable: boolean;
|
||||
consistent: boolean;
|
||||
single_purpose: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface RequirementNode {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
priority: Priority;
|
||||
phase: number | null;
|
||||
status: Status;
|
||||
node_type: NodeType;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
project_id: number | null;
|
||||
gitea_issue_number: number | null;
|
||||
gitea_repo: string | null;
|
||||
traceable: boolean;
|
||||
measurable: boolean;
|
||||
consistent: boolean;
|
||||
single_purpose: boolean;
|
||||
acceptance_criteria: string | null;
|
||||
test_spec: string | null;
|
||||
test_code: string | null;
|
||||
impl_code: string | null;
|
||||
code_language: CodeLanguage | null;
|
||||
target_test_file: string | null;
|
||||
target_impl_file: string | null;
|
||||
test_status: TestStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
quality: QualityReport | null;
|
||||
children: RequirementNode[];
|
||||
}
|
||||
|
||||
export interface RequirementTreeNode {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: Priority;
|
||||
phase: number | null;
|
||||
status: Status;
|
||||
node_type: NodeType;
|
||||
parent_id: string | null;
|
||||
child_count: number;
|
||||
traceable: boolean;
|
||||
measurable: boolean;
|
||||
consistent: boolean;
|
||||
single_purpose: boolean;
|
||||
gitea_issue_number: number | null;
|
||||
children: RequirementTreeNode[];
|
||||
}
|
||||
|
||||
export interface CrossPillarLink {
|
||||
source_id: string;
|
||||
target_id: string;
|
||||
relationship_type: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
repo_path: string | null;
|
||||
repo_url: string | null;
|
||||
requirements_yaml_path: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_nodes: number;
|
||||
pillars: number;
|
||||
requirements: number;
|
||||
sub_requirements: number;
|
||||
leaves: number;
|
||||
by_status: Record<string, number>;
|
||||
by_priority: Record<string, number>;
|
||||
quality_passing: number;
|
||||
quality_failing: number;
|
||||
coverage_percent: number;
|
||||
}
|
||||
|
||||
// ─── History ──────────────────────────────────────────────────────────
|
||||
export interface RequirementHistorySummary {
|
||||
id: number;
|
||||
requirement_id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
change_summary: string | null;
|
||||
}
|
||||
|
||||
export interface RequirementHistoryDetail extends RequirementHistorySummary {
|
||||
description: string | null;
|
||||
priority: string;
|
||||
phase: number | null;
|
||||
node_type: string;
|
||||
acceptance_criteria: string | null;
|
||||
test_spec: string | null;
|
||||
test_code: string | null;
|
||||
impl_code: string | null;
|
||||
code_language: string | null;
|
||||
target_test_file: string | null;
|
||||
target_impl_file: string | null;
|
||||
measurable: boolean;
|
||||
consistent: boolean;
|
||||
single_purpose: boolean;
|
||||
gitea_issue_number: number | null;
|
||||
gitea_repo: string | null;
|
||||
}
|
||||
|
||||
// ─── Graph Layouts ────────────────────────────────────────────────────
|
||||
export interface GraphLayoutResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
project_id: number | null;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GraphLayoutDetailResponse extends GraphLayoutResponse {
|
||||
node_positions: Record<string, { x: number; y: number }>;
|
||||
viewport: { zoom: number; pan: { x: number; y: number } };
|
||||
}
|
||||
|
||||
export interface GraphLayoutCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
project_id?: number | null;
|
||||
node_positions: Record<string, { x: number; y: number }>;
|
||||
viewport: { zoom: number; pan: { x: number; y: number } };
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<Priority, string> = {
|
||||
critical: '#da3633',
|
||||
high: '#d29922',
|
||||
medium: '#e3b341',
|
||||
low: '#3fb950',
|
||||
};
|
||||
39
frontend/src/app/core/services/auth.service.ts
Normal file
39
frontend/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
const TOKEN_KEY = 'req-planner-token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
private baseUrl = '/api';
|
||||
|
||||
login(password: string): Observable<{ token: string }> {
|
||||
return this.http
|
||||
.post<{ token: string }>(`${this.baseUrl}/auth/login`, { password })
|
||||
.pipe(tap((res) => localStorage.setItem(TOKEN_KEY, res.token)));
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
const token = this.getToken();
|
||||
if (!token) return false;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp * 1000 > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
frontend/src/app/core/services/requirement.service.ts
Normal file
125
frontend/src/app/core/services/requirement.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
RequirementNode,
|
||||
RequirementTreeNode,
|
||||
CrossPillarLink,
|
||||
Project,
|
||||
Stats,
|
||||
RequirementHistorySummary,
|
||||
RequirementHistoryDetail,
|
||||
GraphLayoutResponse,
|
||||
GraphLayoutDetailResponse,
|
||||
GraphLayoutCreate,
|
||||
} from '../models/requirement.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RequirementService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api';
|
||||
|
||||
// ─── Requirements ─────────────────────────────────────────────
|
||||
getRequirements(projectId?: number, parentId?: string): Observable<RequirementNode[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (projectId != null) params['project_id'] = String(projectId);
|
||||
if (parentId != null) params['parent_id'] = parentId;
|
||||
return this.http.get<RequirementNode[]>(`${this.baseUrl}/requirements`, { params });
|
||||
}
|
||||
|
||||
getTree(projectId?: number): Observable<RequirementTreeNode[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (projectId != null) params['project_id'] = String(projectId);
|
||||
return this.http.get<RequirementTreeNode[]>(`${this.baseUrl}/requirements/tree`, { params });
|
||||
}
|
||||
|
||||
getRequirement(id: string): Observable<RequirementNode> {
|
||||
return this.http.get<RequirementNode>(`${this.baseUrl}/requirements/${id}`);
|
||||
}
|
||||
|
||||
getChildren(id: string): Observable<RequirementNode[]> {
|
||||
return this.http.get<RequirementNode[]>(`${this.baseUrl}/requirements/${id}/children`);
|
||||
}
|
||||
|
||||
createRequirement(data: Partial<RequirementNode>): Observable<RequirementNode> {
|
||||
return this.http.post<RequirementNode>(`${this.baseUrl}/requirements`, data);
|
||||
}
|
||||
|
||||
updateRequirement(id: string, data: Partial<RequirementNode>): Observable<RequirementNode> {
|
||||
return this.http.put<RequirementNode>(`${this.baseUrl}/requirements/${id}`, data);
|
||||
}
|
||||
|
||||
deleteRequirement(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/requirements/${id}`);
|
||||
}
|
||||
|
||||
|
||||
// ─── Links ────────────────────────────────────────────────────
|
||||
getLinks(): Observable<CrossPillarLink[]> {
|
||||
return this.http.get<CrossPillarLink[]>(`${this.baseUrl}/links`);
|
||||
}
|
||||
|
||||
getLinksForNode(nodeId: string): Observable<CrossPillarLink[]> {
|
||||
return this.http.get<CrossPillarLink[]>(`${this.baseUrl}/links`, { params: { node_id: nodeId } });
|
||||
}
|
||||
|
||||
createLink(data: CrossPillarLink): Observable<CrossPillarLink> {
|
||||
return this.http.post<CrossPillarLink>(`${this.baseUrl}/links`, data);
|
||||
}
|
||||
|
||||
deleteLink(sourceId: string, targetId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/links/${sourceId}/${targetId}`);
|
||||
}
|
||||
|
||||
// ─── Projects ─────────────────────────────────────────────────
|
||||
getProjects(): Observable<Project[]> {
|
||||
return this.http.get<Project[]>(`${this.baseUrl}/projects`);
|
||||
}
|
||||
|
||||
createProject(data: Partial<Project>): Observable<Project> {
|
||||
return this.http.post<Project>(`${this.baseUrl}/projects`, data);
|
||||
}
|
||||
|
||||
// ─── History ──────────────────────────────────────────────────
|
||||
getHistory(id: string): Observable<RequirementHistorySummary[]> {
|
||||
return this.http.get<RequirementHistorySummary[]>(`${this.baseUrl}/requirements/${id}/history`);
|
||||
}
|
||||
|
||||
getHistoryVersion(id: string, version: number): Observable<RequirementHistoryDetail> {
|
||||
return this.http.get<RequirementHistoryDetail>(`${this.baseUrl}/requirements/${id}/history/${version}`);
|
||||
}
|
||||
|
||||
restoreVersion(id: string, version: number): Observable<RequirementNode> {
|
||||
return this.http.post<RequirementNode>(`${this.baseUrl}/requirements/${id}/history/${version}/restore`, {});
|
||||
}
|
||||
|
||||
// ─── Layouts ──────────────────────────────────────────────────
|
||||
getLayouts(projectId?: number): Observable<GraphLayoutResponse[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (projectId != null) params['project_id'] = String(projectId);
|
||||
return this.http.get<GraphLayoutResponse[]>(`${this.baseUrl}/layouts`, { params });
|
||||
}
|
||||
|
||||
getLayout(id: number): Observable<GraphLayoutDetailResponse> {
|
||||
return this.http.get<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/${id}`);
|
||||
}
|
||||
|
||||
saveLayout(data: GraphLayoutCreate): Observable<GraphLayoutDetailResponse> {
|
||||
return this.http.post<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts`, data);
|
||||
}
|
||||
|
||||
updateLayout(id: number, data: Partial<GraphLayoutCreate>): Observable<GraphLayoutDetailResponse> {
|
||||
return this.http.put<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/${id}`, data);
|
||||
}
|
||||
|
||||
deleteLayout(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/layouts/${id}`);
|
||||
}
|
||||
|
||||
// ─── 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 });
|
||||
}
|
||||
}
|
||||
74
frontend/src/app/features/dashboard/dashboard.component.html
Normal file
74
frontend/src/app/features/dashboard/dashboard.component.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<div class="dashboard">
|
||||
@if (stats) {
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total_nodes }}</div>
|
||||
<div class="stat-label">Total Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.pillars }}</div>
|
||||
<div class="stat-label">Pillars</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.requirements }}</div>
|
||||
<div class="stat-label">Requirements</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.sub_requirements }}</div>
|
||||
<div class="stat-label">Sub-Requirements</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.leaves }}</div>
|
||||
<div class="stat-label">Leaves</div>
|
||||
</div>
|
||||
<div class="stat-card accent">
|
||||
<div class="stat-value">{{ stats.coverage_percent }}%</div>
|
||||
<div class="stat-label">Test Coverage</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-row">
|
||||
<div class="chart-card">
|
||||
<h2 class="chart-title">Quality Gates</h2>
|
||||
<div class="quality-bar">
|
||||
<div class="bar-segment pass" [style.width.%]="stats.total_nodes ? (stats.quality_passing / stats.total_nodes * 100) : 0"></div>
|
||||
<div class="bar-segment fail" [style.width.%]="stats.total_nodes ? (stats.quality_failing / stats.total_nodes * 100) : 0"></div>
|
||||
</div>
|
||||
<div class="quality-legend">
|
||||
<span class="legend-item"><span class="dot pass"></span> Passing: {{ stats.quality_passing }}</span>
|
||||
<span class="legend-item"><span class="dot fail"></span> Failing: {{ stats.quality_failing }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h2 class="chart-title">By Status</h2>
|
||||
<div class="breakdown-list">
|
||||
@for (entry of statusEntries; track entry[0]) {
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-dot" [style.background]="getStatusColor(entry[0])"></span>
|
||||
<span class="breakdown-label">{{ entry[0] }}</span>
|
||||
<span class="breakdown-value">{{ entry[1] }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h2 class="chart-title">By Priority</h2>
|
||||
<div class="breakdown-list">
|
||||
@for (entry of priorityEntries; track entry[0]) {
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-dot" [style.background]="getPriorityColor(entry[0])"></span>
|
||||
<span class="breakdown-label">{{ entry[0] }}</span>
|
||||
<span class="breakdown-value">{{ entry[1] }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="loading">Loading dashboard...</div>
|
||||
}
|
||||
</div>
|
||||
146
frontend/src/app/features/dashboard/dashboard.component.scss
Normal file
146
frontend/src/app/features/dashboard/dashboard.component.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
.dashboard {
|
||||
padding: 24px 32px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
||||
&.accent {
|
||||
border-color: var(--accent);
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.quality-bar {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bar-segment {
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.pass { background: var(--green); }
|
||||
&.fail { background: var(--red); }
|
||||
}
|
||||
|
||||
.quality-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.pass { background: var(--green); }
|
||||
&.fail { background: var(--red); }
|
||||
}
|
||||
|
||||
.breakdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.breakdown-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
50
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
50
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Component, OnInit, inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RequirementService } from '../../core/services/requirement.service';
|
||||
import { Stats, PRIORITY_COLORS, Priority } from '../../core/models/requirement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
private reqService = inject(RequirementService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
|
||||
stats: Stats | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reqService.getStats().subscribe({
|
||||
next: (stats) => {
|
||||
this.stats = stats;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getPriorityColor(priority: string): string {
|
||||
return PRIORITY_COLORS[priority as Priority] || '#8b949e';
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
draft: '#8b949e',
|
||||
todo: '#58a6ff',
|
||||
in_progress: '#d29922',
|
||||
done: '#3fb950',
|
||||
};
|
||||
return colors[status] || '#8b949e';
|
||||
}
|
||||
|
||||
get statusEntries(): [string, number][] {
|
||||
return this.stats ? Object.entries(this.stats.by_status) : [];
|
||||
}
|
||||
|
||||
get priorityEntries(): [string, number][] {
|
||||
return this.stats ? Object.entries(this.stats.by_priority) : [];
|
||||
}
|
||||
}
|
||||
411
frontend/src/app/features/detail/detail-panel.component.html
Normal file
411
frontend/src/app/features/detail/detail-panel.component.html
Normal file
@@ -0,0 +1,411 @@
|
||||
<div class="detail-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-top">
|
||||
<span class="node-id">{{ node.id }}</span>
|
||||
<span class="node-type" [style.color]="node.node_type === 'pillar' ? '#58a6ff' : '#8b949e'">
|
||||
{{ node.node_type }}
|
||||
</span>
|
||||
<button class="close-btn" (click)="close.emit()">x</button>
|
||||
</div>
|
||||
|
||||
@if (!editing) {
|
||||
<h2 class="node-title">{{ node.title }}</h2>
|
||||
} @else {
|
||||
<input class="edit-input title-input" [(ngModel)]="editData.title" placeholder="Title" />
|
||||
}
|
||||
|
||||
<div class="badges-row">
|
||||
<span class="badge priority-badge" [style.background]="getPriorityColor(node.priority)">
|
||||
{{ node.priority }}
|
||||
</span>
|
||||
<span class="badge status-badge" [style.background]="getStatusColor(node.status)">
|
||||
{{ node.status }}
|
||||
</span>
|
||||
@if (node.phase != null) {
|
||||
<span class="badge phase-badge">Phase {{ node.phase }}</span>
|
||||
}
|
||||
@if (node.gitea_issue_number && node.gitea_repo) {
|
||||
<a class="badge issue-badge" [href]="getGiteaIssueUrl()" target="_blank" title="Open in Gitea">
|
||||
#{{ node.gitea_issue_number }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Quality Gates -->
|
||||
<div class="section quality-section">
|
||||
<h3 class="section-title">Quality Gates</h3>
|
||||
<div class="quality-badges">
|
||||
<span class="quality-badge auto" [class.pass]="node.traceable" [class.fail]="!node.traceable" title="Traceable (auto-calculated)">T</span>
|
||||
<span class="quality-badge toggle" [class.pass]="effectiveMeasurable" [class.fail]="!effectiveMeasurable" [title]="measurableForced ? 'Measurable (forced off — missing test spec)' : 'Measurable (click to toggle)'" (click)="toggleGate('measurable')">M</span>
|
||||
<span class="quality-badge toggle" [class.pass]="node.consistent" [class.fail]="!node.consistent" title="Consistent (click to toggle)" (click)="toggleGate('consistent')">C</span>
|
||||
<span class="quality-badge toggle" [class.pass]="node.single_purpose" [class.fail]="!node.single_purpose" title="Single Purpose (click to toggle)" (click)="toggleGate('single_purpose')">S</span>
|
||||
</div>
|
||||
@if (validationReport) {
|
||||
@if (validationReport.errors.length > 0) {
|
||||
<div class="validation-list errors">
|
||||
@for (err of validationReport.errors; track err) {
|
||||
<div class="validation-item error-item">{{ err }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (validationReport.warnings.length > 0) {
|
||||
<div class="validation-list warnings">
|
||||
@for (warn of validationReport.warnings; track warn) {
|
||||
<div class="validation-item warning-item">{{ warn }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (validationReport.valid && node.traceable && node.measurable && node.consistent && node.single_purpose) {
|
||||
<div class="validation-pass">All quality gates passed</div>
|
||||
} @else if (validationReport.valid && validationReport.warnings.length === 0) {
|
||||
<div class="validation-warn">No errors, but not all gates satisfied</div>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Description</h3>
|
||||
@if (!editing) {
|
||||
<p class="description">{{ node.description || 'No description' }}</p>
|
||||
} @else {
|
||||
<textarea class="edit-textarea" [(ngModel)]="editData.description" placeholder="Description..." rows="4"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Editing fields -->
|
||||
@if (editing) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">Properties</h3>
|
||||
<div class="edit-row">
|
||||
<label>Priority</label>
|
||||
<select class="edit-select" [(ngModel)]="editData.priority">
|
||||
@for (p of priorities; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Status</label>
|
||||
<select class="edit-select" [(ngModel)]="editData.status">
|
||||
@for (s of statuses; track s) {
|
||||
<option [value]="s">{{ s }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Phase</label>
|
||||
<input class="edit-input" type="number" [(ngModel)]="editData.phase" placeholder="Phase" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Gitea Repo</label>
|
||||
<input class="edit-input" [(ngModel)]="editData.gitea_repo" placeholder="owner/repo" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Issue #</label>
|
||||
<input class="edit-input" type="number" [(ngModel)]="editData.gitea_issue_number" placeholder="Issue number" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acceptance Criteria -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Acceptance Criteria</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block">{{ node.acceptance_criteria || 'No acceptance criteria defined' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea" [(ngModel)]="editData.acceptance_criteria" placeholder="Define what must be true for this requirement to be satisfied..." rows="4"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Test Specification -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Test Specification</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block">{{ node.test_spec || 'No test specification defined' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea mono" [(ngModel)]="editData.test_spec" placeholder="GIVEN...\nWHEN...\nTHEN..." rows="6"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Test Code -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Test Code</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block mono">{{ node.test_code || 'No test code' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea mono" [(ngModel)]="editData.test_code" placeholder="Test implementation..." rows="8"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Implementation Code -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Implementation Code</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block mono">{{ node.impl_code || 'No implementation code' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea mono" [(ngModel)]="editData.impl_code" placeholder="Implementation code..." rows="8"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Parent Requirement -->
|
||||
@if (parentNode) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">Parent</h3>
|
||||
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
|
||||
<div class="child-header">
|
||||
<span class="child-id">{{ parentNode.id }}</span>
|
||||
<span class="child-priority" [style.background]="getPriorityColor(parentNode.priority)">
|
||||
{{ parentNode.priority }}
|
||||
</span>
|
||||
<div class="child-quality">
|
||||
<span [class.pass]="parentNode.traceable" [class.fail]="!parentNode.traceable">T</span>
|
||||
<span [class.pass]="parentNode.measurable" [class.fail]="!parentNode.measurable">M</span>
|
||||
<span [class.pass]="parentNode.consistent" [class.fail]="!parentNode.consistent">C</span>
|
||||
<span [class.pass]="parentNode.single_purpose" [class.fail]="!parentNode.single_purpose">S</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="child-title">{{ parentNode.title }}</div>
|
||||
<span class="child-status" [style.color]="getStatusColor(parentNode.status)">
|
||||
{{ parentNode.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Child Requirements -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
Child Requirements
|
||||
<span class="child-count">({{ children.length }})</span>
|
||||
</h3>
|
||||
<div class="children-list">
|
||||
@for (child of children; track child.id) {
|
||||
<div class="child-card clickable" (click)="goToNode(child.id)">
|
||||
<div class="child-header">
|
||||
<span class="child-id">{{ child.id }}</span>
|
||||
<span class="child-priority" [style.background]="getPriorityColor(child.priority)">
|
||||
{{ child.priority }}
|
||||
</span>
|
||||
<div class="child-quality">
|
||||
<span [class.pass]="child.traceable" [class.fail]="!child.traceable">T</span>
|
||||
<span [class.pass]="child.measurable" [class.fail]="!child.measurable">M</span>
|
||||
<span [class.pass]="child.consistent" [class.fail]="!child.consistent">C</span>
|
||||
<span [class.pass]="child.single_purpose" [class.fail]="!child.single_purpose">S</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="child-title">{{ child.title }}</div>
|
||||
@if (child.description) {
|
||||
<div class="child-desc">{{ child.description }}</div>
|
||||
}
|
||||
<span class="child-status" [style.color]="getStatusColor(child.status)">
|
||||
{{ child.status }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Child Form -->
|
||||
@if (showAddChild) {
|
||||
<div class="section add-child-form">
|
||||
<h3 class="section-title">New Child Requirement</h3>
|
||||
<input class="edit-input" [(ngModel)]="$any(newChild).id" placeholder="ID (e.g. {{ node.id }}.1)" />
|
||||
<input class="edit-input" [(ngModel)]="newChild.title" placeholder="Title" />
|
||||
<textarea class="edit-textarea" [(ngModel)]="newChild.description" placeholder="Description..." rows="3"></textarea>
|
||||
<div class="edit-row">
|
||||
<label>Priority</label>
|
||||
<select class="edit-select" [(ngModel)]="newChild.priority">
|
||||
@for (p of priorities; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (createError) {
|
||||
<div class="create-error">{{ createError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button class="action-btn primary" (click)="createChild()">Create</button>
|
||||
<button class="action-btn" (click)="toggleAddChild()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Also Satisfies (outgoing links) -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
Also Satisfies
|
||||
<span class="child-count">({{ outgoingLinks.length }})</span>
|
||||
</h3>
|
||||
<div class="links-list">
|
||||
@for (link of outgoingLinks; track link.target_id) {
|
||||
<div class="link-card">
|
||||
<span class="link-id" (click)="goToNode(link.target_id)">{{ link.target_id }}</span>
|
||||
<button class="link-remove" (click)="deleteLink(link.source_id, link.target_id)" title="Remove link">x</button>
|
||||
</div>
|
||||
}
|
||||
@if (outgoingLinks.length === 0 && !showAddLink) {
|
||||
<div class="no-children">No satisfaction links</div>
|
||||
}
|
||||
</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>
|
||||
@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>
|
||||
} @else {
|
||||
<button class="action-btn small" (click)="toggleAddLink()">+ Add Link</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Satisfied By (incoming links) -->
|
||||
@if (incomingLinks.length > 0) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
Satisfied By
|
||||
<span class="child-count">({{ incomingLinks.length }})</span>
|
||||
</h3>
|
||||
<div class="links-list">
|
||||
@for (link of incomingLinks; track link.source_id) {
|
||||
<div class="link-card clickable" (click)="goToNode(link.source_id)">
|
||||
<span class="link-id">{{ link.source_id }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="section meta-section">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Created</span>
|
||||
<span class="meta-value">{{ node.created_at | date:'short' }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Updated</span>
|
||||
<span class="meta-value">{{ node.updated_at | date:'short' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="section">
|
||||
<h3 class="section-title clickable-title" (click)="toggleHistory()">
|
||||
<span>History</span>
|
||||
@if (historyList.length > 0) {
|
||||
<span class="child-count">({{ historyList.length }})</span>
|
||||
}
|
||||
<span class="toggle-arrow">{{ showHistory ? '\u25BC' : '\u25B6' }}</span>
|
||||
</h3>
|
||||
@if (showHistory) {
|
||||
<div class="history-list">
|
||||
@for (entry of historyList; track entry.id) {
|
||||
<div class="history-item" [class.active]="selectedVersion?.version === entry.version" (click)="viewVersion(entry.version)">
|
||||
<div class="history-header">
|
||||
<span class="history-version">v{{ entry.version }}</span>
|
||||
<span class="history-time">{{ entry.created_at | date:'short' }}</span>
|
||||
</div>
|
||||
<div class="history-summary">{{ entry.change_summary || 'No summary' }}</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="no-children">No history yet</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedVersion) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
@if (!editing) {
|
||||
<button class="action-btn primary" (click)="startEdit()">Edit</button>
|
||||
<button class="action-btn" (click)="toggleAddChild()">Add Child</button>
|
||||
<button class="action-btn danger" (click)="deleteNode()">Delete</button>
|
||||
} @else {
|
||||
<button class="action-btn primary" (click)="saveEdit()">Save</button>
|
||||
<button class="action-btn" (click)="cancelEdit()">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
668
frontend/src/app/features/detail/detail-panel.component.scss
Normal file
668
frontend/src/app/features/detail/detail-panel.component.scss
Normal file
@@ -0,0 +1,668 @@
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 48px;
|
||||
bottom: 0;
|
||||
width: 420px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.node-type {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.badges-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.phase-badge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.issue-badge {
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #388bfd;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.child-count {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.quality-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quality-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
|
||||
&.pass {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(63, 185, 80, 0.3);
|
||||
}
|
||||
|
||||
&.fail {
|
||||
background: rgba(218, 54, 51, 0.15);
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
}
|
||||
|
||||
&.toggle {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&.auto {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.validation-list {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.validation-item {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.warning-item {
|
||||
background: rgba(210, 153, 34, 0.1);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.validation-pass {
|
||||
font-size: 12px;
|
||||
color: var(--green);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.validation-warn {
|
||||
font-size: 12px;
|
||||
color: var(--yellow);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.create-error {
|
||||
font-size: 12px;
|
||||
color: var(--red);
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-size: 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
&.mono {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.children-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.child-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.child-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.child-id {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.child-priority {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.child-quality {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
span.pass { color: var(--green); }
|
||||
span.fail { color: var(--red); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.child-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.child-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.child-status {
|
||||
font-size: 11px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.no-children {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.meta-section {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Edit styles
|
||||
.edit-input,
|
||||
.edit-textarea,
|
||||
.edit-select {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&.mono {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.edit-select, .edit-input {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// Actions bar
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--red);
|
||||
&:hover {
|
||||
background: rgba(218, 54, 51, 0.15);
|
||||
border-color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-child-form {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
// Link management styles
|
||||
.links-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #bc8cff;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: #bc8cff;
|
||||
background: rgba(188, 140, 255, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-id {
|
||||
color: #bc8cff;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.link-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--red);
|
||||
background: rgba(218, 54, 51, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.add-link-form {
|
||||
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 {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.history-version {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history-summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-detail {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.history-detail-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.history-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-field {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&.changed {
|
||||
background: rgba(210, 153, 34, 0.1);
|
||||
border-left: 3px solid var(--yellow);
|
||||
}
|
||||
}
|
||||
|
||||
.history-field-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.history-field-value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-field-pre {
|
||||
color: var(--text-primary);
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
margin: 4px 0 0;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
387
frontend/src/app/features/detail/detail-panel.component.ts
Normal file
387
frontend/src/app/features/detail/detail-panel.component.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
inject,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RequirementService } from '../../core/services/requirement.service';
|
||||
import {
|
||||
RequirementNode,
|
||||
CrossPillarLink,
|
||||
Priority,
|
||||
Status,
|
||||
NodeType,
|
||||
PRIORITY_COLORS,
|
||||
QualityReport,
|
||||
RequirementHistorySummary,
|
||||
RequirementHistoryDetail,
|
||||
} from '../../core/models/requirement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-detail-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './detail-panel.component.html',
|
||||
styleUrl: './detail-panel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailPanelComponent implements OnChanges {
|
||||
@Input({ required: true }) node!: RequirementNode;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() nodeUpdated = new EventEmitter<RequirementNode>();
|
||||
@Output() nodeCreated = new EventEmitter<void>();
|
||||
@Output() nodeDeleted = new EventEmitter<string>();
|
||||
@Output() linksChanged = new EventEmitter<void>();
|
||||
@Output() navigateToNode = new EventEmitter<string>();
|
||||
|
||||
private reqService = inject(RequirementService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
|
||||
editing = false;
|
||||
editData: Partial<RequirementNode> = {};
|
||||
showAddChild = false;
|
||||
newChild: Partial<RequirementNode> = {};
|
||||
validationReport: QualityReport | null = null;
|
||||
children: RequirementNode[] = [];
|
||||
parentNode: RequirementNode | null = null;
|
||||
createError: string | null = null;
|
||||
|
||||
// History
|
||||
historyList: RequirementHistorySummary[] = [];
|
||||
showHistory = false;
|
||||
selectedVersion: RequirementHistoryDetail | null = null;
|
||||
|
||||
// Link management
|
||||
outgoingLinks: CrossPillarLink[] = []; // this node "also satisfies" these
|
||||
incomingLinks: CrossPillarLink[] = []; // these nodes satisfy this one
|
||||
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;
|
||||
}
|
||||
|
||||
get measurableForced(): boolean {
|
||||
return this.validationReport != null && !this.validationReport.measurable && this.node.measurable;
|
||||
}
|
||||
|
||||
priorities: Priority[] = ['critical', 'high', 'medium', 'low'];
|
||||
statuses: Status[] = ['idea', 'draft', 'todo', 'in_progress', 'done'];
|
||||
nodeTypes: NodeType[] = ['pillar', 'planning', 'requirement', 'sub_requirement', 'leaf'];
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['node']) {
|
||||
this.editing = false;
|
||||
this.showAddChild = false;
|
||||
this.showAddLink = false;
|
||||
this.showHistory = false;
|
||||
this.historyList = [];
|
||||
this.selectedVersion = null;
|
||||
this.validationReport = this.node.quality || null;
|
||||
this.loadParent();
|
||||
this.loadChildren();
|
||||
this.loadLinks();
|
||||
}
|
||||
}
|
||||
|
||||
private loadParent(): void {
|
||||
if (!this.node.parent_id) {
|
||||
this.parentNode = null;
|
||||
return;
|
||||
}
|
||||
this.reqService.getRequirement(this.node.parent_id).subscribe({
|
||||
next: (parent) => {
|
||||
this.parentNode = parent;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadChildren(): void {
|
||||
this.reqService.getChildren(this.node.id).subscribe({
|
||||
next: (children) => {
|
||||
this.children = children;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getPriorityColor(priority: Priority): string {
|
||||
return PRIORITY_COLORS[priority] || '#30363d';
|
||||
}
|
||||
|
||||
getStatusColor(status: Status): string {
|
||||
const colors: Record<Status, string> = {
|
||||
idea: '#6e40c9',
|
||||
draft: '#8b949e',
|
||||
todo: '#58a6ff',
|
||||
in_progress: '#d29922',
|
||||
done: '#3fb950',
|
||||
};
|
||||
return colors[status] || '#8b949e';
|
||||
}
|
||||
|
||||
getGiteaIssueUrl(): string {
|
||||
return `https://theres-a-git-in-this-tea.andrewawesomo.net/${this.node.gitea_repo}/issues/${this.node.gitea_issue_number}`;
|
||||
}
|
||||
|
||||
startEdit(): void {
|
||||
this.editing = true;
|
||||
this.editData = {
|
||||
title: this.node.title,
|
||||
description: this.node.description,
|
||||
priority: this.node.priority,
|
||||
status: this.node.status,
|
||||
phase: this.node.phase,
|
||||
gitea_issue_number: this.node.gitea_issue_number,
|
||||
gitea_repo: this.node.gitea_repo,
|
||||
acceptance_criteria: this.node.acceptance_criteria,
|
||||
test_spec: this.node.test_spec,
|
||||
test_code: this.node.test_code,
|
||||
impl_code: this.node.impl_code,
|
||||
};
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing = false;
|
||||
this.editData = {};
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
this.reqService.updateRequirement(this.node.id, this.editData).subscribe({
|
||||
next: (updated) => {
|
||||
this.editing = false;
|
||||
this.nodeUpdated.emit(updated);
|
||||
Object.assign(this.node, updated);
|
||||
this.validationReport = updated.quality || null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
toggleAddChild(): void {
|
||||
this.showAddChild = !this.showAddChild;
|
||||
this.createError = null;
|
||||
if (this.showAddChild) {
|
||||
const childType = this.node.node_type === 'pillar' ? 'requirement' : this.node.node_type === 'requirement' ? 'sub_requirement' : 'leaf';
|
||||
const nextIndex = this.children.length + 1;
|
||||
this.newChild = {
|
||||
id: `${this.node.id}.${nextIndex}`,
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'draft',
|
||||
node_type: childType,
|
||||
parent_id: this.node.id,
|
||||
project_id: this.node.project_id,
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
createChild(): void {
|
||||
if (!(this.newChild as any).id?.trim() || !this.newChild.title?.trim()) return;
|
||||
this.reqService.createRequirement(this.newChild).subscribe({
|
||||
next: () => {
|
||||
this.showAddChild = false;
|
||||
this.loadChildren();
|
||||
this.nodeCreated.emit();
|
||||
},
|
||||
error: (err) => {
|
||||
this.createError = err?.error?.detail || 'Failed to create requirement';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Link Management ──────────────────────────────────────────
|
||||
|
||||
private loadLinks(): void {
|
||||
this.reqService.getLinksForNode(this.node.id).subscribe({
|
||||
next: (links) => {
|
||||
this.outgoingLinks = links.filter(l => l.source_id === this.node.id);
|
||||
this.incomingLinks = links.filter(l => l.target_id === this.node.id);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
this.reqService.createLink({
|
||||
source_id: this.node.id,
|
||||
target_id: targetId,
|
||||
relationship_type: 'also_satisfies',
|
||||
}).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';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteLink(sourceId: string, targetId: string): void {
|
||||
this.reqService.deleteLink(sourceId, targetId).subscribe({
|
||||
next: () => {
|
||||
this.loadLinks();
|
||||
this.linksChanged.emit();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleGate(gate: 'measurable' | 'consistent' | 'single_purpose'): void {
|
||||
const newValue = !this.node[gate];
|
||||
this.reqService.updateRequirement(this.node.id, { [gate]: newValue }).subscribe({
|
||||
next: (updated) => {
|
||||
Object.assign(this.node, updated);
|
||||
this.validationReport = updated.quality || null;
|
||||
this.nodeUpdated.emit(updated);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── History ─────────────────────────────────────────────────
|
||||
|
||||
toggleHistory(): void {
|
||||
this.showHistory = !this.showHistory;
|
||||
this.selectedVersion = null;
|
||||
if (this.showHistory) {
|
||||
this.loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
this.reqService.getHistory(this.node.id).subscribe({
|
||||
next: (list) => {
|
||||
this.historyList = list;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
viewVersion(version: number): void {
|
||||
if (this.selectedVersion?.version === version) {
|
||||
this.selectedVersion = null;
|
||||
return;
|
||||
}
|
||||
this.reqService.getHistoryVersion(this.node.id, version).subscribe({
|
||||
next: (detail) => {
|
||||
this.selectedVersion = detail;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
restoreVersion(version: number): void {
|
||||
if (!confirm(`Restore to version ${version}? Current values will be saved as a new version first.`)) return;
|
||||
this.reqService.restoreVersion(this.node.id, version).subscribe({
|
||||
next: (restored) => {
|
||||
Object.assign(this.node, restored);
|
||||
this.validationReport = restored.quality || null;
|
||||
this.nodeUpdated.emit(restored);
|
||||
this.selectedVersion = null;
|
||||
this.loadHistory();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isFieldChanged(field: string): boolean {
|
||||
if (!this.selectedVersion) return false;
|
||||
return (this.selectedVersion as any)[field] !== (this.node as any)[field];
|
||||
}
|
||||
|
||||
goToNode(nodeId: string): void {
|
||||
this.navigateToNode.emit(nodeId);
|
||||
}
|
||||
}
|
||||
203
frontend/src/app/features/graph/graph-view.component.html
Normal file
203
frontend/src/app/features/graph/graph-view.component.html
Normal file
@@ -0,0 +1,203 @@
|
||||
<div class="graph-container" [class.detail-open]="detailOpen">
|
||||
<div class="graph-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="toolbar-btn primary" (click)="toggleCreatePillar()">+ Pillar</button>
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(input)="onSearchInput()"
|
||||
(focus)="onSearchFocus()"
|
||||
(blur)="onSearchBlur()"
|
||||
(keydown)="onSearchKeydown($event)"
|
||||
placeholder="Search by ID or title..."
|
||||
class="search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchQuery) {
|
||||
<button class="search-clear" (click)="clearSearch()">x</button>
|
||||
}
|
||||
@if (showSearchDropdown) {
|
||||
<div class="search-dropdown">
|
||||
@for (item of filteredSearchItems; track item.id; let i = $index) {
|
||||
<div
|
||||
class="search-option"
|
||||
[class.highlighted]="i === searchHighlightedIndex"
|
||||
(mousedown)="selectSearchResult(item)"
|
||||
>
|
||||
<span class="search-option-id">{{ item.id }}</span>
|
||||
<span class="search-option-title">{{ item.title }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
<span class="zoom-label">{{ currentZoomLevel }}</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="toolbar-btn" (click)="zoomIn()" title="Zoom In">+</button>
|
||||
<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>
|
||||
<div class="layout-controls">
|
||||
<button class="toolbar-btn" (click)="showSaveLayout = true" title="Save Layout">Save</button>
|
||||
@if (savedLayouts.length > 0) {
|
||||
<select class="layout-select" (change)="onLayoutSelect($event)" [value]="''">
|
||||
<option value="" disabled selected>Load...</option>
|
||||
@for (layout of savedLayouts; track layout.id) {
|
||||
<option [value]="layout.id">{{ layout.name }}{{ layout.is_default ? ' *' : '' }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (savedLayouts.length > 0) {
|
||||
<button class="toolbar-btn" (click)="showManageLayouts = true" title="Manage Layouts">Layouts</button>
|
||||
}
|
||||
</div>
|
||||
<button class="toolbar-btn help-btn" (click)="showHelp = !showHelp" title="Help">?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showHelp) {
|
||||
<div class="help-overlay" (click)="showHelp = 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>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<div class="help-section">Mouse</div>
|
||||
<div class="help-row"><span class="help-key">Click</span><span>Select node, open detail panel</span></div>
|
||||
<div class="help-row"><span class="help-key">Right-click</span><span>Highlight entire subtree downward</span></div>
|
||||
<div class="help-row"><span class="help-key">{{ isMac ? '⌘' : 'Ctrl' }}+Click</span><span>Focus mode: show node, direct children, and paths to roots</span></div>
|
||||
<div class="help-row"><span class="help-key">{{ isMac ? '⌘⇧' : 'Ctrl+Shift' }}+Click</span><span>Deep focus: show node, all descendants to leaves, and paths to roots</span></div>
|
||||
<div class="help-row"><span class="help-key">Shift+Click</span><span>Highlight parent path only (no "also satisfies")</span></div>
|
||||
<div class="help-row"><span class="help-key">{{ isMac ? '⌥' : 'Alt' }}+Click</span><span>Highlight "also satisfies" links for a node</span></div>
|
||||
<div class="help-row"><span class="help-key">Click background</span><span>Exit focus mode or close detail panel</span></div>
|
||||
<div class="help-row"><span class="help-key">Drag node</span><span>Reposition (saved across sessions)</span></div>
|
||||
|
||||
<div class="help-section">Keyboard</div>
|
||||
<div class="help-row"><span class="help-key">+ =</span><span>Zoom in</span></div>
|
||||
<div class="help-row"><span class="help-key">-</span><span>Zoom out</span></div>
|
||||
<div class="help-row"><span class="help-key">0</span><span>Fit graph to screen</span></div>
|
||||
<div class="help-row"><span class="help-key">{{ isMac ? '⌘Z' : 'Ctrl+Z' }}</span><span>Undo last canvas action</span></div>
|
||||
<div class="help-row"><span class="help-key">Esc</span><span>Exit focus mode</span></div>
|
||||
|
||||
<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">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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showCreatePillar) {
|
||||
<div class="create-pillar-bar">
|
||||
<input
|
||||
class="create-input"
|
||||
[(ngModel)]="newPillar.id"
|
||||
placeholder="ID (e.g. P-01)"
|
||||
/>
|
||||
<input
|
||||
class="create-input wide"
|
||||
[(ngModel)]="newPillar.title"
|
||||
placeholder="Pillar title"
|
||||
/>
|
||||
<input
|
||||
class="create-input wide"
|
||||
[(ngModel)]="newPillar.description"
|
||||
placeholder="Description (optional)"
|
||||
/>
|
||||
<button class="toolbar-btn primary" (click)="createPillar()">Create</button>
|
||||
<button class="toolbar-btn" (click)="toggleCreatePillar()">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="graph-canvas-wrapper">
|
||||
<div class="graph-canvas" #cyContainer></div>
|
||||
@if (loading) {
|
||||
<div class="loading-overlay">
|
||||
<span>Loading requirements graph...</span>
|
||||
</div>
|
||||
}
|
||||
@if (!loading && treeData.length === 0 && !showCreatePillar) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">No requirements yet</div>
|
||||
<div class="empty-desc">Create a pillar to get started</div>
|
||||
<button class="toolbar-btn primary" (click)="toggleCreatePillar()">+ Create First Pillar</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showSaveLayout) {
|
||||
<div class="help-overlay" (click)="showSaveLayout = false">
|
||||
<div class="help-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="help-header">
|
||||
<span>Save Current Layout</span>
|
||||
<button class="help-close" (click)="showSaveLayout = false">x</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<input class="layout-input" [(ngModel)]="newLayoutName" placeholder="Layout name" />
|
||||
<input class="layout-input" [(ngModel)]="newLayoutDescription" placeholder="Description (optional)" />
|
||||
<label class="layout-checkbox">
|
||||
<input type="checkbox" [(ngModel)]="newLayoutDefault" />
|
||||
Set as default (auto-load on startup)
|
||||
</label>
|
||||
<div class="layout-modal-actions">
|
||||
<button class="toolbar-btn primary" (click)="saveCurrentLayout()" [disabled]="!newLayoutName.trim()">Save</button>
|
||||
<button class="toolbar-btn" (click)="showSaveLayout = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showManageLayouts) {
|
||||
<div class="help-overlay" (click)="showManageLayouts = false">
|
||||
<div class="help-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="help-header">
|
||||
<span>Manage Layouts</span>
|
||||
<button class="help-close" (click)="showManageLayouts = false">x</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
@for (layout of savedLayouts; track layout.id) {
|
||||
<div class="layout-manage-row">
|
||||
<div class="layout-manage-info">
|
||||
<span class="layout-manage-name">{{ layout.name }}{{ layout.is_default ? ' (default)' : '' }}</span>
|
||||
<span class="layout-manage-date">{{ layout.updated_at | date:'short' }}</span>
|
||||
@if (layout.description) {
|
||||
<span class="layout-manage-desc">{{ layout.description }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="layout-manage-actions">
|
||||
<button class="toolbar-btn small" (click)="loadLayout(layout.id); showManageLayouts = false">Load</button>
|
||||
@if (!layout.is_default) {
|
||||
<button class="toolbar-btn small" (click)="setDefaultLayout(layout.id)">Default</button>
|
||||
}
|
||||
<button class="toolbar-btn small danger" (click)="deleteLayout(layout.id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="no-layouts">No saved layouts</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (detailOpen && selectedNode) {
|
||||
<app-detail-panel
|
||||
[node]="selectedNode"
|
||||
(close)="closeDetail()"
|
||||
(nodeUpdated)="onNodeUpdated($event)"
|
||||
(nodeCreated)="onNodeCreated()"
|
||||
(nodeDeleted)="onNodeDeleted($event)"
|
||||
(linksChanged)="onLinksChanged()"
|
||||
(navigateToNode)="loadNodeDetail($event)"
|
||||
/>
|
||||
}
|
||||
442
frontend/src/app/features/graph/graph-view.component.scss
Normal file
442
frontend/src/app/features/graph/graph-view.component.scss
Normal file
@@ -0,0 +1,442 @@
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: margin-right 0.2s ease;
|
||||
|
||||
&.detail-open {
|
||||
margin-right: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 30px 6px 10px;
|
||||
font-size: 13px;
|
||||
width: 280px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.search-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: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.search-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(88, 166, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.search-option-id {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.search-option-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zoom-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-pillar-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.create-input {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
width: 120px;
|
||||
|
||||
&.wide {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.graph-canvas-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.help-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
width: 460px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.help-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);
|
||||
}
|
||||
|
||||
.help-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.help-body {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.help-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help-key {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Layout controls
|
||||
.layout-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layout-select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
max-width: 130px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-input {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layout-manage-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.layout-manage-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.layout-manage-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.layout-manage-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.layout-manage-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.layout-manage-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.no-layouts {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar-btn.small {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toolbar-btn.danger {
|
||||
color: var(--red);
|
||||
&:hover {
|
||||
background: rgba(218, 54, 51, 0.15);
|
||||
border-color: var(--red);
|
||||
}
|
||||
}
|
||||
1386
frontend/src/app/features/graph/graph-view.component.ts
Normal file
1386
frontend/src/app/features/graph/graph-view.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
124
frontend/src/app/features/login/login.component.ts
Normal file
124
frontend/src/app/features/login/login.component.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">req-planner</h1>
|
||||
<form (ngSubmit)="submit()">
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
class="login-input"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
/>
|
||||
@if (error) {
|
||||
<div class="login-error">{{ error }}</div>
|
||||
}
|
||||
<button type="submit" class="login-btn" [disabled]="!password">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 40px;
|
||||
width: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary, #21262d);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
|
||||
.login-input::placeholder {
|
||||
color: var(--text-muted, #484f58);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #f85149;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
padding: 10px;
|
||||
background: var(--accent, #58a6ff);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover, #79c0ff);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
password = '';
|
||||
error = '';
|
||||
|
||||
submit(): void {
|
||||
this.error = '';
|
||||
this.auth.login(this.password).subscribe({
|
||||
next: () => this.router.navigate(['/graph']),
|
||||
error: () => (this.error = 'Invalid password'),
|
||||
});
|
||||
}
|
||||
}
|
||||
0
frontend/src/assets/.gitkeep
Normal file
0
frontend/src/assets/.gitkeep
Normal file
BIN
frontend/src/favicon.ico
Normal file
BIN
frontend/src/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
13
frontend/src/index.html
Normal file
13
frontend/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frontend</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
70
frontend/src/styles.scss
Normal file
70
frontend/src/styles.scss
Normal file
@@ -0,0 +1,70 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--bg-card: #1c2128;
|
||||
--border: #30363d;
|
||||
--border-light: #484f58;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--green: #3fb950;
|
||||
--red: #da3633;
|
||||
--yellow: #d29922;
|
||||
--orange: #e3b341;
|
||||
--purple: #bc8cff;
|
||||
--priority-critical: #da3633;
|
||||
--priority-high: #d29922;
|
||||
--priority-medium: #e3b341;
|
||||
--priority-low: #3fb950;
|
||||
--status-draft: #8b949e;
|
||||
--status-todo: #58a6ff;
|
||||
--status-in-progress: #d29922;
|
||||
--status-done: #3fb950;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
&:hover { color: var(--accent-hover); }
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
&:hover { background: var(--border-light); }
|
||||
}
|
||||
1
frontend/src/types.d.ts
vendored
Normal file
1
frontend/src/types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module 'cytoscape-dagre';
|
||||
14
frontend/tsconfig.app.json
Normal file
14
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
32
frontend/tsconfig.json
Normal file
32
frontend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
14
frontend/tsconfig.spec.json
Normal file
14
frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user