ba0da3d26c
- 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.
323 lines
12 KiB
JavaScript
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(
|
|
"بدون مجلد",
|
|
);
|
|
});
|