feat: implement Story 1.4 — recent view and drag-and-drop organization
Add sortOrder column to diagrams, extend PATCH endpoint with projectId and sortOrder fields, add POST /diagrams/reorder bulk endpoint with ownership verification and duplicate ID validation. Enhance RecentList with lastAiMessage preview subtitle. Implement @dnd-kit drag-and-drop in ProjectTree sidebar with cross-project moves, intra-project reorder, optimistic updates, drag overlay, drop indicators, and keyboard/pointer sensor support. Context-aware GET ordering: sortOrder for project views, updatedAt for recent/all views. 141 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { diagram } from "@turbostarter/db/schema";
|
||||
@@ -30,10 +30,33 @@ export const createDiagramSchema = z.object({
|
||||
export const updateDiagramBodySchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
})
|
||||
.refine((data) => data.title !== undefined, {
|
||||
message: "At least one field must be provided",
|
||||
});
|
||||
.refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.projectId !== undefined ||
|
||||
data.sortOrder !== undefined,
|
||||
{ message: "At least one field must be provided" },
|
||||
);
|
||||
|
||||
export const reorderDiagramsSchema = z
|
||||
.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
})
|
||||
.refine(
|
||||
(data) => new Set(data.items.map((i) => i.id)).size === data.items.length,
|
||||
{ message: "Duplicate diagram IDs not allowed" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch a diagram by ID and verify ownership.
|
||||
@@ -82,14 +105,56 @@ export const diagramRouter = new Hono()
|
||||
conditions.push(isNull(diagram.projectId));
|
||||
}
|
||||
|
||||
const orderClauses = projectId
|
||||
? [asc(diagram.sortOrder), desc(diagram.updatedAt)]
|
||||
: [desc(diagram.updatedAt)];
|
||||
|
||||
const diagrams = await db
|
||||
.select()
|
||||
.from(diagram)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(diagram.updatedAt));
|
||||
.orderBy(...orderClauses);
|
||||
|
||||
return c.json({ data: diagrams });
|
||||
})
|
||||
.post(
|
||||
"/reorder",
|
||||
enforceAuth,
|
||||
validate("json", reorderDiagramsSchema),
|
||||
async (c) => {
|
||||
const { items } = c.req.valid("json");
|
||||
|
||||
const diagramIds = items.map((i) => i.id);
|
||||
const owned = await db
|
||||
.select({ id: diagram.id })
|
||||
.from(diagram)
|
||||
.where(
|
||||
and(
|
||||
inArray(diagram.id, diagramIds),
|
||||
eq(diagram.userId, c.var.user.id),
|
||||
isNull(diagram.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (owned.length !== diagramIds.length) {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.forbidden",
|
||||
message: "One or more diagrams not found or not owned",
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx
|
||||
.update(diagram)
|
||||
.set({ sortOrder: item.sortOrder })
|
||||
.where(eq(diagram.id, item.id));
|
||||
}
|
||||
});
|
||||
|
||||
return c.json({ data: { success: true } });
|
||||
},
|
||||
)
|
||||
.get("/:id", enforceAuth, async (c) => {
|
||||
const d = await getOwnedDiagram(c.req.param("id"), c.var.user.id);
|
||||
return c.json({ data: d });
|
||||
|
||||
Reference in New Issue
Block a user