fix(ui): harden issue breadcrumb source routing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-27 08:02:26 -05:00
parent 0f9faa297b
commit 3986eb615c
6 changed files with 101 additions and 15 deletions

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
createIssueDetailLocationState,
createIssueDetailPath,
readIssueDetailBreadcrumb,
} from "./issueDetailBreadcrumb";
describe("issueDetailBreadcrumb", () => {
it("prefers the full breadcrumb from route state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
label: "Inbox",
href: "/inbox/mine",
});
});
it("falls back to the source query param when route state is unavailable", () => {
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
label: "Inbox",
href: "/inbox",
});
});
it("adds the source query param when building an issue detail path", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
});
it("reuses the current source query param when state has been dropped", () => {
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
});
});

View file

@ -1,3 +1,5 @@
type IssueDetailSource = "issues" | "inbox";
type IssueDetailBreadcrumb = {
label: string;
href: string;
@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = {
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource;
};
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<IssueDetailBreadcrumb>;
return typeof candidate.label === "string" && typeof candidate.href === "string";
}
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
return { issueDetailBreadcrumb: { label, href } };
function isIssueDetailSource(value: unknown): value is IssueDetailSource {
return value === "issues" || value === "inbox";
}
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
function readIssueDetailSource(state: unknown): IssueDetailSource | null {
if (typeof state !== "object" || state === null) return null;
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
const source = (state as IssueDetailLocationState).issueDetailSource;
return isIssueDetailSource(source) ? source : null;
}
function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null {
if (!search) return null;
const params = new URLSearchParams(search);
const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM);
return isIssueDetailSource(source) ? source : null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Issues", href: "/issues" };
}
export function createIssueDetailLocationState(
label: string,
href: string,
source?: IssueDetailSource,
): IssueDetailLocationState {
return {
issueDetailBreadcrumb: { label, href },
issueDetailSource: source,
};
}
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
if (!source) return `/issues/${issuePathId}`;
const params = new URLSearchParams();
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
return `/issues/${issuePathId}?${params.toString()}`;
}
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
if (typeof state === "object" && state !== null) {
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
if (isIssueDetailBreadcrumb(candidate)) return candidate;
}
const source = readIssueDetailSourceFromSearch(search);
return source ? breadcrumbForSource(source) : null;
}