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;
|
||||
}
|
||||
|
||||
type ChecklistItem = { id: string; text: string; done: boolean };
|
||||
|
||||
interface NoteInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
kind?: "text" | "checklist";
|
||||
items?: ChecklistItem[] | null;
|
||||
isPinned?: boolean;
|
||||
isArchived?: boolean;
|
||||
labelIds?: number[];
|
||||
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 } {
|
||||
if (v === null) return { ok: true, value: null };
|
||||
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" };
|
||||
data.color = c;
|
||||
} 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 (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" };
|
||||
data.isPinned = body.isPinned;
|
||||
@@ -214,6 +260,8 @@ async function loadRecipientsForNote(noteId: number) {
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
color: r.color,
|
||||
kind: r.kind,
|
||||
items: r.items,
|
||||
recipient: userMap.get(r.recipientUserId) ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -283,6 +331,8 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
|
||||
title: data.title ?? "",
|
||||
content: data.content ?? "",
|
||||
color: data.color ?? "default",
|
||||
kind: data.kind ?? "text",
|
||||
items: data.items ?? null,
|
||||
isPinned: data.isPinned ?? false,
|
||||
isArchived: data.isArchived ?? false,
|
||||
folderId: folderId ?? null,
|
||||
@@ -318,6 +368,17 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
res.status(400).json({ error: "Invalid folderId" });
|
||||
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) {
|
||||
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,
|
||||
content: r.content,
|
||||
color: r.color,
|
||||
kind: r.kind,
|
||||
items: r.items,
|
||||
createdAt: r.sentAt,
|
||||
updatedAt: r.readAt ?? r.sentAt,
|
||||
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 colorFallback = snapshotRow?.color ?? note?.color ?? "default";
|
||||
|
||||
const kindFallback = snapshotRow?.kind ?? note?.kind ?? "text";
|
||||
const itemsFallback = snapshotRow?.items ?? note?.items ?? null;
|
||||
|
||||
res.json({
|
||||
id: id.id,
|
||||
title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback,
|
||||
content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback,
|
||||
color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback,
|
||||
kind: useSnapshot ? recipientRow!.kind : note?.kind ?? kindFallback,
|
||||
items: useSnapshot ? recipientRow!.items : note?.items ?? itemsFallback,
|
||||
createdAt: useSnapshot
|
||||
? recipientRow!.sentAt
|
||||
: note?.createdAt ?? snapshotRow?.sentAt ?? null,
|
||||
@@ -595,6 +663,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
kind: note.kind,
|
||||
items: note.items,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
@@ -625,6 +695,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
color: r.color,
|
||||
kind: r.kind,
|
||||
items: r.items,
|
||||
sentAt: r.sentAt,
|
||||
senderUserId: userId,
|
||||
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";
|
||||
|
||||
export type NoteKind = "text" | "checklist";
|
||||
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
userId: number;
|
||||
@@ -7,6 +15,8 @@ export interface Note {
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
kind: NoteKind;
|
||||
items: ChecklistItem[] | null;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
@@ -49,6 +59,11 @@ export interface SentNoteRecipient {
|
||||
sentAt: string;
|
||||
readAt: string | null;
|
||||
archivedAt: string | null;
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
kind?: NoteKind;
|
||||
items?: ChecklistItem[] | null;
|
||||
recipient: UserSummary | null;
|
||||
}
|
||||
|
||||
@@ -62,6 +77,8 @@ export interface ReceivedNote {
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
kind: NoteKind;
|
||||
items: ChecklistItem[] | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sender: UserSummary | null;
|
||||
@@ -87,6 +104,8 @@ export interface NoteThread {
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
kind: NoteKind;
|
||||
items: ChecklistItem[] | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
senderUserId: number;
|
||||
|
||||
@@ -1138,7 +1138,15 @@
|
||||
"recipientsCount_other": "{{count}} مستلمين",
|
||||
"repliesCount_one": "رد واحد",
|
||||
"repliesCount_other": "{{count}} ردود",
|
||||
"openThread": "فتح"
|
||||
"openThread": "فتح",
|
||||
"checklist": {
|
||||
"toggle": "قائمة مهام",
|
||||
"addItem": "إضافة عنصر",
|
||||
"itemPlaceholder": "عنصر القائمة",
|
||||
"emptyHint": "لا توجد عناصر بعد — أضف عنصراً أدناه.",
|
||||
"removeItem": "حذف العنصر",
|
||||
"progress": "{{done}} / {{total}}"
|
||||
}
|
||||
},
|
||||
"executiveMeetings": {
|
||||
"title": "قائمة الاجتماعات",
|
||||
|
||||
@@ -1039,7 +1039,15 @@
|
||||
},
|
||||
"conversationWith": "Conversation with {{name}}",
|
||||
"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": {
|
||||
"title": "Meetings list",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Inbox,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
ListTodo,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -47,7 +48,9 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
type ChecklistItem,
|
||||
type Note,
|
||||
type NoteKind,
|
||||
type NoteLabel,
|
||||
type NoteThread,
|
||||
type ReceivedNote,
|
||||
@@ -642,10 +645,24 @@ function NoteCard({
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
{note.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
|
||||
{note.content}
|
||||
</div>
|
||||
{note.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={note.items ?? []}
|
||||
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>
|
||||
<button
|
||||
@@ -771,10 +788,18 @@ function ReceivedList({
|
||||
{n.title}
|
||||
</div>
|
||||
)}
|
||||
{n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
||||
{n.content}
|
||||
</div>
|
||||
{n.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={n.items ?? []}
|
||||
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>
|
||||
))}
|
||||
@@ -822,10 +847,18 @@ function SentList({
|
||||
{n.title}
|
||||
</div>
|
||||
)}
|
||||
{n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
|
||||
{n.content}
|
||||
</div>
|
||||
{n.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={n.items ?? []}
|
||||
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">
|
||||
{t("notes.to", "To:")}{" "}
|
||||
@@ -1191,10 +1224,17 @@ function ThreadDialog({
|
||||
<Mail size={12} />
|
||||
{t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
|
||||
</div>
|
||||
{thread.content && (
|
||||
<div className="text-sm text-foreground/85 whitespace-pre-wrap">
|
||||
{thread.content}
|
||||
</div>
|
||||
{thread.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={thread.items ?? []}
|
||||
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 && (
|
||||
<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({
|
||||
value,
|
||||
onChange,
|
||||
@@ -1531,6 +1764,8 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
const [content, setContent] = useState("");
|
||||
const [color, setColor] = useState("default");
|
||||
const [labelIds, setLabelIds] = useState<number[]>([]);
|
||||
const [kind, setKind] = useState<NoteKind>("text");
|
||||
const [items, setItems] = useState<ChecklistItem[]>([]);
|
||||
const create = useCreateNote();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1557,23 +1792,41 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, title, content, color, labelIds]);
|
||||
}, [open, title, content, color, labelIds, kind, items]);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setContent("");
|
||||
setColor("default");
|
||||
setLabelIds([]);
|
||||
setKind("text");
|
||||
setItems([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
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 },
|
||||
);
|
||||
};
|
||||
@@ -1596,15 +1849,30 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
|
||||
data-testid="notes-composer-title"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
{kind === "checklist" ? (
|
||||
<div className="mt-2 px-1">
|
||||
<ChecklistEditor
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
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">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
<KindToggle
|
||||
kind={kind}
|
||||
onChange={setKind}
|
||||
testId="notes-composer-kind-toggle"
|
||||
/>
|
||||
<LabelMenu
|
||||
labels={labels}
|
||||
selected={labelIds}
|
||||
@@ -1644,11 +1912,27 @@ function EditNoteDialog({
|
||||
const [content, setContent] = useState(note.content);
|
||||
const [color, setColor] = useState(note.color);
|
||||
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 save = () => {
|
||||
const cleanItems =
|
||||
kind === "checklist"
|
||||
? items
|
||||
.map((it) => ({ ...it, text: it.text.trim() }))
|
||||
.filter((it) => it.text.length > 0)
|
||||
: [];
|
||||
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 },
|
||||
);
|
||||
};
|
||||
@@ -1673,14 +1957,29 @@ function EditNoteDialog({
|
||||
placeholder={t("notes.titlePlaceholder", "Title")}
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold text-base"
|
||||
/>
|
||||
<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]"
|
||||
/>
|
||||
{kind === "checklist" ? (
|
||||
<div className="px-1 min-h-[160px]" data-testid="notes-edit-checklist-wrap">
|
||||
<ChecklistEditor
|
||||
items={items}
|
||||
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">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
<KindToggle
|
||||
kind={kind}
|
||||
onChange={setKind}
|
||||
testId="notes-edit-kind-toggle"
|
||||
/>
|
||||
<LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
|
||||
<div className="ms-auto">
|
||||
<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 { z } from "zod/v4";
|
||||
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),
|
||||
}));
|
||||
|
||||
export type ChecklistItem = { id: string; text: string; done: boolean };
|
||||
|
||||
export const notesTable = pgTable("notes", {
|
||||
id: serial("id").primaryKey(),
|
||||
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(""),
|
||||
content: text("content").notNull().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),
|
||||
isArchived: boolean("is_archived").notNull().default(false),
|
||||
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(""),
|
||||
content: text("content").notNull().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"),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
|
||||
Reference in New Issue
Block a user