mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
fix(ui): keep issue breadcrumb context out of the URL
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2ac1c62ab1
commit
962a882799
6 changed files with 185 additions and 49 deletions
|
|
@ -3,50 +3,80 @@ import {
|
|||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
hasLegacyIssueDetailQuery,
|
||||
readIssueDetailLocationState,
|
||||
readIssueDetailBreadcrumb,
|
||||
rememberIssueDetailLocationState,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
|
||||
const sessionStorageMock = (() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { sessionStorage: sessionStorageMock },
|
||||
});
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
it("returns clean issue detail paths", () => {
|
||||
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
|
||||
});
|
||||
|
||||
it("prefers the full breadcrumb from route state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
|
||||
expect(readIssueDetailBreadcrumb("PAP-465", 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({
|
||||
expect(readIssueDetailBreadcrumb("PAP-465", 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&fromHref=%2Finbox%2Fmine",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the current source query param when state has been dropped", () => {
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
|
||||
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
|
||||
);
|
||||
it("can detect legacy query-based breadcrumb links", () => {
|
||||
expect(hasLegacyIssueDetailQuery("?from=inbox&fromHref=%2Finbox%2Fmine")).toBe(true);
|
||||
expect(hasLegacyIssueDetailQuery("?q=test")).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the exact breadcrumb href from the query fallback", () => {
|
||||
expect(
|
||||
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||
readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||
).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/PAP/inbox/unread",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads hidden breadcrumb context from session storage when route state is unavailable", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
sessionStorageMock.clear();
|
||||
rememberIssueDetailLocationState("PAP-465", state);
|
||||
|
||||
expect(
|
||||
readIssueDetailLocationState("PAP-465", null),
|
||||
).toEqual({
|
||||
issueDetailBreadcrumb: { label: "Inbox", href: "/inbox/mine" },
|
||||
issueDetailSource: "inbox",
|
||||
issueDetailInboxQuickArchiveArmed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue