fix(notifications): re-open tab and match API mentions for event highlights

Re-apply the requested slide-over tab on every open via an open counter, so a
second comment/mention notification for the work order already on screen still
switches to Comments and re-scrolls to the highlighted comment. Treat a board
comment's mentions id array (plain @Name text) as a mention alongside encoded
@[id:name] tokens so mention items highlight and comment items exclude them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
albrand 2026-09-18 14:01:55 -03:00
parent de1c23b118
commit 74e7c526e8
8 changed files with 115 additions and 12 deletions

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
@ -17,13 +17,6 @@ const HIGHLIGHTED_COMMENT_STYLE = {
paddingLeft: 8,
} as const;
// Brings the first comment a notification pointed at into view when it mounts.
function scrollIntoViewOnMount(node: HTMLDivElement | null): void {
if (node != null && typeof node.scrollIntoView === "function") {
node.scrollIntoView({ block: "nearest" });
}
}
function formatCommentTime(value: string): string {
if (!value) return "";
const date = new Date(value);
@ -181,6 +174,15 @@ export function SlideOverCommentsTab({
[comments, highlight, currentUserId],
);
const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id;
const firstHighlightedRef = useRef<HTMLDivElement | null>(null);
// Bring the first comment a notification pointed at into view whenever the target changes — the
// tab may already be mounted when a later notification re-opens this same work order.
useEffect(() => {
const node = firstHighlightedRef.current;
if (firstHighlightedId != null && typeof node?.scrollIntoView === "function") {
node.scrollIntoView({ block: "nearest" });
}
}, [firstHighlightedId]);
const [commentText, setCommentText] = useState("");
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
const [draftText, setDraftText] = useState("");
@ -266,7 +268,7 @@ export function SlideOverCommentsTab({
return (
<div
key={String(comment.id)}
ref={comment.id === firstHighlightedId ? scrollIntoViewOnMount : undefined}
ref={comment.id === firstHighlightedId ? firstHighlightedRef : undefined}
data-highlighted={isHighlighted}
style={{
borderBottom: "1px solid var(--color-bg-muted)",

View file

@ -29,6 +29,8 @@ type UseSlideOverEditStateArgs = {
setTab: (tab: SlideOverTab) => void;
/** Tab to land on when a work order opens outside edit mode. */
initialTab?: SlideOverTab;
/** Increments on every open so re-opening the same work order re-applies the requested tab. */
openSeq?: number;
};
export function useSlideOverEditState({
@ -40,6 +42,7 @@ export function useSlideOverEditState({
closeDisabled,
setTab,
initialTab = "info",
openSeq,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
@ -69,6 +72,15 @@ export function useSlideOverEditState({
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
// Re-apply the requested tab on every open, including a second notification for the work order
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so
// an in-flight edit draft is never discarded.
useEffect(() => {
if (!row) return;
setTab(editMode ? "info" : initialTab);
// eslint-disable-next-line react-hooks/exhaustive-deps -- openSeq gates each open
}, [openSeq]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);

View file

@ -36,6 +36,7 @@ type UseWorkOrderSlideOverArgs = {
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openSeq?: number;
};
type CompletionUploadMutate = (
@ -114,6 +115,7 @@ export function useWorkOrderSlideOver({
onClose,
saving,
initialTab,
openSeq,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
@ -163,6 +165,7 @@ export function useWorkOrderSlideOver({
closeDisabled,
setTab,
initialTab,
openSeq,
});
const uploadCompletionPdf = (file: File) => {

View file

@ -39,6 +39,8 @@ type WorkOrderSlideOverProps = {
initialTab?: SlideOverTab;
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
commentHighlight?: CommentHighlight | null;
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
openSeq?: number;
};
export function WorkOrderSlideOver({
@ -58,8 +60,17 @@ export function WorkOrderSlideOver({
saving,
initialTab,
commentHighlight,
openSeq,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving, initialTab });
const state = useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
initialTab,
openSeq,
});
const { infoSource, activeDraft } = state;
return (

View file

@ -53,6 +53,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
commentHighlight={page.slideOverHighlight}
openSeq={page.slideOverOpenSeq}
users={users}
sites={locations}
vendors={vendors}

View file

@ -24,6 +24,9 @@ function useSlideOverState() {
const [edit, setEdit] = useState(false);
const [tab, setTab] = useState<SlideOverTab>("info");
const [highlight, setHighlight] = useState<CommentHighlight | null>(null);
// Bumps on every open() so the slide-over re-applies tab/highlight even when a second
// notification targets the work order already on screen (same row id).
const [openSeq, setOpenSeq] = useState(0);
const open = (
next: WorkOrderTableRow,
@ -35,6 +38,7 @@ function useSlideOverState() {
setEdit(nextEdit);
setTab(nextTab);
setHighlight(nextHighlight);
setOpenSeq((seq) => seq + 1);
};
const close = () => {
@ -43,7 +47,7 @@ function useSlideOverState() {
setHighlight(null);
};
return { row, edit, tab, highlight, open, close };
return { row, edit, tab, highlight, openSeq, open, close };
}
export function useWorkOrdersListPage() {
@ -155,6 +159,7 @@ export function useWorkOrdersListPage() {
slideOverEdit: slideOver.edit,
slideOverTab: slideOver.tab,
slideOverHighlight: slideOver.highlight,
slideOverOpenSeq: slideOver.openSeq,
confirmCancel,
setConfirmCancel,
confirmComplete,

View file

@ -30,7 +30,13 @@ export function highlightedCommentIds(
if (Number.isNaN(createdAt) || createdAt < since || isOwnWorkOrderComment(comment, userId)) {
continue;
}
const mentionsUser = extractMentionIds(comment.commenttext ?? "").includes(userId);
// Board comments expose mentions two ways: an encoded `@[id:name]` token in the text, or a
// `mentions` id array alongside plain `@Name` text. Treat either as a mention of the user.
const mentionIds = new Set(extractMentionIds(comment.commenttext ?? ""));
for (const id of comment.mentions ?? []) {
mentionIds.add(String(id));
}
const mentionsUser = mentionIds.has(userId);
if (mentionsUser === highlight.mentions) {
ids.add(String(comment.id));
}

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail";
import { highlightedCommentIds } from "@/domain/work-orders/utils/comment-highlight";
import { encodeMentionToken } from "@/domain/work-orders/utils/comment-mentions";
function makeComment(overrides: Partial<WorkOrderComment>): WorkOrderComment {
return {
id: "c1",
createdDate: "2026-01-01T00:00:00",
commenttext: "",
commentType: "internal",
userName: "Someone Else",
authorId: "other",
commenter: null,
recordType: null,
...overrides,
};
}
const SINCE = "2025-12-31T00:00:00Z";
describe("highlightedCommentIds", () => {
it("treats an API mentions id array as a mention even with plain @Name text", () => {
const comment = makeComment({
id: "m1",
commenttext: "Hey @Jane can you check this",
mentions: ["user-1"],
});
const mentionHit = highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1");
expect(mentionHit.has("m1")).toBe(true);
// The same comment must NOT tint under Recent activity (comments bucket).
const commentBucket = highlightedCommentIds(
[comment],
{ since: SINCE, mentions: false },
"user-1",
);
expect(commentBucket.has("m1")).toBe(false);
});
it("still recognises encoded @[id:name] tokens", () => {
const comment = makeComment({
id: "e1",
commenttext: `Ping ${encodeMentionToken("user-1", "Jane Doe")} please`,
});
expect(
highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1").has("e1"),
).toBe(true);
});
it("keeps a non-mention comment in the comments bucket", () => {
const comment = makeComment({ id: "p1", commenttext: "Status update, no mentions" });
expect(
highlightedCommentIds([comment], { since: SINCE, mentions: false }, "user-1").has("p1"),
).toBe(true);
expect(
highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1").has("p1"),
).toBe(false);
});
});