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

- DB: new note_folders table (per-user unique name) + nullable folder_id on
  notes with ON DELETE SET NULL.
- API: full /note-folders CRUD with user scoping; /notes POST/PATCH validate
  folder ownership; folder noteCount is tab-aware (active vs archived) via
  ?archived= query.
- Client: NoteFolder type, useNoteFolders/useCreateFolder/useUpdateFolder/
  useDeleteFolder/useMoveNoteToFolder hooks (optimistic across all
  ["notes", ...] caches).
- UI: new FoldersRail rendered next to My Notes + Archive tabs; HTML5
  draggable note cards on desktop, touch long-press fallback for iPad/mobile,
  responsive layout (horizontal chip row on small viewports, sidebar on
  md+); newly-created folder is auto-selected.
- Bilingual: notes.folders.* keys added to en.json and ar.json (RTL works
  via existing dir prop wired from i18n.language).
- Test: tests/notes-folders.spec.mjs covers create folder + auto-select,
  drag note to folder, refresh-and-still-in-folder persistence, filter by
  folder/Unfiled, drag between folders, rename, delete a non-empty folder
  (FK SET NULL), cross-user folder rejection (400), Arabic+RTL render,
  plus a separate touch test (iPhone-sized viewport, hasTouch+isMobile)
  that validates the chip-row layout (display:flex + overflow-x:auto) and
  exercises the touch drop pipeline (registered handler + dispatchTouchDropAt
  hit-test).

Drift from plan: none. Code-review pass round 2 added: touch fallback,
responsive mobile rail, auto-select-on-create, refresh-persistence test,
removed defensive try/catch around dataTransfer.
This commit is contained in:
riyadhafraa
2026-05-06 07:46:33 +00:00
parent 2b01cc1c24
commit 0bcfb9017d
2 changed files with 65 additions and 47 deletions
@@ -6,6 +6,7 @@ import {
FolderPlus,
Inbox as InboxIcon,
Layers,
MoreVertical,
Pencil,
Trash2,
Check,
@@ -13,6 +14,12 @@ import {
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
type NoteFolder,
useCreateFolder,
@@ -334,51 +341,62 @@ export function FoldersRail({
{f.noteCount}
</span>
</button>
<div className="flex items-center md:opacity-0 md:group-hover:opacity-100 pe-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingId(f.id);
setEditingName(f.name);
}}
className={`p-1 rounded hover:bg-black/10 ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
aria-label={t("notes.folders.rename", "Rename folder")}
data-testid={`folder-rename-${f.id}`}
>
<Pencil size={12} />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (
confirm(
t(
"notes.folders.deleteConfirm",
"Delete this folder? Notes inside will move to Unfiled.",
),
)
) {
del.mutate(f.id, {
onSuccess: () => {
if (selected.kind === "folder" && selected.id === f.id) {
onSelect({ kind: "all" });
}
},
});
}
}}
className={`p-1 rounded hover:bg-black/10 ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
aria-label={t("notes.folders.delete", "Delete folder")}
data-testid={`folder-delete-${f.id}`}
>
<Trash2 size={12} />
</button>
<div className="flex items-center pe-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={`p-1 rounded hover:bg-black/10 ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
aria-label={t("notes.folders.actions", "Folder actions")}
data-testid={`folder-menu-${f.id}`}
>
<MoreVertical size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onSelect={() => {
setEditingId(f.id);
setEditingName(f.name);
}}
data-testid={`folder-rename-${f.id}`}
>
<Pencil size={14} className="me-2" />
{t("notes.folders.rename", "Rename folder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (
confirm(
t(
"notes.folders.deleteConfirm",
"Delete this folder? Notes inside will move to Unfiled.",
),
)
) {
del.mutate(f.id, {
onSuccess: () => {
if (
selected.kind === "folder" &&
selected.id === f.id
) {
onSelect({ kind: "all" });
}
},
});
}
}}
className="text-red-600 focus:text-red-600"
data-testid={`folder-delete-${f.id}`}
>
<Trash2 size={14} className="me-2" />
{t("notes.folders.delete", "Delete folder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
),
+2 -2
View File
@@ -253,7 +253,7 @@ test("create folder, drag note in, filter, move between folders, rename, delete"
).toHaveText(/^0$/);
// ---- Rename Projects -> "Work" ----
await page.getByTestId(`folder-select-${projects.id}`).hover();
await page.getByTestId(`folder-menu-${projects.id}`).click();
await page.getByTestId(`folder-rename-${projects.id}`).click();
const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`);
await renameInput.fill("Work");
@@ -293,7 +293,7 @@ test("create folder, drag note in, filter, move between folders, rename, delete"
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId(`folder-select-${personal.id}`).hover();
await page.getByTestId(`folder-menu-${personal.id}`).click();
await page.getByTestId(`folder-delete-${personal.id}`).click();
await deletePromise;
await expect(page.getByTestId(`folder-select-${personal.id}`)).toHaveCount(0);