refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
readCatalogSelection,
writeCatalogSelection,
} from "./catalog-selection-url";
describe("catalog selection URL", () => {
it("restores category context without disturbing catalog, search or pagination", () => {
const url = new URL(
"https://cms.test/admin/catalog?catalog=bc&search=chair&page=4",
);
const selected = writeCatalogSelection(url, { rootId: 8, pageId: 12 });
expect(readCatalogSelection(selected)).toEqual({ rootId: 8, pageId: 12 });
expect(selected.searchParams.get("catalog")).toBe("bc");
expect(selected.searchParams.get("search")).toBe("chair");
expect(selected.searchParams.get("page")).toBe("4");
expect(url.searchParams.has("category")).toBe(false);
expect(
writeCatalogSelection(selected, {
rootId: 8,
pageId: null,
}).searchParams.has("category"),
).toBe(false);
});
it.each(["-1", "0", "1.5", "1abc", "Infinity", "9007199254740992"])(
"rejects invalid identifiers: %s",
(id) => {
expect(
readCatalogSelection(
new URL(`https://cms.test/?root=${id}&category=${id}`),
),
).toEqual({ rootId: null, pageId: null });
},
);
});
@@ -0,0 +1,29 @@
export interface CatalogSelection {
rootId: number | null;
pageId: number | null;
}
function positiveId(value: string | null) {
if (!value || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function readCatalogSelection(url: URL): CatalogSelection {
return {
rootId: positiveId(url.searchParams.get("root")),
pageId: positiveId(url.searchParams.get("category")),
};
}
export function writeCatalogSelection(
url: URL,
selection: CatalogSelection,
): URL {
const next = new URL(url);
for (const [key, value] of [
["root", selection.rootId],
["category", selection.pageId],
] as const) {
if (value === null) next.searchParams.delete(key);
else next.searchParams.set(key, String(value));
}
return next;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { createLatestRequest } from "./latest-request";
describe("catalog request ownership", () => {
it("keeps the latest category when an earlier transport completes last", async () => {
const requests = createLatestRequest();
let displayed = "";
let finishOld!: (value: string) => void;
const oldResponse = new Promise<string>((resolve) => {
finishOld = resolve;
});
const oldRequest = requests.start();
const oldWork = oldResponse.then((value) => {
if (oldRequest.isCurrent()) displayed = value;
});
const current = requests.start();
await Promise.resolve("category B").then((value) => {
if (current.isCurrent()) displayed = value;
});
finishOld("category A");
await oldWork;
expect(oldRequest.signal.aborted).toBe(true);
expect(displayed).toBe("category B");
});
it("ignores late save/read completion after editor unmount", () => {
const requests = createLatestRequest();
const pending = requests.start();
requests.cancel();
expect(pending.signal.aborted).toBe(true);
expect(pending.isCurrent()).toBe(false);
});
it("allows a fresh request after effect cleanup and isolates page/items channels", () => {
const pages = createLatestRequest();
const items = createLatestRequest();
const page = pages.start();
const firstItems = items.start();
items.cancel();
const latestItems = items.start();
expect(page.isCurrent()).toBe(true);
expect(firstItems.isCurrent()).toBe(false);
expect(latestItems.isCurrent()).toBe(true);
});
});
@@ -0,0 +1,19 @@
/** Cancels replaced requests and rejects late completions, even if transport ignores abort. */
export function createLatestRequest() {
let current: AbortController | null = null;
return {
start() {
current?.abort();
const controller = new AbortController();
current = controller;
return {
signal: controller.signal,
isCurrent: () => current === controller && !controller.signal.aborted,
};
},
cancel() {
current?.abort();
current = null;
},
};
}
@@ -0,0 +1,131 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import {
type CatalogSelection,
readCatalogSelection,
writeCatalogSelection,
} from "./catalog-selection-url";
export function requestCatalogNavigation() {
return window.dispatchEvent(
new Event("catalog:before-navigation", { cancelable: true }),
);
}
/** Selection history stays local: changing category does not reload the server page. */
export function useCatalogNavigation({
enabled,
restore,
mayLeave,
}: {
enabled: boolean;
restore: (selection: CatalogSelection) => void;
mayLeave: () => boolean;
}) {
const current = useRef({ restore, mayLeave });
useEffect(() => {
current.current = { restore, mayLeave };
}, [restore, mayLeave]);
const previousUrl = useRef("");
const position = useRef(0);
const restoringHistory = useRef(false);
useEffect(() => {
if (!enabled) return;
previousUrl.current = window.location.href;
position.current = window.history.state?.catalogSelectionPosition ?? 0;
window.history.replaceState(
{ ...window.history.state, catalogSelectionPosition: position.current },
"",
);
current.current.restore(
readCatalogSelection(new URL(window.location.href)),
);
const onPopState = () => {
if (restoringHistory.current) {
restoringHistory.current = false;
return;
}
const targetPosition = window.history.state?.catalogSelectionPosition;
if (!current.current.mayLeave()) {
if (
typeof targetPosition === "number" &&
targetPosition !== position.current
) {
restoringHistory.current = true;
window.history.go(position.current - targetPosition);
return;
}
window.history.replaceState(
window.history.state,
"",
previousUrl.current,
);
return;
}
position.current =
typeof targetPosition === "number" ? targetPosition : position.current;
previousUrl.current = window.location.href;
current.current.restore(
readCatalogSelection(new URL(window.location.href)),
);
};
const onLink = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link = (event.target as Element).closest?.(
"a[href]",
) as HTMLAnchorElement | null;
if (
!link ||
link.target === "_blank" ||
link.hasAttribute("download") ||
link.href === window.location.href
)
return;
if (!current.current.mayLeave()) {
event.preventDefault();
event.stopPropagation();
}
};
const onNavigation = (event: Event) => {
if (!current.current.mayLeave()) event.preventDefault();
};
window.addEventListener("catalog:before-navigation", onNavigation);
window.addEventListener("popstate", onPopState);
document.addEventListener("click", onLink, true);
return () => {
window.removeEventListener("catalog:before-navigation", onNavigation);
window.removeEventListener("popstate", onPopState);
document.removeEventListener("click", onLink, true);
};
}, [enabled]);
return useCallback(
(selection: CatalogSelection) => {
if (!enabled) return;
const url = writeCatalogSelection(
new URL(window.location.href),
selection,
);
if (url.href !== window.location.href) {
position.current += 1;
window.history.pushState(
{
...window.history.state,
catalogSelectionPosition: position.current,
},
"",
url,
);
}
previousUrl.current = url.href;
},
[enabled],
);
}
@@ -0,0 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { createLatestRequest } from "./latest-request";
export function useLatestRequest() {
const [requests] = useState(createLatestRequest);
useEffect(() => () => requests.cancel(), [requests]);
return requests;
}