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

+61 -16
View File
@@ -351,6 +351,30 @@ export async function createAutoCategory(input: {
const MAX_ORGANIZE_GROUPS = 50;
const MAX_ORGANIZE_ITEMS = 500;
/**
* One page the run wrote to, described well enough for the client to fold the
* result into its live catalog tree. `isNew` is false when the group reused an
* existing destination, in which case the page already exists and every field
* that describes a *new* page (`parentId`, `pageLayout`, the icon) is only the
* requested value, not a verified read-back of the stored row.
*/
export interface OrganizedPageChange {
pageId: number;
caption: string;
parentId: number;
pageLayout: string;
iconImage: number;
iconColor: number;
orderNum: number;
visible: string;
enabled: string;
isNew: boolean;
moved: number;
added: number;
/** Import pages the offers were taken from, so counts can be rebalanced. */
movedFrom: Array<{ fromPageId: number; count: number }>;
}
export interface OrganizeImportGroup {
caption: string;
pageLayout: string;
@@ -378,12 +402,7 @@ export async function organizeImportFurni(input: {
groups: OrganizeImportGroup[];
}): Promise<
ActionResult<{
created: Array<{
pageId: number;
caption: string;
moved: number;
added: number;
}>;
created: OrganizedPageChange[];
}>
> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
@@ -412,12 +431,7 @@ export async function organizeImportFurni(input: {
);
const importPageIds = new Set(await getImportedCategoryPageIds());
const created: Array<{
pageId: number;
caption: string;
moved: number;
added: number;
}> = [];
const created: OrganizedPageChange[] = [];
let totalMoved = 0;
let totalAdded = 0;
@@ -432,15 +446,18 @@ export async function organizeImportFurni(input: {
error: "Invalid destination page id",
};
}
const isNew = destinationPageId == null;
const pageLayout = group.pageLayout || "default_3x3";
const iconImage = group.iconImage ?? 0;
const pageId =
destinationPageId ??
Number(
await createPageCommand("normal", {
caption: group.caption,
parentId: input.parentId,
pageLayout: group.pageLayout || "default_3x3",
pageLayout,
captionSave: group.caption.slice(0, 25),
iconImage: group.iconImage ?? 0,
iconImage,
iconColor: 0,
minRank: 1,
orderNum: 0,
@@ -492,6 +509,7 @@ export async function organizeImportFurni(input: {
),
];
const validMoveIds = new Set<number>();
const hostPageByItemId = new Map<number, number>();
if (moverIds.length > 0) {
const hostRows = await queryRows<{ id: number; page_id: number }>(sql`
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
@@ -500,14 +518,17 @@ export async function organizeImportFurni(input: {
)})
`);
for (const row of hostRows) {
if (importPageIds.has(Number(row.page_id))) {
const pageId = Number(row.page_id);
if (importPageIds.has(pageId)) {
validMoveIds.add(Number(row.id));
hostPageByItemId.set(Number(row.id), pageId);
}
}
}
let moved = 0;
let added = 0;
const movedFromCounts = new Map<number, number>();
const moveCaseOrder: string[] = [];
const moveCaseName: string[] = [];
@@ -534,6 +555,13 @@ export async function organizeImportFurni(input: {
)}'`,
);
moveIds.push(Number(row.catalogItemId));
const hostPageId = hostPageByItemId.get(Number(row.catalogItemId));
if (hostPageId != null) {
movedFromCounts.set(
hostPageId,
(movedFromCounts.get(hostPageId) ?? 0) + 1,
);
}
moved++;
} else {
await insertCatalogItemRow({
@@ -586,7 +614,24 @@ export async function organizeImportFurni(input: {
targetType: "catalog_page",
targetId: pageId,
});
created.push({ pageId, caption, moved, added });
created.push({
pageId,
caption,
parentId: input.parentId,
pageLayout,
iconImage,
iconColor: 0,
orderNum: 0,
visible: "1",
enabled: "1",
isNew,
moved,
added,
movedFrom: [...movedFromCounts].map(([fromPageId, count]) => ({
fromPageId,
count,
})),
});
totalMoved += moved;
totalAdded += added;
}