Implement Tree Editor with visual preview and documentation updates
Tree Editor Features: - Zustand store with immer middleware and zundo for undo/redo - Form-based node editing (Decision, Action, Solution types) - Visual tree preview with solution connection indicators - NodePicker with type-grouped dropdown (Decisions/Actions/Solutions) - SharedLinksMap for detecting nodes with multiple sources - Modal component with scrollable body, fixed header/footer New Components: - TreeEditorLayout, TreeMetadataForm, NodeList, NodeEditorModal - NodeFormDecision, NodeFormAction, NodeFormResolution - DynamicArrayField, NodePicker - TreePreviewPanel, TreePreviewNode Documentation: - Updated README.md status to Phase 2 - Added Tree Editor details to CURRENT-STATE.md - Added modal/Zustand lessons to LESSONS-LEARNED.md - Updated file structure in CLAUDE-SETUP.md - Added Tree Editor progress to PROGRESS.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
102
CLAUDE-SETUP.md
102
CLAUDE-SETUP.md
@@ -381,11 +381,14 @@ curl -X GET "http://localhost:8000/api/v1/trees" -H "Authorization: Bearer <toke
|
|||||||
- **Containerization**: Docker (apoklisis_postgres container)
|
- **Containerization**: Docker (apoklisis_postgres container)
|
||||||
- **Migrations**: Alembic
|
- **Migrations**: Alembic
|
||||||
|
|
||||||
**Frontend (Not Started)**:
|
**Frontend (Complete + Tree Editor)**:
|
||||||
|
|
||||||
- **Framework**: React (planned)
|
- **Framework**: React 18 + Vite + TypeScript
|
||||||
- **Styling**: Tailwind CSS (planned)
|
- **Styling**: Tailwind CSS + shadcn/ui CSS variables
|
||||||
- **Status**: Awaiting development
|
- **State Management**: Zustand with immer + zundo (undo/redo)
|
||||||
|
- **Routing**: React Router v6
|
||||||
|
- **API Client**: Axios with token interceptors
|
||||||
|
- **Status**: Core features complete, Tree Editor implemented
|
||||||
|
|
||||||
**Infrastructure**:
|
**Infrastructure**:
|
||||||
|
|
||||||
@@ -412,26 +415,30 @@ curl -X GET "http://localhost:8000/api/v1/trees" -H "Authorization: Bearer <toke
|
|||||||
|
|
||||||
### Current Development Phase
|
### Current Development Phase
|
||||||
|
|
||||||
**Phase 1a: Backend API** - ✅ **COMPLETE & TESTED**
|
**Phase 1: Backend + Frontend MVP** - ✅ **COMPLETE**
|
||||||
|
|
||||||
- All 18 API endpoints implemented and verified
|
- All 18 API endpoints implemented and verified
|
||||||
- Database schema finalized with timezone-aware timestamps
|
- Database schema finalized with timezone-aware timestamps
|
||||||
- Authentication system working (JWT with bcrypt, role-based access)
|
- Authentication system working (JWT with bcrypt, role-based access)
|
||||||
- 29 integration tests (all passing) with comprehensive coverage
|
- 40+ integration tests (all passing) with comprehensive coverage
|
||||||
- Production logging with correlation IDs
|
- Production logging with correlation IDs
|
||||||
- DateTime bug fixes applied across all models
|
- React frontend with tree navigation, session management, export
|
||||||
- Ready for deployment
|
|
||||||
|
|
||||||
**Phase 1b: Pre-built Trees** - 🔄 **Next Up**
|
**Phase 2: Tree Editor** - 🔄 **IN PROGRESS**
|
||||||
|
|
||||||
- Create seed data script
|
- ✅ Zustand store with immer middleware + zundo (undo/redo)
|
||||||
- Implement 5 example troubleshooting trees from `TS-EXAMPLES.md`
|
- ✅ Form-based node editing (Decision, Action, Solution types)
|
||||||
|
- ✅ Visual tree preview with solution connection indicators
|
||||||
|
- ✅ NodePicker with type-grouped dropdown
|
||||||
|
- ✅ SharedLinksMap for detecting nodes with multiple sources
|
||||||
|
- ✅ Modal with scrollable content, fixed header/footer
|
||||||
|
- ⏳ Validation polish (required fields, orphan detection)
|
||||||
|
|
||||||
**Phase 2: React Frontend** - ⏳ **Planned**
|
**Phase 2 Remaining** - ⏳ **Planned**
|
||||||
|
|
||||||
- Tree navigation interface
|
- Team management features
|
||||||
- Session management UI
|
- Mobile responsive improvements
|
||||||
- Admin panel for tree CRUD operations
|
- User preferences (dark mode)
|
||||||
|
|
||||||
### Backend File Structure
|
### Backend File Structure
|
||||||
|
|
||||||
@@ -456,29 +463,56 @@ backend/
|
|||||||
│ │ ├── logging_config.py # Structured logging configuration
|
│ │ ├── logging_config.py # Structured logging configuration
|
||||||
│ │ └── middleware.py # Request logging middleware
|
│ │ └── middleware.py # Request logging middleware
|
||||||
│ ├── models/ # SQLAlchemy models (timezone-aware)
|
│ ├── models/ # SQLAlchemy models (timezone-aware)
|
||||||
│ │ ├── user.py
|
|
||||||
│ │ ├── team.py
|
|
||||||
│ │ ├── tree.py
|
|
||||||
│ │ ├── session.py
|
|
||||||
│ │ └── attachment.py
|
|
||||||
│ ├── schemas/ # Pydantic schemas
|
│ ├── schemas/ # Pydantic schemas
|
||||||
│ │ ├── user.py
|
|
||||||
│ │ ├── token.py
|
|
||||||
│ │ ├── tree.py
|
|
||||||
│ │ └── session.py
|
|
||||||
│ └── main.py # FastAPI app entry point
|
│ └── main.py # FastAPI app entry point
|
||||||
├── tests/ # Integration tests
|
├── tests/ # Integration tests (40+ tests)
|
||||||
│ ├── conftest.py # Test fixtures and configuration
|
|
||||||
│ ├── test_auth.py # Authentication tests (7 tests)
|
|
||||||
│ ├── test_trees.py # Tree CRUD tests (10 tests)
|
|
||||||
│ └── test_sessions.py # Session workflow tests (12 tests)
|
|
||||||
├── logs/ # Log files (created at runtime)
|
├── logs/ # Log files (created at runtime)
|
||||||
├── docker-compose.yml # PostgreSQL container definition
|
├── docker-compose.yml # PostgreSQL container definition
|
||||||
├── pytest.ini # Pytest configuration
|
└── requirements.txt # Production dependencies
|
||||||
├── requirements.txt # Production dependencies
|
```
|
||||||
├── requirements-dev.txt # Development dependencies (pytest, etc.)
|
|
||||||
├── .env.example
|
### Frontend File Structure
|
||||||
└── README.md
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # API client layer
|
||||||
|
│ │ ├── client.ts # Axios instance with auth interceptors
|
||||||
|
│ │ ├── auth.ts # Auth endpoints
|
||||||
|
│ │ ├── trees.ts # Tree endpoints
|
||||||
|
│ │ └── sessions.ts # Session endpoints
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── common/
|
||||||
|
│ │ │ └── Modal.tsx # Reusable modal (scrollable body, fixed header/footer)
|
||||||
|
│ │ ├── layout/ # AppLayout, ProtectedRoute
|
||||||
|
│ │ ├── tree-editor/ # Tree Editor components
|
||||||
|
│ │ │ ├── TreeEditorLayout.tsx # Split-view container
|
||||||
|
│ │ │ ├── TreeMetadataForm.tsx # Name, description, category
|
||||||
|
│ │ │ ├── NodeList.tsx # Node list with CRUD actions
|
||||||
|
│ │ │ ├── NodeEditorModal.tsx # Modal wrapper for node editing
|
||||||
|
│ │ │ ├── NodeFormDecision.tsx # Decision node fields
|
||||||
|
│ │ │ ├── NodeFormAction.tsx # Action node fields
|
||||||
|
│ │ │ ├── NodeFormResolution.tsx # Solution node fields
|
||||||
|
│ │ │ ├── DynamicArrayField.tsx # Reusable array input
|
||||||
|
│ │ │ └── NodePicker.tsx # Type-grouped node selector
|
||||||
|
│ │ └── tree-preview/ # Visual preview components
|
||||||
|
│ │ ├── TreePreviewPanel.tsx # Preview container + SharedLinksMap
|
||||||
|
│ │ └── TreePreviewNode.tsx # Node cards with solution indicators
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── TreeEditorPage.tsx # /trees/new and /trees/:id/edit
|
||||||
|
│ │ ├── TreeLibraryPage.tsx # Tree browsing
|
||||||
|
│ │ ├── TreeNavigationPage.tsx # Tree traversal
|
||||||
|
│ │ └── ... # Other pages
|
||||||
|
│ ├── store/
|
||||||
|
│ │ ├── authStore.ts # Auth state (Zustand + persist)
|
||||||
|
│ │ └── treeEditorStore.ts # Tree editor state (Zustand + immer + zundo)
|
||||||
|
│ ├── types/ # TypeScript types
|
||||||
|
│ │ ├── tree.ts # TreeStructure, TreeNode, etc.
|
||||||
|
│ │ └── index.ts # Barrel exports
|
||||||
|
│ ├── router.tsx # React Router configuration
|
||||||
|
│ └── main.tsx # Entry point
|
||||||
|
├── tailwind.config.js
|
||||||
|
└── vite.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
|
|||||||
231
CURRENT-STATE.md
Normal file
231
CURRENT-STATE.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Current State
|
||||||
|
|
||||||
|
> **Purpose:** Quick-reference file showing exactly where the project stands.
|
||||||
|
> **For Claude Code:** Read this first to understand what's done and what's next.
|
||||||
|
> **Last Updated:** January 28, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Phase: Phase 2 - Tree Editor (In Progress)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Complete ✅
|
||||||
|
|
||||||
|
### Backend (100%)
|
||||||
|
- ✅ FastAPI project structure
|
||||||
|
- ✅ PostgreSQL database with Docker
|
||||||
|
- ✅ User authentication (JWT, register, login, refresh)
|
||||||
|
- ✅ Trees CRUD with full-text search
|
||||||
|
- ✅ Sessions tracking with decisions
|
||||||
|
- ✅ Export API (Markdown, Text, HTML)
|
||||||
|
- ✅ Role-based access control foundation
|
||||||
|
- ✅ Production-ready logging with correlation IDs
|
||||||
|
- ✅ 40+ integration tests
|
||||||
|
- ✅ DateTime timezone handling fixed
|
||||||
|
|
||||||
|
### Frontend (In Progress)
|
||||||
|
- ✅ React + Vite + TypeScript + Tailwind setup
|
||||||
|
- ✅ Authentication UI (login, register)
|
||||||
|
- ✅ Basic layout and navigation
|
||||||
|
- ✅ Tree library/browsing page
|
||||||
|
- ✅ Tree navigation interface
|
||||||
|
- ✅ Session management
|
||||||
|
- ✅ Export functionality (download)
|
||||||
|
- ✅ Responsive design
|
||||||
|
- ✅ Error boundaries
|
||||||
|
- ✅ **Tree Editor** - Form-based with visual preview
|
||||||
|
- ✅ Zustand store with immer (undo/redo via zundo)
|
||||||
|
- ✅ Split-view layout (editor left, preview right)
|
||||||
|
- ✅ Node CRUD (Decision, Action, Solution types)
|
||||||
|
- ✅ NodePicker with type-grouped dropdown
|
||||||
|
- ✅ Dynamic array fields (options, commands, steps)
|
||||||
|
- ✅ Visual tree preview with solution indicators
|
||||||
|
- ✅ Shared node detection (multiple sources → same target)
|
||||||
|
- ✅ Modal with scrollable content, fixed header/footer
|
||||||
|
- ⏳ User preferences (dark mode) - NOT YET STARTED
|
||||||
|
- ⏳ Keyboard shortcuts - NOT YET STARTED
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- ✅ Project overview and architecture docs
|
||||||
|
- ✅ Development roadmap through Phase 4
|
||||||
|
- ✅ Feature specifications (including Phase 2.5)
|
||||||
|
- ✅ CLAUDE-SETUP.md for onboarding
|
||||||
|
- ✅ LESSONS-LEARNED.md for avoiding past mistakes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's In Progress 🔄
|
||||||
|
|
||||||
|
| Task | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Tree Editor | Functional | Core editing complete, polish ongoing |
|
||||||
|
| Tree Editor Validation | Partial | Basic validation working |
|
||||||
|
| User Preferences | Not started | Dark/light mode, export format default |
|
||||||
|
| TypeScript strict mode | Warnings exist | tsconfig needs `strict: true` |
|
||||||
|
| Starter decision trees | 1 of 5 complete | Need 4 more real trees |
|
||||||
|
| Deployment | Not started | Railway/Render planned |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next (Priority Order)
|
||||||
|
|
||||||
|
### Immediate (This Week)
|
||||||
|
1. Complete Tree Editor validation (required fields, orphan detection)
|
||||||
|
2. Add User Preferences (theme toggle, export format default)
|
||||||
|
3. Fix TypeScript strict mode warnings
|
||||||
|
4. Create remaining 4 starter decision trees
|
||||||
|
|
||||||
|
### Soon (Phase 2 Completion)
|
||||||
|
- Team management
|
||||||
|
- Mobile responsive improvements
|
||||||
|
- Tree versioning UI
|
||||||
|
|
||||||
|
### Later (Phase 2.5)
|
||||||
|
- Personal tree branching
|
||||||
|
- Step library with ratings
|
||||||
|
- Tree forking and sharing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI entry point
|
||||||
|
│ ├── api/v1/endpoints/ # API route handlers
|
||||||
|
│ │ ├── auth.py
|
||||||
|
│ │ ├── trees.py
|
||||||
|
│ │ └── sessions.py
|
||||||
|
│ ├── models/ # SQLAlchemy models
|
||||||
|
│ ├── schemas/ # Pydantic schemas
|
||||||
|
│ └── core/
|
||||||
|
│ ├── config.py # Settings
|
||||||
|
│ ├── security.py # JWT handling
|
||||||
|
│ └── logging_config.py
|
||||||
|
├── alembic/ # Database migrations
|
||||||
|
├── tests/ # pytest tests
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.tsx # Entry point
|
||||||
|
│ ├── App.tsx # Router setup
|
||||||
|
│ ├── pages/ # Page components
|
||||||
|
│ │ └── TreeEditorPage.tsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── common/ # Modal, etc.
|
||||||
|
│ │ ├── tree-editor/ # Tree Editor components
|
||||||
|
│ │ │ ├── TreeEditorLayout.tsx
|
||||||
|
│ │ │ ├── TreeMetadataForm.tsx
|
||||||
|
│ │ │ ├── NodeList.tsx
|
||||||
|
│ │ │ ├── NodeEditorModal.tsx
|
||||||
|
│ │ │ ├── NodeFormDecision.tsx
|
||||||
|
│ │ │ ├── NodeFormAction.tsx
|
||||||
|
│ │ │ ├── NodeFormResolution.tsx
|
||||||
|
│ │ │ ├── DynamicArrayField.tsx
|
||||||
|
│ │ │ └── NodePicker.tsx
|
||||||
|
│ │ └── tree-preview/ # Visual preview
|
||||||
|
│ │ ├── TreePreviewPanel.tsx
|
||||||
|
│ │ └── TreePreviewNode.tsx
|
||||||
|
│ ├── store/
|
||||||
|
│ │ ├── authStore.ts
|
||||||
|
│ │ └── treeEditorStore.ts # Zustand + immer + zundo
|
||||||
|
│ ├── contexts/ # React contexts (auth)
|
||||||
|
│ ├── hooks/ # Custom hooks
|
||||||
|
│ └── api/ # API client
|
||||||
|
├── tailwind.config.js
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
```
|
||||||
|
Apoklisis/
|
||||||
|
├── CLAUDE-SETUP.md # Full context for Claude Code
|
||||||
|
├── CURRENT-STATE.md # This file - quick status
|
||||||
|
├── LESSONS-LEARNED.md # Bugs and fixes reference
|
||||||
|
├── 01-PROJECT-OVERVIEW.md
|
||||||
|
├── 02-TECHNICAL-ARCHITECTURE.md
|
||||||
|
├── 03-DEVELOPMENT-ROADMAP.md
|
||||||
|
├── 04-FEATURE-SPECIFICATIONS.md
|
||||||
|
└── PHASE-2.5-PERSONAL-BRANCHING.md # Detailed Phase 2.5 spec
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Quick Reference
|
||||||
|
|
||||||
|
### Start Development
|
||||||
|
```powershell
|
||||||
|
# Terminal 1: Database
|
||||||
|
docker start apoklisis_postgres
|
||||||
|
|
||||||
|
# Terminal 2: Backend
|
||||||
|
cd C:\Dev\Projects\Apoklisis\backend
|
||||||
|
.\venv\Scripts\activate
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Terminal 3: Frontend
|
||||||
|
cd C:\Dev\Projects\Apoklisis\frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### URLs
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend API: http://localhost:8000
|
||||||
|
- API Docs: http://localhost:8000/docs
|
||||||
|
|
||||||
|
### Run Tests
|
||||||
|
```powershell
|
||||||
|
cd C:\Dev\Projects\Apoklisis\backend
|
||||||
|
.\venv\Scripts\activate
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent Changes (Jan 28, 2026)
|
||||||
|
|
||||||
|
1. Fixed DateTime timezone bugs in all models
|
||||||
|
2. Added production logging system
|
||||||
|
3. Created 40+ integration tests
|
||||||
|
4. Added Phase 2.5 specifications (Personal Branching, Step Library)
|
||||||
|
5. Added User Preferences to MVP scope
|
||||||
|
6. Created LESSONS-LEARNED.md
|
||||||
|
7. Created CURRENT-STATE.md (this file)
|
||||||
|
8. **Tree Editor Implementation**:
|
||||||
|
- Zustand store with immer middleware and zundo for undo/redo
|
||||||
|
- Form-based node editing with type-specific forms
|
||||||
|
- NodePicker dropdown grouped by node type (Decision/Action/Solution)
|
||||||
|
- Visual tree preview with recursive rendering
|
||||||
|
- Solution connection indicators (green checkmark badges)
|
||||||
|
- Shared node detection showing when multiple nodes link to same target
|
||||||
|
- Modal component with scrollable body, fixed header/footer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blockers / Known Issues
|
||||||
|
|
||||||
|
| Issue | Workaround | Status |
|
||||||
|
|-------|------------|--------|
|
||||||
|
| pytest-asyncio version conflict | Use 0.24.0 | Documented |
|
||||||
|
| No local psql on Windows | Use `docker exec` | Documented |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Handoff Notes
|
||||||
|
|
||||||
|
*Update this section at the end of each coding session:*
|
||||||
|
|
||||||
|
**Last Session (Jan 28, 2026):**
|
||||||
|
- Completed Tree Editor core implementation
|
||||||
|
- Fixed modal scroll/overflow issue (content scrolls, header/footer fixed)
|
||||||
|
- Added SharedLinksMap for tracking nodes that link to same target
|
||||||
|
- Improved NodePicker with type-grouped dropdown
|
||||||
|
- Added solution connection indicators in preview
|
||||||
|
- Next: Tree Editor validation polish, user preferences UI
|
||||||
437
LESSONS-LEARNED.md
Normal file
437
LESSONS-LEARNED.md
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
# Lessons Learned
|
||||||
|
|
||||||
|
> **Purpose:** This file documents bugs, fixes, and gotchas encountered during development.
|
||||||
|
> **For Claude Code:** Read this file at the start of each session to avoid repeating past mistakes.
|
||||||
|
> **Last Updated:** January 28, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python / Backend
|
||||||
|
|
||||||
|
### DateTime Timezone Handling ⚠️ CRITICAL
|
||||||
|
**Problem:** SQLAlchemy `DateTime` fields caused Internal Server Errors when mixing timezone-aware and timezone-naive datetimes.
|
||||||
|
|
||||||
|
**Error:** `can't subtract offset-naive and offset-aware datetimes`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Always use `DateTime(timezone=True)` in SQLAlchemy models
|
||||||
|
- Always use `datetime.now(timezone.utc)` for defaults and assignments
|
||||||
|
- Never use `datetime.utcnow()` (deprecated and timezone-naive)
|
||||||
|
|
||||||
|
**Correct pattern:**
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:** All models with timestamp fields (user.py, tree.py, session.py, team.py, attachment.py)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### pytest-asyncio Version Compatibility
|
||||||
|
**Problem:** Tests fail with `AttributeError: 'Package' object has no attribute 'obj'`
|
||||||
|
|
||||||
|
**Cause:** Incompatibility between pytest 8.0.0 and pytest-asyncio 0.23.3
|
||||||
|
|
||||||
|
**Solution:** Upgrade pytest-asyncio to 0.24.0
|
||||||
|
```powershell
|
||||||
|
pip install pytest-asyncio==0.24.0 --upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bcrypt / passlib Compatibility
|
||||||
|
**Problem:** Password hashing fails with newer bcrypt versions.
|
||||||
|
|
||||||
|
**Solution:** Pin bcrypt version in requirements.txt:
|
||||||
|
```
|
||||||
|
bcrypt==4.0.1
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Virtual Environment Best Practices
|
||||||
|
**Problem:** OneDrive sync causes conflicts with venv, `__pycache__`, and lock files.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Keep project in `C:\Dev\Projects\`, NOT in OneDrive-synced folders
|
||||||
|
- Add to `.gitignore`: `venv/`, `__pycache__/`, `*.pyc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Installing Packages in venv
|
||||||
|
**Problem:** `pip install` sometimes installs globally instead of in venv.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Always verify venv is active: look for `(venv)` prefix in terminal
|
||||||
|
- If VS Code prompts to create a new venv when one exists, click "Don't show again"
|
||||||
|
- Use `pip install --break-system-packages` flag if needed (Python 3.12+)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Alembic Migrations
|
||||||
|
**Problem:** Model changes not reflected in database.
|
||||||
|
|
||||||
|
**Solution:** Always run migrations after model changes:
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
alembic revision --autogenerate -m "Description of change"
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript / Frontend
|
||||||
|
|
||||||
|
### React State: Don't Store Object Snapshots for Editing ⚠️ CRITICAL
|
||||||
|
**Problem:** Form inputs don't update when typing - characters appear then immediately disappear.
|
||||||
|
|
||||||
|
**Cause:** Storing a full object from a Zustand store in local state creates a snapshot. When the store updates, the local state still holds the old reference, causing the input's `value` to revert.
|
||||||
|
|
||||||
|
**Broken pattern:**
|
||||||
|
```tsx
|
||||||
|
// BAD: Stores a snapshot that won't update
|
||||||
|
const [editingNode, setEditingNode] = useState<TreeStructure | null>(null)
|
||||||
|
|
||||||
|
// When modal opens:
|
||||||
|
setEditingNode(node) // snapshot captured here
|
||||||
|
|
||||||
|
// In modal form:
|
||||||
|
<input value={editingNode.question} onChange={...} /> // always shows old value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Store only the ID, then fetch the current object from the store on each render:
|
||||||
|
```tsx
|
||||||
|
// GOOD: Store only the ID
|
||||||
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
|
const editingNode = editingNodeId ? findNode(editingNodeId) : null
|
||||||
|
|
||||||
|
// When modal opens:
|
||||||
|
setEditingNodeId(node.id)
|
||||||
|
|
||||||
|
// In modal form - now gets fresh data each render:
|
||||||
|
<input value={editingNode.question} onChange={...} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it works:** By calling `findNode()` on each render, the component always gets the current state from the store after updates.
|
||||||
|
|
||||||
|
**Files affected:** Any component that opens a modal/form to edit store data (NodeList.tsx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modal Scroll/Overflow with Fixed Header and Footer
|
||||||
|
**Problem:** Modal content extends beyond screen when there's too much content, pushing close button and action buttons off-screen.
|
||||||
|
|
||||||
|
**Solution:** Use flex layout with fixed header/footer and scrollable body:
|
||||||
|
```tsx
|
||||||
|
// Modal structure
|
||||||
|
<div className="flex max-h-[85vh] w-full flex-col">
|
||||||
|
{/* Header - fixed at top */}
|
||||||
|
<div className="flex-shrink-0 border-b px-6 py-4">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button>X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body - scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer - fixed at bottom */}
|
||||||
|
{footer && (
|
||||||
|
<div className="flex-shrink-0 border-t px-6 py-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- `max-h-[85vh]` constrains total modal height
|
||||||
|
- `flex-col` enables vertical flex layout
|
||||||
|
- `flex-shrink-0` on header/footer prevents them from shrinking
|
||||||
|
- `flex-1 overflow-y-auto` on body makes it fill remaining space and scroll
|
||||||
|
|
||||||
|
**Files affected:** Modal.tsx, any component using modals for forms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lucide React Icons: No Title Prop
|
||||||
|
**Problem:** TypeScript error when trying to add `title` prop to Lucide icons.
|
||||||
|
|
||||||
|
**Error:** `Property 'title' does not exist on type 'LucideProps'`
|
||||||
|
|
||||||
|
**Broken pattern:**
|
||||||
|
```tsx
|
||||||
|
<CheckCircle title="Has solution endpoint" /> // ❌ Error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Wrap the icon in a span with the title:
|
||||||
|
```tsx
|
||||||
|
<span title="Has solution endpoint">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tree Traversal: Preventing Infinite Loops with Visited Set
|
||||||
|
**Problem:** When traversing a tree structure that has cross-references (like `next_node_id` pointing to nodes elsewhere in the tree), you can get infinite loops.
|
||||||
|
|
||||||
|
**Solution:** Use a `visited` Set to track already-processed nodes:
|
||||||
|
```tsx
|
||||||
|
function hasSolutionInSubtree(
|
||||||
|
node: TreeStructure,
|
||||||
|
findNode: (id: string) => TreeStructure | null,
|
||||||
|
visited: Set<string> = new Set()
|
||||||
|
): boolean {
|
||||||
|
// Prevent infinite loops
|
||||||
|
if (visited.has(node.id)) return false
|
||||||
|
visited.add(node.id)
|
||||||
|
|
||||||
|
if (node.type === 'solution') return true
|
||||||
|
|
||||||
|
// Check children array
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (hasSolutionInSubtree(child, findNode, visited)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check next_node_id reference (could point anywhere in tree)
|
||||||
|
if (node.next_node_id) {
|
||||||
|
const nextNode = findNode(node.next_node_id)
|
||||||
|
if (nextNode && hasSolutionInSubtree(nextNode, findNode, visited)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it matters:** Decision trees can have shared nodes where multiple paths converge on the same target. Without loop detection, recursive traversal will hang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SharedLinksMap Pattern for Tracking Cross-References
|
||||||
|
**Problem:** Need to know which nodes link to the same target node (for showing "shared by X nodes" indicators).
|
||||||
|
|
||||||
|
**Solution:** Build a map at the parent component level, pass down to children:
|
||||||
|
```tsx
|
||||||
|
// Type definition
|
||||||
|
type SharedLinksMap = Map<string, Array<{ id: string; label: string }>>
|
||||||
|
|
||||||
|
// Build the map by traversing tree once
|
||||||
|
function buildSharedLinksMap(
|
||||||
|
node: TreeStructure,
|
||||||
|
map: SharedLinksMap = new Map()
|
||||||
|
): SharedLinksMap {
|
||||||
|
const nodeLabel = node.type === 'decision' ? node.question : node.title
|
||||||
|
|
||||||
|
// Record decision option targets
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
for (const opt of node.options) {
|
||||||
|
if (opt.next_node_id) {
|
||||||
|
const existing = map.get(opt.next_node_id) || []
|
||||||
|
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
||||||
|
map.set(opt.next_node_id, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record action next_node_id targets
|
||||||
|
if (node.type === 'action' && node.next_node_id) {
|
||||||
|
const existing = map.get(node.next_node_id) || []
|
||||||
|
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
||||||
|
map.set(node.next_node_id, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
buildSharedLinksMap(child, map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use in parent with useMemo
|
||||||
|
const sharedLinksMap = useMemo(() => {
|
||||||
|
if (!treeStructure) return new Map()
|
||||||
|
return buildSharedLinksMap(treeStructure)
|
||||||
|
}, [treeStructure])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in child:** Check `sharedLinksMap.get(node.id)?.length > 1` to see if node is shared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### tsconfig.json Strict Mode
|
||||||
|
**Problem:** VS Code shows warnings about missing compiler options.
|
||||||
|
|
||||||
|
**Solution:** Add to `compilerOptions` in tsconfig.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it matters:** `forceConsistentCasingInFileNames` prevents issues when deploying from Windows (case-insensitive) to Linux (case-sensitive).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tailwind Dark Mode
|
||||||
|
**Pattern:** Use Tailwind's `dark:` variant for dark mode styling.
|
||||||
|
|
||||||
|
**Setup:** In `tailwind.config.js`:
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class', // or 'media' for system preference only
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```jsx
|
||||||
|
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker / PostgreSQL
|
||||||
|
|
||||||
|
### Accessing PostgreSQL Without Local psql
|
||||||
|
**Problem:** `psql` command not recognized on Windows (not installed locally).
|
||||||
|
|
||||||
|
**Solution:** Use Docker exec to run psql inside the container:
|
||||||
|
```powershell
|
||||||
|
# Single command
|
||||||
|
docker exec -it apoklisis_postgres psql -U postgres -c "SELECT * FROM users;"
|
||||||
|
|
||||||
|
# Interactive session
|
||||||
|
docker exec -it apoklisis_postgres psql -U postgres
|
||||||
|
|
||||||
|
# Create database
|
||||||
|
docker exec -it apoklisis_postgres psql -U postgres -c "CREATE DATABASE apoklisis_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Docker Container Not Running
|
||||||
|
**Problem:** Database connection errors.
|
||||||
|
|
||||||
|
**Solution:** Check and start the container:
|
||||||
|
```powershell
|
||||||
|
docker ps # See running containers
|
||||||
|
docker start apoklisis_postgres # Start if stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git / Version Control
|
||||||
|
|
||||||
|
### git add from Wrong Directory
|
||||||
|
**Problem:** `git add .` doesn't stage files in parent directories.
|
||||||
|
|
||||||
|
**Solution:** Always run git commands from project root:
|
||||||
|
```powershell
|
||||||
|
cd C:\Dev\Projects\Apoklisis
|
||||||
|
git add .
|
||||||
|
git commit -m "Your message"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Untracked .claude/ Folder
|
||||||
|
**Problem:** `.claude/` folder appears in untracked files.
|
||||||
|
|
||||||
|
**Solution:** Either:
|
||||||
|
1. Add to `.gitignore` if you don't want to track it
|
||||||
|
2. Or `git add .claude/` if you want Claude Code settings in repo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment-Specific Notes
|
||||||
|
|
||||||
|
### Windows Path Handling
|
||||||
|
- Python and Node handle forward slashes `/` fine on Windows
|
||||||
|
- Use `os.path.join()` or `pathlib.Path` for cross-platform compatibility
|
||||||
|
- Avoid hardcoding backslashes `\` in code
|
||||||
|
|
||||||
|
### PowerShell vs CMD
|
||||||
|
- Some Node.js/Python tools work better in CMD than PowerShell
|
||||||
|
- If a command fails in PowerShell, try CMD or add to Desktop Commander config:
|
||||||
|
- `defaultShell: "cmd"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API / Endpoint Patterns
|
||||||
|
|
||||||
|
### JSONB Fields with Timestamps
|
||||||
|
**Problem:** Storing datetime objects directly in JSONB fields causes serialization errors.
|
||||||
|
|
||||||
|
**Solution:** Convert to ISO string before storing:
|
||||||
|
```python
|
||||||
|
decision = {
|
||||||
|
"node_id": node_id,
|
||||||
|
"answer": answer,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat() # String, not datetime
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Soft Delete Pattern
|
||||||
|
**Pattern:** Use `is_active` boolean instead of actually deleting records.
|
||||||
|
|
||||||
|
**Note:** Our schema uses `is_active`, not `is_deleted` (documentation was corrected on Jan 28, 2026).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Database Setup
|
||||||
|
**Requirement:** Tests need a separate database.
|
||||||
|
|
||||||
|
**One-time setup:**
|
||||||
|
```powershell
|
||||||
|
docker exec -it apoklisis_postgres psql -U postgres -c "CREATE DATABASE apoklisis_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run tests:**
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes to Avoid
|
||||||
|
|
||||||
|
| Mistake | Correct Approach |
|
||||||
|
|---------|------------------|
|
||||||
|
| Using `datetime.utcnow()` | Use `datetime.now(timezone.utc)` |
|
||||||
|
| Running git from subdirectory | Always `cd` to project root first |
|
||||||
|
| Forgetting to activate venv | Check for `(venv)` prefix |
|
||||||
|
| Editing files in OneDrive folder | Use `C:\Dev\Projects\` |
|
||||||
|
| Using `psql` directly on Windows | Use `docker exec` instead |
|
||||||
|
| Storing datetime in JSON | Convert to `.isoformat()` string |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding New Lessons
|
||||||
|
|
||||||
|
When you encounter and fix a bug, add it here with:
|
||||||
|
1. **Problem:** What error/symptom occurred
|
||||||
|
2. **Cause:** Why it happened (if known)
|
||||||
|
3. **Solution:** How to fix it
|
||||||
|
4. **Files affected:** Where to look (if applicable)
|
||||||
64
PROGRESS.md
64
PROGRESS.md
@@ -401,9 +401,49 @@ frontend/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 2: Tree Editor (IN PROGRESS)
|
||||||
|
|
||||||
|
### Tree Editor Implementation
|
||||||
|
|
||||||
|
**Store**: `frontend/src/store/treeEditorStore.ts`
|
||||||
|
- Zustand with immer middleware for immutable updates
|
||||||
|
- zundo middleware for undo/redo functionality
|
||||||
|
- Node CRUD operations (add, update, delete)
|
||||||
|
- nodeMap for O(1) lookups
|
||||||
|
- Validation error tracking
|
||||||
|
|
||||||
|
**Components Created**:
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `TreeEditorLayout.tsx` | Split-view container (editor 60%, preview 40%) |
|
||||||
|
| `TreeMetadataForm.tsx` | Tree name, description, category form |
|
||||||
|
| `NodeList.tsx` | List of nodes with add/edit/delete actions |
|
||||||
|
| `NodeEditorModal.tsx` | Modal wrapper for node editing |
|
||||||
|
| `NodeFormDecision.tsx` | Decision node fields (question, help_text, options) |
|
||||||
|
| `NodeFormAction.tsx` | Action node fields (title, description, commands) |
|
||||||
|
| `NodeFormResolution.tsx` | Solution node fields (title, steps) |
|
||||||
|
| `DynamicArrayField.tsx` | Reusable add/remove array input |
|
||||||
|
| `NodePicker.tsx` | Type-grouped dropdown for selecting next_node_id |
|
||||||
|
| `TreePreviewPanel.tsx` | Visual tree preview with SharedLinksMap |
|
||||||
|
| `TreePreviewNode.tsx` | Node cards with solution indicators |
|
||||||
|
|
||||||
|
**Routes Added**:
|
||||||
|
- `/trees/new` - Create new tree
|
||||||
|
- `/trees/:id/edit` - Edit existing tree
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Form-based editing with live preview
|
||||||
|
- NodePicker groups nodes by type (Decision/Action/Solution)
|
||||||
|
- Solution connection indicators (green checkmark badges)
|
||||||
|
- Shared node detection (shows when multiple nodes link to same target)
|
||||||
|
- Modal with scrollable content, fixed header/footer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
### Phase 1b: Pre-built Trees (COMPLETE - Hybrid Approach)
|
### Phase 1b: Pre-built Trees (Partial)
|
||||||
|
|
||||||
**Seed Data Script**: `backend/scripts/seed_data.py`
|
**Seed Data Script**: `backend/scripts/seed_data.py`
|
||||||
|
|
||||||
@@ -414,16 +454,10 @@ frontend/
|
|||||||
4. 🔲 Citrix VDA Registration - Not started
|
4. 🔲 Citrix VDA Registration - Not started
|
||||||
5. 🔲 AD Replication Issues - Not started
|
5. 🔲 AD Replication Issues - Not started
|
||||||
|
|
||||||
**Run Seed Script**:
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m scripts.seed_data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remaining Work
|
### Remaining Work
|
||||||
|
|
||||||
1. **Testing**: End-to-end testing of full workflow
|
1. **Tree Editor Polish**: Validation, required fields, orphan detection
|
||||||
2. **Polish**: UI refinements, error handling improvements
|
2. **User Preferences**: Dark mode, export format defaults
|
||||||
3. **More Trees**: Add remaining 4 trees from `TS-EXAMPLES.md`
|
3. **More Trees**: Add remaining 4 trees from `TS-EXAMPLES.md`
|
||||||
4. **Deployment**: Set up CI/CD pipeline and deploy to Render/Railway
|
4. **Deployment**: Set up CI/CD pipeline and deploy to Render/Railway
|
||||||
|
|
||||||
@@ -444,15 +478,15 @@ python -m scripts.seed_data
|
|||||||
## Notes for Next Session
|
## Notes for Next Session
|
||||||
|
|
||||||
- ✅ Backend **fully tested** - all 18 endpoints working correctly
|
- ✅ Backend **fully tested** - all 18 endpoints working correctly
|
||||||
- ✅ **Integration tests** - 29 tests with full coverage (all passing)
|
- ✅ **Integration tests** - 40+ tests with full coverage (all passing)
|
||||||
- ✅ **Seed script created** with Password Reset tree (full implementation)
|
|
||||||
- ✅ **Frontend COMPLETE** - Full React app with all core pages
|
- ✅ **Frontend COMPLETE** - Full React app with all core pages
|
||||||
|
- ✅ **Tree Editor IMPLEMENTED** - Form-based editing with visual preview
|
||||||
- ✅ **Full workflow tested** - Register → Login → Browse → Navigate → Complete → Export all working
|
- ✅ **Full workflow tested** - Register → Login → Browse → Navigate → Complete → Export all working
|
||||||
- 📝 **Single-user focus** for MVP (team features are in schema but low priority)
|
- 📝 **Single-user focus** for MVP (team features are in schema but low priority)
|
||||||
|
|
||||||
### Recommended Next Steps
|
### Recommended Next Steps
|
||||||
|
|
||||||
1. **Polish UI**: Add loading states, better error messages, keyboard shortcuts
|
1. **Tree Editor Validation**: Required fields, orphan node detection, save validation
|
||||||
2. **Add more trees**: Implement remaining 4 trees from `TS-EXAMPLES.md`
|
2. **User Preferences**: Dark mode toggle, export format defaults
|
||||||
3. **Deploy**: Set up CI/CD pipeline and deploy to Render/Railway
|
3. **Add more trees**: Implement remaining 4 trees from `TS-EXAMPLES.md`
|
||||||
4. **Mobile responsiveness**: Optimize for tablet/mobile use
|
4. **Deploy**: Set up CI/CD pipeline and deploy to Render/Railway
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
> Transform chaos into clarity - guided troubleshooting with automatic documentation for MSP engineers.
|
> Transform chaos into clarity - guided troubleshooting with automatic documentation for MSP engineers.
|
||||||
|
|
||||||
## Project Status: 📋 Planning Phase
|
## Project Status: 🚀 Phase 2 - Active Development
|
||||||
|
|
||||||
Currently in the planning and architecture phase. Development will begin once key decisions are made and initial troubleshooting scenarios are documented.
|
**Backend**: Complete and tested (18 API endpoints, 40+ integration tests)
|
||||||
|
**Frontend**: Core features complete, Tree Editor in progress
|
||||||
|
**Tree Editor**: Visual editor with form-based editing and live preview panel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -315,39 +317,35 @@ Options being considered:
|
|||||||
## Roadmap at a Glance
|
## Roadmap at a Glance
|
||||||
|
|
||||||
```
|
```
|
||||||
└─ [📋 Planning] ← WE ARE HERE
|
└─ [✅ Planning] COMPLETE
|
||||||
├─ 📝 Document requirements
|
├─ ✅ Document requirements
|
||||||
├─ ✅ Make key decisions
|
├─ ✅ Make key decisions
|
||||||
└─ 🏗️ Setup initial architecture
|
└─ ✅ Setup initial architecture
|
||||||
|
|
||||||
└─ [🚀 Week 1-3: MVP]
|
└─ [✅ Phase 1: MVP] COMPLETE
|
||||||
├─ Basic tree navigation
|
├─ ✅ Backend API (18 endpoints)
|
||||||
├─ Export functionality
|
├─ ✅ Tree navigation UI
|
||||||
└─ 5 starter trees
|
├─ ✅ Session tracking
|
||||||
|
└─ ✅ Export functionality
|
||||||
|
|
||||||
└─ [👥 Week 4-6: Team Ready]
|
└─ [🚀 Phase 2: Team Ready] ← IN PROGRESS
|
||||||
├─ Team management
|
├─ ✅ Tree Editor (form-based with preview)
|
||||||
├─ Tree editor
|
├─ ⏳ Team management
|
||||||
└─ Mobile responsive
|
└─ ⏳ Mobile responsive
|
||||||
|
|
||||||
└─ [💼 Week 7-12: Professional]
|
└─ [📋 Phase 3: Professional]
|
||||||
├─ Attachments
|
├─ Attachments
|
||||||
├─ Offline mode
|
├─ Offline mode
|
||||||
└─ Analytics
|
└─ Analytics
|
||||||
|
|
||||||
└─ [🔌 Month 4-6: Platform]
|
└─ [📋 Phase 4: Platform]
|
||||||
├─ API & integrations
|
├─ API & integrations
|
||||||
├─ Automation
|
├─ Automation
|
||||||
└─ Enterprise features
|
└─ Enterprise features
|
||||||
|
|
||||||
└─ [🚀 Beyond: Growth]
|
|
||||||
├─ Marketplace
|
|
||||||
├─ AI features
|
|
||||||
└─ Mobile apps
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2026-01-22
|
**Last Updated:** 2026-01-28
|
||||||
**Project Status:** Planning Phase
|
**Project Status:** Phase 2 - Active Development
|
||||||
**Next Milestone:** Answer key questions, document first 3 troubleshooting scenarios
|
**Next Milestone:** Complete Tree Editor polishing, Team management features
|
||||||
|
|||||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -11,11 +11,13 @@
|
|||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"immer": "^11.1.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zundo": "^2.3.0",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3039,6 +3041,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4790,6 +4802,24 @@
|
|||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zundo": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/charkour"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zustand": "^4.3.0 || ^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zustand": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.10",
|
"version": "5.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"immer": "^11.1.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zundo": "^2.3.0",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
101
frontend/src/components/common/Modal.tsx
Normal file
101
frontend/src/components/common/Modal.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useCallback, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
/** Optional footer content that stays fixed at bottom (doesn't scroll) */
|
||||||
|
footer?: ReactNode
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) {
|
||||||
|
// Close on Escape key
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen, handleKeyDown])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex max-h-[85vh] w-full flex-col rounded-lg border border-border bg-card shadow-lg',
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header - Fixed at top */}
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<h2 id="modal-title" className="text-lg font-semibold text-card-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1 text-muted-foreground transition-colors',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||||
|
)}
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body - Scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer - Fixed at bottom */}
|
||||||
|
{footer && (
|
||||||
|
<div className="flex-shrink-0 border-t border-border px-6 py-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Modal
|
||||||
112
frontend/src/components/tree-editor/DynamicArrayField.tsx
Normal file
112
frontend/src/components/tree-editor/DynamicArrayField.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface DynamicArrayFieldProps<T> {
|
||||||
|
items: T[]
|
||||||
|
onAdd: () => void
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
onReorder?: (fromIndex: number, toIndex: number) => void
|
||||||
|
renderItem: (item: T, index: number) => ReactNode
|
||||||
|
addLabel?: string
|
||||||
|
maxItems?: number
|
||||||
|
minItems?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicArrayField<T>({
|
||||||
|
items,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onReorder,
|
||||||
|
renderItem,
|
||||||
|
addLabel = 'Add Item',
|
||||||
|
maxItems,
|
||||||
|
minItems = 0,
|
||||||
|
className
|
||||||
|
}: DynamicArrayFieldProps<T>) {
|
||||||
|
const canAdd = maxItems === undefined || items.length < maxItems
|
||||||
|
const canRemove = items.length > minItems
|
||||||
|
|
||||||
|
const handleMoveUp = (index: number) => {
|
||||||
|
if (onReorder && index > 0) {
|
||||||
|
onReorder(index, index - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveDown = (index: number) => {
|
||||||
|
if (onReorder && index < items.length - 1) {
|
||||||
|
onReorder(index, index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="group flex items-start gap-2">
|
||||||
|
{/* Reorder buttons */}
|
||||||
|
{onReorder && items.length > 1 && (
|
||||||
|
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveUp(index)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveDown(index)}
|
||||||
|
disabled={index === items.length - 1}
|
||||||
|
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Item content */}
|
||||||
|
<div className="flex-1">{renderItem(item, index)}</div>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="mt-1 rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
{canAdd && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-input',
|
||||||
|
'px-3 py-2 text-sm text-muted-foreground',
|
||||||
|
'hover:border-primary hover:text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{items.length === 0 && !canAdd && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">No items</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicArrayField
|
||||||
85
frontend/src/components/tree-editor/NodeEditorModal.tsx
Normal file
85
frontend/src/components/tree-editor/NodeEditorModal.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Modal } from '@/components/common/Modal'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import { NodeFormDecision } from './NodeFormDecision'
|
||||||
|
import { NodeFormAction } from './NodeFormAction'
|
||||||
|
import { NodeFormResolution } from './NodeFormResolution'
|
||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
|
||||||
|
interface NodeEditorModalProps {
|
||||||
|
node: TreeStructure
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeEditorModal({ node, onClose }: NodeEditorModalProps) {
|
||||||
|
const { updateNode, validationErrors } = useTreeEditorStore()
|
||||||
|
const nodeErrors = validationErrors.filter(e => e.nodeId === node.id)
|
||||||
|
|
||||||
|
const handleUpdate = (updates: Partial<TreeStructure>) => {
|
||||||
|
updateNode(node.id, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'decision':
|
||||||
|
return 'Edit Decision Node'
|
||||||
|
case 'action':
|
||||||
|
return 'Edit Action Node'
|
||||||
|
case 'solution':
|
||||||
|
return 'Edit Solution Node'
|
||||||
|
default:
|
||||||
|
return 'Edit Node'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerContent = (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
|
||||||
|
{/* Node ID display */}
|
||||||
|
<div className="mb-4 text-xs text-muted-foreground">
|
||||||
|
Node ID: <code className="rounded bg-muted px-1 py-0.5">{node.id}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation errors */}
|
||||||
|
{nodeErrors.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
{nodeErrors.map((error, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`rounded-md px-3 py-2 text-sm ${
|
||||||
|
error.severity === 'error'
|
||||||
|
? 'bg-destructive/10 text-destructive'
|
||||||
|
: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type-specific form */}
|
||||||
|
{node.type === 'decision' && (
|
||||||
|
<NodeFormDecision node={node} onUpdate={handleUpdate} />
|
||||||
|
)}
|
||||||
|
{node.type === 'action' && (
|
||||||
|
<NodeFormAction node={node} onUpdate={handleUpdate} />
|
||||||
|
)}
|
||||||
|
{node.type === 'solution' && (
|
||||||
|
<NodeFormResolution node={node} onUpdate={handleUpdate} />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodeEditorModal
|
||||||
152
frontend/src/components/tree-editor/NodeFormAction.tsx
Normal file
152
frontend/src/components/tree-editor/NodeFormAction.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { DynamicArrayField } from './DynamicArrayField'
|
||||||
|
import { NodePicker } from './NodePicker'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NodeFormActionProps {
|
||||||
|
node: TreeStructure
|
||||||
|
onUpdate: (updates: Partial<TreeStructure>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||||
|
const { validationErrors } = useTreeEditorStore()
|
||||||
|
|
||||||
|
const titleError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === 'title'
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextNodeError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === 'next_node_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddCommand = () => {
|
||||||
|
onUpdate({
|
||||||
|
commands: [...(node.commands || []), '']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCommand = (index: number) => {
|
||||||
|
const newCommands = [...(node.commands || [])]
|
||||||
|
newCommands.splice(index, 1)
|
||||||
|
onUpdate({ commands: newCommands })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateCommand = (index: number, value: string) => {
|
||||||
|
const newCommands = [...(node.commands || [])]
|
||||||
|
newCommands[index] = value
|
||||||
|
onUpdate({ commands: newCommands })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReorderCommands = (fromIndex: number, toIndex: number) => {
|
||||||
|
const newCommands = [...(node.commands || [])]
|
||||||
|
const [moved] = newCommands.splice(fromIndex, 1)
|
||||||
|
newCommands.splice(toIndex, 0, moved)
|
||||||
|
onUpdate({ commands: newCommands })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={node.title || ''}
|
||||||
|
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||||
|
placeholder="e.g., Restart the Service"
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
titleError ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{titleError && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{titleError.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={node.description || ''}
|
||||||
|
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||||
|
placeholder="Detailed instructions for this action..."
|
||||||
|
rows={3}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commands */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Commands
|
||||||
|
</label>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
|
PowerShell or CLI commands to execute
|
||||||
|
</p>
|
||||||
|
<DynamicArrayField
|
||||||
|
items={node.commands || []}
|
||||||
|
onAdd={handleAddCommand}
|
||||||
|
onRemove={handleRemoveCommand}
|
||||||
|
onReorder={handleReorderCommands}
|
||||||
|
addLabel="Add Command"
|
||||||
|
renderItem={(command, index) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => handleUpdateCommand(index, e.target.value)}
|
||||||
|
placeholder="e.g., Get-Service BrokerAgent"
|
||||||
|
className={cn(
|
||||||
|
'block w-full rounded-md border border-input px-3 py-2 font-mono text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expected Outcome */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Expected Outcome
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={node.expected_outcome || ''}
|
||||||
|
onChange={(e) => onUpdate({ expected_outcome: e.target.value })}
|
||||||
|
placeholder="e.g., Service should show as Running"
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Node */}
|
||||||
|
<NodePicker
|
||||||
|
value={node.next_node_id || ''}
|
||||||
|
onChange={(nodeId) => onUpdate({ next_node_id: nodeId })}
|
||||||
|
parentNodeId={node.id}
|
||||||
|
excludeNodeId={node.id}
|
||||||
|
label="Next Node (after action)"
|
||||||
|
placeholder="Select or create next node..."
|
||||||
|
error={nextNodeError?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodeFormAction
|
||||||
220
frontend/src/components/tree-editor/NodeFormDecision.tsx
Normal file
220
frontend/src/components/tree-editor/NodeFormDecision.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { Play } from 'lucide-react'
|
||||||
|
import { DynamicArrayField } from './DynamicArrayField'
|
||||||
|
import { NodePicker } from './NodePicker'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import type { TreeStructure, TreeOption } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NodeFormDecisionProps {
|
||||||
|
node: TreeStructure
|
||||||
|
onUpdate: (updates: Partial<TreeStructure>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert index to letter (0=A, 1=B, 2=C, etc.)
|
||||||
|
const indexToLetter = (index: number): string => {
|
||||||
|
return String.fromCharCode(65 + index) // 65 is ASCII for 'A'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||||
|
const { reorderOptions, validationErrors } = useTreeEditorStore()
|
||||||
|
const isRootNode = node.id === 'root'
|
||||||
|
|
||||||
|
const questionError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === 'question'
|
||||||
|
)
|
||||||
|
|
||||||
|
const optionsError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === 'options'
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddOption = () => {
|
||||||
|
const newOption: TreeOption = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: '',
|
||||||
|
next_node_id: ''
|
||||||
|
}
|
||||||
|
onUpdate({
|
||||||
|
options: [...(node.options || []), newOption]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveOption = (index: number) => {
|
||||||
|
const newOptions = [...(node.options || [])]
|
||||||
|
newOptions.splice(index, 1)
|
||||||
|
onUpdate({ options: newOptions })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateOption = (index: number, updates: Partial<TreeOption>) => {
|
||||||
|
const newOptions = [...(node.options || [])]
|
||||||
|
newOptions[index] = { ...newOptions[index], ...updates }
|
||||||
|
onUpdate({ options: newOptions })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||||
|
reorderOptions(node.id, fromIndex, toIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Root node banner */}
|
||||||
|
{isRootNode && (
|
||||||
|
<div className="rounded-lg border-2 border-blue-500/30 bg-blue-500/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-full bg-blue-500/20 p-2">
|
||||||
|
<Play className="h-5 w-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
Starting Question
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
This is the first question users will see when they start this troubleshooting tree.
|
||||||
|
Each option below creates a different troubleshooting path.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Question */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
{isRootNode && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
What's the main question to diagnose the issue?
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={node.question || ''}
|
||||||
|
onChange={(e) => onUpdate({ question: e.target.value })}
|
||||||
|
placeholder={isRootNode
|
||||||
|
? "e.g., What type of issue are you experiencing?"
|
||||||
|
: "e.g., Can you ping the server?"}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
questionError ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{questionError && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{questionError.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Help Text
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={node.help_text || ''}
|
||||||
|
onChange={(e) => onUpdate({ help_text: e.target.value })}
|
||||||
|
placeholder="Additional context or instructions for this decision..."
|
||||||
|
rows={2}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
{isRootNode ? (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Each option can branch to a different next step.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{optionsError && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{optionsError.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
<DynamicArrayField
|
||||||
|
items={node.options || []}
|
||||||
|
onAdd={handleAddOption}
|
||||||
|
onRemove={handleRemoveOption}
|
||||||
|
onReorder={handleReorderOptions}
|
||||||
|
addLabel={isRootNode ? "Add Another Branch" : "Add Option"}
|
||||||
|
minItems={1}
|
||||||
|
renderItem={(option, index) => {
|
||||||
|
const optionLabelError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === `options[${index}].label`
|
||||||
|
)
|
||||||
|
const optionNextError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
|
||||||
|
)
|
||||||
|
const letter = indexToLetter(index)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-input bg-muted/30 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Letter badge */}
|
||||||
|
<span className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||||
|
isRootNode
|
||||||
|
? 'bg-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={option.label}
|
||||||
|
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
||||||
|
placeholder={isRootNode
|
||||||
|
? `Branch ${letter}: e.g., "Network Issues", "Application Errors"...`
|
||||||
|
: `Option ${letter} label`}
|
||||||
|
className={cn(
|
||||||
|
'block flex-1 rounded-md border px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
optionLabelError ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{optionLabelError && (
|
||||||
|
<p className="mb-2 text-xs text-destructive">{optionLabelError.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="pl-8">
|
||||||
|
<NodePicker
|
||||||
|
value={option.next_node_id}
|
||||||
|
onChange={(nodeId) => handleUpdateOption(index, { next_node_id: nodeId })}
|
||||||
|
parentNodeId={node.id}
|
||||||
|
excludeNodeId={node.id}
|
||||||
|
placeholder={isRootNode
|
||||||
|
? `What happens when user selects "${option.label || `Branch ${letter}`}"?`
|
||||||
|
: "Select or create next node..."}
|
||||||
|
error={optionNextError?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Example hint for root node */}
|
||||||
|
{isRootNode && (node.options?.length || 0) < 2 && (
|
||||||
|
<div className="mt-3 rounded-md border border-dashed border-muted-foreground/30 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
<strong>Tip:</strong> Most troubleshooting trees start with 2-5 main branches.
|
||||||
|
For example: "Connection Issues", "Performance Problems", "Error Messages", "Other".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodeFormDecision
|
||||||
129
frontend/src/components/tree-editor/NodeFormResolution.tsx
Normal file
129
frontend/src/components/tree-editor/NodeFormResolution.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { DynamicArrayField } from './DynamicArrayField'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NodeFormResolutionProps {
|
||||||
|
node: TreeStructure
|
||||||
|
onUpdate: (updates: Partial<TreeStructure>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps) {
|
||||||
|
const { validationErrors } = useTreeEditorStore()
|
||||||
|
|
||||||
|
const titleError = validationErrors.find(
|
||||||
|
e => e.nodeId === node.id && e.field === 'title'
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddStep = () => {
|
||||||
|
onUpdate({
|
||||||
|
resolution_steps: [...(node.resolution_steps || []), '']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveStep = (index: number) => {
|
||||||
|
const newSteps = [...(node.resolution_steps || [])]
|
||||||
|
newSteps.splice(index, 1)
|
||||||
|
onUpdate({ resolution_steps: newSteps })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStep = (index: number, value: string) => {
|
||||||
|
const newSteps = [...(node.resolution_steps || [])]
|
||||||
|
newSteps[index] = value
|
||||||
|
onUpdate({ resolution_steps: newSteps })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReorderSteps = (fromIndex: number, toIndex: number) => {
|
||||||
|
const newSteps = [...(node.resolution_steps || [])]
|
||||||
|
const [moved] = newSteps.splice(fromIndex, 1)
|
||||||
|
newSteps.splice(toIndex, 0, moved)
|
||||||
|
onUpdate({ resolution_steps: newSteps })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={node.title || ''}
|
||||||
|
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||||
|
placeholder="e.g., VDA Successfully Registered"
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
titleError ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{titleError && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{titleError.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={node.description || ''}
|
||||||
|
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||||
|
placeholder="Summary of the resolution and any follow-up recommendations..."
|
||||||
|
rows={3}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resolution Steps */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Resolution Steps
|
||||||
|
</label>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
|
Step-by-step instructions for resolving the issue
|
||||||
|
</p>
|
||||||
|
<DynamicArrayField
|
||||||
|
items={node.resolution_steps || []}
|
||||||
|
onAdd={handleAddStep}
|
||||||
|
onRemove={handleRemoveStep}
|
||||||
|
onReorder={handleReorderSteps}
|
||||||
|
addLabel="Add Step"
|
||||||
|
renderItem={(step, index) => (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-2 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={step}
|
||||||
|
onChange={(e) => handleUpdateStep(index, e.target.value)}
|
||||||
|
placeholder={`Step ${index + 1}`}
|
||||||
|
className={cn(
|
||||||
|
'block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note about terminal node */}
|
||||||
|
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<strong>Note:</strong> Solution nodes are terminal - they end the troubleshooting flow.
|
||||||
|
The session will be marked complete when the user reaches this node.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodeFormResolution
|
||||||
499
frontend/src/components/tree-editor/NodeList.tsx
Normal file
499
frontend/src/components/tree-editor/NodeList.tsx
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
GripVertical,
|
||||||
|
HelpCircle,
|
||||||
|
Zap,
|
||||||
|
CheckCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Play
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import { NodeEditorModal } from './NodeEditorModal'
|
||||||
|
import type { TreeStructure, NodeType } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NodeListItemProps {
|
||||||
|
node: TreeStructure
|
||||||
|
depth: number
|
||||||
|
parentId: string | null
|
||||||
|
index: number
|
||||||
|
isLast: boolean
|
||||||
|
/** Which option label led to this node (from parent decision) */
|
||||||
|
fromOption?: string
|
||||||
|
onEdit: (node: TreeStructure) => void
|
||||||
|
onDelete: (nodeId: string) => void
|
||||||
|
onDuplicate: (nodeId: string) => void
|
||||||
|
onAddChild: (parentId: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent, nodeId: string, parentId: string | null, index: number) => void
|
||||||
|
onDragOver: (e: React.DragEvent, parentId: string | null, index: number) => void
|
||||||
|
onDrop: (e: React.DragEvent, parentId: string | null, index: number) => void
|
||||||
|
dragOverTarget: { parentId: string | null; index: number } | null
|
||||||
|
/** Array of booleans indicating which ancestor levels should show continuing lines */
|
||||||
|
ancestorLines?: boolean[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeListItem({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
parentId,
|
||||||
|
index,
|
||||||
|
isLast,
|
||||||
|
fromOption,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
onAddChild,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
dragOverTarget,
|
||||||
|
ancestorLines = []
|
||||||
|
}: NodeListItemProps) {
|
||||||
|
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||||
|
const isSelected = selectedNodeId === node.id
|
||||||
|
const isRootNode = node.id === 'root'
|
||||||
|
const hasError = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error')
|
||||||
|
const hasWarning = validationErrors.some(e => e.nodeId === node.id && e.severity === 'warning')
|
||||||
|
const hasChildren = node.children && node.children.length > 0
|
||||||
|
|
||||||
|
const isDragTarget =
|
||||||
|
dragOverTarget?.parentId === parentId && dragOverTarget?.index === index
|
||||||
|
|
||||||
|
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
|
decision: <HelpCircle className="h-4 w-4" />,
|
||||||
|
action: <Zap className="h-4 w-4" />,
|
||||||
|
solution: <CheckCircle className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeColors: Record<NodeType, string> = {
|
||||||
|
decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||||
|
action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
|
||||||
|
solution: 'bg-green-500/20 text-green-600 dark:text-green-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNodeLabel = () => {
|
||||||
|
if (node.type === 'decision') return node.question || 'Untitled Question'
|
||||||
|
return node.title || `Untitled ${node.type}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which option label leads to each child node
|
||||||
|
const getOptionLabelForChild = (childId: string): string | undefined => {
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
const option = node.options.find(opt => opt.next_node_id === childId)
|
||||||
|
return option?.label
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCollapse = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsCollapsed(!isCollapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree line prefix for proper hierarchy visualization
|
||||||
|
const renderTreeLines = () => {
|
||||||
|
if (depth === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* Render continuing lines from ancestors */}
|
||||||
|
{ancestorLines.map((showLine, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'inline-block w-5 text-center text-muted-foreground/50',
|
||||||
|
showLine ? 'border-l border-muted-foreground/30' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{/* Render current level connector */}
|
||||||
|
<span className="inline-block w-5 text-center text-muted-foreground/50 font-mono text-xs">
|
||||||
|
{isLast ? '└' : '├'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-block w-3 text-muted-foreground/50 font-mono text-xs">──</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Drop indicator above */}
|
||||||
|
{isDragTarget && (
|
||||||
|
<div className="h-1 bg-primary rounded-full mx-2" style={{ marginLeft: `${depth * 20 + 8}px` }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
draggable={node.id !== 'root'}
|
||||||
|
onDragStart={(e) => onDragStart(e, node.id, parentId, index)}
|
||||||
|
onDragOver={(e) => onDragOver(e, parentId, index)}
|
||||||
|
onDrop={(e) => onDrop(e, parentId, index)}
|
||||||
|
onClick={() => selectNode(node.id)}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors cursor-pointer',
|
||||||
|
isRootNode
|
||||||
|
? isSelected
|
||||||
|
? 'bg-blue-500/20 ring-2 ring-blue-500 shadow-sm'
|
||||||
|
: 'bg-blue-500/10 border border-blue-500/30 hover:bg-blue-500/15'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary'
|
||||||
|
: 'hover:bg-accent',
|
||||||
|
hasError && 'ring-1 ring-destructive',
|
||||||
|
hasWarning && !hasError && 'ring-1 ring-yellow-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Tree lines */}
|
||||||
|
{renderTreeLines()}
|
||||||
|
|
||||||
|
{/* Collapse toggle for nodes with children */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
className="rounded p-0.5 hover:bg-muted"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag handle */}
|
||||||
|
{node.id !== 'root' && (
|
||||||
|
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||||
|
)}
|
||||||
|
{node.id === 'root' && <div className="w-4" />}
|
||||||
|
|
||||||
|
{/* Node type icon - special treatment for root */}
|
||||||
|
{isRootNode ? (
|
||||||
|
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-500/30 text-blue-600 dark:text-blue-400 font-semibold">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">START</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', nodeTypeColors[node.type])}>
|
||||||
|
{nodeTypeIcons[node.type]}
|
||||||
|
<span className="hidden sm:inline">{node.type}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* From option label */}
|
||||||
|
{fromOption && (
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{fromOption}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node label */}
|
||||||
|
<span className="flex-1 truncate text-foreground">
|
||||||
|
{getNodeLabel()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Node ID */}
|
||||||
|
<span
|
||||||
|
className="hidden text-xs text-muted-foreground sm:inline cursor-help"
|
||||||
|
title={`Full ID: ${node.id}`}
|
||||||
|
>
|
||||||
|
{node.id === 'root' ? 'root' : node.id.slice(0, 8) + '...'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||||
|
{node.type === 'decision' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onAddChild(node.id)
|
||||||
|
}}
|
||||||
|
title="Add child node"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEdit(node)
|
||||||
|
}}
|
||||||
|
title="Edit node"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{node.id !== 'root' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDuplicate(node.id)
|
||||||
|
}}
|
||||||
|
title="Duplicate node"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete(node.id)
|
||||||
|
}}
|
||||||
|
title="Delete node"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsed indicator */}
|
||||||
|
{hasChildren && isCollapsed && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground py-1"
|
||||||
|
style={{ marginLeft: `${(depth + 1) * 20 + 32}px` }}
|
||||||
|
>
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5">
|
||||||
|
{node.children!.length} hidden
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render children */}
|
||||||
|
{!isCollapsed && node.children?.map((child, childIndex) => {
|
||||||
|
const optionLabel = getOptionLabelForChild(child.id)
|
||||||
|
const isLastChild = childIndex === node.children!.length - 1
|
||||||
|
// Build ancestor lines array for children
|
||||||
|
const childAncestorLines = depth > 0 ? [...ancestorLines, !isLast] : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeListItem
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
parentId={node.id}
|
||||||
|
index={childIndex}
|
||||||
|
isLast={isLastChild}
|
||||||
|
fromOption={optionLabel}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onAddChild={onAddChild}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
dragOverTarget={dragOverTarget}
|
||||||
|
ancestorLines={childAncestorLines}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeList() {
|
||||||
|
const { treeStructure, addNode, deleteNode, duplicateNode, reorderNodes, findNode } = useTreeEditorStore()
|
||||||
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
|
const [addingToParent, setAddingToParent] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Get the current node from store (will update when store changes)
|
||||||
|
const editingNode = editingNodeId ? findNode(editingNodeId) : null
|
||||||
|
const [dragState, setDragState] = useState<{
|
||||||
|
nodeId: string
|
||||||
|
parentId: string | null
|
||||||
|
index: number
|
||||||
|
} | null>(null)
|
||||||
|
const [dragOverTarget, setDragOverTarget] = useState<{
|
||||||
|
parentId: string | null
|
||||||
|
index: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const handleAddNode = (type: NodeType) => {
|
||||||
|
const newId = addNode(addingToParent, type)
|
||||||
|
setAddingToParent(null)
|
||||||
|
// Open editor for the new node
|
||||||
|
setEditingNodeId(newId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (
|
||||||
|
e: React.DragEvent,
|
||||||
|
nodeId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
setDragState({ nodeId, parentId, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (
|
||||||
|
e: React.DragEvent,
|
||||||
|
parentId: string | null,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!dragState) return
|
||||||
|
|
||||||
|
// Don't allow dropping on itself or its descendants
|
||||||
|
if (dragState.nodeId === parentId) return
|
||||||
|
|
||||||
|
setDragOverTarget({ parentId, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (
|
||||||
|
e: React.DragEvent,
|
||||||
|
targetParentId: string | null,
|
||||||
|
targetIndex: number
|
||||||
|
) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!dragState) return
|
||||||
|
|
||||||
|
const { parentId: sourceParentId, index: sourceIndex } = dragState
|
||||||
|
|
||||||
|
// Only handle reordering within same parent for now
|
||||||
|
if (sourceParentId === targetParentId && sourceParentId) {
|
||||||
|
const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||||
|
if (sourceIndex !== adjustedIndex) {
|
||||||
|
reorderNodes(sourceParentId, sourceIndex, adjustedIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragState(null)
|
||||||
|
setDragOverTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDragState(null)
|
||||||
|
setDragOverTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!treeStructure) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No tree structure. Add a root node to get started.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card" onDragEnd={handleDragEnd}>
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-3">
|
||||||
|
<h2 className="text-sm font-semibold text-card-foreground">Nodes</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddingToParent(treeStructure.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium',
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
Add Node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[500px] space-y-0.5 overflow-y-auto p-2">
|
||||||
|
<NodeListItem
|
||||||
|
node={treeStructure}
|
||||||
|
depth={0}
|
||||||
|
parentId={null}
|
||||||
|
index={0}
|
||||||
|
isLast={true}
|
||||||
|
onEdit={(node) => setEditingNodeId(node.id)}
|
||||||
|
onDelete={deleteNode}
|
||||||
|
onDuplicate={duplicateNode}
|
||||||
|
onAddChild={setAddingToParent}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
dragOverTarget={dragOverTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Node Type Selector */}
|
||||||
|
{addingToParent && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-xs rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold">Select Node Type</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddNode('decision')}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
||||||
|
'border border-blue-500/30 bg-blue-500/10 hover:bg-blue-500/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Decision</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Question with options</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddNode('action')}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
||||||
|
'border border-yellow-500/30 bg-yellow-500/10 hover:bg-yellow-500/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4 text-yellow-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Action</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Task to perform</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddNode('solution')}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
||||||
|
'border border-green-500/30 bg-green-500/10 hover:bg-green-500/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Solution</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Resolution endpoint</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddingToParent(null)}
|
||||||
|
className="mt-3 w-full rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node Editor Modal */}
|
||||||
|
{editingNode && (
|
||||||
|
<NodeEditorModal
|
||||||
|
node={editingNode}
|
||||||
|
onClose={() => setEditingNodeId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodeList
|
||||||
156
frontend/src/components/tree-editor/NodePicker.tsx
Normal file
156
frontend/src/components/tree-editor/NodePicker.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import type { NodeType } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// Special values for creating new nodes
|
||||||
|
const CREATE_PREFIX = '__create_'
|
||||||
|
const CREATE_DECISION = `${CREATE_PREFIX}decision__`
|
||||||
|
const CREATE_ACTION = `${CREATE_PREFIX}action__`
|
||||||
|
const CREATE_SOLUTION = `${CREATE_PREFIX}solution__`
|
||||||
|
|
||||||
|
// Unicode symbols for node types (works in select options)
|
||||||
|
const NODE_TYPE_SYMBOLS: Record<NodeType, string> = {
|
||||||
|
decision: 'ⓘ', // Information/question symbol
|
||||||
|
action: '⚡', // Lightning bolt for action
|
||||||
|
solution: '✓' // Checkmark for solution
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodePickerProps {
|
||||||
|
value: string
|
||||||
|
onChange: (nodeId: string) => void
|
||||||
|
/** The parent node ID - new nodes will be added as children of this node */
|
||||||
|
parentNodeId: string
|
||||||
|
excludeNodeId?: string
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
/** Callback when a new node is created (receives the new node ID) */
|
||||||
|
onNodeCreated?: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
parentNodeId,
|
||||||
|
excludeNodeId,
|
||||||
|
placeholder = 'Select a node...',
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
onNodeCreated
|
||||||
|
}: NodePickerProps) {
|
||||||
|
const { getAvailableTargetNodes, addNode } = useTreeEditorStore()
|
||||||
|
const availableNodes = getAvailableTargetNodes(excludeNodeId)
|
||||||
|
|
||||||
|
// Group nodes by type
|
||||||
|
const groupedNodes = useMemo(() => {
|
||||||
|
const decisions = availableNodes.filter(n => n.type === 'decision')
|
||||||
|
const actions = availableNodes.filter(n => n.type === 'action')
|
||||||
|
const solutions = availableNodes.filter(n => n.type === 'solution')
|
||||||
|
return { decisions, actions, solutions }
|
||||||
|
}, [availableNodes])
|
||||||
|
|
||||||
|
const handleChange = (selectedValue: string) => {
|
||||||
|
// Check if it's a "create new" option
|
||||||
|
if (selectedValue.startsWith(CREATE_PREFIX)) {
|
||||||
|
let nodeType: NodeType
|
||||||
|
if (selectedValue === CREATE_DECISION) {
|
||||||
|
nodeType = 'decision'
|
||||||
|
} else if (selectedValue === CREATE_ACTION) {
|
||||||
|
nodeType = 'action'
|
||||||
|
} else if (selectedValue === CREATE_SOLUTION) {
|
||||||
|
nodeType = 'solution'
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new node as a child of the parent
|
||||||
|
const newNodeId = addNode(parentNodeId, nodeType)
|
||||||
|
|
||||||
|
// Set this new node as the selected value
|
||||||
|
onChange(newNodeId)
|
||||||
|
|
||||||
|
// Notify parent if callback provided
|
||||||
|
onNodeCreated?.(newNodeId)
|
||||||
|
} else {
|
||||||
|
// Normal selection
|
||||||
|
onChange(selectedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the label for the currently selected node
|
||||||
|
const selectedNode = availableNodes.find(n => n.id === value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'block w-full rounded-md border px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
error ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">{placeholder}</option>
|
||||||
|
|
||||||
|
{/* Create new options */}
|
||||||
|
<optgroup label="Create New Node">
|
||||||
|
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
||||||
|
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
||||||
|
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
||||||
|
</optgroup>
|
||||||
|
|
||||||
|
{/* Existing nodes grouped by type */}
|
||||||
|
{groupedNodes.decisions.length > 0 && (
|
||||||
|
<optgroup label="── Decisions ──">
|
||||||
|
{groupedNodes.decisions.map((node) => (
|
||||||
|
<option key={node.id} value={node.id}>
|
||||||
|
{NODE_TYPE_SYMBOLS.decision} {node.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupedNodes.actions.length > 0 && (
|
||||||
|
<optgroup label="── Actions ──">
|
||||||
|
{groupedNodes.actions.map((node) => (
|
||||||
|
<option key={node.id} value={node.id}>
|
||||||
|
{NODE_TYPE_SYMBOLS.action} {node.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupedNodes.solutions.length > 0 && (
|
||||||
|
<optgroup label="── Solutions ──">
|
||||||
|
{groupedNodes.solutions.map((node) => (
|
||||||
|
<option key={node.id} value={node.id}>
|
||||||
|
{NODE_TYPE_SYMBOLS.solution} {node.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Show what's selected */}
|
||||||
|
{value && selectedNode && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
→ {selectedNode.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodePicker
|
||||||
44
frontend/src/components/tree-editor/TreeEditorLayout.tsx
Normal file
44
frontend/src/components/tree-editor/TreeEditorLayout.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||||
|
import { NodeList } from './NodeList'
|
||||||
|
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface TreeEditorLayoutProps {
|
||||||
|
isMobile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 overflow-hidden',
|
||||||
|
isMobile ? 'flex-col' : 'flex-row'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Left Panel - Form Editor */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col overflow-y-auto border-border bg-background',
|
||||||
|
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<TreeMetadataForm />
|
||||||
|
<NodeList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Preview */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-hidden bg-muted/30',
|
||||||
|
isMobile ? 'hidden' : 'block'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TreePreviewPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TreeEditorLayout
|
||||||
132
frontend/src/components/tree-editor/TreeMetadataForm.tsx
Normal file
132
frontend/src/components/tree-editor/TreeMetadataForm.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { treesApi } from '@/api'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function TreeMetadataForm() {
|
||||||
|
const { name, description, category, setName, setDescription, setCategory, validationErrors } =
|
||||||
|
useTreeEditorStore()
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<string[]>([])
|
||||||
|
const [customCategory, setCustomCategory] = useState(false)
|
||||||
|
|
||||||
|
// Load existing categories
|
||||||
|
useEffect(() => {
|
||||||
|
treesApi.categories().then(setCategories).catch(console.error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCategoryChange = (value: string) => {
|
||||||
|
if (value === '__custom__') {
|
||||||
|
setCustomCategory(true)
|
||||||
|
setCategory('')
|
||||||
|
} else {
|
||||||
|
setCustomCategory(false)
|
||||||
|
setCategory(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameError = validationErrors.find(
|
||||||
|
(e) => !e.nodeId && e.message.toLowerCase().includes('name')
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-card-foreground">Tree Details</h2>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="tree-name" className="block text-sm font-medium text-foreground">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tree-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., VDA Registration Troubleshooting"
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
nameError ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{nameError.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="tree-description" className="block text-sm font-medium text-foreground">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="tree-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of what this tree troubleshoots..."
|
||||||
|
rows={2}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="tree-category" className="block text-sm font-medium text-foreground">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
{!customCategory ? (
|
||||||
|
<select
|
||||||
|
id="tree-category"
|
||||||
|
value={category || ''}
|
||||||
|
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">No category</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__custom__">+ Add custom category</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="Enter new category"
|
||||||
|
className={cn(
|
||||||
|
'block flex-1 rounded-md border border-input px-3 py-2 text-sm',
|
||||||
|
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCustomCategory(false)
|
||||||
|
setCategory('')
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TreeMetadataForm
|
||||||
9
frontend/src/components/tree-editor/index.ts
Normal file
9
frontend/src/components/tree-editor/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { TreeEditorLayout } from './TreeEditorLayout'
|
||||||
|
export { TreeMetadataForm } from './TreeMetadataForm'
|
||||||
|
export { NodeList } from './NodeList'
|
||||||
|
export { NodeEditorModal } from './NodeEditorModal'
|
||||||
|
export { NodeFormDecision } from './NodeFormDecision'
|
||||||
|
export { NodeFormAction } from './NodeFormAction'
|
||||||
|
export { NodeFormResolution } from './NodeFormResolution'
|
||||||
|
export { DynamicArrayField } from './DynamicArrayField'
|
||||||
|
export { NodePicker } from './NodePicker'
|
||||||
390
frontend/src/components/tree-preview/TreePreviewNode.tsx
Normal file
390
frontend/src/components/tree-preview/TreePreviewNode.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, Copy, Check, Play, Users } from 'lucide-react'
|
||||||
|
import type { TreeStructure, NodeType } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { SharedLinksMap } from './TreePreviewPanel'
|
||||||
|
|
||||||
|
type FindNodeFn = (nodeId: string) => TreeStructure | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively check if a node's subtree contains any solution nodes
|
||||||
|
* Also follows next_node_id references using the findNode function
|
||||||
|
* @param visited - Set to track visited nodes and prevent infinite loops
|
||||||
|
*/
|
||||||
|
function hasSolutionInSubtree(
|
||||||
|
node: TreeStructure,
|
||||||
|
findNode: FindNodeFn,
|
||||||
|
visited: Set<string> = new Set()
|
||||||
|
): boolean {
|
||||||
|
// Prevent infinite loops from circular references
|
||||||
|
if (visited.has(node.id)) return false
|
||||||
|
visited.add(node.id)
|
||||||
|
|
||||||
|
// This node is a solution
|
||||||
|
if (node.type === 'solution') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children array
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
if (node.children.some(child => hasSolutionInSubtree(child, findNode, visited))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check next_node_id reference (for action nodes)
|
||||||
|
if (node.next_node_id) {
|
||||||
|
const nextNode = findNode(node.next_node_id)
|
||||||
|
if (nextNode && hasSolutionInSubtree(nextNode, findNode, visited)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreePreviewNodeProps {
|
||||||
|
node: TreeStructure
|
||||||
|
selectedNodeId: string | null
|
||||||
|
onSelect: (nodeId: string) => void
|
||||||
|
depth: number
|
||||||
|
/** Optional label showing which option led to this node */
|
||||||
|
fromOption?: string
|
||||||
|
/** Callback when hovering over a node reference */
|
||||||
|
onHoverNodeId?: (nodeId: string | null) => void
|
||||||
|
/** Currently hovered node ID */
|
||||||
|
hoveredNodeId?: string | null
|
||||||
|
/** Function to look up any node by ID (for following next_node_id references) */
|
||||||
|
findNode: FindNodeFn
|
||||||
|
/** Map of targetNodeId -> sources that link to it (for showing shared connections) */
|
||||||
|
sharedLinksMap: SharedLinksMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreePreviewNode({
|
||||||
|
node,
|
||||||
|
selectedNodeId,
|
||||||
|
onSelect,
|
||||||
|
depth,
|
||||||
|
fromOption,
|
||||||
|
onHoverNodeId,
|
||||||
|
hoveredNodeId,
|
||||||
|
findNode,
|
||||||
|
sharedLinksMap
|
||||||
|
}: TreePreviewNodeProps) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||||
|
const [copiedId, setCopiedId] = useState(false)
|
||||||
|
const isSelected = selectedNodeId === node.id
|
||||||
|
const isHovered = hoveredNodeId === node.id
|
||||||
|
const isRootNode = node.id === 'root'
|
||||||
|
|
||||||
|
// Check if this node (or its children/next_node_id) leads to a solution
|
||||||
|
const leadsTosolution = useMemo(() => {
|
||||||
|
// Don't show indicator on solution nodes themselves
|
||||||
|
if (node.type === 'solution') return false
|
||||||
|
return hasSolutionInSubtree(node, findNode)
|
||||||
|
}, [node, findNode])
|
||||||
|
|
||||||
|
const nodeTypeColors: Record<NodeType, string> = {
|
||||||
|
decision: 'border-blue-500/50 bg-blue-500/10',
|
||||||
|
action: 'border-yellow-500/50 bg-yellow-500/10',
|
||||||
|
solution: 'border-green-500/50 bg-green-500/10'
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeSelectedColors: Record<NodeType, string> = {
|
||||||
|
decision: 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20',
|
||||||
|
action: 'border-yellow-500 bg-yellow-500/20 ring-2 ring-yellow-500/50 shadow-lg shadow-yellow-500/20',
|
||||||
|
solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20'
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeHoveredColors: Record<NodeType, string> = {
|
||||||
|
decision: 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50',
|
||||||
|
action: 'border-yellow-400 bg-yellow-500/15 ring-1 ring-yellow-400/50',
|
||||||
|
solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50'
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
|
decision: <HelpCircle className="h-4 w-4 text-blue-500" />,
|
||||||
|
action: <Zap className="h-4 w-4 text-yellow-500" />,
|
||||||
|
solution: <CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNodeLabel = () => {
|
||||||
|
if (node.type === 'decision') return node.question || 'Untitled Question'
|
||||||
|
return node.title || `Untitled ${node.type}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = node.children && node.children.length > 0
|
||||||
|
|
||||||
|
const handleCopyId = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigator.clipboard.writeText(node.id)
|
||||||
|
setCopiedId(true)
|
||||||
|
setTimeout(() => setCopiedId(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCollapse = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsCollapsed(!isCollapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which option label leads to each child node
|
||||||
|
const getOptionLabelForChild = (childId: string): string | undefined => {
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
const option = node.options.find(opt => opt.next_node_id === childId)
|
||||||
|
return option?.label
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a specific option/next_node_id leads to a solution
|
||||||
|
const nodeLeadsToSolution = (nextNodeId: string | undefined): boolean => {
|
||||||
|
if (!nextNodeId) return false
|
||||||
|
// First try to find in children
|
||||||
|
const childNode = node.children?.find(child => child.id === nextNodeId)
|
||||||
|
if (childNode) {
|
||||||
|
return hasSolutionInSubtree(childNode, findNode)
|
||||||
|
}
|
||||||
|
// Otherwise look up using findNode (for shared/external node references)
|
||||||
|
const targetNode = findNode(nextNodeId)
|
||||||
|
if (!targetNode) return false
|
||||||
|
return hasSolutionInSubtree(targetNode, findNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* From option label */}
|
||||||
|
{fromOption && (
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5">{fromOption}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node card */}
|
||||||
|
<div
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
className={cn(
|
||||||
|
'relative cursor-pointer rounded-lg border-2 p-3 transition-all',
|
||||||
|
isRootNode
|
||||||
|
? isSelected
|
||||||
|
? 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20'
|
||||||
|
: isHovered
|
||||||
|
? 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50'
|
||||||
|
: 'border-blue-500/50 bg-blue-500/10'
|
||||||
|
: isSelected
|
||||||
|
? nodeTypeSelectedColors[node.type]
|
||||||
|
: isHovered
|
||||||
|
? nodeTypeHoveredColors[node.type]
|
||||||
|
: nodeTypeColors[node.type],
|
||||||
|
'hover:shadow-md',
|
||||||
|
isRootNode ? 'min-w-[260px] max-w-[360px]' : 'min-w-[220px] max-w-[320px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Solution path indicator - shows when this branch leads to a solution */}
|
||||||
|
{leadsTosolution && (
|
||||||
|
<div
|
||||||
|
className="absolute -top-1.5 -right-1.5 flex items-center justify-center rounded-full bg-green-500/90 p-0.5 shadow-sm"
|
||||||
|
title="This branch leads to a solution"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Root node START header */}
|
||||||
|
{isRootNode && (
|
||||||
|
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-blue-500/30">
|
||||||
|
<div className="rounded-full bg-blue-500/30 p-1.5">
|
||||||
|
<Play className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
Starting Question
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* Collapse toggle for nodes with children */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
className="mt-0.5 rounded p-0.5 hover:bg-muted"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-5" /> // Spacer for alignment
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRootNode && nodeTypeIcons[node.type]}
|
||||||
|
{isRootNode && <HelpCircle className="h-4 w-4 text-blue-500" />}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground leading-tight">
|
||||||
|
{getNodeLabel()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Node ID with copy button */}
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<span
|
||||||
|
className="text-xs text-muted-foreground cursor-help"
|
||||||
|
title={`Full ID: ${node.id}`}
|
||||||
|
>
|
||||||
|
{node.id === 'root' ? 'root' : node.id.slice(0, 8) + '...'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyId}
|
||||||
|
className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Copy full ID"
|
||||||
|
>
|
||||||
|
{copiedId ? (
|
||||||
|
<Check className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show options for decision nodes */}
|
||||||
|
{node.type === 'decision' && node.options && node.options.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1 border-t border-border/50 pt-2">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Options:</p>
|
||||||
|
{node.options.map((opt, i) => {
|
||||||
|
const leadsToSolution = nodeLeadsToSolution(opt.next_node_id)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-xs rounded px-1 py-0.5 -mx-1',
|
||||||
|
opt.next_node_id && 'hover:bg-muted cursor-pointer'
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => opt.next_node_id && onHoverNodeId?.(opt.next_node_id)}
|
||||||
|
onMouseLeave={() => onHoverNodeId?.(null)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-muted text-[10px] font-medium">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-foreground">{opt.label || 'Untitled'}</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
{leadsToSolution && (
|
||||||
|
<span title="Leads to solution">
|
||||||
|
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{opt.next_node_id ? (
|
||||||
|
<span className="text-blue-500">→</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 text-[10px]">(no link)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show next_node_id for action nodes */}
|
||||||
|
{node.type === 'action' && node.next_node_id && (() => {
|
||||||
|
const nextNode = findNode(node.next_node_id!)
|
||||||
|
const nextNodeLeadsToSolution = nodeLeadsToSolution(node.next_node_id)
|
||||||
|
const nextNodeLabel = nextNode
|
||||||
|
? (nextNode.type === 'decision' ? nextNode.question : nextNode.title) || 'Untitled'
|
||||||
|
: node.next_node_id!.slice(0, 8) + '...'
|
||||||
|
|
||||||
|
// Check if this target is shared by multiple sources
|
||||||
|
const sourcesLinkingToTarget = sharedLinksMap.get(node.next_node_id!) || []
|
||||||
|
const otherSources = sourcesLinkingToTarget.filter(s => s.id !== node.id)
|
||||||
|
const isSharedTarget = otherSources.length > 0
|
||||||
|
|
||||||
|
// Build tooltip for shared connection
|
||||||
|
const sharedTooltip = isSharedTarget
|
||||||
|
? `Shared endpoint - also connected from: ${otherSources.map(s => s.label).join(', ')}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-2 text-xs border-t border-border/50 pt-2 hover:bg-muted/50 cursor-pointer rounded px-1 -mx-1"
|
||||||
|
onMouseEnter={() => onHoverNodeId?.(node.next_node_id!)}
|
||||||
|
onMouseLeave={() => onHoverNodeId?.(null)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">Next:</span>
|
||||||
|
{isSharedTarget && (
|
||||||
|
<span title={sharedTooltip} className="flex items-center">
|
||||||
|
<Users className="h-3 w-3 text-purple-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
'truncate',
|
||||||
|
nextNode?.type === 'solution' ? 'text-green-500 font-medium' : 'text-foreground'
|
||||||
|
)}>
|
||||||
|
{nextNodeLabel.slice(0, 30)}{nextNodeLabel.length > 30 ? '...' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
{(nextNodeLeadsToSolution || nextNode?.type === 'solution') && (
|
||||||
|
<span title={nextNode?.type === 'solution' ? 'Solution' : 'Leads to solution'}>
|
||||||
|
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-yellow-500">→</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Show shared sources count */}
|
||||||
|
{isSharedTarget && (
|
||||||
|
<div className="mt-1 text-[10px] text-purple-500/80 pl-4">
|
||||||
|
Shared by {sourcesLinkingToTarget.length} nodes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children - show as branches */}
|
||||||
|
{hasChildren && !isCollapsed && (
|
||||||
|
<div className="relative mt-3 ml-6 pl-6 border-l-2 border-border">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{node.children!.map((child) => {
|
||||||
|
const optionLabel = getOptionLabelForChild(child.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={child.id} className="relative">
|
||||||
|
{/* Horizontal connector line */}
|
||||||
|
<div className="absolute -left-6 top-6 h-0.5 w-6 bg-border" />
|
||||||
|
|
||||||
|
<TreePreviewNode
|
||||||
|
node={child}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
fromOption={optionLabel}
|
||||||
|
onHoverNodeId={onHoverNodeId}
|
||||||
|
hoveredNodeId={hoveredNodeId}
|
||||||
|
findNode={findNode}
|
||||||
|
sharedLinksMap={sharedLinksMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show collapsed indicator */}
|
||||||
|
{hasChildren && isCollapsed && (
|
||||||
|
<div className="mt-2 ml-6 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded bg-muted px-2 py-1">
|
||||||
|
{node.children!.length} child node{node.children!.length !== 1 ? 's' : ''} hidden
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TreePreviewNode
|
||||||
114
frontend/src/components/tree-preview/TreePreviewPanel.tsx
Normal file
114
frontend/src/components/tree-preview/TreePreviewPanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
import { TreePreviewNode } from './TreePreviewNode'
|
||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
|
||||||
|
/** Map of targetNodeId -> array of {sourceNodeId, sourceNodeLabel} that link to it */
|
||||||
|
export type SharedLinksMap = Map<string, Array<{ id: string; label: string }>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of which nodes link to which targets
|
||||||
|
* This helps identify shared nodes (multiple sources linking to same target)
|
||||||
|
*/
|
||||||
|
function buildSharedLinksMap(
|
||||||
|
node: TreeStructure,
|
||||||
|
map: SharedLinksMap = new Map()
|
||||||
|
): SharedLinksMap {
|
||||||
|
const nodeLabel = node.type === 'decision' ? node.question : node.title
|
||||||
|
|
||||||
|
// Check decision options
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
for (const opt of node.options) {
|
||||||
|
if (opt.next_node_id) {
|
||||||
|
const existing = map.get(opt.next_node_id) || []
|
||||||
|
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
||||||
|
map.set(opt.next_node_id, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check action next_node_id
|
||||||
|
if (node.type === 'action' && node.next_node_id) {
|
||||||
|
const existing = map.get(node.next_node_id) || []
|
||||||
|
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
||||||
|
map.set(node.next_node_id, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into children
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
buildSharedLinksMap(child, map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreePreviewPanel() {
|
||||||
|
const { treeStructure, name, selectedNodeId, selectNode, findNode } = useTreeEditorStore()
|
||||||
|
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Build map of shared links (which nodes link to which targets)
|
||||||
|
const sharedLinksMap = useMemo(() => {
|
||||||
|
if (!treeStructure) return new Map()
|
||||||
|
return buildSharedLinksMap(treeStructure)
|
||||||
|
}, [treeStructure])
|
||||||
|
|
||||||
|
if (!treeStructure) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
|
||||||
|
No tree structure to preview
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-border bg-background px-4 py-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Preview: {name || 'Untitled Tree'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click a node to select • Hover options to highlight targets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree Visualization */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<div className="inline-block min-w-full">
|
||||||
|
<TreePreviewNode
|
||||||
|
node={treeStructure}
|
||||||
|
selectedNodeId={selectedNodeId}
|
||||||
|
onSelect={selectNode}
|
||||||
|
depth={0}
|
||||||
|
onHoverNodeId={setHoveredNodeId}
|
||||||
|
hoveredNodeId={hoveredNodeId}
|
||||||
|
findNode={findNode}
|
||||||
|
sharedLinksMap={sharedLinksMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="border-t border-border bg-background px-4 py-2">
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded bg-blue-500/50" />
|
||||||
|
<span>Decision</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded bg-yellow-500/50" />
|
||||||
|
<span>Action</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded bg-green-500/50" />
|
||||||
|
<span>Solution</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TreePreviewPanel
|
||||||
2
frontend/src/components/tree-preview/index.ts
Normal file
2
frontend/src/components/tree-preview/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { TreePreviewPanel } from './TreePreviewPanel'
|
||||||
|
export { TreePreviewNode } from './TreePreviewNode'
|
||||||
344
frontend/src/pages/TreeEditorPage.tsx
Normal file
344
frontend/src/pages/TreeEditorPage.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||||
|
import { useStore } from 'zustand'
|
||||||
|
import { Undo2, Redo2, Save } from 'lucide-react'
|
||||||
|
import { treesApi } from '@/api'
|
||||||
|
import type { TreeCreate, TreeUpdate } from '@/types'
|
||||||
|
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
|
||||||
|
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||||
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function TreeEditorPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isEditMode = !!id
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
isDirty,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
validationErrors,
|
||||||
|
initNewTree,
|
||||||
|
loadTree,
|
||||||
|
loadDraft,
|
||||||
|
discardDraft,
|
||||||
|
reset,
|
||||||
|
validate,
|
||||||
|
getTreeForSave,
|
||||||
|
markSaved,
|
||||||
|
setLoading,
|
||||||
|
setSaving
|
||||||
|
} = useTreeEditorStore()
|
||||||
|
|
||||||
|
// Access undo/redo from temporal store
|
||||||
|
const { undo, redo, pastStates, futureStates } = useStore(useTreeEditorTemporal)
|
||||||
|
|
||||||
|
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Block navigation if there are unsaved changes
|
||||||
|
const blocker = useBlocker(
|
||||||
|
({ currentLocation, nextLocation }) =>
|
||||||
|
isDirty && currentLocation.pathname !== nextLocation.pathname
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keyboard shortcuts for undo/redo/save
|
||||||
|
useKeyboardShortcuts([
|
||||||
|
{
|
||||||
|
key: 'z',
|
||||||
|
ctrl: true,
|
||||||
|
handler: () => {
|
||||||
|
if (pastStates.length > 0) undo()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'z',
|
||||||
|
ctrl: true,
|
||||||
|
shift: true,
|
||||||
|
handler: () => {
|
||||||
|
if (futureStates.length > 0) redo()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 's',
|
||||||
|
ctrl: true,
|
||||||
|
handler: () => {
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Initialize or load tree
|
||||||
|
useEffect(() => {
|
||||||
|
const initialize = async () => {
|
||||||
|
if (isEditMode) {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const tree = await treesApi.get(id)
|
||||||
|
loadTree(tree)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tree:', err)
|
||||||
|
navigate('/trees')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
initNewTree()
|
||||||
|
// Check for draft after initializing
|
||||||
|
const draftExists = localStorage.getItem('tree-editor-draft') !== null
|
||||||
|
if (draftExists) {
|
||||||
|
setShowDraftPrompt(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}, [id, isEditMode])
|
||||||
|
|
||||||
|
// Handle unsaved changes warning
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [isDirty])
|
||||||
|
|
||||||
|
const handleRestoreDraft = () => {
|
||||||
|
loadDraft()
|
||||||
|
setShowDraftPrompt(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscardDraft = () => {
|
||||||
|
discardDraft()
|
||||||
|
setShowDraftPrompt(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setSaveError(null)
|
||||||
|
|
||||||
|
// Validate first
|
||||||
|
const errors = validate()
|
||||||
|
const hasErrors = errors.some(e => e.severity === 'error')
|
||||||
|
if (hasErrors) {
|
||||||
|
setSaveError('Please fix validation errors before saving')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const treeData = getTreeForSave()
|
||||||
|
if (isEditMode) {
|
||||||
|
await treesApi.update(id!, treeData as TreeUpdate)
|
||||||
|
} else {
|
||||||
|
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||||
|
// Navigate to edit mode with the new ID
|
||||||
|
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||||
|
}
|
||||||
|
markSaved()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save tree:', err)
|
||||||
|
setSaveError('Failed to save tree. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
|
||||||
|
|
||||||
|
// Handle blocker
|
||||||
|
const handleBlockerProceed = () => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
blocker.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlockerReset = () => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
blocker.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile warning
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
|
{/* Mobile Warning */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="bg-yellow-100 px-4 py-2 text-center text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200">
|
||||||
|
Desktop recommended for tree editing. Viewing mode only on mobile.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Draft Restore Prompt */}
|
||||||
|
{showDraftPrompt && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Restore Draft?</h2>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
You have an unsaved draft from a previous session. Would you like to restore it?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRestoreDraft}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||||
|
'hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Restore Draft
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDiscardDraft}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||||
|
'hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Start Fresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unsaved Changes Dialog */}
|
||||||
|
{blocker.state === 'blocked' && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Unsaved Changes</h2>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
You have unsaved changes. Are you sure you want to leave?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleBlockerReset}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||||
|
'hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Stay
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBlockerProceed}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-destructive',
|
||||||
|
'hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Leave Without Saving
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/trees')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← Back to Library
|
||||||
|
</button>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{isEditMode ? 'Edit Tree' : 'Create New Tree'}
|
||||||
|
{name && <span className="ml-2 text-muted-foreground">- {name}</span>}
|
||||||
|
</h1>
|
||||||
|
{isDirty && (
|
||||||
|
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
Unsaved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Undo/Redo */}
|
||||||
|
<div className="flex items-center rounded-md border border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => undo()}
|
||||||
|
disabled={pastStates.length === 0}
|
||||||
|
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-l-md p-2 transition-colors',
|
||||||
|
pastStates.length > 0
|
||||||
|
? 'text-foreground hover:bg-accent'
|
||||||
|
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="h-6 w-px bg-border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => redo()}
|
||||||
|
disabled={futureStates.length === 0}
|
||||||
|
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-r-md p-2 transition-colors',
|
||||||
|
futureStates.length > 0
|
||||||
|
? 'text-foreground hover:bg-accent'
|
||||||
|
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-2 h-6 w-px bg-border" />
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !isDirty}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||||
|
'hover:bg-primary/90 disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{saveError && (
|
||||||
|
<div className="bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation Errors Summary */}
|
||||||
|
{validationErrors.filter(e => e.severity === 'error').length > 0 && (
|
||||||
|
<div className="bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{validationErrors.filter(e => e.severity === 'error').length} validation error(s) found.
|
||||||
|
Please fix them before saving.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Editor */}
|
||||||
|
<TreeEditorLayout isMobile={isMobile} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TreeEditorPage
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Plus, Pencil } from 'lucide-react'
|
||||||
import { treesApi } from '@/api'
|
import { treesApi } from '@/api'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -59,11 +60,23 @@ export function TreeLibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8 flex items-start justify-between">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
<div>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
||||||
Select a troubleshooting tree to start a new session
|
<p className="mt-2 text-muted-foreground">
|
||||||
</p>
|
Select a troubleshooting tree to start a new session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/trees/new"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||||
|
'hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Tree
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
@@ -148,15 +161,28 @@ export function TreeLibraryPage() {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
v{tree.version} · {tree.usage_count} uses
|
v{tree.version} · {tree.usage_count} uses
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => handleStartSession(tree.id)}
|
<Link
|
||||||
className={cn(
|
to={`/trees/${tree.id}/edit`}
|
||||||
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
className={cn(
|
||||||
'hover:bg-primary/90'
|
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||||
)}
|
'hover:bg-accent hover:text-accent-foreground'
|
||||||
>
|
)}
|
||||||
Start Session
|
title="Edit tree"
|
||||||
</button>
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStartSession(tree.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||||
|
'hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Start Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export { default as LoginPage } from './LoginPage'
|
|||||||
export { default as RegisterPage } from './RegisterPage'
|
export { default as RegisterPage } from './RegisterPage'
|
||||||
export { default as TreeLibraryPage } from './TreeLibraryPage'
|
export { default as TreeLibraryPage } from './TreeLibraryPage'
|
||||||
export { default as TreeNavigationPage } from './TreeNavigationPage'
|
export { default as TreeNavigationPage } from './TreeNavigationPage'
|
||||||
|
export { default as TreeEditorPage } from './TreeEditorPage'
|
||||||
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
||||||
export { default as SessionDetailPage } from './SessionDetailPage'
|
export { default as SessionDetailPage } from './SessionDetailPage'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RegisterPage,
|
RegisterPage,
|
||||||
TreeLibraryPage,
|
TreeLibraryPage,
|
||||||
TreeNavigationPage,
|
TreeNavigationPage,
|
||||||
|
TreeEditorPage,
|
||||||
SessionHistoryPage,
|
SessionHistoryPage,
|
||||||
SessionDetailPage,
|
SessionDetailPage,
|
||||||
} from '@/pages'
|
} from '@/pages'
|
||||||
@@ -38,6 +39,14 @@ export const router = createBrowserRouter([
|
|||||||
path: 'trees',
|
path: 'trees',
|
||||||
element: <TreeLibraryPage />,
|
element: <TreeLibraryPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'trees/new',
|
||||||
|
element: <TreeEditorPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'trees/:id/edit',
|
||||||
|
element: <TreeEditorPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'trees/:id/navigate',
|
path: 'trees/:id/navigate',
|
||||||
element: <TreeNavigationPage />,
|
element: <TreeNavigationPage />,
|
||||||
|
|||||||
691
frontend/src/store/treeEditorStore.ts
Normal file
691
frontend/src/store/treeEditorStore.ts
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { temporal } from 'zundo'
|
||||||
|
import { immer } from 'zustand/middleware/immer'
|
||||||
|
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType } from '@/types'
|
||||||
|
|
||||||
|
// Validation error interface
|
||||||
|
export interface ValidationError {
|
||||||
|
nodeId?: string
|
||||||
|
field?: string
|
||||||
|
message: string
|
||||||
|
severity: 'error' | 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft storage key
|
||||||
|
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
|
||||||
|
|
||||||
|
// Helper to generate unique IDs
|
||||||
|
const generateId = () => crypto.randomUUID()
|
||||||
|
|
||||||
|
// Helper to find a node in the tree structure
|
||||||
|
const findNodeInTree = (
|
||||||
|
nodeId: string,
|
||||||
|
structure: TreeStructure | null
|
||||||
|
): TreeStructure | null => {
|
||||||
|
if (!structure) return null
|
||||||
|
if (structure.id === nodeId) return structure
|
||||||
|
if (structure.children) {
|
||||||
|
for (const child of structure.children) {
|
||||||
|
const found = findNodeInTree(nodeId, child)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to find parent of a node
|
||||||
|
const findParentNode = (
|
||||||
|
nodeId: string,
|
||||||
|
structure: TreeStructure | null,
|
||||||
|
parent: TreeStructure | null = null
|
||||||
|
): TreeStructure | null => {
|
||||||
|
if (!structure) return null
|
||||||
|
if (structure.id === nodeId) return parent
|
||||||
|
if (structure.children) {
|
||||||
|
for (const child of structure.children) {
|
||||||
|
const found = findParentNode(nodeId, child, structure)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get all node IDs
|
||||||
|
const getAllNodeIds = (structure: TreeStructure | null): string[] => {
|
||||||
|
if (!structure) return []
|
||||||
|
const ids = [structure.id]
|
||||||
|
if (structure.children) {
|
||||||
|
for (const child of structure.children) {
|
||||||
|
ids.push(...getAllNodeIds(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to deep clone a node
|
||||||
|
const deepCloneNode = (node: TreeStructure): TreeStructure => {
|
||||||
|
const clone: TreeStructure = { ...node, id: generateId() }
|
||||||
|
|
||||||
|
// Update title/question to indicate it's a copy
|
||||||
|
if (clone.question) {
|
||||||
|
clone.question = `${clone.question} (Copy)`
|
||||||
|
} else if (clone.title) {
|
||||||
|
clone.title = `${clone.title} (Copy)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone options with new IDs
|
||||||
|
if (clone.options) {
|
||||||
|
clone.options = clone.options.map(opt => ({
|
||||||
|
...opt,
|
||||||
|
id: generateId(),
|
||||||
|
next_node_id: '' // Clear references - user must reassign
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear next_node_id - user must reassign
|
||||||
|
if (clone.next_node_id) {
|
||||||
|
clone.next_node_id = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone children recursively
|
||||||
|
if (clone.children) {
|
||||||
|
clone.children = clone.children.map(child => deepCloneNode(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeEditorState {
|
||||||
|
// Tree data
|
||||||
|
treeId: string | null // null for new tree
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
treeStructure: TreeStructure | null
|
||||||
|
originalTree: Tree | null // For comparison in edit mode
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
selectedNodeId: string | null
|
||||||
|
isDirty: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
validationErrors: ValidationError[]
|
||||||
|
|
||||||
|
// Auto-save state
|
||||||
|
lastSavedAt: Date | null
|
||||||
|
draftSavedAt: Date | null
|
||||||
|
hasDraft: boolean
|
||||||
|
|
||||||
|
// Actions - Initialization
|
||||||
|
initNewTree: () => void
|
||||||
|
loadTree: (tree: Tree) => void
|
||||||
|
loadDraft: () => boolean
|
||||||
|
discardDraft: () => void
|
||||||
|
reset: () => void
|
||||||
|
|
||||||
|
// Actions - Metadata
|
||||||
|
setName: (name: string) => void
|
||||||
|
setDescription: (description: string) => void
|
||||||
|
setCategory: (category: string) => void
|
||||||
|
|
||||||
|
// Actions - Node CRUD
|
||||||
|
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
|
||||||
|
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => void
|
||||||
|
deleteNode: (nodeId: string) => void
|
||||||
|
duplicateNode: (nodeId: string) => string | null
|
||||||
|
|
||||||
|
// Actions - Node ordering
|
||||||
|
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
|
||||||
|
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
|
||||||
|
|
||||||
|
// Actions - Selection
|
||||||
|
selectNode: (nodeId: string | null) => void
|
||||||
|
|
||||||
|
// Actions - Validation
|
||||||
|
validate: () => ValidationError[]
|
||||||
|
clearValidation: () => void
|
||||||
|
|
||||||
|
// Actions - Save/Draft
|
||||||
|
autoSaveDraft: () => void
|
||||||
|
markSaved: () => void
|
||||||
|
getTreeForSave: () => TreeCreate | TreeUpdate
|
||||||
|
|
||||||
|
// Actions - State
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
setSaving: (saving: boolean) => void
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
findNode: (nodeId: string) => TreeStructure | null
|
||||||
|
getAllNodeIds: () => string[]
|
||||||
|
getAvailableTargetNodes: (excludeNodeId?: string) => Array<{ id: string; label: string; type: NodeType }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create store with immer and temporal (undo/redo) middleware
|
||||||
|
export const useTreeEditorStore = create<TreeEditorState>()(
|
||||||
|
temporal(
|
||||||
|
immer((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
treeId: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
treeStructure: null,
|
||||||
|
originalTree: null,
|
||||||
|
selectedNodeId: null,
|
||||||
|
isDirty: false,
|
||||||
|
isLoading: false,
|
||||||
|
isSaving: false,
|
||||||
|
validationErrors: [],
|
||||||
|
lastSavedAt: null,
|
||||||
|
draftSavedAt: null,
|
||||||
|
hasDraft: false,
|
||||||
|
|
||||||
|
// Check for existing draft on init
|
||||||
|
initNewTree: () => {
|
||||||
|
const hasDraft = localStorage.getItem(DRAFT_STORAGE_KEY) !== null
|
||||||
|
set((state) => {
|
||||||
|
state.treeId = null
|
||||||
|
state.name = ''
|
||||||
|
state.description = ''
|
||||||
|
state.category = ''
|
||||||
|
state.treeStructure = {
|
||||||
|
id: 'root',
|
||||||
|
type: 'decision',
|
||||||
|
question: '',
|
||||||
|
options: [],
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
state.originalTree = null
|
||||||
|
state.selectedNodeId = 'root'
|
||||||
|
state.isDirty = false
|
||||||
|
state.isLoading = false
|
||||||
|
state.isSaving = false
|
||||||
|
state.validationErrors = []
|
||||||
|
state.lastSavedAt = null
|
||||||
|
state.draftSavedAt = null
|
||||||
|
state.hasDraft = hasDraft
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadTree: (tree: Tree) => {
|
||||||
|
set((state) => {
|
||||||
|
state.treeId = tree.id
|
||||||
|
state.name = tree.name
|
||||||
|
state.description = tree.description || ''
|
||||||
|
state.category = tree.category || ''
|
||||||
|
state.treeStructure = tree.tree_structure
|
||||||
|
state.originalTree = tree
|
||||||
|
state.selectedNodeId = tree.tree_structure?.id || null
|
||||||
|
state.isDirty = false
|
||||||
|
state.isLoading = false
|
||||||
|
state.validationErrors = []
|
||||||
|
state.lastSavedAt = new Date()
|
||||||
|
state.draftSavedAt = null
|
||||||
|
state.hasDraft = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadDraft: () => {
|
||||||
|
const draftJson = localStorage.getItem(DRAFT_STORAGE_KEY)
|
||||||
|
if (!draftJson) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const draft = JSON.parse(draftJson)
|
||||||
|
set((state) => {
|
||||||
|
state.treeId = draft.treeId || null
|
||||||
|
state.name = draft.name || ''
|
||||||
|
state.description = draft.description || ''
|
||||||
|
state.category = draft.category || ''
|
||||||
|
state.treeStructure = draft.treeStructure || null
|
||||||
|
state.isDirty = true
|
||||||
|
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
|
||||||
|
state.hasDraft = false
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
discardDraft: () => {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
|
set((state) => {
|
||||||
|
state.hasDraft = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.treeId = null
|
||||||
|
state.name = ''
|
||||||
|
state.description = ''
|
||||||
|
state.category = ''
|
||||||
|
state.treeStructure = null
|
||||||
|
state.originalTree = null
|
||||||
|
state.selectedNodeId = null
|
||||||
|
state.isDirty = false
|
||||||
|
state.isLoading = false
|
||||||
|
state.isSaving = false
|
||||||
|
state.validationErrors = []
|
||||||
|
state.lastSavedAt = null
|
||||||
|
state.draftSavedAt = null
|
||||||
|
state.hasDraft = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Metadata actions
|
||||||
|
setName: (name: string) => {
|
||||||
|
set((state) => {
|
||||||
|
state.name = name
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
setDescription: (description: string) => {
|
||||||
|
set((state) => {
|
||||||
|
state.description = description
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
setCategory: (category: string) => {
|
||||||
|
set((state) => {
|
||||||
|
state.category = category
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
// Node CRUD
|
||||||
|
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
|
||||||
|
const newId = generateId()
|
||||||
|
|
||||||
|
const newNode: TreeStructure = {
|
||||||
|
id: newId,
|
||||||
|
type,
|
||||||
|
...(type === 'decision' && {
|
||||||
|
question: '',
|
||||||
|
options: [],
|
||||||
|
children: []
|
||||||
|
}),
|
||||||
|
...(type === 'action' && {
|
||||||
|
title: '',
|
||||||
|
description: ''
|
||||||
|
}),
|
||||||
|
...(type === 'solution' && {
|
||||||
|
title: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
if (!parentId) {
|
||||||
|
// Adding as root
|
||||||
|
state.treeStructure = newNode
|
||||||
|
} else {
|
||||||
|
// Find parent and add to children
|
||||||
|
const parent = findNodeInTree(parentId, state.treeStructure)
|
||||||
|
if (parent) {
|
||||||
|
if (!parent.children) {
|
||||||
|
parent.children = []
|
||||||
|
}
|
||||||
|
if (insertIndex !== undefined && insertIndex >= 0) {
|
||||||
|
parent.children.splice(insertIndex, 0, newNode)
|
||||||
|
} else {
|
||||||
|
parent.children.push(newNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.selectedNodeId = newId
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
|
||||||
|
get().autoSaveDraft()
|
||||||
|
return newId
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => {
|
||||||
|
set((state) => {
|
||||||
|
const node = findNodeInTree(nodeId, state.treeStructure)
|
||||||
|
if (node) {
|
||||||
|
Object.assign(node, updates)
|
||||||
|
state.isDirty = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNode: (nodeId: string) => {
|
||||||
|
if (nodeId === 'root') {
|
||||||
|
// Don't allow deleting root, just clear it
|
||||||
|
set((state) => {
|
||||||
|
if (state.treeStructure) {
|
||||||
|
state.treeStructure.question = ''
|
||||||
|
state.treeStructure.options = []
|
||||||
|
state.treeStructure.children = []
|
||||||
|
}
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const parent = findParentNode(nodeId, state.treeStructure)
|
||||||
|
if (parent && parent.children) {
|
||||||
|
const index = parent.children.findIndex(c => c.id === nodeId)
|
||||||
|
if (index !== -1) {
|
||||||
|
parent.children.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection if deleted node was selected
|
||||||
|
if (state.selectedNodeId === nodeId) {
|
||||||
|
state.selectedNodeId = parent?.id || 'root'
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
duplicateNode: (nodeId: string) => {
|
||||||
|
const state = get()
|
||||||
|
const node = findNodeInTree(nodeId, state.treeStructure)
|
||||||
|
if (!node) return null
|
||||||
|
|
||||||
|
const clonedNode = deepCloneNode(node)
|
||||||
|
|
||||||
|
// Find parent and add cloned node as sibling
|
||||||
|
const parent = findParentNode(nodeId, state.treeStructure)
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
if (parent && parent.children) {
|
||||||
|
const index = parent.children.findIndex(c => c.id === nodeId)
|
||||||
|
parent.children.splice(index + 1, 0, clonedNode)
|
||||||
|
} else if (nodeId === 'root') {
|
||||||
|
// Can't duplicate root - just select it
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.selectedNodeId = clonedNode.id
|
||||||
|
s.isDirty = true
|
||||||
|
})
|
||||||
|
|
||||||
|
get().autoSaveDraft()
|
||||||
|
return clonedNode.id
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reordering
|
||||||
|
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => {
|
||||||
|
set((state) => {
|
||||||
|
const parent = findNodeInTree(parentId, state.treeStructure)
|
||||||
|
if (parent && parent.children && parent.children.length > 0) {
|
||||||
|
const [moved] = parent.children.splice(fromIndex, 1)
|
||||||
|
parent.children.splice(toIndex, 0, moved)
|
||||||
|
state.isDirty = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => {
|
||||||
|
set((state) => {
|
||||||
|
const node = findNodeInTree(nodeId, state.treeStructure)
|
||||||
|
if (node && node.options && node.options.length > 0) {
|
||||||
|
const [moved] = node.options.splice(fromIndex, 1)
|
||||||
|
node.options.splice(toIndex, 0, moved)
|
||||||
|
state.isDirty = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
get().autoSaveDraft()
|
||||||
|
},
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
selectNode: (nodeId: string | null) => {
|
||||||
|
set((state) => {
|
||||||
|
state.selectedNodeId = nodeId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validate: () => {
|
||||||
|
const state = get()
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
|
||||||
|
// Check tree name
|
||||||
|
if (!state.name.trim()) {
|
||||||
|
errors.push({ message: 'Tree name is required', severity: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tree structure exists
|
||||||
|
if (!state.treeStructure) {
|
||||||
|
errors.push({ message: 'Tree must have at least one node', severity: 'error' })
|
||||||
|
set((s) => { s.validationErrors = errors })
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNodeIds = getAllNodeIds(state.treeStructure)
|
||||||
|
const referencedIds = new Set<string>()
|
||||||
|
let hasSolution = false
|
||||||
|
|
||||||
|
// Traverse and validate all nodes
|
||||||
|
const validateNode = (node: TreeStructure) => {
|
||||||
|
// Check type-specific required fields
|
||||||
|
if (node.type === 'decision') {
|
||||||
|
if (!node.question?.trim()) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: 'question',
|
||||||
|
message: `Decision node "${node.id}" requires a question`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!node.options || node.options.length === 0) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: 'options',
|
||||||
|
message: `Decision node "${node.id}" requires at least one option`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Validate options
|
||||||
|
node.options.forEach((opt, i) => {
|
||||||
|
if (!opt.label?.trim()) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: `options[${i}].label`,
|
||||||
|
message: `Option ${i + 1} in "${node.id}" requires a label`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (opt.next_node_id) {
|
||||||
|
referencedIds.add(opt.next_node_id)
|
||||||
|
if (!allNodeIds.includes(opt.next_node_id)) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: `options[${i}].next_node_id`,
|
||||||
|
message: `Option "${opt.label}" references non-existent node "${opt.next_node_id}"`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'action') {
|
||||||
|
if (!node.title?.trim()) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: 'title',
|
||||||
|
message: `Action node "${node.id}" requires a title`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (node.next_node_id) {
|
||||||
|
referencedIds.add(node.next_node_id)
|
||||||
|
if (!allNodeIds.includes(node.next_node_id)) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: 'next_node_id',
|
||||||
|
message: `Action "${node.title}" references non-existent node "${node.next_node_id}"`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'solution') {
|
||||||
|
hasSolution = true
|
||||||
|
if (!node.title?.trim()) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
field: 'title',
|
||||||
|
message: `Solution node "${node.id}" requires a title`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate children
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach(child => validateNode(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateNode(state.treeStructure)
|
||||||
|
|
||||||
|
// Check for at least one solution
|
||||||
|
if (!hasSolution) {
|
||||||
|
errors.push({
|
||||||
|
message: 'Tree must have at least one solution (terminal) node',
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for orphaned nodes (not root and not referenced)
|
||||||
|
allNodeIds.forEach(id => {
|
||||||
|
if (id !== 'root' && !referencedIds.has(id)) {
|
||||||
|
// Check if it's a direct child of another node (via children array)
|
||||||
|
let isChildOfAny = false
|
||||||
|
const checkIfChild = (node: TreeStructure) => {
|
||||||
|
if (node.children?.some(c => c.id === id)) {
|
||||||
|
isChildOfAny = true
|
||||||
|
}
|
||||||
|
node.children?.forEach(checkIfChild)
|
||||||
|
}
|
||||||
|
checkIfChild(state.treeStructure!)
|
||||||
|
|
||||||
|
if (!isChildOfAny) {
|
||||||
|
errors.push({
|
||||||
|
nodeId: id,
|
||||||
|
message: `Node "${id}" is orphaned (not reachable from root)`,
|
||||||
|
severity: 'warning'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
set((s) => { s.validationErrors = errors })
|
||||||
|
return errors
|
||||||
|
},
|
||||||
|
|
||||||
|
clearValidation: () => {
|
||||||
|
set((state) => { state.validationErrors = [] })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auto-save draft (debounced externally, called after each change)
|
||||||
|
autoSaveDraft: () => {
|
||||||
|
const state = get()
|
||||||
|
const draft = {
|
||||||
|
treeId: state.treeId,
|
||||||
|
name: state.name,
|
||||||
|
description: state.description,
|
||||||
|
category: state.category,
|
||||||
|
treeStructure: state.treeStructure,
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
|
||||||
|
set((s) => { s.draftSavedAt = new Date() })
|
||||||
|
},
|
||||||
|
|
||||||
|
markSaved: () => {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
|
set((state) => {
|
||||||
|
state.isDirty = false
|
||||||
|
state.lastSavedAt = new Date()
|
||||||
|
state.draftSavedAt = null
|
||||||
|
state.hasDraft = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getTreeForSave: (): TreeCreate | TreeUpdate => {
|
||||||
|
const state = get()
|
||||||
|
return {
|
||||||
|
name: state.name,
|
||||||
|
description: state.description || undefined,
|
||||||
|
category: state.category || undefined,
|
||||||
|
tree_structure: state.treeStructure!
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (loading: boolean) => {
|
||||||
|
set((state) => { state.isLoading = loading })
|
||||||
|
},
|
||||||
|
|
||||||
|
setSaving: (saving: boolean) => {
|
||||||
|
set((state) => { state.isSaving = saving })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
findNode: (nodeId: string) => {
|
||||||
|
return findNodeInTree(nodeId, get().treeStructure)
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllNodeIds: () => {
|
||||||
|
return getAllNodeIds(get().treeStructure)
|
||||||
|
},
|
||||||
|
|
||||||
|
getAvailableTargetNodes: (excludeNodeId?: string) => {
|
||||||
|
const state = get()
|
||||||
|
const allIds = getAllNodeIds(state.treeStructure)
|
||||||
|
|
||||||
|
return allIds
|
||||||
|
.filter(id => id !== excludeNodeId)
|
||||||
|
.map(id => {
|
||||||
|
const node = findNodeInTree(id, state.treeStructure)
|
||||||
|
let label = id
|
||||||
|
let type: NodeType = 'decision'
|
||||||
|
if (node) {
|
||||||
|
type = node.type
|
||||||
|
if (node.question) label = node.question.slice(0, 50)
|
||||||
|
else if (node.title) label = node.title.slice(0, 50)
|
||||||
|
}
|
||||||
|
// Use short ID format, but 'root' stays as-is
|
||||||
|
const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...'
|
||||||
|
return { id, label: `${label} (${shortId})`, type }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
// Zundo options for undo/redo
|
||||||
|
limit: 50, // Keep last 50 states
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Only track these fields in history
|
||||||
|
name: state.name,
|
||||||
|
description: state.description,
|
||||||
|
category: state.category,
|
||||||
|
treeStructure: state.treeStructure
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Export temporal store for undo/redo access
|
||||||
|
// Use with: useStore(useTreeEditorStore.temporal, selector)
|
||||||
|
export const useTreeEditorTemporal = useTreeEditorStore.temporal
|
||||||
|
|
||||||
|
export default useTreeEditorStore
|
||||||
Reference in New Issue
Block a user