mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 15:33:12 +00:00
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:
parent
de1c23b118
commit
74e7c526e8
8 changed files with 115 additions and 12 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
63
src/test/domain/work-orders/utils/comment-highlight.test.ts
Normal file
63
src/test/domain/work-orders/utils/comment-highlight.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue