feat(catalog): update the catalog live after an import, no page refresh
Organising imports, the Studio furni batch, the catalog totals and the "import from a source" stats all used to need a full page reload, or at best a router.refresh() that re-rendered the whole admin route, before anything on screen reflected what the import had just written. - live-catalog-merge.ts (new): pure tree and total arithmetic. Applies a delta of created pages, added offers and moved offers, recomputes depth for the touched subtree, bumps parent child counts and the item totals. Returns the input untouched when a delta is empty, so subscribers can bail out instead of re-rendering. Depth resolution tolerates a parent cycle in a dirty DB and still terminates, matching getTreeFlat. - use-live-catalog.ts (new): one module-level store exposed through useSyncExternalStore, so every consumer shares a single instance without threading a provider through the admin layout. Deltas only apply to the "normal" catalog, so public and public_handlers trees stay separate. seedCatalogTotals() takes the first server value per mode and never overwrites it afterwards, so a later hard render cannot make the header totals jump backwards. - actions/catalog.ts: organizeImportFurni now reports each group through the new OrganizedPageChange, carrying parentId, pageLayout, the icon, isNew and the per-source movedFrom counts, so the client can fold the result into the tree without reading the page back. - organize-imports-dialog.tsx: drops useRouter and router.refresh(); the response is applied as a delta the moment the run finishes. - studio-client.tsx: reads the tree from the store instead of freezing it with useState(initialTree), loads it on mount when empty, and refreshes it once a batch import settles. The batch is server-side and derives its import pages from furnidata, so that one path re-reads the tree via GET /api/admin/catalog/tree?mode=full rather than trusting the delta. - studio/furni/page.tsx: stops calling getTreeFlat() and no longer passes initialTree; the store is the single source of truth for the rail. - import-clone-client.tsx: tracks which items are already present, so present and clonable update per cloned row instead of only at the end. - catalog-manager-dialog.tsx: seeds the totals once and renders the live values, so the header reflects an import that just ran. - e2e/ui/fixtures/entry.tsx: drops the removed initialTree prop.
This commit is contained in:
1 parent
7507c3b55c
commit
9cc57cddfc
10 files changed
+716
-32
No files matched your search
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
import {
|
||||
applyCatalogTotals,
|
||||
applyCatalogTreeDelta,
|
||||
type CatalogTotals,
|
||||
type CatalogTreeDelta,
|
||||
isEmptyCatalogDelta,
|
||||
normalizeTreePages,
|
||||
} from "./live-catalog-merge";
|
||||
|
||||
export type CatalogMode = "normal" | "bc";
|
||||
|
||||
interface LiveCatalogSnapshot {
|
||||
tree: TreeNode[];
|
||||
totals: Record<CatalogMode, CatalogTotals | null>;
|
||||
treeLoaded: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: LiveCatalogSnapshot = {
|
||||
tree: [],
|
||||
totals: { normal: null, bc: null },
|
||||
treeLoaded: false,
|
||||
};
|
||||
|
||||
// A module-level external store keeps the catalog tree alive across client
|
||||
// navigations and — more importantly — lets an import push its delta straight
|
||||
// into the mounted tree instead of re-rendering the whole route.
|
||||
let snapshot: LiveCatalogSnapshot = EMPTY_SNAPSHOT;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(next: LiveCatalogSnapshot) {
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
const getSnapshot = () => snapshot;
|
||||
const getServerSnapshot = () => EMPTY_SNAPSHOT;
|
||||
|
||||
export function useLiveCatalog(): LiveCatalogSnapshot {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adopt server-rendered totals as the baseline. Only the first value per mode
|
||||
* wins: after an import the store holds counts that are newer than any re-render
|
||||
* of the server prop (which is itself `unstable_cache`d for 30s).
|
||||
*/
|
||||
export function seedCatalogTotals(
|
||||
mode: CatalogMode,
|
||||
totals: CatalogTotals | null | undefined,
|
||||
): void {
|
||||
if (!totals) return;
|
||||
if (snapshot.totals[mode]) return;
|
||||
emit({
|
||||
...snapshot,
|
||||
totals: { ...snapshot.totals, [mode]: { ...totals } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an import run's changes into the live tree and totals. Safe to call from
|
||||
* an event handler; a delta that changes nothing leaves the snapshot untouched.
|
||||
*/
|
||||
export function applyCatalogDelta(delta: CatalogTreeDelta): void {
|
||||
if (isEmptyCatalogDelta(delta)) return;
|
||||
const normal = snapshot.totals.normal;
|
||||
emit({
|
||||
...snapshot,
|
||||
tree: applyCatalogTreeDelta(snapshot.tree, delta),
|
||||
totals: {
|
||||
...snapshot.totals,
|
||||
normal: normal ? applyCatalogTotals(normal, delta) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTree(): Promise<void> {
|
||||
const res = await adminFetch("/api/admin/catalog/tree?mode=full");
|
||||
if (!res.ok) throw new Error(`Catalog tree refresh failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
emit({
|
||||
...snapshot,
|
||||
tree: normalizeTreePages(data?.pages),
|
||||
treeLoaded: true,
|
||||
});
|
||||
}
|
||||
|
||||
let treeRequest: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Reload the whole tree over the API. Used where the server decides the shape of
|
||||
* the result — the furniture importers derive their category pages from
|
||||
* furnidata, so there is nothing for the client to predict. Still a plain
|
||||
* in-place data update: no route re-render, no remount, no lost editor state.
|
||||
*/
|
||||
export function refreshCatalogTree(): Promise<void> {
|
||||
if (treeRequest) return treeRequest;
|
||||
treeRequest = loadTree()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
treeRequest = null;
|
||||
});
|
||||
return treeRequest;
|
||||
}
|
||||
|
||||
/** Load the tree once per session; later calls are no-ops. */
|
||||
export function ensureCatalogTreeLoaded(): Promise<void> {
|
||||
return snapshot.treeLoaded ? Promise.resolve() : refreshCatalogTree();
|
||||
}
|
||||
|
||||
/** Test seam: drop all live catalog state between cases. */
|
||||
export function resetLiveCatalogForTests(): void {
|
||||
treeRequest = null;
|
||||
emit(EMPTY_SNAPSHOT);
|
||||
}
|
||||
Reference in new issue
Block a user