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:
riyadhafraa
2026-04-20 12:13:22 +00:00
parent 3df6eb7c54
commit a8258384c7
+8 -2
View File
@@ -98,8 +98,8 @@ export default function HomePage() {
const [orderedApps, setOrderedApps] = useState<App[] | null>(null);
useEffect(() => {
if (apps && !updateOrder.isPending) setOrderedApps(apps);
}, [apps, updateOrder.isPending]);
if (apps) setOrderedApps((prev) => prev ?? apps);
}, [apps]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -112,12 +112,18 @@ export default function HomePage() {
const oldIndex = orderedApps.findIndex((a) => a.id === active.id);
const newIndex = orderedApps.findIndex((a) => a.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const previous = orderedApps;
const next = arrayMove(orderedApps, oldIndex, newIndex);
setOrderedApps(next);
updateOrder.mutate(
{ data: { order: next.map((a) => a.id) } },
{
onSuccess: (data) => {
setOrderedApps(data);
queryClient.setQueryData(getListAppsQueryKey(), data);
},
onError: () => {
setOrderedApps(previous);
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
},
},