diff --git a/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx b/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx index 4127484f85..54f36bd310 100644 --- a/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx @@ -8,6 +8,10 @@ import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; +import { + type InboxBackTarget, + useInboxBackTarget, +} from "@posthog/ui/features/inbox/hooks/useInboxBackTarget"; import { useInboxRestoreReport } from "@posthog/ui/features/inbox/hooks/useInboxRestoreReport"; import { copyInboxReportLink } from "@posthog/ui/features/inbox/utils/copyInboxReportLink"; import { Spinner } from "@radix-ui/themes"; @@ -35,28 +39,44 @@ export function DismissedReportDetail({ reportId, cachedReport = null, }: DismissedReportDetailProps) { + // Follow the user's path in: if they archived a report while viewing it, the + // redirect here recorded its origin (Reports / Pulls / Runs) so the back link + // returns there. Arriving directly (deep link, Archive-tab click, refresh) + // falls back to "Back to archive". + const back = useInboxBackTarget({ + to: "/code/inbox/dismissed", + label: "Back to archive", + }); return ( - {(report) => } + {(report) => } ); } -function DismissedReportDetailContent({ report }: { report: SignalReport }) { +function DismissedReportDetailContent({ + report, + back, +}: { + report: SignalReport; + back: InboxBackTarget; +}) { // Resolved reports are terminal (their PR already merged) — nothing to // restore, so only suppressed reports get a Restore action. const canRestore = report.status === "suppressed"; return ( ReactNode; } @@ -57,6 +63,8 @@ export function InboxReportDetailGate({ cachedReport = null, backTo, backLabel, + backLinkTo, + backLinkLabel, missingCopy, children, }: InboxReportDetailGateProps) { @@ -110,8 +118,16 @@ export function InboxReportDetailGate({ to: redirectTo, params: { reportId: redirectReportId }, replace: true, + // Carry where we came from into the Archive route so its back link reads + // "Back to reports/pulls/runs" rather than "Back to archive". This branch + // only fires from a non-Archive route, so `backTo` is the pipeline origin + // the user is returning to. + state: + redirectTo === "/code/inbox/dismissed/$reportId" + ? { inboxBackOrigin: { to: backTo, label: backLabel } } + : undefined, }); - }, [redirectTo, redirectReportId, navigate]); + }, [redirectTo, redirectReportId, navigate, backTo, backLabel]); if ((isLoading && !resolvedReport) || statusUnconfirmed) { return ( @@ -139,7 +155,10 @@ export function InboxReportDetailGate({ gap="3" className="border-(--gray-5) border-b px-6 py-6" > - + {missingCopy} diff --git a/packages/ui/src/features/inbox/hooks/useInboxBackTarget.test.ts b/packages/ui/src/features/inbox/hooks/useInboxBackTarget.test.ts new file mode 100644 index 0000000000..aa6d352b92 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useInboxBackTarget.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { asInboxBackTarget } from "./useInboxBackTarget"; + +describe("asInboxBackTarget", () => { + it.each([ + ["reports origin", { to: "/code/inbox/reports", label: "Back to reports" }], + [ + "pulls origin", + { to: "/code/inbox/pulls", label: "Back to pull requests" }, + ], + ["runs origin", { to: "/code/inbox/runs", label: "Back to runs" }], + [ + "archive origin", + { to: "/code/inbox/dismissed", label: "Back to archive" }, + ], + ])("accepts a valid %s", (_label, value) => { + expect(asInboxBackTarget(value)).toEqual(value); + }); + + it.each([ + ["undefined (no history state)", undefined], + ["null", null], + ["a non-object", "reports"], + ["a route outside the inbox", { to: "/code/tasks", label: "Back" }], + [ + "a non-list inbox route", + { to: "/code/inbox/reports/abc", label: "Back" }, + ], + ["a missing label", { to: "/code/inbox/reports" }], + ["an empty label", { to: "/code/inbox/reports", label: "" }], + ["a missing route", { label: "Back to reports" }], + ["a non-string route", { to: 7, label: "Back" }], + ])("rejects %s and falls back", (_label, value) => { + expect(asInboxBackTarget(value)).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/inbox/hooks/useInboxBackTarget.ts b/packages/ui/src/features/inbox/hooks/useInboxBackTarget.ts new file mode 100644 index 0000000000..a35413e8c7 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useInboxBackTarget.ts @@ -0,0 +1,59 @@ +import { useLocation } from "@tanstack/react-router"; + +const INBOX_LIST_ROUTE_VALUES = [ + "/code/inbox/pulls", + "/code/inbox/reports", + "/code/inbox/runs", + "/code/inbox/dismissed", +] as const; + +/** List routes an inbox detail screen's back link can return to. */ +export type InboxListRoute = (typeof INBOX_LIST_ROUTE_VALUES)[number]; + +/** + * Where a detail screen's back link should go. The Archive redirect records the + * origin here so a report archived while open keeps "Back to reports" (or pulls + * / runs) instead of flipping to "Back to archive" the moment its status + * changes. The back link follows the path the user took in, not the report's + * current state. + */ +export interface InboxBackTarget { + to: InboxListRoute; + label: string; +} + +// Carried in history state across the status↔route redirect; declared here so +// `navigate({ state })` and `useLocation().state` stay typed. +declare module "@tanstack/react-router" { + interface HistoryState { + inboxBackOrigin?: InboxBackTarget; + } +} + +const INBOX_LIST_ROUTES = new Set(INBOX_LIST_ROUTE_VALUES); + +/** + * Validate untyped history state before trusting it: it may have come from an + * older app version, a hand-edited URL, or a corrupted entry. + */ +export function asInboxBackTarget(value: unknown): InboxBackTarget | null { + if (!value || typeof value !== "object") return null; + const { to, label } = value as Record; + if (typeof label !== "string" || label.length === 0) return null; + if (typeof to !== "string" || !INBOX_LIST_ROUTES.has(to as InboxListRoute)) { + return null; + } + return { to: to as InboxListRoute, label }; +} + +/** + * Resolves a detail screen's back link: the origin recorded when the status↔route + * redirect carried us here, or `fallback` when we arrived directly (deep link, + * tab click, or a page refresh that dropped the history state). + */ +export function useInboxBackTarget(fallback: InboxBackTarget): InboxBackTarget { + const origin = useLocation({ + select: (location) => location.state.inboxBackOrigin, + }); + return asInboxBackTarget(origin) ?? fallback; +}