From 10694f8d423aa10e2de32d171e139c5de3436bac Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 6 May 2026 08:07:43 +0000 Subject: [PATCH] notes: user-defined folders with drag-drop (task #413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB - New `note_folders` table (per-user unique name); `notes.folder_id` nullable with `ON DELETE SET NULL` so deleting a folder preserves its notes. API - /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped. - /notes POST/PATCH validate folder ownership before assignment (no cross-user folder bleed). - Folder noteCount is tab-aware (active vs archived) via ?archived=. - OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react and api-zod (`pnpm --filter @workspace/api-spec run codegen`). Client - NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder / useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all ["notes", ...] caches; rollback on error). UI (FoldersRail next to My Notes + Archive tabs only) - Desktop: sidebar with All / Unfiled / user folders + counts. - Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid. - Per-folder kebab dropdown with Rename + Delete. - Newly-created folder is auto-selected. - HTML5 draggable note cards on desktop; touch long-press fallback (350ms) for iPad/mobile via shared drop pipeline (registerFolderDropHandler + hit-tested `[data-folder-drop]`). - Dev-only `window.__notesTouchTest` helper (gated to non-production builds) exposes the touch pipeline so e2e tests can drive it deterministically without synthesizing native TouchEvents. Bilingual - notes.folders.* keys added to en.json and ar.json (RTL inherited from existing dir prop wired from i18n.language). Tests (artifacts/tx-os/tests/notes-folders.spec.mjs) - create folder + auto-select - drag note → folder, refresh-and-still-in-folder persistence - filter by folder / Unfiled / All - drag between folders (count math) - rename via kebab - delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL) - cross-user folder rejection (400) - Arabic + RTL render - separate touch test (414×800, hasTouch+isMobile) covers chip-row layout and exercises the touch drop pipeline through window helper. All API node tests + tx-os Playwright suite + tsc clean. --- artifacts/tx-os/src/lib/notes-api.ts | 51 ++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index a184c00a..8baede19 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -341,10 +341,25 @@ export function useMoveNoteToFolder() { }), onMutate: async ({ id, folderId }) => { await qc.cancelQueries({ queryKey: ["notes"] }); - const snapshots: Array<[unknown, Note[] | undefined]> = []; - const queries = qc.getQueriesData({ queryKey: ["notes"] }); - for (const [key, data] of queries) { - snapshots.push([key, data]); + await qc.cancelQueries({ queryKey: ["note-folders"] }); + const noteSnapshots: Array<[unknown, Note[] | undefined]> = []; + const folderSnapshots: Array<[unknown, NoteFolder[] | undefined]> = []; + + // Resolve the previous folderId from any cached notes list so we can + // adjust the source folder badge optimistically too. + let previousFolderId: number | null | undefined; + for (const [, data] of qc.getQueriesData({ queryKey: ["notes"] })) { + if (!Array.isArray(data)) continue; + const hit = data.find((n) => n.id === id); + if (hit) { + previousFolderId = hit.folderId; + break; + } + } + + const noteQueries = qc.getQueriesData({ queryKey: ["notes"] }); + for (const [key, data] of noteQueries) { + noteSnapshots.push([key, data]); if (Array.isArray(data)) { qc.setQueryData( key, @@ -352,11 +367,35 @@ export function useMoveNoteToFolder() { ); } } - return { snapshots }; + + // Optimistically adjust folder badge counts: -1 on the source, +1 on + // the destination. `null` (Unfiled) has no badge in the folders list. + const folderQueries = qc.getQueriesData({ + queryKey: ["note-folders"], + }); + for (const [key, data] of folderQueries) { + folderSnapshots.push([key, data]); + if (Array.isArray(data)) { + qc.setQueryData( + key, + data.map((f) => { + let next = f.noteCount; + if (previousFolderId === f.id) next -= 1; + if (folderId === f.id) next += 1; + return next === f.noteCount ? f : { ...f, noteCount: Math.max(0, next) }; + }), + ); + } + } + + return { noteSnapshots, folderSnapshots }; }, onError: (_err, _vars, ctx) => { if (!ctx) return; - for (const [key, data] of ctx.snapshots) { + for (const [key, data] of ctx.noteSnapshots) { + qc.setQueryData(key as readonly unknown[], data); + } + for (const [key, data] of ctx.folderSnapshots) { qc.setQueryData(key as readonly unknown[], data); } },