notes: add per-note checklist (to-do list) option
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.
This commit is contained in:
@@ -57,16 +57,42 @@ function parseLabelIds(v: unknown): number[] | undefined {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChecklistItem = { id: string; text: string; done: boolean };
|
||||||
|
|
||||||
interface NoteInput {
|
interface NoteInput {
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
kind?: "text" | "checklist";
|
||||||
|
items?: ChecklistItem[] | null;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
labelIds?: number[];
|
labelIds?: number[];
|
||||||
folderId?: number | null;
|
folderId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseKind(v: unknown): "text" | "checklist" | undefined {
|
||||||
|
return v === "text" || v === "checklist" ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItems(v: unknown): ChecklistItem[] | null | undefined {
|
||||||
|
if (v === null) return null;
|
||||||
|
if (!Array.isArray(v)) return undefined;
|
||||||
|
if (v.length > 200) return undefined;
|
||||||
|
const out: ChecklistItem[] = [];
|
||||||
|
for (const it of v) {
|
||||||
|
if (!it || typeof it !== "object") return undefined;
|
||||||
|
const obj = it as Record<string, unknown>;
|
||||||
|
const id = typeof obj.id === "string" && obj.id.length > 0 && obj.id.length <= 64
|
||||||
|
? obj.id : null;
|
||||||
|
const text = typeof obj.text === "string" ? obj.text.slice(0, 500) : null;
|
||||||
|
const done = typeof obj.done === "boolean" ? obj.done : false;
|
||||||
|
if (id === null || text === null) return undefined;
|
||||||
|
out.push({ id, text, done });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } {
|
function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } {
|
||||||
if (v === null) return { ok: true, value: null };
|
if (v === null) return { ok: true, value: null };
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
@@ -107,6 +133,26 @@ function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInp
|
|||||||
if (c === undefined) return { ok: false, error: "Invalid color" };
|
if (c === undefined) return { ok: false, error: "Invalid color" };
|
||||||
data.color = c;
|
data.color = c;
|
||||||
} else if (isCreate) data.color = "default";
|
} else if (isCreate) data.color = "default";
|
||||||
|
if (body.kind !== undefined) {
|
||||||
|
const k = parseKind(body.kind);
|
||||||
|
if (!k) return { ok: false, error: "Invalid kind" };
|
||||||
|
data.kind = k;
|
||||||
|
} else if (isCreate) data.kind = "text";
|
||||||
|
if (body.items !== undefined) {
|
||||||
|
const its = parseItems(body.items);
|
||||||
|
if (its === undefined) return { ok: false, error: "Invalid items" };
|
||||||
|
data.items = its;
|
||||||
|
}
|
||||||
|
// Normalize items to match kind whenever kind is being set explicitly so
|
||||||
|
// the column is never out of sync (e.g. checklist-mode without items, or
|
||||||
|
// text-mode that still carries stale items from the previous shape).
|
||||||
|
if (data.kind !== undefined) {
|
||||||
|
if (data.kind === "checklist") {
|
||||||
|
data.items = data.items ?? [];
|
||||||
|
} else {
|
||||||
|
data.items = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (body.isPinned !== undefined) {
|
if (body.isPinned !== undefined) {
|
||||||
if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" };
|
if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" };
|
||||||
data.isPinned = body.isPinned;
|
data.isPinned = body.isPinned;
|
||||||
@@ -214,6 +260,8 @@ async function loadRecipientsForNote(noteId: number) {
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
content: r.content,
|
content: r.content,
|
||||||
color: r.color,
|
color: r.color,
|
||||||
|
kind: r.kind,
|
||||||
|
items: r.items,
|
||||||
recipient: userMap.get(r.recipientUserId) ?? null,
|
recipient: userMap.get(r.recipientUserId) ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -283,6 +331,8 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
|
|||||||
title: data.title ?? "",
|
title: data.title ?? "",
|
||||||
content: data.content ?? "",
|
content: data.content ?? "",
|
||||||
color: data.color ?? "default",
|
color: data.color ?? "default",
|
||||||
|
kind: data.kind ?? "text",
|
||||||
|
items: data.items ?? null,
|
||||||
isPinned: data.isPinned ?? false,
|
isPinned: data.isPinned ?? false,
|
||||||
isArchived: data.isArchived ?? false,
|
isArchived: data.isArchived ?? false,
|
||||||
folderId: folderId ?? null,
|
folderId: folderId ?? null,
|
||||||
@@ -318,6 +368,17 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
res.status(400).json({ error: "Invalid folderId" });
|
res.status(400).json({ error: "Invalid folderId" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If the caller patched `items` without specifying `kind`, normalize
|
||||||
|
// against the note's current kind so a text note can never end up
|
||||||
|
// carrying checklist items (and vice versa). parseNoteInput handles the
|
||||||
|
// case where `kind` is provided; this covers the items-only PATCH.
|
||||||
|
if (data.kind === undefined && data.items !== undefined) {
|
||||||
|
if (existing.kind === "checklist") {
|
||||||
|
data.items = data.items ?? [];
|
||||||
|
} else {
|
||||||
|
data.items = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Object.keys(data).length > 0) {
|
if (Object.keys(data).length > 0) {
|
||||||
await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
|
await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
|
||||||
}
|
}
|
||||||
@@ -411,6 +472,8 @@ router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
content: r.content,
|
content: r.content,
|
||||||
color: r.color,
|
color: r.color,
|
||||||
|
kind: r.kind,
|
||||||
|
items: r.items,
|
||||||
createdAt: r.sentAt,
|
createdAt: r.sentAt,
|
||||||
updatedAt: r.readAt ?? r.sentAt,
|
updatedAt: r.readAt ?? r.sentAt,
|
||||||
sender: senderMap.get(r.senderUserId) ?? null,
|
sender: senderMap.get(r.senderUserId) ?? null,
|
||||||
@@ -510,11 +573,16 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
|
|||||||
const contentFallback = snapshotRow?.content ?? note?.content ?? "";
|
const contentFallback = snapshotRow?.content ?? note?.content ?? "";
|
||||||
const colorFallback = snapshotRow?.color ?? note?.color ?? "default";
|
const colorFallback = snapshotRow?.color ?? note?.color ?? "default";
|
||||||
|
|
||||||
|
const kindFallback = snapshotRow?.kind ?? note?.kind ?? "text";
|
||||||
|
const itemsFallback = snapshotRow?.items ?? note?.items ?? null;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: id.id,
|
id: id.id,
|
||||||
title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback,
|
title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback,
|
||||||
content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback,
|
content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback,
|
||||||
color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback,
|
color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback,
|
||||||
|
kind: useSnapshot ? recipientRow!.kind : note?.kind ?? kindFallback,
|
||||||
|
items: useSnapshot ? recipientRow!.items : note?.items ?? itemsFallback,
|
||||||
createdAt: useSnapshot
|
createdAt: useSnapshot
|
||||||
? recipientRow!.sentAt
|
? recipientRow!.sentAt
|
||||||
: note?.createdAt ?? snapshotRow?.sentAt ?? null,
|
: note?.createdAt ?? snapshotRow?.sentAt ?? null,
|
||||||
@@ -595,6 +663,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
|||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
color: note.color,
|
color: note.color,
|
||||||
|
kind: note.kind,
|
||||||
|
items: note.items,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing({
|
.onConflictDoNothing({
|
||||||
@@ -625,6 +695,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
content: r.content,
|
content: r.content,
|
||||||
color: r.color,
|
color: r.color,
|
||||||
|
kind: r.kind,
|
||||||
|
items: r.items,
|
||||||
sentAt: r.sentAt,
|
sentAt: r.sentAt,
|
||||||
senderUserId: userId,
|
senderUserId: userId,
|
||||||
sender: sender ?? null,
|
sender: sender ?? null,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,5 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export type NoteKind = "text" | "checklist";
|
||||||
|
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -7,6 +15,8 @@ export interface Note {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
kind: NoteKind;
|
||||||
|
items: ChecklistItem[] | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -49,6 +59,11 @@ export interface SentNoteRecipient {
|
|||||||
sentAt: string;
|
sentAt: string;
|
||||||
readAt: string | null;
|
readAt: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
color?: string;
|
||||||
|
kind?: NoteKind;
|
||||||
|
items?: ChecklistItem[] | null;
|
||||||
recipient: UserSummary | null;
|
recipient: UserSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +77,8 @@ export interface ReceivedNote {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
kind: NoteKind;
|
||||||
|
items: ChecklistItem[] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
sender: UserSummary | null;
|
sender: UserSummary | null;
|
||||||
@@ -87,6 +104,8 @@ export interface NoteThread {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
kind: NoteKind;
|
||||||
|
items: ChecklistItem[] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
senderUserId: number;
|
senderUserId: number;
|
||||||
|
|||||||
@@ -1138,7 +1138,15 @@
|
|||||||
"recipientsCount_other": "{{count}} مستلمين",
|
"recipientsCount_other": "{{count}} مستلمين",
|
||||||
"repliesCount_one": "رد واحد",
|
"repliesCount_one": "رد واحد",
|
||||||
"repliesCount_other": "{{count}} ردود",
|
"repliesCount_other": "{{count}} ردود",
|
||||||
"openThread": "فتح"
|
"openThread": "فتح",
|
||||||
|
"checklist": {
|
||||||
|
"toggle": "قائمة مهام",
|
||||||
|
"addItem": "إضافة عنصر",
|
||||||
|
"itemPlaceholder": "عنصر القائمة",
|
||||||
|
"emptyHint": "لا توجد عناصر بعد — أضف عنصراً أدناه.",
|
||||||
|
"removeItem": "حذف العنصر",
|
||||||
|
"progress": "{{done}} / {{total}}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"executiveMeetings": {
|
"executiveMeetings": {
|
||||||
"title": "قائمة الاجتماعات",
|
"title": "قائمة الاجتماعات",
|
||||||
|
|||||||
@@ -1039,7 +1039,15 @@
|
|||||||
},
|
},
|
||||||
"conversationWith": "Conversation with {{name}}",
|
"conversationWith": "Conversation with {{name}}",
|
||||||
"ownerReplies": "Your replies",
|
"ownerReplies": "Your replies",
|
||||||
"noConversationsYet": "No conversations yet"
|
"noConversationsYet": "No conversations yet",
|
||||||
|
"checklist": {
|
||||||
|
"toggle": "To-do list",
|
||||||
|
"addItem": "Add item",
|
||||||
|
"itemPlaceholder": "List item",
|
||||||
|
"emptyHint": "No items yet — add one below.",
|
||||||
|
"removeItem": "Remove item",
|
||||||
|
"progress": "{{done}} / {{total}}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"executiveMeetings": {
|
"executiveMeetings": {
|
||||||
"title": "Meetings list",
|
"title": "Meetings list",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Inbox,
|
Inbox,
|
||||||
Mail,
|
Mail,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
ListTodo,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -47,7 +48,9 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
|
type ChecklistItem,
|
||||||
type Note,
|
type Note,
|
||||||
|
type NoteKind,
|
||||||
type NoteLabel,
|
type NoteLabel,
|
||||||
type NoteThread,
|
type NoteThread,
|
||||||
type ReceivedNote,
|
type ReceivedNote,
|
||||||
@@ -642,10 +645,24 @@ function NoteCard({
|
|||||||
{note.title}
|
{note.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{note.content && (
|
{note.kind === "checklist" ? (
|
||||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
|
<ChecklistView
|
||||||
{note.content}
|
items={note.items ?? []}
|
||||||
</div>
|
testIdPrefix={`note-card-checklist-${note.id}`}
|
||||||
|
className="mt-1"
|
||||||
|
onToggle={(itemId, done) => {
|
||||||
|
const next = (note.items ?? []).map((it) =>
|
||||||
|
it.id === itemId ? { ...it, done } : it,
|
||||||
|
);
|
||||||
|
update.mutate({ id: note.id, kind: "checklist", items: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
note.content && (
|
||||||
|
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
|
||||||
|
{note.content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -771,10 +788,18 @@ function ReceivedList({
|
|||||||
{n.title}
|
{n.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{n.content && (
|
{n.kind === "checklist" ? (
|
||||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
<ChecklistView
|
||||||
{n.content}
|
items={n.items ?? []}
|
||||||
</div>
|
testIdPrefix={`received-note-checklist-${n.id}`}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
n.content && (
|
||||||
|
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
||||||
|
{n.content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -822,10 +847,18 @@ function SentList({
|
|||||||
{n.title}
|
{n.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{n.content && (
|
{n.kind === "checklist" ? (
|
||||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
|
<ChecklistView
|
||||||
{n.content}
|
items={n.items ?? []}
|
||||||
</div>
|
testIdPrefix={`sent-note-checklist-${n.id}`}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
n.content && (
|
||||||
|
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
|
||||||
|
{n.content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div className="text-[11px] text-muted-foreground mt-2">
|
<div className="text-[11px] text-muted-foreground mt-2">
|
||||||
{t("notes.to", "To:")}{" "}
|
{t("notes.to", "To:")}{" "}
|
||||||
@@ -1191,10 +1224,17 @@ function ThreadDialog({
|
|||||||
<Mail size={12} />
|
<Mail size={12} />
|
||||||
{t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
|
{t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
|
||||||
</div>
|
</div>
|
||||||
{thread.content && (
|
{thread.kind === "checklist" ? (
|
||||||
<div className="text-sm text-foreground/85 whitespace-pre-wrap">
|
<ChecklistView
|
||||||
{thread.content}
|
items={thread.items ?? []}
|
||||||
</div>
|
testIdPrefix={`thread-checklist-${thread.id}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
thread.content && (
|
||||||
|
<div className="text-sm text-foreground/85 whitespace-pre-wrap">
|
||||||
|
{thread.content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
|
{(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
@@ -1413,6 +1453,199 @@ function GroupedReplies({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newItemId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistEditor({
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
testIdPrefix,
|
||||||
|
}: {
|
||||||
|
items: ChecklistItem[];
|
||||||
|
onChange: (next: ChecklistItem[]) => void;
|
||||||
|
testIdPrefix: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inputRefs = useRef<Map<string, HTMLInputElement | null>>(new Map());
|
||||||
|
const focusItemIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// After append (Enter or Add), shift focus to the freshly inserted row.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = focusItemIdRef.current;
|
||||||
|
if (!id) return;
|
||||||
|
const el = inputRefs.current.get(id);
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
focusItemIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const setItem = (id: string, patch: Partial<ChecklistItem>) => {
|
||||||
|
onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it)));
|
||||||
|
};
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
onChange(items.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
const appendItem = () => {
|
||||||
|
const item: ChecklistItem = { id: newItemId(), text: "", done: false };
|
||||||
|
focusItemIdRef.current = item.id;
|
||||||
|
onChange([...items, item]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground italic px-1">
|
||||||
|
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((it) => (
|
||||||
|
<div
|
||||||
|
key={it.id}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
data-testid={`${testIdPrefix}-item-${it.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={it.done}
|
||||||
|
onChange={(e) => setItem(it.id, { done: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500"
|
||||||
|
aria-label={t("notes.checklist.itemPlaceholder", "List item")}
|
||||||
|
data-testid={`${testIdPrefix}-check-${it.id}`}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) inputRefs.current.set(it.id, el);
|
||||||
|
else inputRefs.current.delete(it.id);
|
||||||
|
}}
|
||||||
|
value={it.text}
|
||||||
|
onChange={(e) => setItem(it.id, { text: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
appendItem();
|
||||||
|
} else if (
|
||||||
|
e.key === "Backspace" &&
|
||||||
|
it.text.length === 0 &&
|
||||||
|
items.length > 1
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
removeItem(it.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("notes.checklist.itemPlaceholder", "List item")}
|
||||||
|
className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${
|
||||||
|
it.done ? "line-through text-muted-foreground" : ""
|
||||||
|
}`}
|
||||||
|
data-testid={`${testIdPrefix}-text-${it.id}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(it.id)}
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-destructive"
|
||||||
|
aria-label={t("notes.checklist.removeItem", "Remove item")}
|
||||||
|
data-testid={`${testIdPrefix}-remove-${it.id}`}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={appendItem}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
|
||||||
|
data-testid={`${testIdPrefix}-add`}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t("notes.checklist.addItem", "Add item")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistView({
|
||||||
|
items,
|
||||||
|
onToggle,
|
||||||
|
testIdPrefix,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
items: ChecklistItem[];
|
||||||
|
onToggle?: (id: string, done: boolean) => void;
|
||||||
|
testIdPrefix: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`space-y-1 ${className}`}
|
||||||
|
data-testid={`${testIdPrefix}-view`}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Prevent the parent card's click handler (which opens edit) when
|
||||||
|
// toggling a checkbox is the intended action.
|
||||||
|
if (onToggle) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((it) => (
|
||||||
|
<label
|
||||||
|
key={it.id}
|
||||||
|
className="flex items-start gap-2 text-sm cursor-pointer"
|
||||||
|
data-testid={`${testIdPrefix}-row-${it.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={it.done}
|
||||||
|
disabled={!onToggle}
|
||||||
|
onChange={(e) => onToggle?.(it.id, e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500 disabled:cursor-default"
|
||||||
|
data-testid={`${testIdPrefix}-check-${it.id}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`flex-1 break-words ${
|
||||||
|
it.done ? "line-through text-muted-foreground" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{it.text || "\u00A0"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindToggle({
|
||||||
|
kind,
|
||||||
|
onChange,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
kind: NoteKind;
|
||||||
|
onChange: (k: NoteKind) => void;
|
||||||
|
testId?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const next: NoteKind = kind === "text" ? "checklist" : "text";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(next)}
|
||||||
|
data-testid={testId}
|
||||||
|
aria-pressed={kind === "checklist"}
|
||||||
|
aria-label={t("notes.checklist.toggle", "To-do list")}
|
||||||
|
title={t("notes.checklist.toggle", "To-do list")}
|
||||||
|
className={`p-1 rounded-md hover:bg-black/5 ${
|
||||||
|
kind === "checklist"
|
||||||
|
? "text-amber-600 bg-amber-100/60"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListTodo size={15} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ColorPicker({
|
function ColorPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -1531,6 +1764,8 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
|||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [color, setColor] = useState("default");
|
const [color, setColor] = useState("default");
|
||||||
const [labelIds, setLabelIds] = useState<number[]>([]);
|
const [labelIds, setLabelIds] = useState<number[]>([]);
|
||||||
|
const [kind, setKind] = useState<NoteKind>("text");
|
||||||
|
const [items, setItems] = useState<ChecklistItem[]>([]);
|
||||||
const create = useCreateNote();
|
const create = useCreateNote();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -1557,23 +1792,41 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
|||||||
document.addEventListener("mousedown", onClick);
|
document.addEventListener("mousedown", onClick);
|
||||||
return () => document.removeEventListener("mousedown", onClick);
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, title, content, color, labelIds]);
|
}, [open, title, content, color, labelIds, kind, items]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setContent("");
|
setContent("");
|
||||||
setColor("default");
|
setColor("default");
|
||||||
setLabelIds([]);
|
setLabelIds([]);
|
||||||
|
setKind("text");
|
||||||
|
setItems([]);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!title.trim() && !content.trim()) {
|
const cleanItems =
|
||||||
|
kind === "checklist"
|
||||||
|
? items
|
||||||
|
.map((it) => ({ ...it, text: it.text.trim() }))
|
||||||
|
.filter((it) => it.text.length > 0)
|
||||||
|
: [];
|
||||||
|
const isEmpty =
|
||||||
|
!title.trim() &&
|
||||||
|
(kind === "checklist" ? cleanItems.length === 0 : !content.trim());
|
||||||
|
if (isEmpty) {
|
||||||
reset();
|
reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ title: title.trim(), content: content.trim(), color, labelIds },
|
{
|
||||||
|
title: title.trim(),
|
||||||
|
content: kind === "checklist" ? "" : content.trim(),
|
||||||
|
color,
|
||||||
|
labelIds,
|
||||||
|
kind,
|
||||||
|
items: kind === "checklist" ? cleanItems : null,
|
||||||
|
},
|
||||||
{ onSuccess: reset },
|
{ onSuccess: reset },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1596,15 +1849,30 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
|||||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
|
||||||
data-testid="notes-composer-title"
|
data-testid="notes-composer-title"
|
||||||
/>
|
/>
|
||||||
<Textarea
|
{kind === "checklist" ? (
|
||||||
value={content}
|
<div className="mt-2 px-1">
|
||||||
onChange={(e) => setContent(e.target.value)}
|
<ChecklistEditor
|
||||||
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
items={items}
|
||||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
|
onChange={setItems}
|
||||||
data-testid="notes-composer-content"
|
testIdPrefix="notes-composer-checklist"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
||||||
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
|
||||||
|
data-testid="notes-composer-content"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1 mt-2">
|
<div className="flex items-center gap-1 mt-2">
|
||||||
<ColorPicker value={color} onChange={setColor} />
|
<ColorPicker value={color} onChange={setColor} />
|
||||||
|
<KindToggle
|
||||||
|
kind={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
testId="notes-composer-kind-toggle"
|
||||||
|
/>
|
||||||
<LabelMenu
|
<LabelMenu
|
||||||
labels={labels}
|
labels={labels}
|
||||||
selected={labelIds}
|
selected={labelIds}
|
||||||
@@ -1644,11 +1912,27 @@ function EditNoteDialog({
|
|||||||
const [content, setContent] = useState(note.content);
|
const [content, setContent] = useState(note.content);
|
||||||
const [color, setColor] = useState(note.color);
|
const [color, setColor] = useState(note.color);
|
||||||
const [labelIds, setLabelIds] = useState<number[]>(note.labelIds);
|
const [labelIds, setLabelIds] = useState<number[]>(note.labelIds);
|
||||||
|
const [kind, setKind] = useState<NoteKind>(note.kind ?? "text");
|
||||||
|
const [items, setItems] = useState<ChecklistItem[]>(note.items ?? []);
|
||||||
const update = useUpdateNote();
|
const update = useUpdateNote();
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
|
const cleanItems =
|
||||||
|
kind === "checklist"
|
||||||
|
? items
|
||||||
|
.map((it) => ({ ...it, text: it.text.trim() }))
|
||||||
|
.filter((it) => it.text.length > 0)
|
||||||
|
: [];
|
||||||
update.mutate(
|
update.mutate(
|
||||||
{ id: note.id, title: title.trim(), content: content.trim(), color, labelIds },
|
{
|
||||||
|
id: note.id,
|
||||||
|
title: title.trim(),
|
||||||
|
content: kind === "checklist" ? "" : content.trim(),
|
||||||
|
color,
|
||||||
|
labelIds,
|
||||||
|
kind,
|
||||||
|
items: kind === "checklist" ? cleanItems : null,
|
||||||
|
},
|
||||||
{ onSuccess: onClose },
|
{ onSuccess: onClose },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1673,14 +1957,29 @@ function EditNoteDialog({
|
|||||||
placeholder={t("notes.titlePlaceholder", "Title")}
|
placeholder={t("notes.titlePlaceholder", "Title")}
|
||||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold text-base"
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold text-base"
|
||||||
/>
|
/>
|
||||||
<Textarea
|
{kind === "checklist" ? (
|
||||||
value={content}
|
<div className="px-1 min-h-[160px]" data-testid="notes-edit-checklist-wrap">
|
||||||
onChange={(e) => setContent(e.target.value)}
|
<ChecklistEditor
|
||||||
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
items={items}
|
||||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
|
onChange={setItems}
|
||||||
/>
|
testIdPrefix="notes-edit-checklist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
||||||
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1 mt-2">
|
<div className="flex items-center gap-1 mt-2">
|
||||||
<ColorPicker value={color} onChange={setColor} />
|
<ColorPicker value={color} onChange={setColor} />
|
||||||
|
<KindToggle
|
||||||
|
kind={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
testId="notes-edit-kind-toggle"
|
||||||
|
/>
|
||||||
<LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
|
<LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
|
||||||
<div className="ms-auto">
|
<div className="ms-auto">
|
||||||
<Button size="sm" onClick={save}>
|
<Button size="sm" onClick={save}>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
|
import { pgTable, text, serial, timestamp, integer, varchar, boolean, jsonb, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { usersTable } from "./users";
|
import { usersTable } from "./users";
|
||||||
@@ -13,6 +13,8 @@ export const noteFoldersTable = pgTable("note_folders", {
|
|||||||
userNameUnique: uniqueIndex("note_folders_user_name_unique").on(t.userId, t.name),
|
userNameUnique: uniqueIndex("note_folders_user_name_unique").on(t.userId, t.name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type ChecklistItem = { id: string; text: string; done: boolean };
|
||||||
|
|
||||||
export const notesTable = pgTable("notes", {
|
export const notesTable = pgTable("notes", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
||||||
@@ -20,6 +22,10 @@ export const notesTable = pgTable("notes", {
|
|||||||
title: varchar("title", { length: 300 }).notNull().default(""),
|
title: varchar("title", { length: 300 }).notNull().default(""),
|
||||||
content: text("content").notNull().default(""),
|
content: text("content").notNull().default(""),
|
||||||
color: varchar("color", { length: 32 }).notNull().default("default"),
|
color: varchar("color", { length: 32 }).notNull().default("default"),
|
||||||
|
// "text" (plain note) or "checklist" (per-note to-do list). Items live in
|
||||||
|
// `items` for checklist notes; for text notes `items` is null.
|
||||||
|
kind: varchar("kind", { length: 16 }).notNull().default("text"),
|
||||||
|
items: jsonb("items").$type<ChecklistItem[]>(),
|
||||||
isPinned: boolean("is_pinned").notNull().default(false),
|
isPinned: boolean("is_pinned").notNull().default(false),
|
||||||
isArchived: boolean("is_archived").notNull().default(false),
|
isArchived: boolean("is_archived").notNull().default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -65,6 +71,10 @@ export const noteRecipientsTable = pgTable("note_recipients", {
|
|||||||
title: varchar("title", { length: 300 }).notNull().default(""),
|
title: varchar("title", { length: 300 }).notNull().default(""),
|
||||||
content: text("content").notNull().default(""),
|
content: text("content").notNull().default(""),
|
||||||
color: varchar("color", { length: 32 }).notNull().default("default"),
|
color: varchar("color", { length: 32 }).notNull().default("default"),
|
||||||
|
// Snapshot of the source note's kind/items at send time so checklist
|
||||||
|
// notes survive (and stay independent of) sender edits/deletes.
|
||||||
|
kind: varchar("kind", { length: 16 }).notNull().default("text"),
|
||||||
|
items: jsonb("items").$type<ChecklistItem[]>(),
|
||||||
status: varchar("status", { length: 16 }).notNull().default("unread"),
|
status: varchar("status", { length: 16 }).notNull().default("unread"),
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(),
|
sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
readAt: timestamp("read_at", { withTimezone: true }),
|
readAt: timestamp("read_at", { withTimezone: true }),
|
||||||
|
|||||||
Reference in New Issue
Block a user