Files
TX/artifacts/tx-os/tests/notes-folders.spec.mjs
T
riyadhafraa ba0da3d26c 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.
2026-05-06 07:26:11 +00:00

323 lines
12 KiB
JavaScript

// 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(
"بدون مجلد",
);
});