refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user