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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="@container h-full p-6">
|
||||
<div className="flex h-full">
|
||||
<DiagramSidebar
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={setSelectedProjectId}
|
||||
/>
|
||||
<div className="@container flex-1 overflow-y-auto p-6">
|
||||
<DiagramGrid diagrams={data?.data ?? []} isLoading={isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DiagramType>("flowchart");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(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) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project</label>
|
||||
<Select
|
||||
value={selectedProjectId ?? "none"}
|
||||
onValueChange={(v) => setSelectedProjectId(v === "none" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="No project (Unorganized)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project (Unorganized)</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Diagram Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
|
||||
@@ -10,7 +10,7 @@ export type DiagramResponse = Omit<SelectDiagram, "createdAt" | "updatedAt" | "d
|
||||
deletedAt: string | null;
|
||||
};
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
export function timeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 0) return "just now";
|
||||
if (seconds < 60) return "just now";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@turbostarter/ui-web/tabs";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
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 { ProjectTree } from "./ProjectTree";
|
||||
import { RecentList } from "./RecentList";
|
||||
|
||||
interface DiagramSidebarProps {
|
||||
selectedProjectId: string | null;
|
||||
onSelectProject: (projectId: string | null) => 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 (
|
||||
<div className="flex h-full w-64 shrink-0 flex-col border-r">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h2 className="text-sm font-semibold">Diagrams</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsCreating(true)}
|
||||
title="New Project"
|
||||
>
|
||||
<Icons.Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<div className="border-b p-2">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreateProject();
|
||||
}}
|
||||
className="flex gap-1"
|
||||
>
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsCreating(false);
|
||||
setNewProjectName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" size="icon" className="h-7 w-7 shrink-0" disabled={!newProjectName.trim()}>
|
||||
<Icons.Check className="h-3 w-3" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="projects" className="flex flex-1 flex-col overflow-hidden">
|
||||
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="projects" className="text-xs">Projects</TabsTrigger>
|
||||
<TabsTrigger value="recent" className="text-xs">Recent</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="projects" className="flex-1 overflow-y-auto p-2">
|
||||
<ProjectTree selectedProjectId={selectedProjectId} onSelectProject={onSelectProject} />
|
||||
</TabsContent>
|
||||
<TabsContent value="recent" className="flex-1 overflow-y-auto p-2">
|
||||
<RecentList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100">
|
||||
<Icons.MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={onStartRename}>
|
||||
<Icons.Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Icons.Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project “{project.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Diagrams in this project will be moved to Unorganized. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx
Normal file
203
apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx
Normal file
@@ -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<Set<string>>(new Set());
|
||||
const [renamingId, setRenamingId] = useState<string | null>(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 (
|
||||
<div className="space-y-1">
|
||||
{/* All Diagrams */}
|
||||
<button
|
||||
className={itemClass(selectedProjectId === null)}
|
||||
onClick={() => onSelectProject(null)}
|
||||
>
|
||||
<Icons.LayoutDashboard className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">All Diagrams</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{diagrams.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Unorganized */}
|
||||
<button
|
||||
className={itemClass(selectedProjectId === "unorganized")}
|
||||
onClick={() => onSelectProject("unorganized")}
|
||||
>
|
||||
<Icons.Inbox className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Unorganized</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{unorganizedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
{projects.length > 0 && <div className="my-2 border-t" />}
|
||||
|
||||
{/* 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 (
|
||||
<div key={proj.id}>
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(proj.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Icons.ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<Icons.ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRenaming ? (
|
||||
<div className="flex-1 px-1">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`${itemClass(isActive)} flex-1`}
|
||||
onClick={() => onSelectProject(proj.id)}
|
||||
>
|
||||
<Icons.FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{proj.name}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{projectDiagrams.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<ProjectContextMenu
|
||||
project={proj}
|
||||
onStartRename={() => {
|
||||
setRenameName(proj.name);
|
||||
setRenamingId(proj.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded diagram list */}
|
||||
{isExpanded && projectDiagrams.length > 0 && (
|
||||
<div className="ml-6 space-y-0.5">
|
||||
{projectDiagrams.map((d) => {
|
||||
const config = diagramTypeConfig[d.type];
|
||||
const TypeIcon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => router.push(pathsConfig.dashboard.user.diagram(d.id))}
|
||||
>
|
||||
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
|
||||
<span className="truncate">{d.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex justify-center p-4">
|
||||
<Icons.Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diagrams.length === 0) {
|
||||
return (
|
||||
<p className="p-2 text-center text-xs text-muted-foreground">
|
||||
No diagrams yet
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{diagrams.map((d) => {
|
||||
const config = diagramTypeConfig[d.type];
|
||||
const TypeIcon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => router.push(pathsConfig.dashboard.user.diagram(d.id))}
|
||||
>
|
||||
<TypeIcon className={`h-4 w-4 shrink-0 ${config.color}`} />
|
||||
<span className="flex-1 truncate text-left">{d.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{d.updatedAt ? timeAgo(new Date(d.updatedAt)) : "just now"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
111
packages/api/src/modules/diagram/project-router.ts
Normal file
111
packages/api/src/modules/diagram/project-router.ts
Normal file
@@ -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 } });
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
109
packages/api/tests/diagram/project-schema.test.ts
Normal file
109
packages/api/tests/diagram/project-schema.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user