From 619f77286fa72bea5bcbd0d194af799089c56c5b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Fri, 13 Feb 2026 22:06:37 +0530 Subject: [PATCH] feat: add task detail URL handling and utility functions for taskId management --- frontend/src/app/boards/[boardId]/page.tsx | 52 +++++++++++++++++-- .../[boardId]/task-detail-query.test.ts | 27 ++++++++++ .../app/boards/[boardId]/task-detail-query.ts | 21 ++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/boards/[boardId]/task-detail-query.test.ts create mode 100644 frontend/src/app/boards/[boardId]/task-detail-query.ts diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 53b176bc..e1029fd6 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -3,7 +3,12 @@ export const dynamic = "force-dynamic"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { @@ -31,6 +36,7 @@ import { import { DashboardShell } from "@/components/templates/DashboardShell"; import { BoardChatComposer } from "@/components/BoardChatComposer"; import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor"; +import { buildUrlWithTaskId } from "./task-detail-query"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -702,6 +708,7 @@ LiveFeedCard.displayName = "LiveFeedCard"; export default function BoardDetailPage() { const router = useRouter(); const params = useParams(); + const pathname = usePathname(); const searchParams = useSearchParams(); const boardIdParam = params?.boardId; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; @@ -781,6 +788,7 @@ export default function BoardDetailPage() { null, ); const [isLoading, setIsLoading] = useState(false); + const [hasLoadedBoardSnapshot, setHasLoadedBoardSnapshot] = useState(false); const [error, setError] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const selectedTaskIdRef = useRef(null); @@ -1172,6 +1180,7 @@ export default function BoardDetailPage() { const loadBoard = useCallback(async () => { if (!isSignedIn || !boardId) return; + setHasLoadedBoardSnapshot(false); setIsLoading(true); setIsApprovalsLoading(true); setError(null); @@ -1225,6 +1234,7 @@ export default function BoardDetailPage() { } finally { setIsLoading(false); setIsApprovalsLoading(false); + setHasLoadedBoardSnapshot(true); } }, [boardId, isSignedIn]); @@ -2339,12 +2349,21 @@ export default function BoardDetailPage() { setIsLiveFeedOpen(false); const fullTask = tasksRef.current.find((item) => item.id === task.id); if (!fullTask) return; + const currentTaskIdFromUrl = searchParams.get("taskId"); + if (currentTaskIdFromUrl !== fullTask.id) { + router.replace( + buildUrlWithTaskId(pathname, searchParams, fullTask.id), + { + scroll: false, + }, + ); + } selectedTaskIdRef.current = fullTask.id; setSelectedTask(fullTask); setIsDetailOpen(true); void loadComments(task.id); }, - [loadComments], + [loadComments, pathname, router, searchParams], ); const selectedTaskDependencies = useMemo(() => { @@ -2398,15 +2417,38 @@ export default function BoardDetailPage() { }, [openComments, selectedTask, tasks]); useEffect(() => { - if (!taskIdFromUrl) return; + if (!hasLoadedBoardSnapshot) return; + if (!taskIdFromUrl) { + openedTaskIdFromUrlRef.current = null; + return; + } if (openedTaskIdFromUrlRef.current === taskIdFromUrl) return; const exists = tasks.some((task) => task.id === taskIdFromUrl); - if (!exists) return; + if (!exists) { + router.replace(buildUrlWithTaskId(pathname, searchParams, null), { + scroll: false, + }); + return; + } openedTaskIdFromUrlRef.current = taskIdFromUrl; openComments({ id: taskIdFromUrl }); - }, [openComments, taskIdFromUrl, tasks]); + }, [ + hasLoadedBoardSnapshot, + openComments, + pathname, + router, + searchParams, + taskIdFromUrl, + tasks, + ]); const closeComments = () => { + openedTaskIdFromUrlRef.current = null; + if (searchParams.get("taskId")) { + router.replace(buildUrlWithTaskId(pathname, searchParams, null), { + scroll: false, + }); + } setIsDetailOpen(false); selectedTaskIdRef.current = null; setSelectedTask(null); diff --git a/frontend/src/app/boards/[boardId]/task-detail-query.test.ts b/frontend/src/app/boards/[boardId]/task-detail-query.test.ts new file mode 100644 index 00000000..ec5fba08 --- /dev/null +++ b/frontend/src/app/boards/[boardId]/task-detail-query.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { buildUrlWithTaskId, withTaskIdSearchParam } from "./task-detail-query"; + +describe("task-detail-query", () => { + it("adds taskId when absent", () => { + expect(withTaskIdSearchParam("", "task-1")).toBe("?taskId=task-1"); + }); + + it("replaces taskId while preserving other params", () => { + expect(withTaskIdSearchParam("view=list&taskId=old", "task-2")).toBe( + "?view=list&taskId=task-2", + ); + }); + + it("removes taskId while preserving other params", () => { + expect(withTaskIdSearchParam("view=list&taskId=old", null)).toBe( + "?view=list", + ); + }); + + it("builds full url with taskId param updates", () => { + expect( + buildUrlWithTaskId("/boards/board-1", "filter=active", "task-1"), + ).toBe("/boards/board-1?filter=active&taskId=task-1"); + }); +}); diff --git a/frontend/src/app/boards/[boardId]/task-detail-query.ts b/frontend/src/app/boards/[boardId]/task-detail-query.ts new file mode 100644 index 00000000..37a8af82 --- /dev/null +++ b/frontend/src/app/boards/[boardId]/task-detail-query.ts @@ -0,0 +1,21 @@ +type SearchParamsInput = string | { toString(): string }; + +export const withTaskIdSearchParam = ( + searchParams: SearchParamsInput, + taskId: string | null, +): string => { + const params = new URLSearchParams(searchParams.toString()); + if (taskId) { + params.set("taskId", taskId); + } else { + params.delete("taskId"); + } + const next = params.toString(); + return next ? `?${next}` : ""; +}; + +export const buildUrlWithTaskId = ( + pathname: string, + searchParams: SearchParamsInput, + taskId: string | null, +): string => `${pathname}${withTaskIdSearchParam(searchParams, taskId)}`;