notes: user-defined folders with drag-drop (task #413)

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.
This commit is contained in:
riyadhafraa
2026-05-06 08:07:43 +00:00
parent 6e49010940
commit 10694f8d42
+45 -6
View File
@@ -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<Note[]>({ 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<Note[]>({ 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<Note[]>({ queryKey: ["notes"] });
for (const [key, data] of noteQueries) {
noteSnapshots.push([key, data]);
if (Array.isArray(data)) {
qc.setQueryData<Note[]>(
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<NoteFolder[]>({
queryKey: ["note-folders"],
});
for (const [key, data] of folderQueries) {
folderSnapshots.push([key, data]);
if (Array.isArray(data)) {
qc.setQueryData<NoteFolder[]>(
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);
}
},