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:
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user