feat: remove workspace system, add pinned flows and label renames
Replace workspace system with pinned flows API (pin/unpin/list/reorder). Rename user-facing labels: Tree→Flow, Procedure→Project. Add sidebar nav sub-items for flow type filtering. Remove 11 workspace files, add migrations 037-038, clean all workspace references. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,10 +24,10 @@
|
||||
- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with gradient). Wordmark: "Resolution" in white + "Flow" in `text-gradient-brand`
|
||||
- **Brand assets:** `brand-assets/` (source SVGs + brand-guide.html), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
||||
- **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `tailwind.config.js` and `index.css`)
|
||||
- **Layout:** App shell with persistent sidebar + top bar + main workspace (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Workspace system:** Top-level context switcher (Troubleshooting, Procedures, Policies, Finance). Sidebar categories, tags, stats, and content adapt per workspace. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Navigation:** Sidebar nav with type sub-items (All Flows → Troubleshooting / Projects). Pinned flows section for quick access. NO workspace switcher. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. `tree_type` column values unchanged in DB.
|
||||
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
|
||||
- **Interactive mockup:** `docs/mockups/resolutionflow-workspaces-mockup.html` (open in browser for visual reference)
|
||||
|
||||
**Component styling rules:**
|
||||
- Primary buttons: `bg-gradient-brand` with `shadow-lg shadow-primary/20`, hover lifts with stronger shadow
|
||||
@@ -40,7 +40,7 @@
|
||||
- Cards: `bg-card border-border rounded-xl`, hover brightens border
|
||||
- Section labels: `font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground`
|
||||
|
||||
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, workspace context, and component specs.
|
||||
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, navigation, and component specs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
608
WORKSPACE-REMOVAL-PLAN.md
Normal file
608
WORKSPACE-REMOVAL-PLAN.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# Workspace Removal & Navigation Refactor — Implementation Plan
|
||||
|
||||
> **Purpose:** Combined implementation plan for removing the workspace system, renaming UI labels (Trees→Flows, Procedures→Projects), adding pinned flows, and restructuring sidebar navigation.
|
||||
> **Source of Truth:** [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) v2
|
||||
> **Date:** February 15, 2026
|
||||
> **Tailwind Version:** v3 only — do not use v4 syntax or patterns (see `package.json` line 56)
|
||||
|
||||
---
|
||||
|
||||
## Why This Change
|
||||
|
||||
Workspaces added unnecessary cognitive overhead for the target MSP audience. UX research (Hick's Law, context-switching studies) shows that at the current product scale (10-15 beta testers, <50 flows per account), a workspace switcher creates friction without organizational benefit. The replacement is a flat navigation model with type sub-items and pinned favorites.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend: Add Pinned Flows (ship BEFORE removing workspaces)
|
||||
|
||||
> **Important sequencing:** Add the pinned flows feature first and verify it works. Then remove workspaces in Phase 2. This ensures the replacement feature is stable before tearing out the old one. If both are done in the same session, at minimum run the pinned flows tests before starting workspace removal.
|
||||
|
||||
### 1a. Add Pinned Flows Table
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "add_user_pinned_trees"
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_pinned_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_user_pinned_tree UNIQUE (user_id, tree_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_pinned_trees_user ON user_pinned_trees(user_id);
|
||||
CREATE INDEX idx_user_pinned_trees_tree ON user_pinned_trees(tree_id);
|
||||
```
|
||||
|
||||
### 1b. Pinned Flows API Contract
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| `GET` | `/api/v1/trees/pinned` | List user's pinned flows (ordered by display_order) | Required |
|
||||
| `POST` | `/api/v1/trees/{id}/pin` | Pin a flow to sidebar | Required |
|
||||
| `DELETE` | `/api/v1/trees/{id}/pin` | Unpin a flow from sidebar | Required |
|
||||
| `PATCH` | `/api/v1/trees/pinned/reorder` | Update display_order for all pinned flows | Required |
|
||||
|
||||
**Constraints & Limits:**
|
||||
- Max 15 pinned flows per user (return 409 if exceeded)
|
||||
- `UNIQUE(user_id, tree_id)` — pinning an already-pinned flow returns 200 (idempotent), not an error
|
||||
- Unpinning an already-unpinned flow returns 200 (idempotent)
|
||||
- When a tree is deleted, cascade removes the pin automatically (FK ON DELETE CASCADE)
|
||||
- Pins are per-user, not per-account — each user has their own pinned set
|
||||
|
||||
**Response Shapes:**
|
||||
|
||||
```typescript
|
||||
// GET /api/v1/trees/pinned
|
||||
interface PinnedFlowsResponse {
|
||||
items: PinnedFlow[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PinnedFlow {
|
||||
id: string; // pin record id
|
||||
tree_id: string;
|
||||
tree_name: string;
|
||||
tree_type: 'troubleshooting' | 'procedural';
|
||||
category_emoji?: string;
|
||||
category_name?: string;
|
||||
pinned_at: string; // ISO datetime
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
// POST /api/v1/trees/{id}/pin → returns PinnedFlow
|
||||
// DELETE /api/v1/trees/{id}/pin → returns { success: true }
|
||||
// PATCH /api/v1/trees/pinned/reorder
|
||||
// body: { order: [{ tree_id: string, display_order: number }] }
|
||||
// returns: PinnedFlowsResponse
|
||||
```
|
||||
|
||||
**Error Codes:**
|
||||
- `404` — tree not found or user lacks access
|
||||
- `409` — max pins reached (15)
|
||||
- `200` — idempotent success (pin already exists / already unpinned)
|
||||
|
||||
### 1c. Add Pinned Flows Backend Files
|
||||
|
||||
```
|
||||
CREATE: backend/app/models/user_pinned_tree.py
|
||||
MODIFY: backend/app/models/__init__.py — add UserPinnedTree import
|
||||
MODIFY: backend/app/api/endpoints/trees.py — add pin/unpin/list-pinned/reorder endpoints
|
||||
MODIFY: backend/app/schemas/tree.py — add PinnedFlow schema, add is_pinned to TreeListItem
|
||||
MODIFY: backend/app/api/router.py — register pin routes (nested under trees router)
|
||||
```
|
||||
|
||||
**Verify pinned flows work before proceeding:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
pytest tests/ -k "pin" -v # Run pin-related tests
|
||||
# Manual: POST a pin, GET pinned list, verify response shapes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Backend: Remove Workspace System
|
||||
|
||||
### 2a. New Forward Migration (DO NOT use downgrade path)
|
||||
|
||||
> **⚠️ Critical:** The existing `036_add_workspaces.py` downgrade function drops `tree_categories.color`, which we want to keep. Create a new forward migration instead.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "remove_workspace_system"
|
||||
```
|
||||
|
||||
The migration must:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# 1. Drop workspace_id FK from trees (if column exists)
|
||||
op.drop_constraint('trees_workspace_id_fkey', 'trees', type_='foreignkey')
|
||||
op.drop_column('trees', 'workspace_id')
|
||||
|
||||
# 2. Drop workspace_id FK from tree_categories (if column exists)
|
||||
op.drop_constraint('tree_categories_workspace_id_fkey', 'tree_categories', type_='foreignkey')
|
||||
op.drop_column('tree_categories', 'workspace_id')
|
||||
|
||||
# 3. Drop workspaces table
|
||||
op.drop_table('workspaces')
|
||||
|
||||
# DO NOT drop tree_categories.color — we still use it
|
||||
|
||||
def downgrade():
|
||||
# Recreate workspaces table and FKs if needed (reverse of above)
|
||||
pass
|
||||
```
|
||||
|
||||
### 2b. Delete Workspace Backend Files
|
||||
|
||||
```
|
||||
DELETE: backend/app/api/endpoints/workspaces.py
|
||||
DELETE: backend/app/models/workspace.py
|
||||
DELETE: backend/app/schemas/workspace.py (if exists)
|
||||
```
|
||||
|
||||
### 2c. Clean Up Orphaned Workspace References
|
||||
|
||||
> **⚠️ Important:** Workspace references exist beyond the obvious files. Scrub these:
|
||||
|
||||
| File | What to Remove |
|
||||
|------|---------------|
|
||||
| `backend/app/api/router.py` | Remove workspace route registration |
|
||||
| `backend/app/models/__init__.py` (line 23, 55) | Remove `Workspace` import and `__all__` entry |
|
||||
| `backend/app/models/account.py` (line 18, 49) | Remove `workspaces` relationship on Account model |
|
||||
| `backend/app/models/category.py` (line 40) | Remove `workspace_id` column if present |
|
||||
| `backend/app/models/tree.py` | Remove `workspace_id` column and relationship if present |
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
grep -r "workspace" app/ --include="*.py" -l
|
||||
# Should only return this plan file and alembic migration history — no active code
|
||||
pytest --override-ini="addopts="
|
||||
# All tests must pass
|
||||
```
|
||||
|
||||
**Migration smoke test (run against BOTH clean and existing DB):**
|
||||
|
||||
```bash
|
||||
# Test on existing DB with workspace data:
|
||||
alembic upgrade head
|
||||
# Verify no errors, workspace tables gone, tree_categories.color still exists
|
||||
|
||||
# Test on clean DB (full migration chain):
|
||||
dropdb patherly_test && createdb patherly_test
|
||||
DATABASE_URL=postgresql://...patherly_test alembic upgrade head
|
||||
# Verify clean run through all migrations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend: Remove Workspace System
|
||||
|
||||
### 3a. Move sidebarCollapsed State First
|
||||
|
||||
The `sidebarCollapsed` state currently lives in `workspaceStore.ts`. Move it before deleting:
|
||||
|
||||
```typescript
|
||||
// frontend/src/store/userPreferencesStore.ts — ADD these:
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Implementation:
|
||||
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
|
||||
toggleSidebar: () => {
|
||||
const next = !get().sidebarCollapsed;
|
||||
localStorage.setItem('sidebar-collapsed', String(next));
|
||||
set({ sidebarCollapsed: next });
|
||||
},
|
||||
```
|
||||
|
||||
> Note: `userPreferencesStore` already uses Zustand persist — verify the localStorage key name doesn't conflict.
|
||||
|
||||
### 3b. Delete Workspace Frontend Files
|
||||
|
||||
```
|
||||
DELETE: frontend/src/store/workspaceStore.ts
|
||||
DELETE: frontend/src/components/workspace/WorkspaceSwitcher.tsx
|
||||
DELETE: frontend/src/components/workspace/WorkspaceCreateModal.tsx
|
||||
DELETE: frontend/src/constants/workspaceLabels.ts
|
||||
DELETE: frontend/src/types/workspace.ts (or remove Workspace type if shared file)
|
||||
DELETE: frontend/src/api/workspaces.ts
|
||||
DELETE: docs/mockups/resolutionflow-workspaces-mockup.html
|
||||
```
|
||||
|
||||
**Before deleting `workspace/` directory**, move these components:
|
||||
|
||||
```
|
||||
MOVE: frontend/src/components/workspace/CategoryList.tsx → frontend/src/components/sidebar/CategoryList.tsx
|
||||
MOVE: frontend/src/components/workspace/TagCloud.tsx → frontend/src/components/sidebar/TagCloud.tsx
|
||||
```
|
||||
|
||||
### 3c. Update Shell Files That Import Workspace Store
|
||||
|
||||
> **⚠️ Important:** These files are marked "keep" in the design system doc but they currently import workspace code. Each needs internal refactoring:
|
||||
|
||||
| File | Lines to Change | Action |
|
||||
|------|----------------|--------|
|
||||
| `AppLayout.tsx` (line 6, 17) | `import { useWorkspaceStore }` | Replace with `import { useUserPreferencesStore }` — use `sidebarCollapsed` and `toggleSidebar` from there |
|
||||
| `TopBar.tsx` (line 6, 20) | `import { useWorkspaceStore }` | Replace with `useUserPreferencesStore` for `sidebarCollapsed`. Remove `getActiveWorkspace()` and `labels` — use static labels or `getFlowLabels()` |
|
||||
| `Sidebar.tsx` (line 4, 111+) | `WorkspaceSwitcher` import and render | Remove workspace switcher component. Add pinned flows section and nav sub-items (Phase 4) |
|
||||
|
||||
### 3d. Clean Up Orphaned Frontend References
|
||||
|
||||
> **⚠️ Important:** Additional workspace references exist beyond the obvious files:
|
||||
|
||||
| File | What to Change |
|
||||
|------|---------------|
|
||||
| `frontend/src/types/index.ts` (line 11, 14) | Remove `Workspace` type export |
|
||||
| `frontend/src/components/layout/QuickLaunch.tsx` | Replace workspace-dependent labels with static "New Flow" / "New Project" |
|
||||
| `frontend/src/components/layout/CommandPalette.tsx` | Replace workspace-dependent search placeholder and result labels |
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
grep -r "workspace" src/ --include="*.ts" --include="*.tsx" -l
|
||||
# Should return nothing except possibly test files or comments
|
||||
grep -r "workspaceStore\|WorkspaceSwitcher\|workspacesApi\|workspaceLabels" src/ -l
|
||||
# Must return nothing
|
||||
npm run build
|
||||
# Must compile clean with zero errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Label Renames (Repo-Wide Audit)
|
||||
|
||||
### 4a. Create Flow Type Labels
|
||||
|
||||
```typescript
|
||||
// frontend/src/constants/flowLabels.ts (NEW — replaces workspaceLabels.ts)
|
||||
|
||||
export interface FlowTypeLabels {
|
||||
navLabel: string;
|
||||
singular: string;
|
||||
plural: string;
|
||||
newButton: string;
|
||||
searchPlaceholder: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const FLOW_TYPE_LABELS: Record<string, FlowTypeLabels> = {
|
||||
all: {
|
||||
navLabel: 'All Flows',
|
||||
singular: 'Flow',
|
||||
plural: 'Flows',
|
||||
newButton: '+ Create Flow',
|
||||
searchPlaceholder: 'Search flows, sessions, tags…',
|
||||
icon: '📦',
|
||||
},
|
||||
troubleshooting: {
|
||||
navLabel: 'Troubleshooting',
|
||||
singular: 'Flow',
|
||||
plural: 'Flows',
|
||||
newButton: '+ New Troubleshooting Flow',
|
||||
searchPlaceholder: 'Search troubleshooting flows…',
|
||||
icon: '🔧',
|
||||
},
|
||||
procedural: {
|
||||
navLabel: 'Projects',
|
||||
singular: 'Project',
|
||||
plural: 'Projects',
|
||||
newButton: '+ New Project',
|
||||
searchPlaceholder: 'Search projects, runbooks…',
|
||||
icon: '📋',
|
||||
},
|
||||
};
|
||||
|
||||
export function getFlowLabels(typeFilter?: string): FlowTypeLabels {
|
||||
if (typeFilter && typeFilter in FLOW_TYPE_LABELS) {
|
||||
return FLOW_TYPE_LABELS[typeFilter];
|
||||
}
|
||||
return FLOW_TYPE_LABELS.all;
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. Label Audit — Files Requiring Changes
|
||||
|
||||
Every instance of old terminology must be found and replaced. This is a **repo-wide pass**, not a targeted find-replace.
|
||||
|
||||
**User-facing label changes:**
|
||||
|
||||
| Old Label | New Label | Notes |
|
||||
|-----------|-----------|-------|
|
||||
| "All Trees" | "All Flows" | Sidebar nav, page titles |
|
||||
| "Tree Editor" | "Flow Editor" | Sidebar nav |
|
||||
| "New Tree" | "Create Flow" | Buttons, menus |
|
||||
| "All Procedures" | "Projects" | Sub-nav item |
|
||||
| "New Procedure" | "New Project" | Buttons, menus |
|
||||
| "Procedure" (singular) | "Project" | Throughout UI |
|
||||
|
||||
**Known files with old labels (non-exhaustive):**
|
||||
|
||||
| File | Old Text | New Text |
|
||||
|------|----------|----------|
|
||||
| `Sidebar.tsx` | "All Trees", "Tree Editor" | "All Flows", "Flow Editor" |
|
||||
| `TreeLibraryPage.tsx` (line 279, 298) | "All Trees", "Tree" references | "All Flows", "Flow" |
|
||||
| `QuickStartPage.tsx` (line 150) | Workspace/procedure labels | Flow/Project labels |
|
||||
| `QuickLaunch.tsx` | "New Tree", "New Procedure" | "Create Flow", "New Project" |
|
||||
| `CommandPalette.tsx` | Search labels | Flow-based labels |
|
||||
| `TopBar.tsx` | Search placeholder | Use `getFlowLabels()` or static "Search flows, sessions, tags…" |
|
||||
|
||||
**Catch-all verification:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
# Find any remaining user-facing "Tree" or "Procedure" labels (excluding variable names and imports)
|
||||
grep -rn '".*Tree.*"' src/ --include="*.tsx" --include="*.ts" | grep -v "import\|//\|interface\|type \|treesApi\|tree_type\|treeId\|TreeNav\|TreeGrid\|TreeList\|TreeTable"
|
||||
grep -rn '".*Procedure.*"' src/ --include="*.tsx" --include="*.ts" | grep -v "import\|//\|type "
|
||||
```
|
||||
|
||||
> **Important:** Only rename **user-facing strings** (UI text, placeholder text, toast messages, page titles). Do NOT rename: variable names (`treesApi`, `TreeListItem`), route paths (`/trees`), database columns (`tree_type`), or API endpoints (`/api/v1/trees`). Internal code can say "tree" — users never see it.
|
||||
|
||||
### 4c. Acceptance Criteria for Label Audit
|
||||
|
||||
- [ ] No user-visible text says "Tree" (except proper nouns or technical docs)
|
||||
- [ ] No user-visible text says "Procedure" — all say "Project"
|
||||
- [ ] Search bar placeholder says "Search flows, sessions, tags…"
|
||||
- [ ] Page title on library page says "Flow Library"
|
||||
- [ ] "+ Create Flow" button on library page (or "+ New Project" when filtered to projects)
|
||||
- [ ] Sidebar nav says "All Flows", "Flow Editor"
|
||||
- [ ] Empty states use "flow" / "project" language
|
||||
- [ ] Toast messages use "flow" / "project" language
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Sidebar: Nav Sub-Items & Pinned Flows UI
|
||||
|
||||
### 5a. Extend NavItem for Children
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/layout/NavItem.tsx — extend props
|
||||
interface NavItemProps {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
badge?: number | 'dot';
|
||||
isActive?: boolean;
|
||||
children?: NavSubItem[]; // NEW
|
||||
}
|
||||
|
||||
interface NavSubItem {
|
||||
href: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Sub-item rendering:**
|
||||
- Indented `pl-9` (past parent icon)
|
||||
- No icon, just text + optional count badge
|
||||
- Font: `text-[0.8125rem] text-muted-foreground`, active: `text-foreground`
|
||||
- Active: `bg-[var(--sidebar-active)]` but without the left gradient bar (only parent gets that)
|
||||
- Sub-items always visible (not collapsible) — there are only 2
|
||||
|
||||
### 5b. Sidebar Structure
|
||||
|
||||
```
|
||||
── PINNED ─────────────────── (collapsible section)
|
||||
📧 Email Delivery Issues (click → start session or go to flow)
|
||||
🔒 AD Account Lockout
|
||||
👤 New User Onboarding
|
||||
───────────────────────────────
|
||||
📊 Dashboard
|
||||
📦 All Flows 47
|
||||
🔧 Troubleshooting 29
|
||||
📋 Projects 18
|
||||
✏️ Flow Editor
|
||||
⏱️ Sessions 4
|
||||
📄 Exports
|
||||
📚 Step Library •
|
||||
───────────────────────────────
|
||||
CATEGORIES
|
||||
● Networking 12
|
||||
● Active Directory 8
|
||||
● Email 11
|
||||
───────────────────────────────
|
||||
POPULAR TAGS
|
||||
[vpn] [dns] [exchange] [onboarding]
|
||||
═══════════════════════════════
|
||||
👥 Team
|
||||
⚙️ Settings
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Clicking "All Flows" → `/trees` (no type filter)
|
||||
- Clicking "Troubleshooting" → `/trees?type=troubleshooting`
|
||||
- Clicking "Projects" → `/trees?type=procedural`
|
||||
- When a sub-item is active, parent "All Flows" stays highlighted (dimmer state)
|
||||
- Badge counts update based on actual tree counts by type
|
||||
|
||||
### 5c. Pinned Flows Section
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/sidebar/PinnedFlowsSection.tsx (NEW)
|
||||
interface PinnedFlowsSectionProps {
|
||||
flows: PinnedFlow[];
|
||||
onFlowClick: (treeId: string) => void;
|
||||
onUnpin: (treeId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Each pinned item: emoji + name (truncated with ellipsis) + hover reveals quick-start button
|
||||
- Right-click context menu: "Start Session", "Edit Flow", "Unpin from Sidebar"
|
||||
- Empty state: "⭐ Pin your most-used flows here" in `text-xs text-muted-foreground`
|
||||
- Section header has collapse chevron
|
||||
- Max 15 items shown; section scrolls internally if needed
|
||||
- Drag-to-reorder (calls `PATCH /api/v1/trees/pinned/reorder`)
|
||||
|
||||
### 5d. Pinned Flows Frontend Wiring
|
||||
|
||||
```
|
||||
CREATE: frontend/src/api/pinnedFlows.ts — pinTree(id), unpinTree(id), listPinned(), reorderPinned(order)
|
||||
CREATE: frontend/src/components/sidebar/PinnedFlowsSection.tsx
|
||||
MODIFY: frontend/src/types/tree.ts (or index.ts) — add is_pinned?: boolean to TreeListItem, add PinnedFlow type
|
||||
MODIFY: frontend/src/api/trees.ts — add is_pinned to list response handling
|
||||
MODIFY: frontend/src/components/layout/Sidebar.tsx — import and render PinnedFlowsSection above nav
|
||||
MODIFY: frontend/src/pages/TreeLibraryPage.tsx — add pin star to flow cards (visible on hover, filled if pinned)
|
||||
MODIFY: flow card three-dot menu — add "Pin to Sidebar" / "Unpin from Sidebar" action
|
||||
```
|
||||
|
||||
**Pin/unpin interaction:**
|
||||
- Toast on pin: "📌 Pinned **{name}** to sidebar"
|
||||
- Toast on unpin: "Unpinned **{name}**"
|
||||
- Library flow cards show subtle star icon on hover; filled star if pinned
|
||||
- Pin star click calls API, optimistically updates UI
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Library Page Cleanup
|
||||
|
||||
### 6a. Remove Folder Sidebar Panel
|
||||
|
||||
> **⚠️ Important:** `TreeLibraryPage.tsx` has deep folder state dependencies (lines 9, 34, 261+). This is not a simple component removal.
|
||||
|
||||
**What to remove:**
|
||||
- `FolderSidebar` component import and rendering (the persistent left panel)
|
||||
- `FolderEditModal` import and state
|
||||
- The CSS column that gives FolderSidebar its own grid track
|
||||
- `mobileFolderOpen` state
|
||||
|
||||
**What to KEEP:**
|
||||
- `selectedFolderId` state — keep for now, wire to a future "Filter by Folder" dropdown
|
||||
- `folders` state and `foldersApi.list()` call — keep data available
|
||||
- `FolderSidebar.tsx` and `FolderEditModal.tsx` files — do not delete, just stop rendering them
|
||||
- All folder-related backend code (models, API, database tables) — untouched
|
||||
|
||||
**Replacement UX (deferred but noted):**
|
||||
- Future: "Filter by Folder" dropdown in the filters bar, or "Move to Folder" in the three-dot menu
|
||||
- For now: folder filtering is simply not visible in the UI. The data model and API remain intact.
|
||||
|
||||
**Layout change:**
|
||||
- Library page becomes full-width within the main content area (no second sidebar column)
|
||||
- Grid goes from `sidebar | folders | content` → `sidebar | content`
|
||||
|
||||
### 6b. Verification
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
# Must compile clean
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
- [ ] Library page is full-width (no left folder panel)
|
||||
- [ ] No JavaScript errors in browser console related to folder state
|
||||
- [ ] Category filtering from sidebar clicks still works
|
||||
- [ ] Tag filtering from sidebar clicks still works
|
||||
|
||||
---
|
||||
|
||||
## Complete File Manifest
|
||||
|
||||
### Delete (11 files)
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `frontend/src/store/workspaceStore.ts` | Replaced by userPreferencesStore (sidebarCollapsed) |
|
||||
| `frontend/src/components/workspace/WorkspaceSwitcher.tsx` | Feature removed |
|
||||
| `frontend/src/components/workspace/WorkspaceCreateModal.tsx` | Feature removed |
|
||||
| `frontend/src/constants/workspaceLabels.ts` | Replaced by flowLabels.ts |
|
||||
| `frontend/src/types/workspace.ts` | Type no longer needed |
|
||||
| `frontend/src/api/workspaces.ts` | API removed |
|
||||
| `backend/app/api/endpoints/workspaces.py` | API removed |
|
||||
| `backend/app/models/workspace.py` | Model removed |
|
||||
| `backend/app/schemas/workspace.py` | Schema removed (if exists) |
|
||||
| `docs/mockups/resolutionflow-workspaces-mockup.html` | Outdated mockup |
|
||||
|
||||
### Move (2 files)
|
||||
|
||||
| From | To |
|
||||
|------|----|
|
||||
| `frontend/src/components/workspace/CategoryList.tsx` | `frontend/src/components/sidebar/CategoryList.tsx` |
|
||||
| `frontend/src/components/workspace/TagCloud.tsx` | `frontend/src/components/sidebar/TagCloud.tsx` |
|
||||
|
||||
Then delete the empty `frontend/src/components/workspace/` directory.
|
||||
|
||||
### Create (6+ files)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/alembic/versions/0XX_remove_workspace_system.py` | Drop workspace tables/columns |
|
||||
| `backend/alembic/versions/0XX_add_user_pinned_trees.py` | New pinned flows table |
|
||||
| `backend/app/models/user_pinned_tree.py` | UserPinnedTree SQLAlchemy model |
|
||||
| `frontend/src/constants/flowLabels.ts` | Flow type label constants |
|
||||
| `frontend/src/api/pinnedFlows.ts` | Pin/unpin API client |
|
||||
| `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | Pinned flows sidebar component |
|
||||
|
||||
### Modify (14 files — key changes only)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `backend/app/api/router.py` | Remove workspace routes, add pin routes |
|
||||
| `backend/app/models/__init__.py` | Remove Workspace import (line 23, 55), add UserPinnedTree |
|
||||
| `backend/app/models/account.py` | Remove `workspaces` relationship (line 18, 49) |
|
||||
| `backend/app/models/category.py` | Remove `workspace_id` column (line 40) if present |
|
||||
| `backend/app/models/tree.py` | Remove `workspace_id` column/relationship if present |
|
||||
| `backend/app/api/endpoints/trees.py` | Add pin/unpin/list-pinned/reorder endpoints |
|
||||
| `backend/app/schemas/tree.py` | Add PinnedFlow schema, add `is_pinned` to tree list |
|
||||
| `frontend/src/store/userPreferencesStore.ts` | Absorb `sidebarCollapsed` + `toggleSidebar` |
|
||||
| `frontend/src/components/layout/AppLayout.tsx` | Replace workspaceStore with userPreferencesStore |
|
||||
| `frontend/src/components/layout/TopBar.tsx` | Replace workspaceStore, static search labels |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | Remove workspace switcher, add pinned section + nav sub-items |
|
||||
| `frontend/src/components/layout/NavItem.tsx` | Add `children` sub-item support |
|
||||
| `frontend/src/pages/TreeLibraryPage.tsx` | Remove FolderSidebar, update labels, add pin star |
|
||||
| `frontend/src/components/layout/QuickLaunch.tsx` | Update action labels |
|
||||
| `frontend/src/components/layout/CommandPalette.tsx` | Update search labels |
|
||||
| `frontend/src/pages/QuickStartPage.tsx` | Update workspace/procedure labels (line 150) |
|
||||
| `frontend/src/types/index.ts` | Remove Workspace export (line 11, 14), add PinnedFlow |
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Automated
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
alembic upgrade head # Migration applies clean
|
||||
pytest --override-ini="addopts=" # All tests pass
|
||||
grep -r "workspace" app/ --include="*.py" -l # No active workspace refs
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run build # Compiles clean, zero errors
|
||||
grep -r "workspaceStore\|WorkspaceSwitcher\|workspacesApi\|workspaceLabels" src/ -l
|
||||
# Returns nothing
|
||||
```
|
||||
|
||||
### Manual UI Checks
|
||||
|
||||
- [ ] Sidebar shows "All Flows" with "Troubleshooting" and "Projects" sub-items — no workspace switcher
|
||||
- [ ] Clicking "Troubleshooting" filters library to `?type=troubleshooting`
|
||||
- [ ] Clicking "Projects" filters library to `?type=procedural`
|
||||
- [ ] Clicking "All Flows" removes type filter
|
||||
- [ ] Sub-item counts reflect actual flow counts per type
|
||||
- [ ] Pin a flow from the library card three-dot menu → appears in sidebar "PINNED" section
|
||||
- [ ] Unpin a flow → disappears from sidebar
|
||||
- [ ] Pinned flow click navigates to that flow
|
||||
- [ ] Library page is full-width (no folder sidebar panel)
|
||||
- [ ] Search bar is centered in top bar
|
||||
- [ ] Keyboard shortcut shows "Ctrl+K" on Windows, "⌘K" on Mac
|
||||
- [ ] All user-visible labels say "Flow" / "Project", never "Tree" / "Procedure"
|
||||
- [ ] Empty states use correct terminology
|
||||
- [ ] Toast messages use correct terminology
|
||||
- [ ] Command palette search works with new labels
|
||||
- [ ] Quick Launch actions show correct labels
|
||||
40
backend/alembic/versions/037_add_user_pinned_trees.py
Normal file
40
backend/alembic/versions/037_add_user_pinned_trees.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Add user_pinned_trees table
|
||||
|
||||
Revision ID: 037
|
||||
Revises: 036
|
||||
Create Date: 2026-02-15
|
||||
|
||||
Adds pinned flows feature for sidebar favorites.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '037'
|
||||
down_revision: Union[str, None] = '036'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'user_pinned_trees',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('pinned_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
|
||||
)
|
||||
op.create_index('idx_user_pinned_trees_user', 'user_pinned_trees', ['user_id'])
|
||||
op.create_index('idx_user_pinned_trees_tree', 'user_pinned_trees', ['tree_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_user_pinned_trees_tree', 'user_pinned_trees')
|
||||
op.drop_index('idx_user_pinned_trees_user', 'user_pinned_trees')
|
||||
op.drop_table('user_pinned_trees')
|
||||
69
backend/alembic/versions/038_remove_workspace_system.py
Normal file
69
backend/alembic/versions/038_remove_workspace_system.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Remove workspace system
|
||||
|
||||
Revision ID: 038
|
||||
Revises: 037
|
||||
Create Date: 2026-02-15
|
||||
|
||||
Drops workspace tables and columns. Keeps tree_categories.color.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '038'
|
||||
down_revision: Union[str, None] = '037'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Drop workspace_id FK and column from trees
|
||||
op.drop_index('ix_trees_workspace_id', 'trees')
|
||||
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
|
||||
op.drop_column('trees', 'workspace_id')
|
||||
|
||||
# 2. Drop workspace_id FK and column from tree_categories
|
||||
op.drop_index('ix_tree_categories_workspace_id', 'tree_categories')
|
||||
op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey')
|
||||
op.drop_column('tree_categories', 'workspace_id')
|
||||
|
||||
# 3. Drop workspaces table
|
||||
op.drop_index('ix_workspaces_account_id', 'workspaces')
|
||||
op.drop_index('ix_workspaces_slug', 'workspaces')
|
||||
op.drop_table('workspaces')
|
||||
|
||||
# DO NOT drop tree_categories.color — we still use it
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate workspaces table
|
||||
op.create_table(
|
||||
'workspaces',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('slug', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('icon', sa.String(10), nullable=True),
|
||||
sa.Column('accent_color', sa.String(7), nullable=True),
|
||||
sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
|
||||
)
|
||||
op.create_index('ix_workspaces_slug', 'workspaces', ['slug'])
|
||||
op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id'])
|
||||
|
||||
# Re-add workspace_id columns
|
||||
op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id'])
|
||||
|
||||
op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id'])
|
||||
@@ -17,8 +17,10 @@ from app.models.folder import UserFolder, user_folder_trees
|
||||
from app.schemas.tree import (
|
||||
TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo,
|
||||
ForkCreate, ForkInfo, TreeShareCreate, TreeShareResponse,
|
||||
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError
|
||||
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError,
|
||||
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
|
||||
)
|
||||
from app.models.user_pinned_tree import UserPinnedTree
|
||||
from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin
|
||||
from app.core.permissions import can_edit_tree, can_access_tree
|
||||
from app.core.filters import build_tree_access_filter
|
||||
@@ -1030,3 +1032,185 @@ async def check_tree_can_publish(
|
||||
can_publish=can_publish,
|
||||
errors=[ValidationError(**error) for error in validation_errors]
|
||||
)
|
||||
|
||||
|
||||
# --- Pinned Flows Endpoints ---
|
||||
|
||||
MAX_PINNED_FLOWS = 15
|
||||
|
||||
|
||||
@router.get("/pinned", response_model=PinnedFlowsListResponse)
|
||||
async def list_pinned_flows(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""List user's pinned flows, ordered by display_order."""
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
|
||||
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
|
||||
async def pin_flow(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Pin a flow to the user's sidebar."""
|
||||
# Check tree exists and user can access it
|
||||
tree_result = await db.execute(
|
||||
select(Tree)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(Tree.id == tree_id, Tree.is_active == True)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
|
||||
if not can_access_tree(current_user, tree):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree")
|
||||
|
||||
# Check if already pinned (idempotent)
|
||||
existing = await db.execute(
|
||||
select(UserPinnedTree).where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
pin = existing.scalar_one_or_none()
|
||||
if pin:
|
||||
return PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
)
|
||||
|
||||
# Check max pins
|
||||
count_result = await db.execute(
|
||||
select(func.count(UserPinnedTree.id)).where(
|
||||
UserPinnedTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
if count >= MAX_PINNED_FLOWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Maximum of {MAX_PINNED_FLOWS} pinned flows reached"
|
||||
)
|
||||
|
||||
# Create pin
|
||||
pin = UserPinnedTree(
|
||||
user_id=current_user.id,
|
||||
tree_id=tree_id,
|
||||
display_order=count, # Append at end
|
||||
)
|
||||
db.add(pin)
|
||||
await db.commit()
|
||||
await db.refresh(pin)
|
||||
|
||||
return PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tree_id}/pin")
|
||||
async def unpin_flow(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Unpin a flow from the user's sidebar."""
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree).where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
pin = result.scalar_one_or_none()
|
||||
if pin:
|
||||
await db.delete(pin)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
|
||||
async def reorder_pinned_flows(
|
||||
reorder_data: PinnedFlowReorderRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Update display_order for all pinned flows."""
|
||||
for item in reorder_data.order:
|
||||
await db.execute(
|
||||
update(UserPinnedTree)
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == item.tree_id
|
||||
)
|
||||
.values(display_order=item.display_order)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Return updated list
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.workspace import Workspace
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse
|
||||
from app.api.deps import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[WorkspaceResponse])
|
||||
async def list_workspaces(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""List all workspaces for the user's account."""
|
||||
if not current_user.account_id:
|
||||
return []
|
||||
|
||||
# Get workspaces with tree counts
|
||||
query = (
|
||||
select(
|
||||
Workspace,
|
||||
func.count(Tree.id).label("tree_count")
|
||||
)
|
||||
.outerjoin(Tree, (Tree.workspace_id == Workspace.id) & (Tree.deleted_at.is_(None)))
|
||||
.where(Workspace.account_id == current_user.account_id)
|
||||
.group_by(Workspace.id)
|
||||
.order_by(Workspace.sort_order, Workspace.name)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
WorkspaceResponse(
|
||||
**{c.key: getattr(ws, c.key) for c in Workspace.__table__.columns},
|
||||
tree_count=tree_count
|
||||
)
|
||||
for ws, tree_count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
data: WorkspaceCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Create a new workspace."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="No account found")
|
||||
|
||||
# Check slug uniqueness within account
|
||||
existing = await db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.account_id == current_user.account_id,
|
||||
Workspace.slug == data.slug
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Workspace slug already exists")
|
||||
|
||||
workspace = Workspace(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
description=data.description,
|
||||
icon=data.icon,
|
||||
accent_color=data.accent_color,
|
||||
account_id=current_user.account_id,
|
||||
sort_order=data.sort_order,
|
||||
)
|
||||
db.add(workspace)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
return WorkspaceResponse(
|
||||
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
|
||||
tree_count=0
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def update_workspace(
|
||||
workspace_id: UUID,
|
||||
data: WorkspaceUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Update a workspace."""
|
||||
workspace = await db.get(Workspace, workspace_id)
|
||||
if not workspace or workspace.account_id != current_user.account_id:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "slug" in update_data:
|
||||
existing = await db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.account_id == current_user.account_id,
|
||||
Workspace.slug == update_data["slug"],
|
||||
Workspace.id != workspace_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Workspace slug already exists")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(workspace, key, value)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get tree count
|
||||
count_result = await db.execute(
|
||||
select(func.count(Tree.id)).where(
|
||||
Tree.workspace_id == workspace_id,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
tree_count = count_result.scalar() or 0
|
||||
|
||||
return WorkspaceResponse(
|
||||
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
|
||||
tree_count=tree_count
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
workspace_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Delete a workspace. Trees are unassigned, not deleted."""
|
||||
workspace = await db.get(Workspace, workspace_id)
|
||||
if not workspace or workspace.account_id != current_user.account_id:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
if workspace.is_default:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the default workspace")
|
||||
|
||||
# Unassign trees (set workspace_id to NULL)
|
||||
await db.execute(
|
||||
Tree.__table__.update()
|
||||
.where(Tree.workspace_id == workspace_id)
|
||||
.values(workspace_id=None)
|
||||
)
|
||||
|
||||
await db.delete(workspace)
|
||||
await db.commit()
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown, workspaces
|
||||
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
|
||||
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -25,4 +25,3 @@ api_router.include_router(webhooks.router)
|
||||
api_router.include_router(shares.router)
|
||||
api_router.include_router(shared.router) # Public endpoints (no auth)
|
||||
api_router.include_router(tree_markdown.router)
|
||||
api_router.include_router(workspaces.router)
|
||||
|
||||
@@ -20,7 +20,7 @@ from .session_share import SessionShare, SessionShareView
|
||||
from .account_limit_override import AccountLimitOverride
|
||||
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||
from .platform_setting import PlatformSetting
|
||||
from .workspace import Workspace
|
||||
from .user_pinned_tree import UserPinnedTree
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -52,5 +52,5 @@ __all__ = [
|
||||
"PlanFeatureDefault",
|
||||
"AccountFeatureOverride",
|
||||
"PlatformSetting",
|
||||
"Workspace",
|
||||
"UserPinnedTree",
|
||||
]
|
||||
|
||||
@@ -15,7 +15,6 @@ if TYPE_CHECKING:
|
||||
from app.models.step_category import StepCategory
|
||||
from app.models.step_library import StepLibrary
|
||||
from app.models.account_limit_override import AccountLimitOverride
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
|
||||
class Account(Base):
|
||||
@@ -46,4 +45,3 @@ class Account(Base):
|
||||
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
|
||||
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
|
||||
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)
|
||||
workspaces: Mapped[list["Workspace"]] = relationship("Workspace", back_populates="account")
|
||||
|
||||
@@ -11,7 +11,6 @@ if TYPE_CHECKING:
|
||||
from app.models.team import Team
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
|
||||
class TreeCategory(Base):
|
||||
@@ -52,13 +51,6 @@ class TreeCategory(Base):
|
||||
String(7), nullable=True, default='#3b82f6',
|
||||
comment="Hex color for category dot indicator"
|
||||
)
|
||||
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Workspace this category belongs to"
|
||||
)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
@@ -79,7 +71,6 @@ class TreeCategory(Base):
|
||||
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="categories")
|
||||
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
|
||||
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="category_rel")
|
||||
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="categories")
|
||||
|
||||
@property
|
||||
def is_global(self) -> bool:
|
||||
|
||||
@@ -15,7 +15,6 @@ if TYPE_CHECKING:
|
||||
from app.models.tag import TreeTag
|
||||
from app.models.folder import UserFolder
|
||||
from app.models.tree_share import TreeShare
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
|
||||
class Tree(Base):
|
||||
@@ -121,14 +120,6 @@ class Tree(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Workspace this tree belongs to (organizational context)"
|
||||
)
|
||||
|
||||
# Fork tracking
|
||||
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
@@ -192,9 +183,7 @@ class Tree(Base):
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="trees")
|
||||
|
||||
# New organization relationships
|
||||
# Organization relationships
|
||||
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")
|
||||
tags: Mapped[list["TreeTag"]] = relationship(
|
||||
"TreeTag",
|
||||
|
||||
37
backend/app/models/user_pinned_tree.py
Normal file
37
backend/app/models/user_pinned_tree.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UserPinnedTree(Base):
|
||||
"""Tracks which trees a user has pinned to their sidebar."""
|
||||
__tablename__ = "user_pinned_trees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
pinned_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.tree import Tree
|
||||
from app.models.category import TreeCategory
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
"""Workspaces are the top-level organizational context for trees/flows.
|
||||
|
||||
They sit above the folder system — a workspace scopes which trees/flows
|
||||
are visible, while folders remain for personal organization within.
|
||||
"""
|
||||
__tablename__ = "workspaces"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
accent_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Relationships
|
||||
account: Mapped["Account"] = relationship("Account", back_populates="workspaces")
|
||||
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="workspace")
|
||||
categories: Mapped[list["TreeCategory"]] = relationship("TreeCategory", back_populates="workspace")
|
||||
@@ -37,7 +37,6 @@ class CategoryResponse(CategoryBase):
|
||||
display_order: int
|
||||
is_active: bool
|
||||
color: Optional[str] = None
|
||||
workspace_id: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tree_count: int = 0 # Computed field
|
||||
@@ -55,7 +54,6 @@ class CategoryListResponse(BaseModel):
|
||||
display_order: int
|
||||
is_active: bool
|
||||
color: Optional[str] = None
|
||||
workspace_id: Optional[UUID] = None
|
||||
tree_count: int = 0
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -245,3 +245,37 @@ class TreeValidationResponse(BaseModel):
|
||||
"""Response for tree validation endpoint."""
|
||||
can_publish: bool
|
||||
errors: list[ValidationError] = []
|
||||
|
||||
|
||||
# --- Pinned Flows Schemas ---
|
||||
|
||||
class PinnedFlowResponse(BaseModel):
|
||||
"""A pinned flow in the sidebar."""
|
||||
id: UUID
|
||||
tree_id: UUID
|
||||
tree_name: str
|
||||
tree_type: str
|
||||
category_emoji: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
pinned_at: datetime
|
||||
display_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PinnedFlowsListResponse(BaseModel):
|
||||
"""List of pinned flows."""
|
||||
items: list[PinnedFlowResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class PinnedFlowReorderItem(BaseModel):
|
||||
"""Single item in a reorder request."""
|
||||
tree_id: UUID
|
||||
display_order: int
|
||||
|
||||
|
||||
class PinnedFlowReorderRequest(BaseModel):
|
||||
"""Request to reorder pinned flows."""
|
||||
order: list[PinnedFlowReorderItem]
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkspaceCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = Field(None, max_length=10)
|
||||
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class WorkspaceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = Field(None, max_length=10)
|
||||
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
accent_color: Optional[str] = None
|
||||
account_id: uuid.UUID
|
||||
is_default: bool
|
||||
sort_order: int
|
||||
tree_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,4 +11,4 @@ export { default as stepCategoriesApi } from './stepCategories'
|
||||
export { default as accountsApi } from './accounts'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
export { default as workspacesApi } from './workspaces'
|
||||
export { default as pinnedFlowsApi } from './pinnedFlows'
|
||||
|
||||
40
frontend/src/api/pinnedFlows.ts
Normal file
40
frontend/src/api/pinnedFlows.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface PinnedFlow {
|
||||
id: string
|
||||
tree_id: string
|
||||
tree_name: string
|
||||
tree_type: 'troubleshooting' | 'procedural'
|
||||
category_emoji?: string
|
||||
category_name?: string
|
||||
pinned_at: string
|
||||
display_order: number
|
||||
}
|
||||
|
||||
export interface PinnedFlowsResponse {
|
||||
items: PinnedFlow[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export const pinnedFlowsApi = {
|
||||
list: async (): Promise<PinnedFlowsResponse> => {
|
||||
const { data } = await apiClient.get('/trees/pinned')
|
||||
return data
|
||||
},
|
||||
|
||||
pin: async (treeId: string): Promise<PinnedFlow> => {
|
||||
const { data } = await apiClient.post(`/trees/${treeId}/pin`)
|
||||
return data
|
||||
},
|
||||
|
||||
unpin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.delete(`/trees/${treeId}/pin`)
|
||||
},
|
||||
|
||||
reorder: async (order: { tree_id: string; display_order: number }[]): Promise<PinnedFlowsResponse> => {
|
||||
const { data } = await apiClient.patch('/trees/pinned/reorder', { order })
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default pinnedFlowsApi
|
||||
@@ -1,25 +0,0 @@
|
||||
import { apiClient } from './client'
|
||||
import type { Workspace, WorkspaceCreate, WorkspaceUpdate } from '@/types'
|
||||
|
||||
export const workspacesApi = {
|
||||
list: async (): Promise<Workspace[]> => {
|
||||
const { data } = await apiClient.get('/workspaces')
|
||||
return data
|
||||
},
|
||||
|
||||
create: async (payload: WorkspaceCreate): Promise<Workspace> => {
|
||||
const { data } = await apiClient.post('/workspaces', payload)
|
||||
return data
|
||||
},
|
||||
|
||||
update: async (id: string, payload: WorkspaceUpdate): Promise<Workspace> => {
|
||||
const { data } = await apiClient.patch(`/workspaces/${id}`, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/workspaces/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default workspacesApi
|
||||
@@ -3,7 +3,7 @@ import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, LogOut, Shield } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
@@ -14,15 +14,9 @@ export function AppLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole } = usePermissions()
|
||||
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
|
||||
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
// Fetch workspaces on mount
|
||||
useEffect(() => {
|
||||
fetchWorkspaces()
|
||||
}, [fetchWorkspaces])
|
||||
|
||||
// Close mobile menu on route change
|
||||
const [prevPath, setPrevPath] = useState(location.pathname)
|
||||
if (prevPath !== location.pathname) {
|
||||
|
||||
@@ -2,6 +2,13 @@ import { Link, useLocation } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavSubItem {
|
||||
href: string
|
||||
label: string
|
||||
count?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
@@ -9,16 +16,22 @@ interface NavItemProps {
|
||||
badge?: number | 'dot'
|
||||
matchPaths?: string[]
|
||||
collapsed?: boolean
|
||||
children?: NavSubItem[]
|
||||
}
|
||||
|
||||
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed }: NavItemProps) {
|
||||
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed, children }: NavItemProps) {
|
||||
const location = useLocation()
|
||||
const fullPath = location.pathname + location.search
|
||||
const isActive = matchPaths
|
||||
? matchPaths.some(p => location.pathname.startsWith(p))
|
||||
: href === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(href)
|
||||
|
||||
// Check if any child is specifically active
|
||||
const activeChild = children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
|
||||
const isParentDimmed = !!activeChild && isActive
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Link
|
||||
@@ -45,33 +58,65 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
|
||||
isActive
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
|
||||
isActive
|
||||
? isParentDimmed
|
||||
? 'bg-[hsl(var(--sidebar-active))]/50 text-foreground/70'
|
||||
: 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{isActive && !isParentDimmed && (
|
||||
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
|
||||
)}
|
||||
|
||||
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
|
||||
<span className="truncate">{label}</span>
|
||||
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
|
||||
<span className="truncate">{label}</span>
|
||||
|
||||
{/* Badge */}
|
||||
{badge !== undefined && badge !== 0 && (
|
||||
badge === 'dot' ? (
|
||||
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
|
||||
) : (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
||||
{badge}
|
||||
</span>
|
||||
)
|
||||
{/* Badge */}
|
||||
{badge !== undefined && badge !== 0 && (
|
||||
badge === 'dot' ? (
|
||||
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
|
||||
) : (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
||||
{badge}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Sub-items */}
|
||||
{children && children.length > 0 && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{children.map(child => {
|
||||
const childActive = fullPath === child.href || fullPath.startsWith(child.href + '&')
|
||||
return (
|
||||
<Link
|
||||
key={child.href}
|
||||
to={child.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
childActive
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{child.label}</span>
|
||||
{child.count !== undefined && (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
||||
{child.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface QuickAction {
|
||||
|
||||
const ACTIONS: QuickAction[] = [
|
||||
{ id: 'new-tree', icon: Plus, label: 'New Troubleshooting Flow', description: 'Create a branching decision tree', path: '/trees/new', color: '#3b82f6' },
|
||||
{ id: 'new-procedure', icon: Plus, label: 'New Procedure', description: 'Create a step-by-step procedure', path: '/flows/new', color: '#8b5cf6' },
|
||||
{ id: 'new-project', icon: Plus, label: 'New Project', description: 'Create a step-by-step project', path: '/flows/new', color: '#8b5cf6' },
|
||||
{ id: 'sessions', icon: Play, label: 'View Sessions', description: 'See active and recent sessions', path: '/sessions', color: '#f59e0b' },
|
||||
{ id: 'step-library', icon: Bookmark, label: 'Step Library', description: 'Browse reusable steps', path: '/step-library', color: '#10b981' },
|
||||
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#6366f1' },
|
||||
@@ -130,7 +130,7 @@ export function QuickLaunch({ open, onClose }: QuickLaunchProps) {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tree.name}</p>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
{tree.tree_type === 'procedural' ? 'Procedure' : 'Troubleshooting'} · {tree.usage_count} uses
|
||||
{tree.tree_type === 'procedural' ? 'Project' : 'Troubleshooting'} · {tree.usage_count} uses
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="ml-auto shrink-0 opacity-40" />
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
|
||||
import { WorkspaceSwitcher } from '@/components/workspace/WorkspaceSwitcher'
|
||||
import { CategoryList } from '@/components/workspace/CategoryList'
|
||||
import { TagCloud } from '@/components/workspace/TagCloud'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { CategoryList } from '@/components/sidebar/CategoryList'
|
||||
import { TagCloud } from '@/components/sidebar/TagCloud'
|
||||
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
|
||||
import { NavItem } from './NavItem'
|
||||
import { categoriesApi, tagsApi, sessionsApi } from '@/api'
|
||||
import { categoriesApi, tagsApi, sessionsApi, treesApi } from '@/api'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
@@ -17,26 +19,27 @@ interface CategoryItem {
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
||||
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId)
|
||||
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useWorkspaceStore(s => s.toggleSidebar)
|
||||
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
||||
|
||||
const [categories, setCategories] = useState<CategoryItem[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null)
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
||||
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
||||
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0 })
|
||||
|
||||
// Fetch categories, tags, and active session count when workspace changes
|
||||
// Fetch sidebar data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [cats, tagList, activeSessions] = await Promise.all([
|
||||
const [cats, tagList, activeSessions, allTrees, pinnedData] = await Promise.all([
|
||||
categoriesApi.list(),
|
||||
tagsApi.list().catch(() => []),
|
||||
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
|
||||
treesApi.list({ sort_by: 'name' }).catch(() => []),
|
||||
pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })),
|
||||
])
|
||||
setCategories(cats.map(c => ({
|
||||
id: c.id,
|
||||
@@ -46,12 +49,18 @@ export function Sidebar() {
|
||||
})))
|
||||
setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15))
|
||||
setActiveSessionCount(activeSessions.length)
|
||||
setPinnedFlows(pinnedData.items)
|
||||
|
||||
const total = allTrees.length
|
||||
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
||||
const procedural = allTrees.filter(t => t.tree_type === 'procedural').length
|
||||
setTreeCounts({ total, troubleshooting, procedural })
|
||||
} catch {
|
||||
// Silently handle errors
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [activeWorkspaceId])
|
||||
}, [])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -91,6 +100,16 @@ export function Sidebar() {
|
||||
navigate(`/trees?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleUnpin = async (treeId: string) => {
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
setPinnedFlows(prev => prev.filter(f => f.tree_id !== treeId))
|
||||
toast.success('Unpinned from sidebar')
|
||||
} catch {
|
||||
toast.error('Failed to unpin flow')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]">
|
||||
{sidebarCollapsed ? (
|
||||
@@ -107,16 +126,26 @@ export function Sidebar() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Workspace Switcher */}
|
||||
<WorkspaceSwitcher />
|
||||
{/* Pinned Flows */}
|
||||
<PinnedFlowsSection flows={pinnedFlows} onUnpin={handleUnpin} />
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
{/* Primary Navigation */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
|
||||
<NavItem href="/trees" icon={Box} label={labels.allItems} matchPaths={['/trees', '/flows']} />
|
||||
<NavItem href="/my-trees" icon={PenLine} label={labels.editor} />
|
||||
<NavItem
|
||||
href="/trees"
|
||||
icon={Box}
|
||||
label="All Flows"
|
||||
badge={treeCounts.total || undefined}
|
||||
matchPaths={['/trees', '/flows']}
|
||||
children={[
|
||||
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
|
||||
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
|
||||
]}
|
||||
/>
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Search, Zap, LogOut, User, Shield, Settings } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { QuickLaunch } from './QuickLaunch'
|
||||
@@ -15,9 +14,7 @@ export function TopBar() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole, isSuperAdmin } = usePermissions()
|
||||
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
||||
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
|
||||
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
@@ -77,22 +74,25 @@ export function TopBar() {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Spacer - push search to center */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="relative flex-1 text-left"
|
||||
className="relative w-full text-left"
|
||||
style={{ maxWidth: '480px' }}
|
||||
>
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="w-full rounded-lg border border-border bg-card py-2 pl-9 pr-14 text-[0.8125rem] text-muted-foreground cursor-pointer hover:border-primary/30 transition-colors">
|
||||
{labels.searchPlaceholder}
|
||||
Search flows, sessions, tags…
|
||||
</div>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
|
||||
⌘K
|
||||
{navigator.platform?.toLowerCase().includes('mac') ? '⌘K' : 'Ctrl+K'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Spacer */}
|
||||
{/* Spacer - push actions to right */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
60
frontend/src/components/sidebar/PinnedFlowsSection.tsx
Normal file
60
frontend/src/components/sidebar/PinnedFlowsSection.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, ChevronRight, Pin } from 'lucide-react'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
|
||||
interface PinnedFlowsSectionProps {
|
||||
flows: PinnedFlow[]
|
||||
onUnpin: (treeId: string) => void
|
||||
}
|
||||
|
||||
export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) {
|
||||
const navigate = useNavigate()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex w-full items-center gap-1 px-3 mb-1 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
Pinned
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 max-h-[280px] overflow-y-auto">
|
||||
{flows.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Pin your most-used flows here
|
||||
</p>
|
||||
) : (
|
||||
flows.map(flow => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onUnpin(flow.tree_id)
|
||||
}}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
title={`${flow.tree_name} (right-click to unpin)`}
|
||||
>
|
||||
<span className="text-sm shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
|
||||
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Loader2 } from 'lucide-react'
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WorkspaceCreateModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ICONS = ['📁', '🔧', '📋', '🚀', '🎯', '💼', '🔒', '📊', '🧪', '⚙️']
|
||||
|
||||
export function WorkspaceCreateModal({ open, onClose }: WorkspaceCreateModalProps) {
|
||||
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [icon, setIcon] = useState('📁')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 50)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || !slug) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await workspacesApi.create({ name: name.trim(), slug, description: description.trim() || undefined, icon })
|
||||
await fetchWorkspaces()
|
||||
toast.success(`Workspace "${name}" created`)
|
||||
setName('')
|
||||
setDescription('')
|
||||
setIcon('📁')
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create workspace'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl animate-scale-in">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-heading font-bold text-foreground">Create Workspace</h2>
|
||||
<button onClick={onClose} className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Icon picker */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">Icon</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ICONS.map(i => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIcon(i)}
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg text-lg transition-colors',
|
||||
icon === i
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'border border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g., Network Operations"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
autoFocus
|
||||
/>
|
||||
{slug && (
|
||||
<p className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||
Slug: <code className="rounded bg-secondary px-1">{slug}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">Description <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||
import { WorkspaceCreateModal } from './WorkspaceCreateModal'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function WorkspaceSwitcher() {
|
||||
const { workspaces, activeWorkspaceId, setActiveWorkspace } = useWorkspaceStore()
|
||||
const activeWorkspace = workspaces.find(w => w.id === activeWorkspaceId)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open])
|
||||
|
||||
const handleSwitch = (ws: typeof workspaces[0]) => {
|
||||
if (ws.id !== activeWorkspaceId) {
|
||||
setActiveWorkspace(ws.id)
|
||||
toast.success(`Switched to ${ws.name}`)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (!activeWorkspace) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative px-3 py-2" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-[hsl(var(--sidebar-hover))] transition-colors"
|
||||
>
|
||||
<span className="text-lg leading-none">{activeWorkspace.icon || '📁'}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-heading font-semibold text-foreground truncate">{activeWorkspace.name}</p>
|
||||
{activeWorkspace.description && (
|
||||
<p className="text-[0.6875rem] text-muted-foreground truncate">{activeWorkspace.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={cn('shrink-0 text-muted-foreground transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-3 right-3 z-50 mt-1 rounded-lg border border-border bg-card shadow-xl animate-scale-in">
|
||||
<div className="p-1">
|
||||
{workspaces.map(ws => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => handleSwitch(ws)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors',
|
||||
ws.id === activeWorkspaceId
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="text-base leading-none">{ws.icon || '📁'}</span>
|
||||
<span className="flex-1 truncate text-sm">{ws.name}</span>
|
||||
<span className="font-label text-[0.6875rem] text-muted-foreground">{ws.tree_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border p-1">
|
||||
<button
|
||||
onClick={() => { setOpen(false); setCreateModalOpen(true) }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add workspace…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WorkspaceCreateModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
frontend/src/constants/flowLabels.ts
Normal file
38
frontend/src/constants/flowLabels.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface FlowTypeLabels {
|
||||
navLabel: string
|
||||
singular: string
|
||||
plural: string
|
||||
newButton: string
|
||||
searchPlaceholder: string
|
||||
}
|
||||
|
||||
export const FLOW_TYPE_LABELS: Record<string, FlowTypeLabels> = {
|
||||
all: {
|
||||
navLabel: 'All Flows',
|
||||
singular: 'Flow',
|
||||
plural: 'Flows',
|
||||
newButton: '+ Create Flow',
|
||||
searchPlaceholder: 'Search flows, sessions, tags\u2026',
|
||||
},
|
||||
troubleshooting: {
|
||||
navLabel: 'Troubleshooting',
|
||||
singular: 'Flow',
|
||||
plural: 'Flows',
|
||||
newButton: '+ New Troubleshooting Flow',
|
||||
searchPlaceholder: 'Search troubleshooting flows\u2026',
|
||||
},
|
||||
procedural: {
|
||||
navLabel: 'Projects',
|
||||
singular: 'Project',
|
||||
plural: 'Projects',
|
||||
newButton: '+ New Project',
|
||||
searchPlaceholder: 'Search projects, runbooks\u2026',
|
||||
},
|
||||
}
|
||||
|
||||
export function getFlowLabels(typeFilter?: string): FlowTypeLabels {
|
||||
if (typeFilter && typeFilter in FLOW_TYPE_LABELS) {
|
||||
return FLOW_TYPE_LABELS[typeFilter]
|
||||
}
|
||||
return FLOW_TYPE_LABELS.all
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
export interface WorkspaceLabels {
|
||||
allItems: string
|
||||
editor: string
|
||||
newItem: string
|
||||
searchPlaceholder: string
|
||||
}
|
||||
|
||||
export const WORKSPACE_LABELS: Record<string, WorkspaceLabels> = {
|
||||
troubleshooting: {
|
||||
allItems: 'All Trees',
|
||||
editor: 'Tree Editor',
|
||||
newItem: 'New Tree',
|
||||
searchPlaceholder: 'Search trees, sessions, tags\u2026',
|
||||
},
|
||||
procedures: {
|
||||
allItems: 'All Procedures',
|
||||
editor: 'Flow Editor',
|
||||
newItem: 'New Procedure',
|
||||
searchPlaceholder: 'Search procedures, runbooks\u2026',
|
||||
},
|
||||
policies: {
|
||||
allItems: 'All Policies',
|
||||
editor: 'Policy Editor',
|
||||
newItem: 'New Policy',
|
||||
searchPlaceholder: 'Search policies, compliance\u2026',
|
||||
},
|
||||
finance: {
|
||||
allItems: 'All Finance Flows',
|
||||
editor: 'Flow Editor',
|
||||
newItem: 'New Flow',
|
||||
searchPlaceholder: 'Search billing, procurement\u2026',
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_LABELS: WorkspaceLabels = {
|
||||
allItems: 'All Flows',
|
||||
editor: 'Flow Editor',
|
||||
newItem: 'New Flow',
|
||||
searchPlaceholder: 'Search flows, sessions, tags\u2026',
|
||||
}
|
||||
|
||||
export function getWorkspaceLabels(slug?: string): WorkspaceLabels {
|
||||
if (slug && slug in WORKSPACE_LABELS) return WORKSPACE_LABELS[slug]
|
||||
return DEFAULT_LABELS
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore'
|
||||
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { QuickStats } from '@/components/dashboard/QuickStats'
|
||||
import { FiltersBar } from '@/components/dashboard/FiltersBar'
|
||||
@@ -32,8 +30,6 @@ function timeAgo(dateStr: string): string {
|
||||
export function QuickStartPage() {
|
||||
const navigate = useNavigate()
|
||||
const { canCreateTrees } = usePermissions()
|
||||
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
|
||||
const labels = getWorkspaceLabels(activeWorkspace?.slug)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<TreeListItem[]>([])
|
||||
@@ -147,17 +143,17 @@ export function QuickStartPage() {
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Welcome back. Here's what's happening in your workspace.
|
||||
Welcome back. Here's what's happening with your flows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to={activeWorkspace?.slug === 'procedures' ? '/flows/new' : '/trees/new'}
|
||||
to="/trees/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{labels.newItem}
|
||||
Create Flow
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@@ -184,7 +180,7 @@ export function QuickStartPage() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => query.length >= 2 && setShowResults(true)}
|
||||
placeholder={labels.searchPlaceholder}
|
||||
placeholder="Search flows, sessions, tags…"
|
||||
className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
{showResults && (
|
||||
@@ -226,7 +222,7 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
) : (
|
||||
<SectionGroup
|
||||
title={labels.allItems}
|
||||
title="All Flows"
|
||||
count={filteredTrees.length}
|
||||
delay={200}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
||||
import { Plus, X, FolderOpen, RotateCcw, Play } from 'lucide-react'
|
||||
import { Plus, X, RotateCcw, Play } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { foldersApi } from '@/api/folders'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
||||
import { FolderSidebar } from '@/components/library/FolderSidebar'
|
||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
@@ -64,9 +63,6 @@ export function TreeLibraryPage() {
|
||||
const [editingFolder, setEditingFolder] = useState<FolderListItem | null>(null)
|
||||
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null)
|
||||
|
||||
// Mobile folder sidebar state
|
||||
const [mobileFolderOpen, setMobileFolderOpen] = useState(false)
|
||||
|
||||
// Delete confirmation state
|
||||
const [treeToDelete, setTreeToDelete] = useState<TreeListItem | null>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
@@ -213,12 +209,6 @@ export function TreeLibraryPage() {
|
||||
setFolderModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditFolder = (folder: FolderListItem) => {
|
||||
setEditingFolder(folder)
|
||||
setNewFolderParentId(null)
|
||||
setFolderModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteTree = async () => {
|
||||
if (!treeToDelete) return
|
||||
setIsDeleting(true)
|
||||
@@ -257,33 +247,20 @@ export function TreeLibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Folder Sidebar */}
|
||||
<FolderSidebar
|
||||
selectedFolderId={selectedFolderId}
|
||||
onFolderSelect={(id) => {
|
||||
setSelectedFolderId(id)
|
||||
setMobileFolderOpen(false)
|
||||
}}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onEditFolder={handleEditFolder}
|
||||
mobileOpen={mobileFolderOpen}
|
||||
onMobileClose={() => setMobileFolderOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
{typeFilter === 'procedural' ? 'Procedures' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
|
||||
{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
|
||||
</h1>
|
||||
<p className="mt-2 text-white/40">
|
||||
{typeFilter === 'procedural'
|
||||
? 'Step-by-step procedures for project work'
|
||||
? 'Step-by-step projects and runbooks'
|
||||
: typeFilter === 'troubleshooting'
|
||||
? 'Branching decision flows for troubleshooting'
|
||||
: 'Browse and start troubleshooting flows and procedures'}
|
||||
: 'Browse and start troubleshooting flows and projects'}
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTrees && (
|
||||
@@ -295,7 +272,7 @@ export function TreeLibraryPage() {
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{typeFilter === 'procedural' ? 'Create Procedure' : 'Create Flow'}
|
||||
{typeFilter === 'procedural' ? 'New Project' : 'Create Flow'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@@ -303,18 +280,6 @@ export function TreeLibraryPage() {
|
||||
{/* Search and Filter */}
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Mobile folder button */}
|
||||
<button
|
||||
onClick={() => setMobileFolderOpen(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium md:hidden',
|
||||
'text-white/40 hover:bg-white/10 hover:text-white',
|
||||
selectedFolderId && 'border-white/30 text-white'
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Folders
|
||||
</button>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -372,7 +337,7 @@ export function TreeLibraryPage() {
|
||||
: 'text-white/40 hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Procedures'}
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Projects'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,13 @@ interface UserPreferencesState {
|
||||
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
|
||||
preferredEditorMode: EditorMode
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
defaultExportFormat: 'markdown',
|
||||
setDefaultExportFormat: (format) => set({ defaultExportFormat: format }),
|
||||
treeLibraryView: 'grid',
|
||||
@@ -28,6 +30,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
|
||||
preferredEditorMode: 'form',
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Workspace } from '@/types'
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
|
||||
interface WorkspaceState {
|
||||
workspaces: Workspace[]
|
||||
activeWorkspaceId: string | null
|
||||
loading: boolean
|
||||
sidebarCollapsed: boolean
|
||||
setActiveWorkspace: (id: string) => void
|
||||
fetchWorkspaces: () => Promise<void>
|
||||
getActiveWorkspace: () => Workspace | undefined
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
||||
workspaces: [],
|
||||
activeWorkspaceId: localStorage.getItem('active-workspace-id'),
|
||||
loading: false,
|
||||
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
|
||||
|
||||
setActiveWorkspace: (id: string) => {
|
||||
localStorage.setItem('active-workspace-id', id)
|
||||
set({ activeWorkspaceId: id })
|
||||
},
|
||||
|
||||
fetchWorkspaces: async () => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
const workspaces = await workspacesApi.list()
|
||||
const state = get()
|
||||
let activeId = state.activeWorkspaceId
|
||||
|
||||
// If no active workspace or active workspace doesn't exist, use default
|
||||
if (!activeId || !workspaces.find(w => w.id === activeId)) {
|
||||
const defaultWs = workspaces.find(w => w.is_default) || workspaces[0]
|
||||
if (defaultWs) {
|
||||
activeId = defaultWs.id
|
||||
localStorage.setItem('active-workspace-id', activeId)
|
||||
}
|
||||
}
|
||||
|
||||
set({ workspaces, activeWorkspaceId: activeId, loading: false })
|
||||
} catch {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
getActiveWorkspace: () => {
|
||||
const { workspaces, activeWorkspaceId } = get()
|
||||
return workspaces.find(w => w.id === activeWorkspaceId)
|
||||
},
|
||||
|
||||
toggleSidebar: () => {
|
||||
const next = !get().sidebarCollapsed
|
||||
localStorage.setItem('sidebar-collapsed', String(next))
|
||||
set({ sidebarCollapsed: next })
|
||||
},
|
||||
}))
|
||||
@@ -8,7 +8,6 @@ export * from './category'
|
||||
export * from './folder'
|
||||
export * from './step'
|
||||
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
||||
export type { Workspace, WorkspaceCreate, WorkspaceUpdate } from './workspace'
|
||||
export * from './admin'
|
||||
|
||||
// API response wrapper types
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
export interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
accent_color: string | null
|
||||
account_id: string
|
||||
is_default: boolean
|
||||
sort_order: number
|
||||
tree_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WorkspaceCreate {
|
||||
name: string
|
||||
slug: string
|
||||
description?: string
|
||||
icon?: string
|
||||
accent_color?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdate {
|
||||
name?: string
|
||||
slug?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
accent_color?: string
|
||||
sort_order?: number
|
||||
}
|
||||
Reference in New Issue
Block a user