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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -1039,6 +1039,18 @@
|
||||
"newLabel": "تصنيف جديد",
|
||||
"noLabelsYet": "لا توجد تصنيفات بعد",
|
||||
"allLabels": "الكل",
|
||||
"folders": {
|
||||
"title": "المجلدات",
|
||||
"new": "مجلد جديد",
|
||||
"namePlaceholder": "اسم المجلد",
|
||||
"all": "كل الملاحظات",
|
||||
"unfiled": "بدون مجلد",
|
||||
"rename": "إعادة تسمية المجلد",
|
||||
"delete": "حذف المجلد",
|
||||
"deleteConfirm": "حذف هذا المجلد؟ ستنتقل الملاحظات بداخله إلى \"بدون مجلد\".",
|
||||
"dragHint": "اسحب ملاحظة إلى المجلد لنقلها.",
|
||||
"moved": "تم نقل الملاحظة"
|
||||
},
|
||||
"tabs": {
|
||||
"active": "ملاحظاتي",
|
||||
"archived": "الأرشيف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
"بدون مجلد",
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user