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,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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user