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:
@@ -467,7 +467,7 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /notes/reorder
|
||||
* PATCH /notes/reorder
|
||||
*
|
||||
* Persist the user-chosen order of notes within a single bucket. A bucket
|
||||
* is the unique combination of (note owner, folderId, isPinned). The
|
||||
@@ -482,7 +482,7 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
|
||||
* keeps the endpoint safe against stale client state without blowing up
|
||||
* the whole reorder.
|
||||
*/
|
||||
router.post("/notes/reorder", requireAuth, async (req, res): Promise<void> => {
|
||||
router.patch("/notes/reorder", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== "object") {
|
||||
@@ -535,12 +535,9 @@ router.post("/notes/reorder", requireAuth, async (req, res): Promise<void> => {
|
||||
}
|
||||
bucketOwnerId = access.ownerId;
|
||||
}
|
||||
// Pull every note in the destination bucket so we can validate ids
|
||||
// before writing. Anything not in this set (wrong owner, wrong folder,
|
||||
// wrong pin state, archived, deleted) is dropped from the update.
|
||||
// All writes happen inside one transaction so concurrent reorders never
|
||||
// observe a half-applied permutation (which would otherwise leave
|
||||
// duplicate sort_order values).
|
||||
// Validate ids against the destination bucket and write every
|
||||
// sort_order inside one transaction so concurrent reorders can't leave
|
||||
// a half-applied permutation with duplicate values.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const bucketRows = await tx
|
||||
.select({ id: notesTable.id })
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user