feat: implement Story 1.2 — organize diagrams into projects

Add project CRUD API (GET/POST/PATCH/DELETE) with ownership checks and
transactional delete. Add diagram list filtering by projectId and
unorganized query params with typed Zod query schema for Hono RPC
type safety. Create DiagramSidebar with Projects tree (expand/collapse,
inline rename) and Recent tab. Add project picker to CreateDiagramDialog.
Includes 15 schema validation tests (107 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 21:45:16 +00:00
parent 392da385f4
commit 85e06c25c7
15 changed files with 1116 additions and 13 deletions

View File

@@ -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