daa4f6c038
- Replace HTML5+touch drag with @dnd-kit (PointerSensor distance:8, TouchSensor delay:200/tol:8) matching home.tsx pattern. - Add sort_order column to notes; ORDER BY asc(sortOrder), updatedAt desc. - New PATCH /notes/reorder endpoint: strict isPinned boolean validation, bucket+permission scoped, all writes in db.transaction for atomicity. - PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change. - Client useReorderNotes hook with optimistic cache update. - handleDragEnd builds reorder payload from FULL bucket (owner notes or shared-folder bucket via ref), not the filtered/search subset, so hidden siblings retain stable order. - SharedFolderView publishes its data.notes via bucketRef when viewer has edit permission, enabling correct reorder in shared folders. - Layout fix at narrow viewport: rail stacks above notes (flex-col md:flex-row) so iPad portrait drag has proper bbox. - Playwright tests: notes-folders.spec.mjs both desktop pointer drag and touch long-press drag pass (32s). - OpenAPI codegen skipped: notes-api.ts is hand-written. - Out of scope (pre-existing failures): executive-meetings reorder/font, notes-share PATCH 403/404, groups-crud rollback.
408 lines
16 KiB
JavaScript
408 lines
16 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(),
|
|
]);
|
|
}
|
|
|
|
// Drive a real pointer drag so @dnd-kit's PointerSensor activates. The
|
|
// sensor's activation constraint is `distance: 8`, so we first nudge the
|
|
// pointer past the threshold from the source, then move to the target in
|
|
// steps (so over-targets are detected on the way), then release.
|
|
async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) {
|
|
const src = page.getByTestId(`note-card-${noteId}`).first();
|
|
const dst = page.getByTestId(dropTargetTestId).first();
|
|
// Scroll the destination first; source typically remains in view (notes
|
|
// grid sits beside or below the folder area). Then measure BOTH boxes
|
|
// after scrolling, otherwise the source coordinates can be stale.
|
|
await dst.scrollIntoViewIfNeeded();
|
|
await src.scrollIntoViewIfNeeded();
|
|
await dst.scrollIntoViewIfNeeded();
|
|
const sBox = await src.boundingBox();
|
|
const dBox = await dst.boundingBox();
|
|
if (!sBox) throw new Error(`source note-card-${noteId} has no bounding box`);
|
|
if (!dBox) throw new Error(`target ${dropTargetTestId} has no bounding box`);
|
|
const sx = sBox.x + sBox.width / 2;
|
|
const sy = sBox.y + sBox.height / 2;
|
|
const dx = dBox.x + dBox.width / 2;
|
|
const dy = dBox.y + dBox.height / 2;
|
|
await page.mouse.move(sx, sy);
|
|
await page.mouse.down();
|
|
// First nudge — must exceed PointerSensor's 8px activation distance.
|
|
await page.mouse.move(sx + 12, sy + 12, { steps: 5 });
|
|
// Then walk to the destination so dnd-kit tracks the over-target.
|
|
await page.mouse.move(dx, dy, { steps: 10 });
|
|
// Hold briefly so dnd-kit's collision detection settles before drop.
|
|
await page.waitForTimeout(50);
|
|
await page.mouse.up();
|
|
}
|
|
|
|
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$/);
|
|
|
|
// Newly created folder is auto-selected — Personal was the most recent
|
|
// create, so its row should be the active selection (button has the
|
|
// selected styling: bg-foreground text-background).
|
|
await expect(
|
|
page.getByTestId(`folder-drop-${personal.id}`),
|
|
).toHaveClass(/bg-foreground/);
|
|
// Reset selection to "All" so the rest of the flow filters correctly.
|
|
await page.getByTestId("folder-drop-all").click();
|
|
|
|
// ---- 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$/);
|
|
|
|
// ---- Persistence after a full page reload ----
|
|
await page.reload();
|
|
await expect(page.getByTestId("notes-page")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`folder-drop-${projects.id}-count`),
|
|
).toHaveText(/^1$/);
|
|
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);
|
|
await page.getByTestId("folder-drop-all").click();
|
|
|
|
// ---- 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-menu-${projects.id}`).click();
|
|
await page.getByTestId(`folder-rename-${projects.id}`).click();
|
|
const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`);
|
|
await renameInput.fill("Work");
|
|
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$/);
|
|
|
|
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-menu-${personal.id}`).click();
|
|
await page.getByTestId(`folder-delete-${personal.id}`).click();
|
|
// In-app AlertDialog (replacing native window.confirm) — click confirm.
|
|
await page.getByTestId(`folder-delete-confirm-${personal.id}`).click();
|
|
await deletePromise;
|
|
await expect(
|
|
page.getByTestId(`folder-delete-dialog-${personal.id}`),
|
|
).toHaveCount(0);
|
|
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(
|
|
"بدون مجلد",
|
|
);
|
|
});
|
|
|
|
// Mobile / touch fallback: long-press on a note card then drag the finger to
|
|
// a folder row should move the note via the touch drop pipeline (no HTML5
|
|
// drag events). Also verifies the rail renders as a horizontal chip row at a
|
|
// narrow viewport.
|
|
test("touch long-press drags a note into a folder on small viewport", async ({
|
|
browser,
|
|
}) => {
|
|
// Narrow viewport with touch capability. We deliberately leave `isMobile`
|
|
// false so Playwright's `page.mouse` API still fires pointer events that
|
|
// @dnd-kit's PointerSensor can pick up — the same code path an iPad would
|
|
// hit (PointerSensor handles touch-derived pointer events identically to
|
|
// mouse-derived ones; the TouchSensor is just a long-press fallback).
|
|
const context = await browser.newContext({
|
|
viewport: { width: 414, height: 800 },
|
|
hasTouch: true,
|
|
});
|
|
const page = await context.newPage();
|
|
const user = await createUser("notes_folders_touch", "Touch Tester");
|
|
await loginViaUi(page, user.username);
|
|
const note = await createNoteViaApi(page, `Touch Note ${uniq()}`);
|
|
|
|
await page.goto("/notes");
|
|
await expect(page.getByTestId("notes-page")).toBeVisible();
|
|
const rail = page.getByTestId("notes-folders-rail");
|
|
await expect(rail).toBeVisible();
|
|
|
|
// Folder rail must be horizontally scrollable on mobile (flex layout, not a
|
|
// fixed-width sidebar).
|
|
const railLayout = await rail.evaluate((el) => {
|
|
const cs = getComputedStyle(el);
|
|
return { display: cs.display, overflowX: cs.overflowX };
|
|
});
|
|
expect(railLayout.display).toBe("flex");
|
|
expect(["auto", "scroll"]).toContain(railLayout.overflowX);
|
|
|
|
// Create a folder via API to keep the test focused on the touch drop path.
|
|
const fResp = await page.request.post("/api/note-folders", {
|
|
data: { name: `TouchFolder_${uniq()}` },
|
|
});
|
|
expect(fResp.ok()).toBeTruthy();
|
|
const folder = await fResp.json();
|
|
await page.reload();
|
|
await expect(page.getByTestId(`folder-drop-${folder.id}`)).toBeVisible();
|
|
|
|
const movePromise = page.waitForResponse(
|
|
(r) =>
|
|
new URL(r.url()).pathname === `/api/notes/${note.id}` &&
|
|
r.request().method() === "PATCH" &&
|
|
r.status() >= 200 &&
|
|
r.status() < 300,
|
|
);
|
|
await dragNoteToFolderTarget(page, note.id, `folder-drop-${folder.id}`);
|
|
await movePromise;
|
|
|
|
await expect(
|
|
page.getByTestId(`folder-drop-${folder.id}-count`),
|
|
).toHaveText(/^1$/);
|
|
|
|
await context.close();
|
|
});
|