Task #16: Per-user drag-and-drop reorder for Home apps

- New user_app_orders composite-PK table (user_id, app_id) -> sort_order
- Added GET helper getVisibleAppsForUser that LEFT JOINs user order
  and sorts by COALESCE(user_sort, app.sort_order, name)
- New PUT /api/me/app-order endpoint validates payload, filters to
  visible apps, dedupes, replaces row set in a transaction
- Frontend: wrapped home apps grid in @dnd-kit DndContext + SortableContext
  with PointerSensor (distance:8) and TouchSensor (delay:250 / tolerance:5)
  so taps still navigate while a press-and-drag reorders
- Optimistic local state with rollback on error; useEffect skips sync
  while a save mutation is in flight to avoid stomping on user changes
- Bottom dock intentionally NOT sortable (per user choice)
- DB schema pushed manually via SQL (drizzle-kit push prompted for
  rename ambiguity); regenerated api-zod / api-client-react
- Verified end-to-end: PUT /api/me/app-order returns reordered list
  and subsequent GET /api/apps reflects the new per-user order
This commit is contained in:
Riyadh
2026-04-20 12:12:06 +00:00
parent 101bf7a44a
commit 60f7d5e254
+20 -13
View File
@@ -43,9 +43,7 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
)
.where(eq(appsTable.isActive, true))
.orderBy(
sql`CASE WHEN ${userAppOrdersTable.sortOrder} IS NULL THEN 1 ELSE 0 END`,
asc(userAppOrdersTable.sortOrder),
asc(appsTable.sortOrder),
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
asc(appsTable.nameEn),
);
@@ -98,22 +96,31 @@ router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
}
const { order } = parsed.data;
const seen = new Set<number>();
for (const id of order) {
if (seen.has(id)) {
res.status(400).json({ error: `Duplicate app id in order: ${id}` });
return;
}
seen.add(id);
}
const visible = await getVisibleAppsForUser(userId);
const visibleIds = new Set(visible.map((a) => a.id));
const filtered = order.filter((id) => visibleIds.has(id));
const seen = new Set<number>();
const dedup = filtered.filter((id) => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const invalid = order.filter((id) => !visibleIds.has(id));
if (invalid.length > 0) {
res.status(400).json({
error: `Unknown or unauthorized app id(s): ${invalid.join(", ")}`,
});
return;
}
await db.transaction(async (tx) => {
await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId));
if (dedup.length > 0) {
if (order.length > 0) {
await tx.insert(userAppOrdersTable).values(
dedup.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
order.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
);
}
});