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:
openhands committed 2026-09-29 15:34:36 +02:00
1 parent 7507c3b55c
commit 9cc57cddfc
10 files changed
+716 -32

No files matched your search

@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/catalog";
import {
applyCatalogTotals,
applyCatalogTreeDelta,
type CatalogTreeDelta,
type CreatedCatalogPage,
EMPTY_CATALOG_DELTA,
isEmptyCatalogDelta,
normalizeTreePages,
recomputeDepth,
} from "./live-catalog-merge";
function node(over: Partial<TreeNode> & { id: number }): TreeNode {
return {
caption: `page ${over.id}`,
parentId: -1,
depth: 0,
orderNum: 0,
enabled: "1",
visible: "1",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 0,
itemCount: 0,
...over,
};
}
function created(
over: Partial<CreatedCatalogPage> & { id: number },
): CreatedCatalogPage {
return {
caption: `new ${over.id}`,
parentId: -1,
pageLayout: "default_3x3",
iconImage: 0,
iconColor: 0,
orderNum: 0,
enabled: "1",
visible: "1",
...over,
};
}
const delta = (over: Partial<CatalogTreeDelta> = {}): CatalogTreeDelta => ({
...EMPTY_CATALOG_DELTA,
...over,
});
describe("recomputeDepth", () => {
it("nests children below their parent and keeps roots at 0", () => {
expect(
recomputeDepth([
node({ id: 1, parentId: -1 }),
node({ id: 2, parentId: 1 }),
node({ id: 3, parentId: 2 }),
node({ id: 4, parentId: -1 }),
]).map((n) => n.depth),
).toEqual([0, 1, 2, 0]);
});
it("terminates on a parent cycle instead of recursing forever", () => {
// A messy DB can contain a looping parent chain. The server's getTreeFlat
// resolves such a cycle to a finite depth too, so the client only has to
// agree that it terminates and never goes negative.
for (const input of [
[node({ id: 1, parentId: 1 })],
[node({ id: 1, parentId: 2 }), node({ id: 2, parentId: 1 })],
[
node({ id: 1, parentId: 3 }),
node({ id: 2, parentId: 1 }),
node({ id: 3, parentId: 2 }),
],
]) {
const depths = recomputeDepth(input).map((n) => n.depth);
expect(depths.every((d) => Number.isInteger(d) && d >= 0)).toBe(true);
expect(depths).toHaveLength(input.length);
}
});
it("returns the same node objects when depths are already correct", () => {
const input = [
node({ id: 1, depth: 0 }),
node({ id: 2, parentId: 1, depth: 1 }),
];
expect(recomputeDepth(input)[1]).toBe(input[1]);
});
});
describe("applyCatalogTreeDelta", () => {
const tree = [
node({ id: 1, parentId: -1, orderNum: 1, childCount: 1, itemCount: 10 }),
node({ id: 2, parentId: 1, orderNum: 1, itemCount: 4 }),
];
it("returns the same array when the delta is empty", () => {
expect(applyCatalogTreeDelta(tree, delta())).toBe(tree);
});
it("inserts a new page in sibling order with the right depth", () => {
const next = applyCatalogTreeDelta(
tree,
delta({ pages: [created({ id: 3, parentId: 1, orderNum: 0 })] }),
);
expect(next.map((n) => [n.id, n.orderNum, n.depth])).toEqual([
[3, 0, 1],
[1, 1, 0],
[2, 1, 1],
]);
});
it("bumps the parent child count and the total item count", () => {
const next = applyCatalogTreeDelta(
tree,
delta({
pages: [created({ id: 3, parentId: 1 })],
addedItems: [{ pageId: 3, count: 6 }],
}),
);
expect(next.find((n) => n.id === 1)?.childCount).toBe(2);
expect(next.find((n) => n.id === 3)?.itemCount).toBe(6);
});
it("skips a page the tree already knows about", () => {
const next = applyCatalogTreeDelta(
tree,
delta({
pages: [created({ id: 2, parentId: 1 })],
addedItems: [{ pageId: 2, count: 3 }],
}),
);
expect(next).toHaveLength(2);
expect(next.find((n) => n.id === 2)?.itemCount).toBe(7);
// A reused destination is not a new child, so childCount must not grow.
expect(next.find((n) => n.id === 1)?.childCount).toBe(1);
});
it("shifts item counts between pages for a move without changing the total", () => {
const next = applyCatalogTreeDelta(
tree,
delta({ movedItems: [{ fromPageId: 2, toPageId: 1, count: 4 }] }),
);
expect(next.find((n) => n.id === 1)?.itemCount).toBe(14);
expect(next.find((n) => n.id === 2)?.itemCount).toBe(0);
});
it("never lets an item count go negative", () => {
const next = applyCatalogTreeDelta(
tree,
delta({ movedItems: [{ fromPageId: 2, toPageId: 1, count: 99 }] }),
);
expect(next.find((n) => n.id === 2)?.itemCount).toBe(0);
});
it("inserts several pages in one pass with correct depths", () => {
const next = applyCatalogTreeDelta(
tree,
delta({
pages: [
created({ id: 3, parentId: 1, orderNum: 2 }),
created({ id: 4, parentId: 3, orderNum: 1 }),
],
}),
);
expect(next.find((n) => n.id === 4)?.depth).toBe(2);
expect(next.find((n) => n.id === 3)?.childCount).toBe(1);
});
});
describe("applyCatalogTotals", () => {
const totals = { totalPages: 9, enabledPages: 8, totalItems: 100 };
it("counts new pages and new offers, but not moves", () => {
expect(
applyCatalogTotals(
totals,
delta({
pages: [
created({ id: 20, enabled: "1" }),
created({ id: 21, enabled: "0" }),
],
addedItems: [{ pageId: 20, count: 5 }],
movedItems: [{ fromPageId: 1, toPageId: 20, count: 3 }],
}),
),
).toEqual({ totalPages: 11, enabledPages: 9, totalItems: 105 });
});
it("returns the same object for an empty delta", () => {
expect(applyCatalogTotals(totals, delta())).toBe(totals);
});
});
describe("normalizeTreePages", () => {
it("coerces an API payload into TreeNode and drops unusable rows", () => {
expect(
normalizeTreePages([
{ id: "4", caption: "Wired", parentId: "0", orderNum: "2" },
{ id: 0, caption: "bogus" },
{ caption: "no id" },
null,
"nope",
]),
).toEqual([
{
id: 4,
caption: "Wired",
parentId: -1,
depth: 0,
orderNum: 2,
enabled: "0",
visible: "0",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 0,
itemCount: 0,
},
]);
});
it("returns an empty list for a non-array payload", () => {
expect(normalizeTreePages(undefined)).toEqual([]);
});
});
describe("isEmptyCatalogDelta", () => {
it("ignores zero-count entries", () => {
expect(
isEmptyCatalogDelta({
pages: [],
addedItems: [{ pageId: 1, count: 0 }],
movedItems: [],
}),
).toBe(false);
expect(isEmptyCatalogDelta(EMPTY_CATALOG_DELTA)).toBe(true);
});
});
@@ -0,0 +1,206 @@
import type { TreeNode } from "@/types/catalog";
/** A catalog page the server just created, in the shape the live tree needs. */
export interface CreatedCatalogPage {
id: number;
caption: string;
parentId: number;
pageLayout: string;
iconImage: number;
iconColor: number;
orderNum: number;
enabled: string;
visible: string;
}
/** catalog_items rows freshly inserted onto a page. */
export interface AddedCatalogItems {
pageId: number;
count: number;
}
/** catalog_items rows re-parented from one page onto another. */
export interface MovedCatalogItems {
fromPageId: number;
toPageId: number;
count: number;
}
/**
* Everything an import run changed in the catalog, expressed so the client can
* fold it into the tree it already holds — no page reload needed.
*/
export interface CatalogTreeDelta {
/** Genuinely new pages. Destination pages that already existed are omitted. */
pages: CreatedCatalogPage[];
/** Newly inserted offers, keyed by the page that received them. */
addedItems: AddedCatalogItems[];
/** Re-parented offers; these do not change the total item count. */
movedItems: MovedCatalogItems[];
}
export const EMPTY_CATALOG_DELTA: CatalogTreeDelta = {
pages: [],
addedItems: [],
movedItems: [],
};
export interface CatalogTotals {
totalPages: number;
enabledPages: number;
totalItems: number;
}
export function isEmptyCatalogDelta(delta: CatalogTreeDelta): boolean {
return (
delta.pages.length === 0 &&
delta.addedItems.length === 0 &&
delta.movedItems.length === 0
);
}
function toCount(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Coerce an API tree payload into TreeNode. The route already answers with
* TreeNode, but this keeps a malformed row from poisoning depth/count maths.
*/
export function normalizeTreePages(raw: unknown): TreeNode[] {
if (!Array.isArray(raw)) return [];
const nodes: TreeNode[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const row = entry as Record<string, unknown>;
const id = toCount(row.id);
if (id <= 0) continue;
const parentId = toCount(row.parentId ?? -1);
nodes.push({
id,
caption: String(row.caption ?? id),
parentId: parentId || -1,
depth: toCount(row.depth),
orderNum: toCount(row.orderNum),
enabled: String(row.enabled ?? "0"),
visible: String(row.visible ?? "0"),
iconImage: toCount(row.iconImage),
iconColor: toCount(row.iconColor),
pageLayout: String(row.pageLayout ?? "default_3x3"),
childCount: toCount(row.childCount),
itemCount: toCount(row.itemCount),
});
}
return nodes;
}
/**
* Recompute depth from parent links, mirroring the server's getTreeFlat:
* roots sit at 0 and a looping parent chain stops at 0 instead of recursing
* forever. The shared `chain` set is safe because every exit removes its id.
*/
export function recomputeDepth(nodes: TreeNode[]): TreeNode[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
const depthOf = new Map<number, number>();
const chain = new Set<number>();
const resolve = (id: number): number => {
const cached = depthOf.get(id);
if (cached !== undefined) return cached;
if (chain.has(id)) return 0;
chain.add(id);
const parentId = byId.get(id)?.parentId ?? -1;
const depth =
parentId > 0 && byId.has(parentId) ? resolve(parentId) + 1 : 0;
chain.delete(id);
depthOf.set(id, depth);
return depth;
};
return nodes.map((node) => {
const depth = resolve(node.id);
return node.depth === depth ? node : { ...node, depth };
});
}
function itemDeltas(delta: CatalogTreeDelta): Map<number, number> {
const deltas = new Map<number, number>();
const bump = (pageId: number, amount: number) => {
if (amount === 0) return;
deltas.set(pageId, (deltas.get(pageId) ?? 0) + amount);
};
for (const entry of delta.addedItems) {
if (Number.isFinite(entry.pageId)) bump(entry.pageId, toCount(entry.count));
}
for (const entry of delta.movedItems) {
if (entry.count <= 0) continue;
if (Number.isFinite(entry.fromPageId)) bump(entry.fromPageId, -entry.count);
if (Number.isFinite(entry.toPageId)) bump(entry.toPageId, entry.count);
}
return deltas;
}
/**
* Fold a delta into a flat tree, returning the same array reference when the
* delta changes nothing so consumers can rely on identity for memoisation.
*/
export function applyCatalogTreeDelta(
tree: TreeNode[],
delta: CatalogTreeDelta,
): TreeNode[] {
if (isEmptyCatalogDelta(delta)) return tree;
const counts = itemDeltas(delta);
const known = new Set(tree.map((n) => n.id));
const fresh: TreeNode[] = [];
const childAdds = new Map<number, number>();
for (const page of delta.pages) {
if (known.has(page.id)) continue;
known.add(page.id);
fresh.push({ ...page, depth: 0, childCount: 0, itemCount: 0 });
childAdds.set(page.parentId, (childAdds.get(page.parentId) ?? 0) + 1);
}
let next = tree;
if (fresh.length > 0) {
next = [...next, ...fresh].sort(
(a, b) => a.orderNum - b.orderNum || a.id - b.id,
);
next = recomputeDepth(next);
}
if (counts.size === 0 && childAdds.size === 0) return next;
return next.map((node) => {
const itemDelta = counts.get(node.id) ?? 0;
const childDelta = childAdds.get(node.id) ?? 0;
if (itemDelta === 0 && childDelta === 0) return node;
return {
...node,
itemCount: Math.max(0, node.itemCount + itemDelta),
childCount: Math.max(0, node.childCount + childDelta),
};
});
}
/**
* New offers raise the item total; re-parented ones only shift between pages.
* Counts here are not derived from the tree, so totals stay correct even when
* the live tree has not been loaded yet.
*/
export function applyCatalogTotals(
totals: CatalogTotals,
delta: CatalogTreeDelta,
): CatalogTotals {
if (isEmptyCatalogDelta(delta)) return totals;
const enabled = delta.pages.filter((p) => p.enabled !== "0").length;
const items = delta.addedItems.reduce(
(sum, entry) => sum + Math.max(0, toCount(entry.count)),
0,
);
return {
totalPages: totals.totalPages + delta.pages.length,
enabledPages: totals.enabledPages + enabled,
totalItems: totals.totalItems + items,
};
}
@@ -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);
}