Task #463: @dnd-kit notes drag + reorder

- Replace HTML5+touch drag with @dnd-kit; MouseSensor (desktop, 8px)
  + TouchSensor (iPad, 200ms long-press) so input sources never overlap.
- Add sort_order column; ORDER BY asc(sortOrder), updatedAt desc.
- PATCH /notes/reorder: strict isPinned boolean check, bucket+permission
  scoped, all writes wrapped in db.transaction for atomicity.
- PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change.
- Client useReorderNotes (PATCH) with optimistic cache update.
- handleDragEnd builds reorder payload from FULL bucket (owner notes
  or shared-folder bucket via ref), not the filtered subset, so hidden
  siblings under search/label filters keep their order.
- Drag guarded for view-only contexts: source must be owned OR in
  editable shared bucket; folder-drop additionally requires isOwn.
- SharedFolderView publishes its data.notes via bucketRef when viewer
  has edit permission, enabling correct reorder in shared folders.
- Layout fix at narrow viewport: rail stacks above notes
  (flex-col md:flex-row) so iPad portrait drag has proper bbox.
- Playwright tests: notes-folders.spec.mjs both desktop pointer drag
  and touch long-press drag pass (33s).
- OpenAPI codegen skipped: notes-api.ts is hand-written.
- Out of scope (pre-existing failures): executive-meetings reorder/font,
  notes-share PATCH 403/404, groups-crud rollback.
This commit is contained in:
riyadhafraa
2026-05-10 13:17:00 +00:00
parent daa4f6c038
commit f937362868
3 changed files with 28 additions and 31 deletions
+1 -1
View File
@@ -588,7 +588,7 @@ export function useReorderNotes() {
orderedIds: number[];
}) =>
req<{ success: true; updated: number }>(`/notes/reorder`, {
method: "POST",
method: "PATCH",
body: JSON.stringify({ folderId, isPinned, orderedIds }),
}),
onMutate: async ({ folderId, isPinned, orderedIds }) => {
+22 -22
View File
@@ -114,7 +114,7 @@ import {
} from "@/components/notes/folders-rail";
import {
DndContext,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
@@ -221,21 +221,19 @@ export default function NotesPage() {
const reorderNotes = useReorderNotes();
const showFolders = view === "active" || view === "archived";
// SharedFolderView publishes its currently visible notes here so that
// the page-level handleDragEnd can build a reorder payload from the
// shared-folder bucket (the page's own `notes` array doesn't include
// notes belonging to folders shared by other users).
// SharedFolderView publishes its visible notes here only when the
// viewer has edit permission, so handleDragEnd can resolve the bucket
// for shared folders. Null means "no editable shared-folder context".
const sharedFolderBucketRef = useRef<{
folderId: number;
notes: Note[];
} | null>(null);
// Sensors mirror the home.tsx pattern: a small pointer threshold so
// clicks/taps on the card body still fire onClick (open editor), and a
// 200ms long-press for touch with a generous tolerance so iPad scroll
// gestures don't accidentally start a drag.
// MouseSensor for desktop (8px threshold so clicks still open the editor)
// and TouchSensor for iPad with a 200ms long-press, kept on separate input
// sources so touch can never activate before the long-press elapses.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 },
}),
@@ -326,7 +324,17 @@ export default function NotesPage() {
| undefined;
if (!a || a.type !== "note" || a.noteId == null) return;
// Only allow drag actions when the source note belongs to a context
// we can actually mutate: the user's own notes, or a shared folder
// they have edit permission on (published via sharedFolderBucketRef).
const sharedBucket = sharedFolderBucketRef.current;
const isOwn = notes.some((n) => n.id === a.noteId);
const isEditableShared =
!!sharedBucket && sharedBucket.notes.some((n) => n.id === a.noteId);
if (!isOwn && !isEditableShared) return;
if (o?.type === "folder-drop" && o.target !== undefined) {
if (!isOwn) return;
moveNoteToTarget(o.target, a.noteId);
return;
}
@@ -335,13 +343,8 @@ export default function NotesPage() {
if (a.noteId === o.noteId) return;
if (a.folderId !== o.folderId) return;
if (a.isPinned !== o.isPinned) return;
// Build the bucket from the FULL underlying source for the active
// surface (owner notes vs shared-folder notes), not the filtered
// view. If we used the filtered subset, search/label filters would
// omit hidden siblings from `orderedIds` and the server would
// reassign sort_order=0..N-1 over only the visible rows, leaving
// duplicates against the hidden ones.
const sharedBucket = sharedFolderBucketRef.current;
// Use the FULL bucket source (not the filtered view) so search /
// label filters can't strand hidden siblings out of orderedIds.
const sourceNotes =
sharedBucket && sharedBucket.folderId === a.folderId
? sharedBucket.notes
@@ -3071,11 +3074,8 @@ function SharedFolderView({
// Recipients use the same top-bar search input as their personal notes;
// filter the read-only list client-side over title + content + checklist
// item text so the experience matches the owner's view.
// Publish the FULL (unfiltered) shared-folder bucket to the page-level
// ref so handleDragEnd can build a correct reorder payload even when a
// search filter is active. We only publish when this viewer can edit
// (otherwise reorder is forbidden anyway). Cleared on unmount and when
// the folder switches.
// Publish the full bucket only for editors so handleDragEnd has a
// correct source. View-only viewers leave the ref null (drag disabled).
useEffect(() => {
if (!data || data.myPermission !== "edit") {
bucketRef.current = null;