fix(ui): keep issue breadcrumb context out of the URL

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-06 06:32:25 -05:00
parent 2ac1c62ab1
commit 962a882799
6 changed files with 185 additions and 49 deletions

View file

@ -13,6 +13,7 @@ type IssueDetailLocationState = {
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
const ISSUE_DETAIL_STORAGE_KEY_PREFIX = "paperclip:issue-detail-breadcrumb:";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
@ -44,6 +45,17 @@ function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null
return href && href.startsWith("/") ? href : null;
}
function inferIssueDetailSource(
state: Partial<IssueDetailLocationState> | null,
breadcrumb: IssueDetailBreadcrumb | null,
): IssueDetailSource | null {
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
if (!breadcrumb) return null;
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
if (breadcrumb.label === "Issues" || breadcrumb.href.includes("/issues")) return "issues";
return null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Issues", href: "/issues" };
@ -71,34 +83,97 @@ export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLoca
};
}
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
const breadcrumb =
(typeof state === "object" && state !== null
? (state as IssueDetailLocationState).issueDetailBreadcrumb
: null);
const breadcrumbHref =
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return `/issues/${issuePathId}`;
const params = new URLSearchParams();
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
return `/issues/${issuePathId}?${params.toString()}`;
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
if (typeof window === "undefined" || !window.sessionStorage) return null;
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
? parsed.issueDetailBreadcrumb
: null;
const source = inferIssueDetailSource(parsed, breadcrumb);
if (!breadcrumb || !source) return null;
return {
issueDetailBreadcrumb: breadcrumb,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
};
} catch {
return null;
}
}
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
function normalizeIssueDetailLocationState(
state: unknown,
search?: string,
): IssueDetailLocationState | null {
if (typeof state === "object" && state !== null) {
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
if (isIssueDetailBreadcrumb(candidate)) return candidate;
if (isIssueDetailBreadcrumb(candidate)) {
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
if (!source) return null;
return {
issueDetailBreadcrumb: candidate,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed:
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
};
}
}
const source = readIssueDetailSourceFromSearch(search);
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return null;
const fallback = breadcrumbForSource(source);
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
return href ? { ...fallback, href } : fallback;
return {
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: false,
};
}
export function rememberIssueDetailLocationState(issuePathId: string, state: unknown, search?: string): void {
if (typeof window === "undefined" || !window.sessionStorage) return;
const normalized = normalizeIssueDetailLocationState(state, search);
if (!normalized) return;
window.sessionStorage.setItem(
`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`,
JSON.stringify(normalized),
);
}
export function createIssueDetailPath(issuePathId: string): string {
return `/issues/${issuePathId}`;
}
export function hasLegacyIssueDetailQuery(search?: string): boolean {
if (!search) return false;
const params = new URLSearchParams(search);
return params.has(ISSUE_DETAIL_SOURCE_QUERY_PARAM) || params.has(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
}
export function readIssueDetailLocationState(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailLocationState | null {
const normalized = normalizeIssueDetailLocationState(state, search);
if (normalized) return normalized;
if (!issuePathId) return null;
return readStoredIssueDetailLocationState(issuePathId);
}
export function readIssueDetailBreadcrumb(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailBreadcrumb | null {
return readIssueDetailLocationState(issuePathId, state, search)?.issueDetailBreadcrumb ?? null;
}
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {