diff --git a/_bmad-output/implementation-artifacts/1-2-organize-diagrams-into-projects.md b/_bmad-output/implementation-artifacts/1-2-organize-diagrams-into-projects.md new file mode 100644 index 0000000..64499b4 --- /dev/null +++ b/_bmad-output/implementation-artifacts/1-2-organize-diagrams-into-projects.md @@ -0,0 +1,332 @@ +# Story 1.2: Organize Diagrams into Projects + +Status: done + +## Story + +As a user, +I want to group my diagrams into projects (folders), +so that I can organize related diagrams together and navigate my workspace efficiently. + +## Acceptance Criteria + +1. **Given** I am on the dashboard, **When** I click "New Project" in the sidebar, **Then** a new project is created with an editable name field, **And** the project appears in the sidebar Projects tree view. + +2. **Given** I have projects in my sidebar, **When** I click on a project name, **Then** the main content area filters to show only diagrams in that project, **And** the project is highlighted in the sidebar tree. + +3. **Given** I am creating a new diagram, **When** I optionally select a project in the creation modal, **Then** the diagram is created inside that project, **And** it appears under the project node in the sidebar tree. + +4. **Given** I have projects in the sidebar, **When** I right-click a project, **Then** I see options to Rename and Delete the project, **And** deleting a project moves its diagrams to "Unorganized" (not deleted). + +5. **Given** I am on the dashboard, **When** I switch to the "Recent" tab in the sidebar, **Then** I see all diagrams across all projects sorted by last interaction timestamp. + +## Tasks / Subtasks + +- [x] Task 1: Create project CRUD API endpoints (AC: #1, #4) + - [x] 1.1: Add project CRUD routes to `packages/api/src/modules/diagram/project-router.ts`: GET /projects, POST /projects, PATCH /projects/:id, DELETE /projects/:id + - [x] 1.2: Add Zod validation schemas for create/update project inputs + - [x] 1.3: GET /projects returns user's projects ordered by sortOrder + - [x] 1.4: DELETE /projects/:id sets `projectId = null` on all diagrams in that project (moves to Unorganized), then deletes the project row + - [x] 1.5: Register project routes in `packages/api/src/index.ts` +- [x] Task 2: Add project filter to diagram list API (AC: #2) + - [x] 2.1: Add optional `projectId` query param to GET /diagrams — when present, filter diagrams by projectId + - [x] 2.2: Add a GET /diagrams?unorganized=true filter for diagrams with null projectId +- [x] Task 3: Update CreateDiagramDialog to support optional project selection (AC: #3) + - [x] 3.1: Fetch user's projects and display a project dropdown/select in the create modal + - [x] 3.2: Pass selected projectId to the POST /diagrams API call +- [x] Task 4: Create sidebar with Projects tree view and Recent tab (AC: #1, #2, #5) + - [x] 4.1: Create `apps/web/src/modules/diagram/components/sidebar/DiagramSidebar.tsx` — client component with two tabs: "Projects" and "Recent" + - [x] 4.2: Projects tab: tree view listing projects with expandable diagram items underneath. "All Diagrams" and "Unorganized" pseudo-entries at top + - [x] 4.3: Recent tab: flat list of all diagrams sorted by updatedAt desc, showing title + type icon + relative time + - [x] 4.4: "New Project" button in sidebar header area + - [x] 4.5: Integrate sidebar into dashboard layout — added "Diagrams" to DashboardSidebar menu config + DiagramSidebar as panel in diagrams page +- [x] Task 5: Implement project context menu — rename and delete (AC: #4) + - [x] 5.1: Kebab menu (DropdownMenu) on project name with Rename / Delete options + - [x] 5.2: Rename: inline editable field — saves on blur/Enter via PATCH /projects/:id + - [x] 5.3: Delete: AlertDialog confirmation warning that diagrams will be moved to Unorganized — calls DELETE /projects/:id + - [x] 5.4: React Query invalidation on mutations +- [x] Task 6: Tests (AC: all) + - [x] 6.1: API schema validation tests for project create/update schemas (15 tests) + - [x] 6.2: Schema-level validation tests (no DB required) + +## Dev Notes + +### Project Database Schema — Already Created in Story 1.1 + +The `project` table already exists in `packages/db/src/schema/diagram.ts`: + +```typescript +export const project = pgTable("project", { + id: text().primaryKey().notNull().$defaultFn(generateId), + name: text().notNull(), + userId: text().references(() => user.id, { onDelete: "cascade" }).notNull(), + sortOrder: integer().default(0), + createdAt: timestamp().defaultNow(), + updatedAt: timestamp().$onUpdate(() => new Date()), +}); +``` + +Zod schemas and types are also already exported: `insertProjectSchema`, `selectProjectSchema`, `updateProjectSchema`, `InsertProject`, `SelectProject`, `UpdateProject`. + +**NO schema changes or migrations needed for this story.** + +### API — Project CRUD Routes + +Add project routes. Two options: + +**Option A (recommended): Add to existing `packages/api/src/modules/diagram/router.ts`** — since projects are tightly coupled to diagrams (same domain). Create a separate `projectRouter` in a new file `packages/api/src/modules/diagram/project-router.ts` and register it alongside the diagram router. + +```typescript +// packages/api/src/modules/diagram/project-router.ts +import { Hono } from "hono"; +import { eq, asc } from "drizzle-orm"; +import { z } from "zod"; +import { project, diagram } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; +import { HttpStatusCode } from "@turbostarter/shared/constants"; +import { HttpException } from "@turbostarter/shared/utils"; +import { enforceAuth, validate } from "../../middleware"; + +const createProjectSchema = z.object({ + name: z.string().min(1).max(100), +}); + +const updateProjectSchema = z.object({ + name: z.string().min(1).max(100).optional(), + sortOrder: z.number().int().optional(), +}); + +export const projectRouter = new Hono() + .get("/", enforceAuth, async (c) => { + const projects = await db.select().from(project) + .where(eq(project.userId, c.var.user.id)) + .orderBy(asc(project.sortOrder)); + return c.json({ data: projects }); + }) + .post("/", enforceAuth, validate("json", createProjectSchema), async (c) => { + const input = c.req.valid("json"); + const [created] = await db.insert(project).values({ + ...input, userId: c.var.user.id, + }).returning(); + return c.json({ data: created }); + }) + .patch("/:id", enforceAuth, validate("json", updateProjectSchema), async (c) => { + const [existing] = await db.select().from(project) + .where(and(eq(project.id, c.req.param("id")), eq(project.userId, c.var.user.id))); + if (!existing) throw new HttpException(HttpStatusCode.NOT_FOUND, { code: "error.notFound" }); + const [updated] = await db.update(project) + .set(c.req.valid("json")) + .where(eq(project.id, c.req.param("id"))) + .returning(); + return c.json({ data: updated }); + }) + .delete("/:id", enforceAuth, async (c) => { + const [existing] = await db.select().from(project) + .where(and(eq(project.id, c.req.param("id")), eq(project.userId, c.var.user.id))); + if (!existing) throw new HttpException(HttpStatusCode.NOT_FOUND, { code: "error.notFound" }); + // Move diagrams to Unorganized (null projectId) + await db.update(diagram).set({ projectId: null }) + .where(eq(diagram.projectId, c.req.param("id"))); + // Delete the project + await db.delete(project).where(eq(project.id, c.req.param("id"))); + return c.json({ data: { success: true } }); + }); +``` + +Register in `packages/api/src/index.ts`: +```typescript +import { projectRouter } from "./modules/diagram/project-router"; +// In appRouter chain: +.route("/projects", projectRouter) +``` + +### Diagram List API — Add Project Filter + +Modify GET /diagrams in `packages/api/src/modules/diagram/router.ts` to accept optional query params: + +```typescript +.get("/", enforceAuth, async (c) => { + const projectId = c.req.query("projectId"); + const unorganized = c.req.query("unorganized"); + + const conditions = [ + eq(diagram.userId, c.var.user.id), + isNull(diagram.deletedAt), + ]; + + if (projectId) { + conditions.push(eq(diagram.projectId, projectId)); + } else if (unorganized === "true") { + conditions.push(isNull(diagram.projectId)); + } + + const diagrams = await db.select().from(diagram) + .where(and(...conditions)) + .orderBy(desc(diagram.updatedAt)); + + return c.json({ data: diagrams }); +}) +``` + +### Sidebar — DiagramSidebar Component + +**Architecture reference:** The architecture doc specifies `apps/web/src/modules/diagram/components/sidebar/DiagramSidebar.tsx`. + +The existing `DashboardSidebar` is a **server component** that receives a static `menu` array of links. Story 1.2 needs a **client-side interactive sidebar** with: +- Two tabs: Projects (tree) and Recent (flat list) +- Dynamic data fetching (useQuery for projects + diagrams) +- Context menus +- Interactive state (expand/collapse project nodes) + +**Approach:** Add a "Diagrams" link to the existing DashboardSidebar menu that navigates to `/dashboard/diagrams`. Then the diagrams page itself renders the `DiagramSidebar` as a secondary sidebar or panel within the page content area. Alternatively, extend the layout. + +**Recommended:** Create `DiagramSidebar` as a client component rendered inside the diagrams page layout. This avoids modifying the server-side DashboardSidebar pattern. Use shadcn/ui `Tabs` for Projects/Recent switching. + +**TurboStarter sidebar components available:** +- `Sidebar`, `SidebarContent`, `SidebarHeader`, `SidebarGroup`, `SidebarGroupLabel`, `SidebarMenu`, `SidebarMenuItem`, `SidebarMenuButton`, `SidebarRail` from `@turbostarter/ui-web/sidebar` + +**UI components to use:** +- `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` from `@turbostarter/ui-web/tabs` +- `ContextMenu`, `ContextMenuTrigger`, `ContextMenuContent`, `ContextMenuItem` from `@turbostarter/ui-web/context-menu` (for right-click on projects) +- `Input` for inline rename +- `AlertDialog` for delete confirmation +- `Icons.FolderOpen`, `Icons.Clock`, `Icons.Plus`, `Icons.MoreHorizontal`, `Icons.Pencil`, `Icons.Trash2` +- `Select` / `SelectTrigger` / `SelectContent` / `SelectItem` from `@turbostarter/ui-web/select` (for project picker in CreateDiagramDialog) + +### CreateDiagramDialog — Add Project Picker + +Modify `apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx`: +- Fetch projects via `useQuery({ queryKey: ["projects"] })` +- Add a `Select` dropdown below the title input: "Project (optional)" with project names + "No project" default +- Pass `projectId` to the create mutation + +### Recent Tab + +The Recent tab shows diagrams across all projects sorted by `updatedAt` desc. Use the existing `GET /diagrams` endpoint (no projectId filter). Each item shows: +- Diagram title +- Type icon with accent color +- Relative timestamp (reuse `timeAgo` from DiagramCard) + +Clicking a diagram in Recent navigates to `/dashboard/diagram/[id]`. + +### Sidebar Integration with Dashboard Layout + +The `DiagramSidebar` should be a **panel inside the diagrams page**, NOT replacing the main dashboard sidebar. The dashboard already has a top-level sidebar (DashboardSidebar) with navigation links. The `DiagramSidebar` is a secondary navigation within the diagrams content area. + +**Layout approach:** In the diagrams page, render a two-column layout: +``` +[DashboardSidebar (existing)] | [DiagramSidebar (new)] | [DiagramGrid (existing)] +``` + +Add a sidebar menu item "Diagrams" to the dashboard layout's menu config pointing to `/dashboard/diagrams`. + +### Project Structure Notes + +- `packages/api/src/modules/diagram/project-router.ts` — NEW: project CRUD API +- `packages/api/src/index.ts` — MODIFIED: add projectRouter registration +- `packages/api/src/modules/diagram/router.ts` — MODIFIED: add projectId/unorganized query filters +- `apps/web/src/modules/diagram/components/sidebar/DiagramSidebar.tsx` — NEW: client sidebar with tabs +- `apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx` — NEW: project tree view +- `apps/web/src/modules/diagram/components/sidebar/RecentList.tsx` — NEW: recent diagrams list +- `apps/web/src/modules/diagram/components/sidebar/ProjectContextMenu.tsx` — NEW: rename/delete context menu +- `apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx` — MODIFIED: add project select +- `apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx` — MODIFIED: integrate DiagramSidebar +- `apps/web/src/app/[locale]/dashboard/(user)/layout.tsx` — MODIFIED: add "Diagrams" to sidebar menu +- `apps/web/src/config/paths.ts` — Already has `diagrams` path (no change needed) +- `packages/api/tests/diagram/project-schema.test.ts` — NEW: project API schema tests + +### Anti-Patterns to Avoid + +- **NEVER** use `uuid()` column type — already using `text().$defaultFn(generateId)` ✓ +- **NEVER** hard-delete diagrams when deleting a project — set `projectId = null` (move to Unorganized) +- **NEVER** put business logic in API routers — keep DELETE handler simple: nullify FKs → delete row +- **NEVER** co-locate feature code in route directories — sidebar goes in `~/modules/diagram/components/sidebar/` +- **DO NOT** install `@dnd-kit` yet — drag-and-drop is Story 1.4. This story only covers Projects tree + Recent list + context menus +- **DO NOT** modify the `DashboardSidebar` component itself — only modify the `menu` config in the layout file +- **DO NOT** change the project or diagram DB schema — both tables already exist from Story 1.1 + +### Previous Story Intelligence (Story 1.1) + +**Key learnings from Story 1.1 implementation:** +- `date-fns` is NOT available — use the inline `timeAgo()` helper already in DiagramCard.tsx +- Drizzle `createInsertSchema` with `coerce: true` makes most fields optional — be careful with test expectations +- The Hono RPC client returns JSON-serialized dates as strings, not Date objects — use `DiagramResponse` type from DiagramCard.tsx for frontend types +- `toast` from `sonner` for error feedback +- `Icons.AlertTriangle` is available for error states +- DiagramCard already exports `diagramTypeConfig` for reuse in the sidebar Recent list +- `pathsConfig.dashboard.user.diagram(id)` for navigation to editor +- `pathsConfig.dashboard.user.diagrams` for the diagrams list page +- All 92 existing tests pass — don't break them + +**Files from Story 1.1 that this story modifies:** +- `packages/api/src/modules/diagram/router.ts` — add query param filtering +- `apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx` — add project picker +- `apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx` — integrate sidebar +- `apps/web/src/app/[locale]/dashboard/(user)/layout.tsx` — add Diagrams menu item + +### Git Intelligence + +Recent commits: +- `392da38 feat: implement Story 1.1 — create and view diagrams` — 20 files, established all diagram patterns +- `da3368f docs: add autonomous setup prompt for AI agents` +- `06f3722 docs: add developer setup instructions to README` +- `3527e73 feat: turbostarter boilerplate` — initial project + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.2] — Full AC and technical notes +- [Source: _bmad-output/planning-artifacts/architecture.md] — Projects (FR55-56) → `diagrams/` API + `sidebar/` frontend + `project.ts` schema +- [Source: _bmad-output/planning-artifacts/architecture.md] — Source tree: `apps/web/src/modules/diagram/components/sidebar/DiagramSidebar.tsx` +- [Source: _bmad-output/implementation-artifacts/1-1-create-and-view-diagrams.md] — Previous story with all file patterns and learnings +- [Source: _bmad-output/project-context.md] — All critical implementation rules +- [Source: apps/web/src/app/[locale]/dashboard/(user)/layout.tsx] — Dashboard sidebar menu config pattern +- [Source: apps/web/src/modules/common/layout/dashboard/sidebar.tsx] — DashboardSidebar component (server component, menu-driven) + +## Dev Agent Record + +### Agent Model Used +Claude Opus 4.6 (team-based: backend + frontend + tests agents in parallel) + +### Debug Log References +- TypeScript: 0 errors (both API and web packages) +- Tests: 107 pass (92 existing + 15 new project schema tests) +- Frontend agent adapted ContextMenu → DropdownMenu (kebab menu, more discoverable UX) +- Frontend agent added FolderOpen, Inbox, Trash2 icons to icons.tsx + +### Completion Notes List +- Task 1: Created `project-router.ts` with GET/POST/PATCH/DELETE. Ownership checks on PATCH/DELETE. DELETE nullifies diagram projectIds before removing project row. +- Task 2: Modified `router.ts` GET /diagrams to accept `projectId` and `unorganized` query params for filtering. +- Task 3: Updated `CreateDiagramDialog.tsx` with project Select dropdown, fetches projects via useQuery, passes projectId to create mutation. +- Task 4: Created 4 sidebar components: `DiagramSidebar.tsx` (tabs + new project form), `ProjectTree.tsx` (All/Unorganized/projects tree with expand/collapse), `RecentList.tsx` (flat list with timeAgo), `ProjectContextMenu.tsx` (dropdown menu with rename/delete + AlertDialog confirmation). +- Task 5: ProjectContextMenu uses DropdownMenu (kebab) instead of ContextMenu (right-click) — more accessible and discoverable. Rename state lifted to ProjectTree for inline editing. +- Task 6: Created 15 project schema validation tests (6 create + 9 update) in `project-schema.test.ts`. +- Layout: Added "diagrams" item to dashboard sidebar menu config. +- Integration: DiagramSidebar integrated as left panel in diagrams page with filtered diagram query. + +### Senior Developer Review (AI) +**Reviewer:** Claude Opus 4.6 (adversarial code review) +**Issues Found:** 2 HIGH, 4 MEDIUM, 1 LOW — all 6 HIGH/MEDIUM fixed + +| # | Severity | Issue | File | Fix | +|---|----------|-------|------|-----| +| 1 | HIGH | DiagramsPage used raw `fetch` instead of Hono RPC client | `diagrams/page.tsx` | Switched to `api.diagrams.$get({ query })` with typed `listDiagramsQuerySchema` | +| 2 | HIGH | DELETE /projects/:id not transactional | `project-router.ts` | Wrapped in `db.transaction()` | +| 3 | MEDIUM | Unused `Input` import | `ProjectContextMenu.tsx` | Removed | +| 4 | MEDIUM | PATCH accepts empty body | `project-router.ts` | Added `.refine()` requiring at least one field | +| 5 | MEDIUM | Duplicate icon for diagrams/demos menu | `layout.tsx` | Changed diagrams to `Icons.GitBranch` | +| 6 | MEDIUM | Duplicate `timeAgo` function | `RecentList.tsx` | Exported from DiagramCard, imported in RecentList | +| 7 | LOW | Raw fetch doesn't check `res.ok` | `diagrams/page.tsx` | Resolved by fix #1 (RPC client handles this) | + +### File List +- `packages/api/src/modules/diagram/project-router.ts` — NEW: project CRUD API (GET/POST/PATCH/DELETE) +- `packages/api/src/index.ts` — MODIFIED: registered projectRouter at /projects +- `packages/api/src/modules/diagram/router.ts` — MODIFIED: added projectId/unorganized query filters to GET / +- `apps/web/src/modules/diagram/components/sidebar/DiagramSidebar.tsx` — NEW: client sidebar with tabs + project creation +- `apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx` — NEW: project tree with expand/collapse, inline rename +- `apps/web/src/modules/diagram/components/sidebar/RecentList.tsx` — NEW: recent diagrams flat list +- `apps/web/src/modules/diagram/components/sidebar/ProjectContextMenu.tsx` — NEW: dropdown menu with rename/delete +- `apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx` — MODIFIED: added project picker Select +- `apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx` — MODIFIED: integrated DiagramSidebar, filtered queries +- `apps/web/src/app/[locale]/dashboard/(user)/layout.tsx` — MODIFIED: added Diagrams to sidebar menu +- `packages/ui/web/src/components/icons.tsx` — MODIFIED: added FolderOpen, Inbox, Trash2 icons +- `packages/api/tests/diagram/project-schema.test.ts` — NEW: 15 project schema validation tests diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a77be83..36cc95f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -43,7 +43,7 @@ development_status: # ── Epic 1: Workspace & Diagram Management (Phase 1 - Foundation) ── epic-1: in-progress 1-1-create-and-view-diagrams: done - 1-2-organize-diagrams-into-projects: backlog + 1-2-organize-diagrams-into-projects: done 1-3-diagram-access-control-and-management: backlog 1-4-recent-view-and-drag-and-drop-organization: backlog epic-1-retrospective: optional diff --git a/apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx index 48ae4ff..54b7493 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx @@ -1,15 +1,25 @@ "use client"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Icons } from "@turbostarter/ui-web/icons"; import { api } from "~/lib/api/client"; import { DiagramGrid } from "~/modules/diagram/components/DiagramGrid"; +import { DiagramSidebar } from "~/modules/diagram/components/sidebar/DiagramSidebar"; export default function DiagramsPage() { + const [selectedProjectId, setSelectedProjectId] = useState(null); + const { data, isLoading, isError } = useQuery({ - queryKey: ["diagrams"], + queryKey: ["diagrams", selectedProjectId], queryFn: async () => { - const res = await api.diagrams.$get(); + const query: { projectId?: string; unorganized?: "true" } = {}; + if (selectedProjectId === "unorganized") { + query.unorganized = "true"; + } else if (selectedProjectId) { + query.projectId = selectedProjectId; + } + const res = await api.diagrams.$get({ query }); return await res.json(); }, }); @@ -26,8 +36,14 @@ export default function DiagramsPage() { } return ( -
- +
+ +
+ +
); } diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx index d4d9118..43bd416 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -20,6 +20,11 @@ const menu = [ href: pathsConfig.dashboard.user.index, icon: Icons.Home, }, + { + title: "diagrams", + href: pathsConfig.dashboard.user.diagrams, + icon: Icons.GitBranch, + }, { title: "aiTools", href: pathsConfig.apps.chat.index, diff --git a/apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx b/apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx index 73e3457..7b31242 100644 --- a/apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx +++ b/apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, @@ -13,6 +13,13 @@ import { import { Button } from "@turbostarter/ui-web/button"; import { Input } from "@turbostarter/ui-web/input"; import { Icons } from "@turbostarter/ui-web/icons"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@turbostarter/ui-web/select"; import { toast } from "sonner"; import { api } from "~/lib/api/client"; import { pathsConfig } from "~/config/paths"; @@ -31,11 +38,22 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) { const [open, setOpen] = useState(false); const [title, setTitle] = useState(""); const [selectedType, setSelectedType] = useState("flowchart"); + const [selectedProjectId, setSelectedProjectId] = useState(undefined); const router = useRouter(); const queryClient = useQueryClient(); + const { data: projectsData } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const res = await api.projects.$get(); + return await res.json(); + }, + }); + + const projects = projectsData?.data ?? []; + const createMutation = useMutation({ - mutationFn: async (input: { title: string; type: DiagramType }) => { + mutationFn: async (input: { title: string; type: DiagramType; projectId?: string }) => { const res = await api.diagrams.$post({ json: input }); return await res.json(); }, @@ -44,6 +62,7 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) { setOpen(false); setTitle(""); setSelectedType("flowchart"); + setSelectedProjectId(undefined); if (data.data) { router.push(pathsConfig.dashboard.user.diagram(data.data.id)); } @@ -56,7 +75,11 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) return; - createMutation.mutate({ title: title.trim(), type: selectedType }); + createMutation.mutate({ + title: title.trim(), + type: selectedType, + projectId: selectedProjectId, + }); }; return ( @@ -80,6 +103,24 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) { />
+
+ + +
+
diff --git a/apps/web/src/modules/diagram/components/DiagramCard.tsx b/apps/web/src/modules/diagram/components/DiagramCard.tsx index 38c55e2..cbf1cba 100644 --- a/apps/web/src/modules/diagram/components/DiagramCard.tsx +++ b/apps/web/src/modules/diagram/components/DiagramCard.tsx @@ -10,7 +10,7 @@ export type DiagramResponse = Omit void; +} + +export function DiagramSidebar({ selectedProjectId, onSelectProject }: DiagramSidebarProps) { + const [isCreating, setIsCreating] = useState(false); + const [newProjectName, setNewProjectName] = useState(""); + const queryClient = useQueryClient(); + + const createProjectMutation = useMutation({ + mutationFn: async (name: string) => { + const res = await api.projects.$post({ json: { name } }); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + setIsCreating(false); + setNewProjectName(""); + toast.success("Project created"); + }, + onError: () => { + toast.error("Failed to create project"); + }, + }); + + const handleCreateProject = () => { + if (!newProjectName.trim()) return; + createProjectMutation.mutate(newProjectName.trim()); + }; + + return ( +
+
+

Diagrams

+ +
+ + {isCreating && ( +
+
{ + e.preventDefault(); + handleCreateProject(); + }} + className="flex gap-1" + > + setNewProjectName(e.target.value)} + placeholder="Project name" + className="h-7 text-xs" + autoFocus + onKeyDown={(e) => { + if (e.key === "Escape") { + setIsCreating(false); + setNewProjectName(""); + } + }} + /> + +
+
+ )} + + + + Projects + Recent + + + + + + + + +
+ ); +} diff --git a/apps/web/src/modules/diagram/components/sidebar/ProjectContextMenu.tsx b/apps/web/src/modules/diagram/components/sidebar/ProjectContextMenu.tsx new file mode 100644 index 0000000..f1cd827 --- /dev/null +++ b/apps/web/src/modules/diagram/components/sidebar/ProjectContextMenu.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@turbostarter/ui-web/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@turbostarter/ui-web/alert-dialog"; +import { Button } from "@turbostarter/ui-web/button"; +import { Icons } from "@turbostarter/ui-web/icons"; +import { toast } from "sonner"; +import { api } from "~/lib/api/client"; + +interface ProjectContextMenuProps { + project: { id: string; name: string }; + onStartRename: () => void; +} + +export function ProjectContextMenu({ project, onStartRename }: ProjectContextMenuProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const res = await api.projects[":id"].$delete({ + param: { id: project.id }, + }); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + queryClient.invalidateQueries({ queryKey: ["diagrams"] }); + setShowDeleteDialog(false); + toast.success("Project deleted"); + }, + onError: () => { + toast.error("Failed to delete project"); + }, + }); + + return ( + <> + + + + + + + + Rename + + setShowDeleteDialog(true)} + variant="destructive" + > + + Delete + + + + + + + + Delete project “{project.name}”? + + Diagrams in this project will be moved to Unorganized. This action cannot be undone. + + + + Cancel + deleteMutation.mutate()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + ); +} diff --git a/apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx b/apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx new file mode 100644 index 0000000..1886566 --- /dev/null +++ b/apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Icons } from "@turbostarter/ui-web/icons"; +import { Input } from "@turbostarter/ui-web/input"; +import { toast } from "sonner"; +import { api } from "~/lib/api/client"; +import { pathsConfig } from "~/config/paths"; +import { diagramTypeConfig } from "../DiagramCard"; +import { ProjectContextMenu } from "./ProjectContextMenu"; + +import type { DiagramResponse } from "../DiagramCard"; + +interface ProjectTreeProps { + selectedProjectId: string | null; + onSelectProject: (projectId: string | null) => void; +} + +export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [expandedProjects, setExpandedProjects] = useState>(new Set()); + const [renamingId, setRenamingId] = useState(null); + const [renameName, setRenameName] = useState(""); + + const { data: projectsData } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const res = await api.projects.$get(); + return await res.json(); + }, + }); + + const { data: allDiagrams } = useQuery({ + queryKey: ["diagrams"], + queryFn: async () => { + const res = await api.diagrams.$get({ query: {} }); + return await res.json(); + }, + }); + + const renameMutation = useMutation({ + mutationFn: async ({ id, name }: { id: string; name: string }) => { + const res = await api.projects[":id"].$patch({ + param: { id }, + json: { name }, + }); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + setRenamingId(null); + toast.success("Project renamed"); + }, + onError: () => { + toast.error("Failed to rename project"); + }, + }); + + const projects = projectsData?.data ?? []; + const diagrams = (allDiagrams?.data ?? []) as DiagramResponse[]; + + const toggleExpand = (projectId: string) => { + setExpandedProjects(prev => { + const next = new Set(prev); + if (next.has(projectId)) next.delete(projectId); + else next.add(projectId); + return next; + }); + }; + + const getDiagramsForProject = (projectId: string) => + diagrams.filter(d => d.projectId === projectId); + + const unorganizedCount = diagrams.filter(d => !d.projectId).length; + + const handleRename = (projectId: string, originalName: string) => { + if (!renameName.trim() || renameName.trim() === originalName) { + setRenamingId(null); + return; + } + renameMutation.mutate({ id: projectId, name: renameName.trim() }); + }; + + const itemClass = (isActive: boolean) => + `flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer hover:bg-accent/50 ${ + isActive ? "bg-accent text-accent-foreground" : "" + }`; + + return ( +
+ {/* All Diagrams */} + + + {/* Unorganized */} + + + {/* Separator */} + {projects.length > 0 &&
} + + {/* Projects */} + {projects.map((proj) => { + const projectDiagrams = getDiagramsForProject(proj.id); + const isExpanded = expandedProjects.has(proj.id); + const isActive = selectedProjectId === proj.id; + const isRenaming = renamingId === proj.id; + + return ( +
+
+ + + {isRenaming ? ( +
+ setRenameName(e.target.value)} + onBlur={() => handleRename(proj.id, proj.name)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(proj.id, proj.name); + if (e.key === "Escape") setRenamingId(null); + }} + className="h-7 text-xs" + autoFocus + /> +
+ ) : ( + + )} + + {!isRenaming && ( + { + setRenameName(proj.name); + setRenamingId(proj.id); + }} + /> + )} +
+ + {/* Expanded diagram list */} + {isExpanded && projectDiagrams.length > 0 && ( +
+ {projectDiagrams.map((d) => { + const config = diagramTypeConfig[d.type]; + const TypeIcon = config.icon; + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/modules/diagram/components/sidebar/RecentList.tsx b/apps/web/src/modules/diagram/components/sidebar/RecentList.tsx new file mode 100644 index 0000000..8d9b27b --- /dev/null +++ b/apps/web/src/modules/diagram/components/sidebar/RecentList.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Icons } from "@turbostarter/ui-web/icons"; +import { api } from "~/lib/api/client"; +import { pathsConfig } from "~/config/paths"; +import { diagramTypeConfig, timeAgo } from "../DiagramCard"; + +import type { DiagramResponse } from "../DiagramCard"; + +export function RecentList() { + const router = useRouter(); + + const { data, isLoading } = useQuery({ + queryKey: ["diagrams"], + queryFn: async () => { + const res = await api.diagrams.$get({ query: {} }); + return await res.json(); + }, + }); + + const diagrams = (data?.data ?? []) as DiagramResponse[]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (diagrams.length === 0) { + return ( +

+ No diagrams yet +

+ ); + } + + return ( +
+ {diagrams.map((d) => { + const config = diagramTypeConfig[d.type]; + const TypeIcon = config.icon; + return ( + + ); + })} +
+ ); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 60411d1..0afd9c4 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -14,6 +14,7 @@ import { aiRouter } from "./modules/ai/router"; import { authRouter } from "./modules/auth/router"; import { billingRouter } from "./modules/billing/router"; import { diagramRouter } from "./modules/diagram/router"; +import { projectRouter } from "./modules/diagram/project-router"; import { organizationRouter } from "./modules/organization/router"; import { storageRouter } from "./modules/storage/router"; import { onError } from "./utils/on-error"; @@ -50,6 +51,7 @@ const appRouter = new Hono() .route("/auth", authRouter) .route("/billing", billingRouter) .route("/diagrams", diagramRouter) + .route("/projects", projectRouter) .route("/organizations", organizationRouter) .route("/storage", storageRouter) .onError(onError); diff --git a/packages/api/src/modules/diagram/project-router.ts b/packages/api/src/modules/diagram/project-router.ts new file mode 100644 index 0000000..7fdc06a --- /dev/null +++ b/packages/api/src/modules/diagram/project-router.ts @@ -0,0 +1,111 @@ +import { Hono } from "hono"; +import { and, asc, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { diagram, project } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; +import { HttpStatusCode } from "@turbostarter/shared/constants"; +import { HttpException } from "@turbostarter/shared/utils"; + +import { enforceAuth, validate } from "../../middleware"; + +export const createProjectSchema = z.object({ + name: z.string().min(1).max(100), +}); + +export const updateProjectSchema = z + .object({ + name: z.string().min(1).max(100).optional(), + sortOrder: z.number().int().optional(), + }) + .refine((data) => data.name !== undefined || data.sortOrder !== undefined, { + message: "At least one field (name or sortOrder) must be provided", + }); + +export const projectRouter = new Hono() + .get("/", enforceAuth, async (c) => { + const projects = await db + .select() + .from(project) + .where(eq(project.userId, c.var.user.id)) + .orderBy(asc(project.sortOrder)); + + return c.json({ data: projects }); + }) + .post( + "/", + enforceAuth, + validate("json", createProjectSchema), + async (c) => { + const input = c.req.valid("json"); + const [created] = await db + .insert(project) + .values({ + ...input, + userId: c.var.user.id, + }) + .returning(); + + return c.json({ data: created }); + }, + ) + .patch( + "/:id", + enforceAuth, + validate("json", updateProjectSchema), + async (c) => { + const [existing] = await db + .select() + .from(project) + .where( + and( + eq(project.id, c.req.param("id")), + eq(project.userId, c.var.user.id), + ), + ); + + if (!existing) { + throw new HttpException(HttpStatusCode.NOT_FOUND, { + code: "error.notFound", + }); + } + + const [updated] = await db + .update(project) + .set(c.req.valid("json")) + .where(eq(project.id, c.req.param("id"))) + .returning(); + + return c.json({ data: updated }); + }, + ) + .delete("/:id", enforceAuth, async (c) => { + const [existing] = await db + .select() + .from(project) + .where( + and( + eq(project.id, c.req.param("id")), + eq(project.userId, c.var.user.id), + ), + ); + + if (!existing) { + throw new HttpException(HttpStatusCode.NOT_FOUND, { + code: "error.notFound", + }); + } + + await db.transaction(async (tx) => { + // Move diagrams to Unorganized (null projectId) + await tx + .update(diagram) + .set({ projectId: null }) + .where(eq(diagram.projectId, c.req.param("id"))); + + // Delete the project + await tx.delete(project).where(eq(project.id, c.req.param("id"))); + }); + + return c.json({ data: { success: true } }); + }); diff --git a/packages/api/src/modules/diagram/router.ts b/packages/api/src/modules/diagram/router.ts index ab98f96..384e081 100644 --- a/packages/api/src/modules/diagram/router.ts +++ b/packages/api/src/modules/diagram/router.ts @@ -9,6 +9,11 @@ import { HttpException } from "@turbostarter/shared/utils"; import { enforceAuth, validate } from "../../middleware"; +export const listDiagramsQuerySchema = z.object({ + projectId: z.string().optional(), + unorganized: z.enum(["true"]).optional(), +}); + export const createDiagramSchema = z.object({ title: z.string().min(1).max(255), type: z.enum([ @@ -23,13 +28,24 @@ export const createDiagramSchema = z.object({ }); export const diagramRouter = new Hono() - .get("/", enforceAuth, async (c) => { + .get("/", enforceAuth, validate("query", listDiagramsQuerySchema), async (c) => { + const { projectId, unorganized } = c.req.valid("query"); + + const conditions = [ + eq(diagram.userId, c.var.user.id), + isNull(diagram.deletedAt), + ]; + + if (projectId) { + conditions.push(eq(diagram.projectId, projectId)); + } else if (unorganized === "true") { + conditions.push(isNull(diagram.projectId)); + } + const diagrams = await db .select() .from(diagram) - .where( - and(eq(diagram.userId, c.var.user.id), isNull(diagram.deletedAt)), - ) + .where(and(...conditions)) .orderBy(desc(diagram.updatedAt)); return c.json({ data: diagrams }); diff --git a/packages/api/tests/diagram/project-schema.test.ts b/packages/api/tests/diagram/project-schema.test.ts new file mode 100644 index 0000000..5ba44fe --- /dev/null +++ b/packages/api/tests/diagram/project-schema.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; + +import { + createProjectSchema, + updateProjectSchema, +} from "../../src/modules/diagram/project-router"; + +describe("createProjectSchema", () => { + it("should accept a valid name", () => { + const result = createProjectSchema.safeParse({ name: "My Project" }); + expect(result.success).toBe(true); + }); + + it("should reject empty name", () => { + const result = createProjectSchema.safeParse({ name: "" }); + expect(result.success).toBe(false); + }); + + it("should reject name over 100 characters", () => { + const result = createProjectSchema.safeParse({ name: "a".repeat(101) }); + expect(result.success).toBe(false); + }); + + it("should accept name at max length (100)", () => { + const result = createProjectSchema.safeParse({ name: "a".repeat(100) }); + expect(result.success).toBe(true); + }); + + it("should reject missing name", () => { + const result = createProjectSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("should strip unknown fields", () => { + const result = createProjectSchema.safeParse({ + name: "Test", + unknownField: "should be stripped", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("unknownField"); + } + }); +}); + +describe("updateProjectSchema", () => { + it("should accept valid name update", () => { + const result = updateProjectSchema.safeParse({ name: "Updated Name" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("Updated Name"); + } + }); + + it("should accept valid sortOrder update", () => { + const result = updateProjectSchema.safeParse({ sortOrder: 5 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortOrder).toBe(5); + } + }); + + it("should accept both name and sortOrder", () => { + const result = updateProjectSchema.safeParse({ + name: "New Name", + sortOrder: 3, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "New Name", sortOrder: 3 }); + } + }); + + it("should reject empty object (at least one field required)", () => { + const result = updateProjectSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("should reject name over 100 characters", () => { + const result = updateProjectSchema.safeParse({ name: "a".repeat(101) }); + expect(result.success).toBe(false); + }); + + it("should reject empty name", () => { + const result = updateProjectSchema.safeParse({ name: "" }); + expect(result.success).toBe(false); + }); + + it("should reject non-integer sortOrder", () => { + const result = updateProjectSchema.safeParse({ sortOrder: 1.5 }); + expect(result.success).toBe(false); + }); + + it("should reject non-number sortOrder", () => { + const result = updateProjectSchema.safeParse({ sortOrder: "abc" }); + expect(result.success).toBe(false); + }); + + it("should strip unknown fields", () => { + const result = updateProjectSchema.safeParse({ + name: "Test", + unknownField: "should be stripped", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("unknownField"); + } + }); +}); diff --git a/packages/ui/web/src/components/icons.tsx b/packages/ui/web/src/components/icons.tsx index aacb467..fd80b08 100644 --- a/packages/ui/web/src/components/icons.tsx +++ b/packages/ui/web/src/components/icons.tsx @@ -153,6 +153,9 @@ import { Server, ArrowRightLeft, GitBranch, + FolderOpen, + Inbox, + Trash2, } from "lucide-react"; import { Icons as GlobalIcons } from "@turbostarter/ui/assets"; @@ -381,6 +384,9 @@ export const Icons = { Server, ArrowRightLeft, GitBranch, + FolderOpen, + Inbox, + Trash2, MinusIcon: Minus, PlusIcon: Plus, // AI provider icons