1ffb470e8f
Task #420 — answers the user request "وين خيار اضيف list to do?". Schema (lib/db/src/schema/notes.ts): - notes + note_recipients gain `kind` (varchar(16) default 'text') and `items` (jsonb<ChecklistItem[]> nullable). Snapshot copy on note_recipients keeps delivered checklists immutable across sender edits/deletes. - Drizzle push applied; lib/db .d.ts rebuilt. API (artifacts/api-server/src/routes/notes.ts): - ChecklistItem type + bounded parser (≤200 items, id ≤64, text ≤500). - parseNoteInput normalizes items↔kind on create and on PATCH that carries `kind`; PATCH handler additionally coerces items-only patches against the note's existing kind so a text note can never end up with checklist items (and vice versa). - POST/PATCH/send/loadRecipientsForNote/received/thread responses and the realtime `note_received` payload all carry kind+items, with the thread response falling back to the recipient snapshot. Client (artifacts/tx-os/src/lib/notes-api.ts + pages/notes.tsx): - Note/ReceivedNote/NoteThread/SentNoteRecipient extended with kind+items. - New `ChecklistEditor`, `ChecklistView`, and `KindToggle` components. - Composer and EditNoteDialog gain the to-do toggle (ListTodo icon) and switch between Textarea and ChecklistEditor; saves send kind+items, with empty-checklist auto-discard mirroring the existing empty-text behaviour. - NoteCard, inbox list, sent list, and ThreadDialog body render the checklist (read-only on snapshots; owner cards can toggle done via PATCH with stopPropagation so the edit dialog doesn't open). i18n: notes.checklist.{toggle,addItem,itemPlaceholder,emptyHint, removeItem,progress} added to en.json + ar.json. Tests: new artifacts/tx-os/tests/notes-checklist.spec.mjs covers composer→persist→reload→toggle, items-only PATCH normalization on both kinds, and checklist delivery to recipient snapshot. All 3 pass. Architect review (evaluate_task) flagged one real issue: items-only PATCH normalization. Fixed in the PATCH handler and locked in by the new normalization test. Pre-existing executive-meetings.ts tsc errors are unchanged and unrelated to this task.
228 lines
8.3 KiB
JavaScript
228 lines
8.3 KiB
JavaScript
// E2E regression for the per-note checklist (to-do list) option.
|
|
// 1) Composer can switch to checklist mode, items can be added and saved.
|
|
// 2) After reload, the saved checklist persists with its items + done state.
|
|
// 3) Sending a checklist note delivers the items to the recipient snapshot.
|
|
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 = [];
|
|
|
|
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 note_recipients WHERE sender_user_id = ANY($1::int[]) OR recipient_user_id = ANY($1::int[])`,
|
|
[createdUserIds],
|
|
);
|
|
await pool.query(`DELETE FROM notes 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(),
|
|
]);
|
|
}
|
|
|
|
test("composer creates a checklist note that persists across reloads", async ({
|
|
page,
|
|
}) => {
|
|
const user = await createUser("notes_checklist_e2e", "Checklist Tester");
|
|
await loginViaUi(page, user.username);
|
|
|
|
await page.goto("/notes");
|
|
await expect(page.getByTestId("notes-page")).toBeVisible();
|
|
|
|
const title = `checklist-test ${uniq()}`;
|
|
|
|
// Open composer, set title, switch to checklist.
|
|
await page.getByTestId("notes-composer").click();
|
|
await page.getByTestId("notes-composer-title").fill(title);
|
|
await page.getByTestId("notes-composer-kind-toggle").click();
|
|
|
|
// Add two items via the "Add item" button.
|
|
const composer = page.getByTestId("notes-composer");
|
|
await composer.getByTestId("notes-composer-checklist-add").click();
|
|
// The newly inserted row gets focus; type into it.
|
|
await page.keyboard.type("Buy milk");
|
|
await composer.getByTestId("notes-composer-checklist-add").click();
|
|
await page.keyboard.type("Walk the dog");
|
|
|
|
// Save via the explicit Save button (synchronous, no portal-click risk).
|
|
const savePromise = page.waitForResponse(
|
|
(r) =>
|
|
new URL(r.url()).pathname === "/api/notes" &&
|
|
r.request().method() === "POST" &&
|
|
r.status() >= 200 &&
|
|
r.status() < 300,
|
|
);
|
|
await page.getByTestId("notes-composer-save").click();
|
|
const resp = await savePromise;
|
|
const note = await resp.json();
|
|
expect(note.title).toBe(title);
|
|
expect(note.kind).toBe("checklist");
|
|
expect(Array.isArray(note.items)).toBe(true);
|
|
expect(note.items.map((i) => i.text)).toEqual(["Buy milk", "Walk the dog"]);
|
|
|
|
// The note card should render the checklist with both rows visible.
|
|
const card = page.getByTestId(`note-card-${note.id}`);
|
|
await expect(card).toBeVisible();
|
|
await expect(card.getByText("Buy milk")).toBeVisible();
|
|
await expect(card.getByText("Walk the dog")).toBeVisible();
|
|
|
|
// Tick the first item from the card, expect a PATCH that flips done.
|
|
const firstItemId = note.items[0].id;
|
|
const togglePromise = page.waitForResponse(
|
|
(r) =>
|
|
new URL(r.url()).pathname === `/api/notes/${note.id}` &&
|
|
r.request().method() === "PATCH" &&
|
|
r.status() >= 200 &&
|
|
r.status() < 300,
|
|
);
|
|
await card
|
|
.getByTestId(`note-card-checklist-${note.id}-check-${firstItemId}`)
|
|
.click();
|
|
const patched = await togglePromise;
|
|
const patchedNote = await patched.json();
|
|
expect(patchedNote.kind).toBe("checklist");
|
|
const patchedFirst = patchedNote.items.find((i) => i.id === firstItemId);
|
|
expect(patchedFirst?.done).toBe(true);
|
|
|
|
// Reload — the persisted checklist with the toggled state must come back.
|
|
await page.reload();
|
|
await expect(page.getByTestId(`note-card-${note.id}`)).toBeVisible();
|
|
const reloadedFirst = page.getByTestId(
|
|
`note-card-checklist-${note.id}-check-${firstItemId}`,
|
|
);
|
|
await expect(reloadedFirst).toBeChecked();
|
|
});
|
|
|
|
test("PATCH items without kind normalizes against the note's current kind", async ({
|
|
page,
|
|
}) => {
|
|
const user = await createUser("notes_checklist_norm", "Norm Tester");
|
|
await loginViaUi(page, user.username);
|
|
|
|
// Create a plain text note (kind defaults to "text").
|
|
const textResp = await page.request.post("/api/notes", {
|
|
data: { title: `text-note ${uniq()}`, content: "hello" },
|
|
});
|
|
expect(textResp.ok()).toBe(true);
|
|
const textNote = await textResp.json();
|
|
expect(textNote.kind).toBe("text");
|
|
|
|
// Patch only `items` (no kind). Server must coerce items back to null
|
|
// because the note's current kind is "text".
|
|
const patchTextResp = await page.request.patch(`/api/notes/${textNote.id}`, {
|
|
data: { items: [{ id: "x", text: "should be dropped", done: false }] },
|
|
});
|
|
expect(patchTextResp.ok()).toBe(true);
|
|
const patchedText = await patchTextResp.json();
|
|
expect(patchedText.kind).toBe("text");
|
|
expect(patchedText.items).toBeNull();
|
|
|
|
// Now create a checklist note and patch only `items` (no kind). Items
|
|
// must persist because the note's current kind is "checklist".
|
|
const clResp = await page.request.post("/api/notes", {
|
|
data: {
|
|
title: `cl-note ${uniq()}`,
|
|
kind: "checklist",
|
|
items: [{ id: "a", text: "first", done: false }],
|
|
},
|
|
});
|
|
const cl = await clResp.json();
|
|
const patchClResp = await page.request.patch(`/api/notes/${cl.id}`, {
|
|
data: { items: [{ id: "a", text: "first", done: true }] },
|
|
});
|
|
expect(patchClResp.ok()).toBe(true);
|
|
const patchedCl = await patchClResp.json();
|
|
expect(patchedCl.kind).toBe("checklist");
|
|
expect(patchedCl.items[0].done).toBe(true);
|
|
});
|
|
|
|
test("checklist notes are delivered to recipients with their items", async ({
|
|
page,
|
|
}) => {
|
|
const sender = await createUser("notes_checklist_send_s", "Sender");
|
|
const recipient = await createUser("notes_checklist_send_r", "Recipient");
|
|
await loginViaUi(page, sender.username);
|
|
|
|
// Create the checklist note directly via the API (we already cover the
|
|
// composer flow above; this test focuses on the send/snapshot path).
|
|
const createResp = await page.request.post("/api/notes", {
|
|
data: {
|
|
title: `send-checklist ${uniq()}`,
|
|
kind: "checklist",
|
|
items: [
|
|
{ id: "a", text: "Item A", done: false },
|
|
{ id: "b", text: "Item B", done: true },
|
|
],
|
|
},
|
|
});
|
|
expect(createResp.ok()).toBe(true);
|
|
const note = await createResp.json();
|
|
|
|
const sendResp = await page.request.post(`/api/notes/${note.id}/send`, {
|
|
data: { recipientUserIds: [recipient.id] },
|
|
});
|
|
expect(sendResp.ok()).toBe(true);
|
|
|
|
// Log out and back in as the recipient, then read the inbox.
|
|
await page.request.post("/api/auth/logout").catch(() => {});
|
|
await loginViaUi(page, recipient.username);
|
|
|
|
const receivedResp = await page.request.get("/api/notes/received?archived=false");
|
|
expect(receivedResp.ok()).toBe(true);
|
|
const received = await receivedResp.json();
|
|
const delivered = received.find((r) => r.id === note.id);
|
|
expect(delivered, "checklist note should appear in recipient inbox").toBeTruthy();
|
|
expect(delivered.kind).toBe("checklist");
|
|
expect(delivered.items.map((i) => i.text)).toEqual(["Item A", "Item B"]);
|
|
expect(delivered.items[1].done).toBe(true);
|
|
});
|