diff --git a/ui/src/hooks/useCompanyPageMemory.test.ts b/ui/src/hooks/useCompanyPageMemory.test.ts new file mode 100644 index 00000000..a64c60b8 --- /dev/null +++ b/ui/src/hooks/useCompanyPageMemory.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + getRememberedPathOwnerCompanyId, + sanitizeRememberedPathForCompany, +} from "../lib/company-page-memory"; + +const companies = [ + { id: "for", issuePrefix: "FOR" }, + { id: "pap", issuePrefix: "PAP" }, +]; + +describe("getRememberedPathOwnerCompanyId", () => { + it("uses the route company instead of stale selected-company state for prefixed routes", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies, + pathname: "/FOR/issues/FOR-1", + fallbackCompanyId: "pap", + }), + ).toBe("for"); + }); + + it("skips saving when a prefixed route cannot yet be resolved to a known company", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies: [], + pathname: "/FOR/issues/FOR-1", + fallbackCompanyId: "pap", + }), + ).toBeNull(); + }); + + it("falls back to the previous company for unprefixed board routes", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies, + pathname: "/dashboard", + fallbackCompanyId: "pap", + }), + ).toBe("pap"); + }); +}); + +describe("sanitizeRememberedPathForCompany", () => { + it("keeps remembered issue paths that belong to the target company", () => { + expect( + sanitizeRememberedPathForCompany({ + path: "/issues/PAP-12", + companyPrefix: "PAP", + }), + ).toBe("/issues/PAP-12"); + }); + + it("falls back to dashboard for remembered issue identifiers from another company", () => { + expect( + sanitizeRememberedPathForCompany({ + path: "/issues/FOR-1", + companyPrefix: "PAP", + }), + ).toBe("/dashboard"); + }); + + it("falls back to dashboard when no remembered path exists", () => { + expect( + sanitizeRememberedPathForCompany({ + path: null, + companyPrefix: "PAP", + }), + ).toBe("/dashboard"); + }); +}); diff --git a/ui/src/hooks/useCompanyPageMemory.ts b/ui/src/hooks/useCompanyPageMemory.ts index d427e587..5206df11 100644 --- a/ui/src/hooks/useCompanyPageMemory.ts +++ b/ui/src/hooks/useCompanyPageMemory.ts @@ -1,10 +1,14 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useLocation, useNavigate } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { toCompanyRelativePath } from "../lib/company-routes"; +import { + getRememberedPathOwnerCompanyId, + isRememberableCompanyPath, + sanitizeRememberedPathForCompany, +} from "../lib/company-page-memory"; const STORAGE_KEY = "paperclip.companyPaths"; -const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]); function getCompanyPaths(): Record { try { @@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) { localStorage.setItem(STORAGE_KEY, JSON.stringify(paths)); } -function isRememberableCompanyPath(path: string): boolean { - const pathname = path.split("?")[0] ?? ""; - const segments = pathname.split("/").filter(Boolean); - if (segments.length === 0) return true; - const [root] = segments; - if (GLOBAL_SEGMENTS.has(root!)) return false; - return true; -} - /** * Remembers the last visited page per company and navigates to it on company switch. * Falls back to /dashboard if no page was previously visited for a company. */ export function useCompanyPageMemory() { - const { selectedCompanyId, selectedCompany, selectionSource } = useCompany(); + const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany(); const location = useLocation(); const navigate = useNavigate(); const prevCompanyId = useRef(selectedCompanyId); + const rememberedPathOwnerCompanyId = useMemo( + () => + getRememberedPathOwnerCompanyId({ + companies, + pathname: location.pathname, + fallbackCompanyId: prevCompanyId.current, + }), + [companies, location.pathname], + ); // Save current path for current company on every location change. // Uses prevCompanyId ref so we save under the correct company even // during the render where selectedCompanyId has already changed. const fullPath = location.pathname + location.search; useEffect(() => { - const companyId = prevCompanyId.current; + const companyId = rememberedPathOwnerCompanyId; const relativePath = toCompanyRelativePath(fullPath); if (companyId && isRememberableCompanyPath(relativePath)) { saveCompanyPath(companyId, relativePath); } - }, [fullPath]); + }, [fullPath, rememberedPathOwnerCompanyId]); // Navigate to saved path when company changes useEffect(() => { @@ -63,9 +67,10 @@ export function useCompanyPageMemory() { ) { if (selectionSource !== "route_sync" && selectedCompany) { const paths = getCompanyPaths(); - const savedPath = paths[selectedCompanyId]; - const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard"; - const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard"; + const targetPath = sanitizeRememberedPathForCompany({ + path: paths[selectedCompanyId], + companyPrefix: selectedCompany.issuePrefix, + }); navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true }); } } diff --git a/ui/src/lib/company-page-memory.ts b/ui/src/lib/company-page-memory.ts new file mode 100644 index 00000000..df549b68 --- /dev/null +++ b/ui/src/lib/company-page-memory.ts @@ -0,0 +1,65 @@ +import { + extractCompanyPrefixFromPath, + normalizeCompanyPrefix, + toCompanyRelativePath, +} from "./company-routes"; + +const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]); + +export function isRememberableCompanyPath(path: string): boolean { + const pathname = path.split("?")[0] ?? ""; + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 0) return true; + const [root] = segments; + if (GLOBAL_SEGMENTS.has(root!)) return false; + return true; +} + +function findCompanyByPrefix(params: { + companies: T[]; + companyPrefix: string; +}): T | null { + const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix); + return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null; +} + +export function getRememberedPathOwnerCompanyId(params: { + companies: T[]; + pathname: string; + fallbackCompanyId: string | null; +}): string | null { + const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname); + if (!routeCompanyPrefix) { + return params.fallbackCompanyId; + } + + return findCompanyByPrefix({ + companies: params.companies, + companyPrefix: routeCompanyPrefix, + })?.id ?? null; +} + +export function sanitizeRememberedPathForCompany(params: { + path: string | null | undefined; + companyPrefix: string; +}): string { + const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard"; + if (!isRememberableCompanyPath(relativePath)) { + return "/dashboard"; + } + + const pathname = relativePath.split("?")[0] ?? ""; + const segments = pathname.split("/").filter(Boolean); + const [root, entityId] = segments; + if (root === "issues" && entityId) { + const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId); + if ( + identifierMatch && + normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix) + ) { + return "/dashboard"; + } + } + + return relativePath; +}