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; pushed via @workspace/db.
- 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 param computed by GROUP BY (drizzle correlated subquery
  was returning 0 — replaced with two queries merged in JS).
- Client: NoteFolder type, useNoteFolders/useCreateFolder/useUpdateFolder/
  useDeleteFolder/useMoveNoteToFolder hooks (optimistic update for moves
  across all ["notes", ...] caches).
- UI: new FoldersRail (artifacts/tx-os/src/components/notes/folders-rail.tsx)
  rendered next to My Notes + Archive tabs; HTML5 draggable note cards with
  module-scoped DRAGGING_NOTE_ID fallback; visible drop highlight; rename +
  delete + create inline; folder-scoped + Unfiled filtering in notes.tsx.
- Bilingual: notes.folders.* keys added to en.json and ar.json (RTL works
  via existing dir prop wired from i18n.language).
- Test: artifacts/tx-os/tests/notes-folders.spec.mjs covers create folder,
  drag note to folder, filter by folder/Unfiled, drag between folders,
  rename, delete a non-empty folder (verifies FK SET NULL + Unfiled count),
  cross-user folder rejection (400), and Arabic+RTL rendering.

Drift from plan: none — all originally scoped work shipped. Strengthened
e2e + cross-user check + tab-aware counts added per code review feedback.
This commit is contained in:
riyadhafraa
2026-05-06 07:26:11 +00:00
parent 6d3e38ec26
commit ba0da3d26c
8 changed files with 1021 additions and 3 deletions
+138 -1
View File
@@ -3,6 +3,7 @@ import { eq, and, desc, inArray, or, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
notesTable,
noteFoldersTable,
noteLabelsTable,
noteLabelAssignmentsTable,
noteRecipientsTable,
@@ -54,6 +55,30 @@ interface NoteInput {
isPinned?: boolean;
isArchived?: boolean;
labelIds?: number[];
folderId?: number | null;
}
function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } {
if (v === null) return { ok: true, value: null };
const n = Number(v);
if (!Number.isInteger(n) || n <= 0) return { ok: false };
return { ok: true, value: n };
}
function parseFolderName(body: any): { ok: true; name: string } | { ok: false; error: string } {
if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
if (typeof body.name !== "string") return { ok: false, error: "Invalid name" };
const name = body.name.trim();
if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" };
return { ok: true, name };
}
async function userOwnsFolder(userId: number, folderId: number): Promise<boolean> {
const [row] = await db
.select({ id: noteFoldersTable.id })
.from(noteFoldersTable)
.where(and(eq(noteFoldersTable.id, folderId), eq(noteFoldersTable.userId, userId)));
return !!row;
}
function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInput } | { ok: false; error: string } {
@@ -86,6 +111,11 @@ function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInp
if (ids === undefined) return { ok: false, error: "Invalid labelIds" };
data.labelIds = ids;
} else if (isCreate) data.labelIds = [];
if (body.folderId !== undefined) {
const fid = parseFolderIdInput(body.folderId);
if (!fid.ok) return { ok: false, error: "Invalid folderId" };
data.folderId = fid.value;
}
return { ok: true, data };
}
@@ -232,7 +262,11 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
res.status(400).json({ error: parsed.error });
return;
}
const { labelIds = [], ...data } = parsed.data;
const { labelIds = [], folderId, ...data } = parsed.data;
if (folderId != null && !(await userOwnsFolder(userId, folderId))) {
res.status(400).json({ error: "Invalid folderId" });
return;
}
const [note] = await db
.insert(notesTable)
.values({
@@ -242,6 +276,7 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
color: data.color ?? "default",
isPinned: data.isPinned ?? false,
isArchived: data.isArchived ?? false,
folderId: folderId ?? null,
})
.returning();
if (labelIds.length > 0) await setNoteLabels(note.id, userId, labelIds);
@@ -270,6 +305,10 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
res.status(404).json({ error: "Note not found" });
return;
}
if (data.folderId != null && !(await userOwnsFolder(userId, data.folderId))) {
res.status(400).json({ error: "Invalid folderId" });
return;
}
if (Object.keys(data).length > 0) {
await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
}
@@ -855,4 +894,102 @@ router.delete("/note-labels/:id", requireAuth, async (req, res): Promise<void> =
res.json({ success: true });
});
// ----- Folders -----
router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
// Counts are scoped to the current tab (active vs archived) so the rail
// matches the visible note set. `archived` query param: "true" → archived
// notes only, otherwise active notes only.
const archived = req.query.archived === "true";
const noteScope = and(
eq(notesTable.userId, userId),
eq(notesTable.isArchived, archived),
);
const [folders, counts] = await Promise.all([
db
.select()
.from(noteFoldersTable)
.where(eq(noteFoldersTable.userId, userId))
.orderBy(noteFoldersTable.name),
db
.select({
folderId: notesTable.folderId,
count: sql<number>`count(*)::int`,
})
.from(notesTable)
.where(noteScope)
.groupBy(notesTable.folderId),
]);
const countMap = new Map<number, number>();
for (const row of counts) {
if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
}
res.json(folders.map((f) => ({ ...f, noteCount: countMap.get(f.id) ?? 0 })));
});
router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = parseFolderName(req.body);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
try {
const [folder] = await db
.insert(noteFoldersTable)
.values({ userId, name: parsed.name })
.returning();
res.status(201).json({ ...folder, noteCount: 0 });
} catch (e) {
res.status(409).json({ error: "Folder already exists" });
}
});
router.patch("/note-folders/:id", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
const parsed = parseFolderName(req.body);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
try {
const [folder] = await db
.update(noteFoldersTable)
.set({ name: parsed.name })
.where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId)))
.returning();
if (!folder) {
res.status(404).json({ error: "Folder not found" });
return;
}
res.json(folder);
} catch (e) {
res.status(409).json({ error: "Folder already exists" });
}
});
router.delete("/note-folders/:id", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
const result = await db
.delete(noteFoldersTable)
.where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId)))
.returning();
if (result.length === 0) {
res.status(404).json({ error: "Folder not found" });
return;
}
res.json({ success: true });
});
export default router;
@@ -0,0 +1,374 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Folder,
FolderOpen,
FolderPlus,
Inbox as InboxIcon,
Layers,
Pencil,
Trash2,
Check,
X,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
type NoteFolder,
useCreateFolder,
useDeleteFolder,
useUpdateFolder,
} from "@/lib/notes-api";
export type FolderSelection =
| { kind: "all" }
| { kind: "unfiled" }
| { kind: "folder"; id: number };
export function selectionMatches(a: FolderSelection, b: FolderSelection): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === "folder" && b.kind === "folder") return a.id === b.id;
return true;
}
export function selectionDropTarget(s: FolderSelection): "all" | "unfiled" | number {
if (s.kind === "folder") return s.id;
return s.kind;
}
// A module-scoped reference to the note id currently being dragged. We use a
// plain JS variable rather than relying solely on DataTransfer.getData because
// some test runners (and the browser, with non-string MIME types) make it
// unreliable across the dragstart -> drop boundary. DataTransfer is still set
// for native drag UX (cursor + drop hint).
let DRAGGING_NOTE_ID: number | null = null;
export function setDraggingNoteId(id: number | null) {
DRAGGING_NOTE_ID = id;
}
export function getDraggingNoteId(): number | null {
return DRAGGING_NOTE_ID;
}
interface Props {
folders: NoteFolder[];
selected: FolderSelection;
onSelect: (s: FolderSelection) => void;
unfiledCount: number;
totalCount: number;
onDropNote: (target: "unfiled" | number) => void;
}
export function FoldersRail({
folders,
selected,
onSelect,
unfiledCount,
totalCount,
onDropNote,
}: Props) {
const { t } = useTranslation();
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState("");
const [hoverTarget, setHoverTarget] = useState<string | null>(null);
const create = useCreateFolder();
const update = useUpdateFolder();
const del = useDeleteFolder();
const submitCreate = () => {
const name = newName.trim();
if (!name) return;
create.mutate(name, {
onSuccess: () => {
setNewName("");
setCreating(false);
},
});
};
const renderRow = (
key: string,
{
icon,
label,
count,
isSelected,
onClick,
onDrop,
dropTargetTestId,
action,
content,
}: {
icon: React.ReactNode;
label: React.ReactNode;
count: number;
isSelected: boolean;
onClick: () => void;
onDrop?: () => void;
dropTargetTestId: string;
action?: React.ReactNode;
content?: React.ReactNode;
},
) => {
const isHover = hoverTarget === key;
return (
<div
key={key}
data-testid={dropTargetTestId}
data-drop-active={isHover ? "true" : "false"}
onDragOver={(e) => {
if (!onDrop) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (hoverTarget !== key) setHoverTarget(key);
}}
onDragLeave={() => {
if (hoverTarget === key) setHoverTarget(null);
}}
onDrop={(e) => {
if (!onDrop) return;
e.preventDefault();
setHoverTarget(null);
onDrop();
}}
className={`group rounded-lg border transition ${
isSelected
? "bg-foreground text-background border-foreground"
: "border-transparent hover:bg-slate-100"
} ${isHover ? "ring-2 ring-amber-400 ring-offset-1" : ""}`}
>
{content ?? (
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start"
>
<span className="shrink-0">{icon}</span>
<span className="flex-1 truncate">{label}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
isSelected
? "bg-background/20 text-background"
: "bg-slate-200/80 text-muted-foreground"
}`}
data-testid={`${dropTargetTestId}-count`}
>
{count}
</span>
{action}
</button>
)}
</div>
);
};
return (
<aside
className="w-56 shrink-0 space-y-1"
data-testid="notes-folders-rail"
>
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1">
{t("notes.folders.title", "Folders")}
</div>
{renderRow("all", {
icon: <Layers size={14} />,
label: t("notes.folders.all", "All notes"),
count: totalCount,
isSelected: selected.kind === "all",
onClick: () => onSelect({ kind: "all" }),
dropTargetTestId: "folder-drop-all",
})}
{renderRow("unfiled", {
icon: <InboxIcon size={14} />,
label: t("notes.folders.unfiled", "Unfiled"),
count: unfiledCount,
isSelected: selected.kind === "unfiled",
onClick: () => onSelect({ kind: "unfiled" }),
onDrop: () => onDropNote("unfiled"),
dropTargetTestId: "folder-drop-unfiled",
})}
<div className="pt-1">
{folders.map((f) => {
const isEditing = editingId === f.id;
const isSel = selected.kind === "folder" && selected.id === f.id;
return renderRow(`f:${f.id}`, {
icon: isSel ? <FolderOpen size={14} /> : <Folder size={14} />,
label: f.name,
count: f.noteCount,
isSelected: isSel,
onClick: () => onSelect({ kind: "folder", id: f.id }),
onDrop: () => onDropNote(f.id),
dropTargetTestId: `folder-drop-${f.id}`,
content: isEditing ? (
<div className="flex items-center gap-1 px-2 py-1.5">
<Folder size={14} className="text-muted-foreground shrink-0" />
<Input
autoFocus
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && editingName.trim()) {
update.mutate(
{ id: f.id, name: editingName.trim() },
{ onSuccess: () => setEditingId(null) },
);
}
if (e.key === "Escape") setEditingId(null);
}}
className="h-7 text-sm"
data-testid={`folder-rename-input-${f.id}`}
/>
<button
className="p-1 text-muted-foreground hover:text-foreground"
onClick={() => {
if (editingName.trim()) {
update.mutate(
{ id: f.id, name: editingName.trim() },
{ onSuccess: () => setEditingId(null) },
);
}
}}
aria-label={t("common.save", "Save")}
data-testid={`folder-rename-save-${f.id}`}
>
<Check size={14} />
</button>
<button
className="p-1 text-muted-foreground hover:text-foreground"
onClick={() => setEditingId(null)}
aria-label={t("common.cancel", "Cancel")}
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-stretch">
<button
type="button"
onClick={() => onSelect({ kind: "folder", id: f.id })}
className="flex-1 flex items-center gap-2 px-2.5 py-1.5 text-sm text-start min-w-0"
data-testid={`folder-select-${f.id}`}
>
<span className="shrink-0">
{isSel ? <FolderOpen size={14} /> : <Folder size={14} />}
</span>
<span className="flex-1 truncate">{f.name}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
isSel
? "bg-background/20 text-background"
: "bg-slate-200/80 text-muted-foreground"
}`}
data-testid={`folder-drop-${f.id}-count`}
>
{f.noteCount}
</span>
</button>
<div className="flex items-center opacity-0 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>
</div>
),
});
})}
</div>
{creating ? (
<div className="flex items-center gap-1 px-2 py-1.5">
<FolderPlus size={14} className="text-muted-foreground" />
<Input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("notes.folders.namePlaceholder", "Folder name")}
onKeyDown={(e) => {
if (e.key === "Enter") submitCreate();
if (e.key === "Escape") {
setNewName("");
setCreating(false);
}
}}
className="h-7 text-sm"
data-testid="folder-create-input"
/>
<Button
size="sm"
variant="ghost"
className="h-7 px-1"
disabled={!newName.trim() || create.isPending}
onClick={submitCreate}
data-testid="folder-create-submit"
>
<Check size={14} />
</Button>
</div>
) : (
<button
type="button"
onClick={() => setCreating(true)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-lg text-muted-foreground hover:bg-slate-100 hover:text-foreground"
data-testid="folder-create-open"
>
<FolderPlus size={14} />
<span>{t("notes.folders.new", "New folder")}</span>
</button>
)}
<div className="text-[10px] text-muted-foreground/80 px-2 pt-1 leading-snug">
{t(
"notes.folders.dragHint",
"Drag a note onto a folder to move it.",
)}
</div>
</aside>
);
}
+95
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export interface Note {
id: number;
userId: number;
folderId: number | null;
title: string;
content: string;
color: string;
@@ -20,6 +21,15 @@ export interface NoteLabel {
createdAt: string;
}
export interface NoteFolder {
id: number;
userId: number;
name: string;
noteCount: number;
createdAt: string;
updatedAt: string;
}
export interface UserSummary {
id: number;
username: string;
@@ -109,6 +119,8 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
export const notesKey = (archived: boolean) => ["notes", { archived }] as const;
export const labelsKey = ["note-labels"] as const;
export const foldersKey = (archived: boolean = false) =>
["note-folders", { archived }] as const;
export const sentNotesKey = ["notes", "sent"] as const;
export const receivedNotesKey = (archived: boolean) =>
["notes", "received", { archived }] as const;
@@ -270,6 +282,89 @@ export function useUpdateLabel() {
});
}
export function useNoteFolders(archived: boolean = false) {
return useQuery({
queryKey: foldersKey(archived),
queryFn: () =>
req<NoteFolder[]>(`/note-folders?archived=${archived ? "true" : "false"}`),
});
}
export function useCreateFolder() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
req<NoteFolder>("/note-folders", {
method: "POST",
body: JSON.stringify({ name }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["note-folders"] }),
});
}
export function useUpdateFolder() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
req<NoteFolder>(`/note-folders/${id}`, {
method: "PATCH",
body: JSON.stringify({ name }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["note-folders"] });
qc.invalidateQueries({ queryKey: ["notes"] });
},
});
}
export function useDeleteFolder() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
req<{ success: true }>(`/note-folders/${id}`, { method: "DELETE" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["note-folders"] });
qc.invalidateQueries({ queryKey: ["notes"] });
},
});
}
export function useMoveNoteToFolder() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, folderId }: { id: number; folderId: number | null }) =>
req<Note>(`/notes/${id}`, {
method: "PATCH",
body: JSON.stringify({ folderId }),
}),
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]);
if (Array.isArray(data)) {
qc.setQueryData<Note[]>(
key,
data.map((n) => (n.id === id ? { ...n, folderId } : n)),
);
}
}
return { snapshots };
},
onError: (_err, _vars, ctx) => {
if (!ctx) return;
for (const [key, data] of ctx.snapshots) {
qc.setQueryData(key as readonly unknown[], data);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["notes"] });
qc.invalidateQueries({ queryKey: ["note-folders"] });
},
});
}
export function useDeleteLabel() {
const qc = useQueryClient();
return useMutation({
+12
View File
@@ -1039,6 +1039,18 @@
"newLabel": "تصنيف جديد",
"noLabelsYet": "لا توجد تصنيفات بعد",
"allLabels": "الكل",
"folders": {
"title": "المجلدات",
"new": "مجلد جديد",
"namePlaceholder": "اسم المجلد",
"all": "كل الملاحظات",
"unfiled": "بدون مجلد",
"rename": "إعادة تسمية المجلد",
"delete": "حذف المجلد",
"deleteConfirm": "حذف هذا المجلد؟ ستنتقل الملاحظات بداخله إلى \"بدون مجلد\".",
"dragHint": "اسحب ملاحظة إلى المجلد لنقلها.",
"moved": "تم نقل الملاحظة"
},
"tabs": {
"active": "ملاحظاتي",
"archived": "الأرشيف",
+12
View File
@@ -940,6 +940,18 @@
"newLabel": "New label",
"noLabelsYet": "No labels yet",
"allLabels": "All",
"folders": {
"title": "Folders",
"new": "New folder",
"namePlaceholder": "Folder name",
"all": "All notes",
"unfiled": "Unfiled",
"rename": "Rename folder",
"delete": "Delete folder",
"deleteConfirm": "Delete this folder? Notes inside will move to Unfiled.",
"dragHint": "Drag a note onto a folder to move it.",
"moved": "Note moved"
},
"tabs": {
"active": "My Notes",
"archived": "Archived",
+53 -2
View File
@@ -62,6 +62,8 @@ import {
useDeleteLabel,
useDeleteNote,
useMarkNoteRead,
useMoveNoteToFolder,
useNoteFolders,
useNoteLabels,
useNoteThread,
useNotes,
@@ -74,6 +76,12 @@ import {
useUserDirectory,
userDisplayName,
} from "@/lib/notes-api";
import {
FoldersRail,
type FolderSelection,
setDraggingNoteId,
getDraggingNoteId,
} from "@/components/notes/folders-rail";
import { useAuth } from "@/contexts/AuthContext";
type TabId = "active" | "received" | "sent" | "archived";
@@ -87,6 +95,9 @@ export default function NotesPage() {
const [view, setView] = useState<TabId>("active");
const [search, setSearch] = useState("");
const [labelFilter, setLabelFilter] = useState<number | null>(null);
const [folderSelection, setFolderSelection] = useState<FolderSelection>({
kind: "all",
});
const [editing, setEditing] = useState<Note | null>(null);
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false);
const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
@@ -119,6 +130,9 @@ export default function NotesPage() {
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
const { data: labels = [] } = useNoteLabels();
const { data: folders = [] } = useNoteFolders(view === "archived");
const moveNote = useMoveNoteToFolder();
const showFolders = view === "active" || view === "archived";
const { data: receivedNotes = [], isLoading: loadingReceived } =
useReceivedNotes(false);
const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
@@ -129,13 +143,28 @@ export default function NotesPage() {
const q = search.trim().toLowerCase();
return notes.filter((n) => {
if (labelFilter && !n.labelIds.includes(labelFilter)) return false;
if (folderSelection.kind === "unfiled" && n.folderId !== null) return false;
if (folderSelection.kind === "folder" && n.folderId !== folderSelection.id)
return false;
if (!q) return true;
return (
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
);
});
}, [notes, search, labelFilter]);
}, [notes, search, labelFilter, folderSelection]);
const unfiledCount = notes.filter((n) => n.folderId === null).length;
const handleDropNote = (target: "unfiled" | number) => {
const id = getDraggingNoteId();
setDraggingNoteId(null);
if (id == null) return;
const note = notes.find((n) => n.id === id);
const nextFolderId = target === "unfiled" ? null : target;
if (note && note.folderId === nextFolderId) return;
moveNote.mutate({ id, folderId: nextFolderId });
};
const filteredReceived = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -280,7 +309,18 @@ export default function NotesPage() {
</div>
</div>
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto">
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto flex gap-6">
{showFolders && (
<FoldersRail
folders={folders}
selected={folderSelection}
onSelect={setFolderSelection}
unfiledCount={unfiledCount}
totalCount={notes.length}
onDropNote={handleDropNote}
/>
)}
<div className="flex-1 min-w-0">
{view === "active" && (
<>
<Composer labels={labels} />
@@ -346,6 +386,7 @@ export default function NotesPage() {
onOpen={(id) => setOpenThreadId(id)}
/>
)}
</div>
</div>
{editing && (
@@ -479,6 +520,16 @@ function NoteCard({
return (
<div
data-testid={`note-card-${note.id}`}
data-folder-id={note.folderId ?? ""}
draggable
onDragStart={(e) => {
setDraggingNoteId(note.id);
try {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", `note:${note.id}`);
} catch {}
}}
onDragEnd={() => setDraggingNoteId(null)}
className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group ${colorBg(
note.color,
)}`}
@@ -0,0 +1,322 @@
// E2E test for the user-defined Notes Folders feature:
// - Create a folder from the rail
// - Drag a note from "Unfiled" into the new folder
// - Filter by selected folder shows only notes in that folder
// - Drag the note from one folder into another
// - Rename + delete a folder; deleting moves its notes back to Unfiled
// - Verifies bilingual rendering: re-runs the rail title check in Arabic
// so the RTL label is exercised end-to-end.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run this UI test");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdNoteIds = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix, displayEn) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
test.afterAll(async () => {
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(
`DELETE FROM note_folders WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
async function loginViaUi(page, username) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
// Synthesize HTML5 drag-and-drop with a shared DataTransfer so React's drag
// handlers fire reliably regardless of pointer-based heuristics.
async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) {
await page.evaluate(
({ srcSel, dstSel }) => {
const src = document.querySelector(srcSel);
const dst = document.querySelector(dstSel);
if (!src || !dst) throw new Error(`drag elements missing: ${srcSel} / ${dstSel}`);
const dt = new DataTransfer();
src.dispatchEvent(
new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer: dt }),
);
dst.dispatchEvent(
new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: dt }),
);
dst.dispatchEvent(
new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer: dt }),
);
dst.dispatchEvent(
new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt }),
);
src.dispatchEvent(
new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer: dt }),
);
},
{
srcSel: `[data-testid="note-card-${noteId}"]`,
dstSel: `[data-testid="${dropTargetTestId}"]`,
},
);
}
async function createNoteViaApi(page, title) {
const resp = await page.request.post("/api/notes", {
data: { title, content: "body of " + title },
});
expect(resp.ok()).toBeTruthy();
const note = await resp.json();
createdNoteIds.push(note.id);
return note;
}
test("create folder, drag note in, filter, move between folders, rename, delete", async ({
page,
}) => {
const user = await createUser("notes_folders_e2e", "Folder Tester");
await loginViaUi(page, user.username);
// Seed two notes via the API so the test focuses on folder UX.
const note1 = await createNoteViaApi(page, `Folder Note 1 ${uniq()}`);
const note2 = await createNoteViaApi(page, `Folder Note 2 ${uniq()}`);
await page.goto("/notes");
await expect(page.getByTestId("notes-page")).toBeVisible();
// Folders rail is visible on the My Notes tab.
const rail = page.getByTestId("notes-folders-rail");
await expect(rail).toBeVisible();
await expect(page.getByTestId("folder-drop-all")).toBeVisible();
await expect(page.getByTestId("folder-drop-unfiled")).toBeVisible();
// Both new notes should be unfiled.
await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^2$/);
// Create folder "Projects".
await page.getByTestId("folder-create-open").click();
await page.getByTestId("folder-create-input").fill("Projects");
const createProjectsResp = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/note-folders" &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId("folder-create-submit").click();
const projectsResp = await createProjectsResp;
const projects = await projectsResp.json();
// Create folder "Personal".
await page.getByTestId("folder-create-open").click();
await page.getByTestId("folder-create-input").fill("Personal");
const createPersonalResp = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/note-folders" &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId("folder-create-submit").click();
const personalResp = await createPersonalResp;
const personal = await personalResp.json();
// Both folders show up with count 0.
await expect(page.getByTestId(`folder-select-${projects.id}`)).toBeVisible();
await expect(page.getByTestId(`folder-select-${personal.id}`)).toBeVisible();
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^0$/);
// ---- Drag note1 into Projects ----
const movePromise1 = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note1.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note1.id, `folder-drop-${projects.id}`);
await movePromise1;
// Counts update.
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^1$/);
await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/);
// ---- Filter by Projects: only note1 visible ----
await page.getByTestId(`folder-select-${projects.id}`).click();
await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible();
await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0);
// ---- Filter Unfiled: only note2 visible ----
await page.getByTestId("folder-drop-unfiled").click();
await expect(page.getByTestId(`note-card-${note2.id}`)).toBeVisible();
await expect(page.getByTestId(`note-card-${note1.id}`)).toHaveCount(0);
// ---- Drag note2 into Personal, then move it from Personal to Projects ----
await page.getByTestId("folder-drop-all").click();
const movePromise2 = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note2.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note2.id, `folder-drop-${personal.id}`);
await movePromise2;
await expect(
page.getByTestId(`folder-drop-${personal.id}-count`),
).toHaveText(/^1$/);
const movePromise3 = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note2.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note2.id, `folder-drop-${projects.id}`);
await movePromise3;
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^2$/);
await expect(
page.getByTestId(`folder-drop-${personal.id}-count`),
).toHaveText(/^0$/);
// ---- Rename Projects -> "Work" ----
await page.getByTestId(`folder-select-${projects.id}`).hover();
await page.getByTestId(`folder-rename-${projects.id}`).click();
const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`);
await renameInput.fill("Work");
const renamePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/note-folders/${projects.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId(`folder-rename-save-${projects.id}`).click();
await renamePromise;
await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText(
"Work",
);
// ---- Move note1 INTO Personal so the folder is non-empty, then delete it
// and verify the note returns to Unfiled (FK ON DELETE SET NULL). ----
const movePromiseToPersonal = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note1.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note1.id, `folder-drop-${personal.id}`);
await movePromiseToPersonal;
await expect(
page.getByTestId(`folder-drop-${personal.id}-count`),
).toHaveText(/^1$/);
page.once("dialog", (d) => d.accept());
const deletePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/note-folders/${personal.id}` &&
r.request().method() === "DELETE" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId(`folder-select-${personal.id}`).hover();
await page.getByTestId(`folder-delete-${personal.id}`).click();
await deletePromise;
await expect(page.getByTestId(`folder-select-${personal.id}`)).toHaveCount(0);
// The orphaned note should have folderId reset to null via FK SET NULL,
// so it should now appear under "Unfiled".
const allNotes = await (await page.request.get(`/api/notes`)).json();
const note1AfterDelete = allNotes.find((n) => n.id === note1.id);
expect(note1AfterDelete).toBeDefined();
expect(note1AfterDelete.folderId).toBeNull();
await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/);
// ---- Cross-user safety: another user's folder cannot be assigned. ----
const otherUser = await createUser("notes_folders_other", "Other User");
const { rows: otherFolderRows } = await pool.query(
`INSERT INTO note_folders (user_id, name) VALUES ($1, $2) RETURNING id`,
[otherUser.id, `OtherFolder_${uniq()}`],
);
const otherFolderId = otherFolderRows[0].id;
const crossResp = await page.request.patch(`/api/notes/${note2.id}`, {
data: { folderId: otherFolderId },
});
expect(crossResp.status()).toBe(400);
await pool.query(`DELETE FROM note_folders WHERE id = $1`, [otherFolderId]);
await page.getByTestId("folder-drop-all").click();
// ---- Bilingual smoke check: flip the user to Arabic, reload, and verify
// the rail renders RTL with the Arabic copy. ----
await pool.query(
`UPDATE users SET preferred_language = 'ar' WHERE id = $1`,
[user.id],
);
await page.evaluate(() => {
localStorage.setItem("tx-lang", "ar");
});
await page.reload();
await expect(page.getByTestId("notes-page")).toHaveAttribute("dir", "rtl");
await expect(page.getByTestId("notes-folders-rail")).toBeVisible();
await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText(
"Work",
);
await expect(page.getByTestId("folder-drop-unfiled")).toContainText(
"بدون مجلد",
);
});
+15
View File
@@ -3,9 +3,20 @@ import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
export const noteFoldersTable = pgTable("note_folders", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
name: varchar("name", { length: 80 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
}, (t) => ({
userNameUnique: uniqueIndex("note_folders_user_name_unique").on(t.userId, t.name),
}));
export const notesTable = pgTable("notes", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
folderId: integer("folder_id").references(() => noteFoldersTable.id, { onDelete: "set null" }),
title: varchar("title", { length: 300 }).notNull().default(""),
content: text("content").notNull().default(""),
color: varchar("color", { length: 32 }).notNull().default("default"),
@@ -79,5 +90,9 @@ export const insertNoteLabelSchema = createInsertSchema(noteLabelsTable).omit({
export type InsertNoteLabel = z.infer<typeof insertNoteLabelSchema>;
export type NoteLabel = typeof noteLabelsTable.$inferSelect;
export const insertNoteFolderSchema = createInsertSchema(noteFoldersTable).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertNoteFolder = z.infer<typeof insertNoteFolderSchema>;
export type NoteFolder = typeof noteFoldersTable.$inferSelect;
export type NoteRecipient = typeof noteRecipientsTable.$inferSelect;
export type NoteReply = typeof noteRepliesTable.$inferSelect;