Task #433: incoming-note popup — render full note + 3-button action row
- Inner body now renders as a real sticky note: keeps colored surface, supports checklist (read-only) when noteKind === "checklist". - Action row trimmed to exactly 3 buttons: Done (تم), Open note (فتح الملاحظة), Reply (رد). Removed the standalone Dismiss button; header X still closes. - Reply variant uses the same 3-button row (Done is a dismiss-only no-op since the note owner can't mark their own note read). - Wire kind + items from backend `note_received` payload into client IncomingNotePayload (noteKind/items) via use-notifications-socket. - Locales: markRead → "Done"/"تم"; new openNote/done keys; openThread unified to "Open note"/"فتح الملاحظة". - Tests: replaced removed `incoming-note-popup-dismiss` testid usages with header `-close`; updated reply variant assertions to expect the 3-button row; fixed pre-existing pluralization bug in reply path (`/replies` → `/reply`) that was unrelated to this task but blocked the reply popup test from validating. Labels and pin were not surfaced in the popup because they are sender-private state (note_recipients only snapshots title/content/color/kind/items); the recipient never receives the sender's labelIds/isPinned, so showing them would be misleading. All affected e2e tests pass (notes-popup-on-receive note + reply, notes-popup-touch-tap both, notes-inbox); queue unit tests pass.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -14,6 +14,7 @@ import {
|
||||
colorBg,
|
||||
useMarkNoteRead,
|
||||
userDisplayName,
|
||||
type ChecklistItem,
|
||||
type UserSummary,
|
||||
} from "@/lib/notes-api";
|
||||
import {
|
||||
@@ -96,6 +97,46 @@ function clampToViewport(pos: StoredPos): StoredPos {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only checklist render that mirrors the visual style of the
|
||||
* NoteCard's `ChecklistView` (notes.tsx). Inlined here to keep the
|
||||
* popup self-contained — the popup is purely a preview surface, so
|
||||
* the checkboxes are not interactive (toggling is reserved for the
|
||||
* full Notes page after "Open note").
|
||||
*/
|
||||
function PopupChecklistPreview({ items }: { items: ChecklistItem[] }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="space-y-1 mt-2"
|
||||
data-testid="incoming-note-popup-checklist"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
data-testid={`incoming-note-popup-checklist-row-${it.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={it.done}
|
||||
disabled
|
||||
readOnly
|
||||
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500 disabled:cursor-default"
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 break-words ${
|
||||
it.done ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{it.text || "\u00A0"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncomingNotePopup() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRtl = i18n.language === "ar";
|
||||
@@ -181,6 +222,10 @@ export function IncomingNotePopup() {
|
||||
const replyPayload = isReply
|
||||
? (current as Extract<PopupPayload, { kind: "reply" }>)
|
||||
: null;
|
||||
const notePayload =
|
||||
current && current.kind !== "reply"
|
||||
? (current as Extract<PopupPayload, { kind?: "note" }>)
|
||||
: null;
|
||||
|
||||
const senderForHeader: UserSummary | null = useMemo(() => {
|
||||
if (!current) return null;
|
||||
@@ -288,6 +333,16 @@ export function IncomingNotePopup() {
|
||||
const bodyContent = replyPayload
|
||||
? replyPayload.replyContent
|
||||
: current.content;
|
||||
// Render the inner sticky-note as faithfully as the NoteCard does:
|
||||
// text body for "text" notes, read-only checklist for "checklist".
|
||||
// Reply-variant popups always render the reply text as plain content.
|
||||
const isChecklistNote =
|
||||
!!notePayload &&
|
||||
notePayload.noteKind === "checklist" &&
|
||||
Array.isArray(notePayload.items);
|
||||
const checklistItems = isChecklistNote
|
||||
? (notePayload!.items as ChecklistItem[])
|
||||
: [];
|
||||
|
||||
return (
|
||||
// The popup is "floating", not modal: render the card directly as
|
||||
@@ -368,22 +423,31 @@ export function IncomingNotePopup() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{/* Body — render the inner note as a real sticky note. The
|
||||
outer floating frame (header + actions) keeps its chrome;
|
||||
this inner block matches the NoteCard surface so users see
|
||||
the same artefact they'd see on the Notes page. */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div
|
||||
className={`rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
|
||||
className={`rounded-xl border border-black/5 shadow-sm p-3 max-h-[40vh] overflow-y-auto ${colorBg(
|
||||
current.color,
|
||||
)}`}
|
||||
data-testid="incoming-note-popup-body"
|
||||
>
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{bodyTitle}
|
||||
</div>
|
||||
{bodyContent && (
|
||||
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words max-h-[40vh] overflow-y-auto">
|
||||
{bodyContent}
|
||||
{bodyTitle && (
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{bodyTitle}
|
||||
</div>
|
||||
)}
|
||||
{isChecklistNote ? (
|
||||
<PopupChecklistPreview items={checklistItems} />
|
||||
) : (
|
||||
bodyContent && (
|
||||
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words">
|
||||
{bodyContent}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queueLength > 1 && (
|
||||
@@ -395,16 +459,19 @@ export function IncomingNotePopup() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row — exactly three buttons: Done, Open note,
|
||||
Reply. The standalone Dismiss button was removed; the
|
||||
X in the header still closes. */}
|
||||
<div className="flex flex-wrap gap-2 sm:flex-nowrap justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
data-testid="incoming-note-popup-dismiss"
|
||||
onClick={handleMarkRead}
|
||||
data-testid="incoming-note-popup-mark-read"
|
||||
>
|
||||
<X size={14} className="me-1" />
|
||||
{t("notes.popup.dismiss")}
|
||||
<Check size={14} className="me-1" />
|
||||
{t("notes.popup.done")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -414,22 +481,8 @@ export function IncomingNotePopup() {
|
||||
data-testid="incoming-note-popup-open"
|
||||
>
|
||||
<ExternalLink size={14} className="me-1" />
|
||||
{replyPayload
|
||||
? t("notes.popup.openThread")
|
||||
: t("notes.popup.openInNotes")}
|
||||
{t("notes.popup.openNote")}
|
||||
</Button>
|
||||
{!replyPayload && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkRead}
|
||||
data-testid="incoming-note-popup-mark-read"
|
||||
>
|
||||
<Check size={14} className="me-1" />
|
||||
{t("notes.popup.markRead")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -153,6 +153,13 @@ export function useNotificationsSocket() {
|
||||
typeof payload.noteId === "number" &&
|
||||
typeof payload.senderUserId === "number"
|
||||
) {
|
||||
const rawKind = (payload as { kind?: unknown }).kind;
|
||||
const noteKind: "text" | "checklist" | null =
|
||||
rawKind === "checklist" || rawKind === "text" ? rawKind : null;
|
||||
const rawItems = (payload as { items?: unknown }).items;
|
||||
const items = Array.isArray(rawItems)
|
||||
? (rawItems as IncomingNotePayload["items"])
|
||||
: null;
|
||||
const full: IncomingNotePayload = {
|
||||
noteId: payload.noteId,
|
||||
recipientRowId: payload.recipientRowId,
|
||||
@@ -162,6 +169,8 @@ export function useNotificationsSocket() {
|
||||
sentAt: payload.sentAt,
|
||||
senderUserId: payload.senderUserId,
|
||||
sender: payload.sender ?? null,
|
||||
noteKind,
|
||||
items,
|
||||
};
|
||||
enqueued = enqueueNotePopupRef.current(
|
||||
full,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserSummary } from "@/lib/notes-api";
|
||||
import type { ChecklistItem, UserSummary } from "@/lib/notes-api";
|
||||
|
||||
/**
|
||||
* Common fields shared by every floating-card popup entry, regardless
|
||||
@@ -21,6 +21,14 @@ interface BasePayload {
|
||||
|
||||
export interface IncomingNotePayload extends BasePayload {
|
||||
kind?: "note";
|
||||
/**
|
||||
* Mirrors the underlying note's `kind` ("text" | "checklist") so the
|
||||
* popup body can render the same surface as the NoteCard. Named
|
||||
* `noteKind` to avoid clashing with the popup-variant discriminator
|
||||
* (`kind: "note" | "reply"`) above.
|
||||
*/
|
||||
noteKind?: "text" | "checklist" | null;
|
||||
items?: ChecklistItem[] | null;
|
||||
}
|
||||
|
||||
export interface IncomingReplyPayload extends BasePayload {
|
||||
|
||||
@@ -1136,10 +1136,12 @@
|
||||
"queueMore_one": "ملاحظة أخرى بالانتظار",
|
||||
"queueMore_other": "{{count}} ملاحظات أخرى بالانتظار",
|
||||
"reply": "رد",
|
||||
"replyBack": "رد عليه",
|
||||
"markRead": "تحديد كمقروءة",
|
||||
"openInNotes": "فتح في الملاحظات",
|
||||
"openThread": "فتح المحادثة",
|
||||
"replyBack": "رد",
|
||||
"markRead": "تم",
|
||||
"openInNotes": "فتح الملاحظة",
|
||||
"openThread": "فتح الملاحظة",
|
||||
"openNote": "فتح الملاحظة",
|
||||
"done": "تم",
|
||||
"dismiss": "إغلاق",
|
||||
"dragHint": "اسحب للتحريك"
|
||||
},
|
||||
|
||||
@@ -1042,10 +1042,12 @@
|
||||
"queueMore_one": "{{count}} more note waiting",
|
||||
"queueMore_other": "{{count}} more notes waiting",
|
||||
"reply": "Reply",
|
||||
"replyBack": "Reply back",
|
||||
"markRead": "Mark as read",
|
||||
"openInNotes": "Open in Notes",
|
||||
"openThread": "Open thread",
|
||||
"replyBack": "Reply",
|
||||
"markRead": "Done",
|
||||
"openInNotes": "Open note",
|
||||
"openThread": "Open note",
|
||||
"openNote": "Open note",
|
||||
"done": "Done",
|
||||
"dismiss": "Dismiss",
|
||||
"dragHint": "Drag to move"
|
||||
},
|
||||
|
||||
@@ -214,7 +214,7 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
|
||||
|
||||
// Dismissing acknowledges (closes) the popup.
|
||||
await recipientPage
|
||||
.getByTestId("incoming-note-popup-dismiss")
|
||||
.getByTestId("incoming-note-popup-close")
|
||||
.click();
|
||||
await expect(popup).toBeHidden();
|
||||
|
||||
@@ -314,7 +314,7 @@ test("sender sees a floating reply card when the recipient replies", async ({
|
||||
const replyPromise = recipientPage.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname ===
|
||||
`/api/notes/${createdNote.id}/replies` &&
|
||||
`/api/notes/${createdNote.id}/reply` &&
|
||||
r.request().method() === "POST" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
@@ -336,11 +336,13 @@ test("sender sees a floating reply card when the recipient replies", async ({
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-drag-handle"),
|
||||
).toBeVisible();
|
||||
// Reply card uses the "open thread" + "reply back" affordances —
|
||||
// the recipient-only "mark read" action MUST be absent.
|
||||
// Reply card now uses the same three-button action row as the
|
||||
// note variant — Done, Open note, Reply — so all three are
|
||||
// present. The header X is also a dismiss path. Done on the reply
|
||||
// variant only dismisses (markRead is a no-op for the note owner).
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-mark-read"),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-open"),
|
||||
).toBeVisible();
|
||||
@@ -367,8 +369,8 @@ test("sender sees a floating reply card when the recipient replies", async ({
|
||||
recipientPage.getByTestId("incoming-note-popup"),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Dismiss closes the floating card.
|
||||
await senderPage.getByTestId("incoming-note-popup-dismiss").click();
|
||||
// Header X closes the floating card.
|
||||
await senderPage.getByTestId("incoming-note-popup-close").click();
|
||||
await expect(popup).toBeHidden();
|
||||
} finally {
|
||||
await senderCtx.close();
|
||||
|
||||
Reference in New Issue
Block a user