2 Commits
Author SHA1 Message Date
openhands 4be7eaed59 fix(catalog): never create a page that reuses a sibling's order number
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 41s
CI / tests-unit (push) Successful in 1m53s
CI / tests-integration (push) Successful in 2m19s
CI / tests-ui (push) Successful in 2m46s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m9s
The emulator complained "Sibling order 2 is used more than once" 74 times
in production, covering 19 pages across 12 parents. The cause was that
nearly every page-creation path passed orderNum 0, so each new page
collided with whatever sibling already sat at 0 or 1, and the Park
placeholder pages all shared the sentinel values 99 and 999.

The production rows themselves are repaired out of band (renumbered 1..N
per affected parent, ordered by order_num then id so the existing visual
order is preserved, plus one dangling catalog_items row whose
items_base no longer existed removed). This commit stops it recurring.

- hierarchy.ts: add nextFreeSiblingOrder(), which ignores -1 and 0 as
  the same root set, treats a missing orderNum as 0, and returns an order
  strictly above the highest sibling in use. An explicit order is still
  honoured whenever it is free, so callers that genuinely want a position
  keep it.
- page-commands.ts: createPageCommand resolves the real order through
  nextFreeSiblingOrder instead of writing the requested 0 straight through.

Note that furni-import.ts and upload-import.ts still take their order
from the furnidata catInfo.order, so two categories carrying the same
furnidata order can still collide. That is caught by the emulator audit
and repaired by fixEmulatorIssues(), but it is not prevented here.
2026-09-29 15:34:44 +02:00
openhands 9cc57cddfc 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.
2026-09-29 15:34:36 +02:00
13 changed files with 788 additions and 38 deletions

No files matched your search

-1
View File
@@ -137,7 +137,6 @@ createRoot(root).render(
<OrganizeImportsLauncher />
<StudioClient
source={buildFurniImportSource("it")}
initialTree={[]}
defaultTranslate={false}
/>
</>
+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;
}
@@ -400,6 +400,10 @@ function FurniGrid({ source }: FurniGridProps) {
const [busyClassname, setBusyClassname] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
const batchAbortRef = useRef<AbortController | null>(null);
// Classnames the server already has, mirroring `items`. Lets markDone flip a
// card and adjust the counters without a refetch, and idempotently — React
// may run a state updater twice in dev.
const presentRef = useRef<Set<string>>(new Set());
const fetchItems = useCallback(
async (
@@ -426,6 +430,11 @@ function FurniGrid({ source }: FurniGridProps) {
setItems(data.items || []);
setMeta(data.meta || null);
setStats(data.stats || null);
presentRef.current = new Set(
(data.items || [])
.filter((it: FurniItem) => it.present)
.map((it: FurniItem) => it.classname),
);
setSelected(new Set());
} catch {
setError("Network error");
@@ -479,6 +488,19 @@ function FurniGrid({ source }: FurniGridProps) {
}
function markDone(classname: string) {
// Counters move as each clone lands, so total/present/clonable stay
// truthful for the whole run instead of jumping at the end.
if (presentRef.current.has(classname)) return;
presentRef.current.add(classname);
setStats((prev) =>
prev
? {
...prev,
present: prev.present + 1,
clonable: Math.max(0, prev.clonable - 1),
}
: prev,
);
setItems((prev) =>
prev.map((it) =>
it.classname === classname ? { ...it, present: true } : it,
+4 -4
View File
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
import { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { siteSettings } from "@/lib/services/site-settings";
@@ -14,9 +13,11 @@ export default async function StudioFurniPage(_props: {
redirect("/admin");
}
const [hotel, catalogTree, translateEnabled] = await Promise.all([
// The catalog tree is not read here: StudioClient pulls it from the live
// catalog store, which an import updates in place instead of forcing a
// route re-render.
const [hotel, translateEnabled] = await Promise.all([
getHabboGamedataHotel(),
getTreeFlat(),
siteSettings.getBool("furnidata_translate_enabled", true),
]);
@@ -24,7 +25,6 @@ export default async function StudioFurniPage(_props: {
<StudioClient
actorId={Number(session.user.id)}
source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
defaultTranslate={translateEnabled}
/>
);
@@ -12,6 +12,10 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import {
seedCatalogTotals,
useLiveCatalog,
} from "@/features/catalog/client/use-live-catalog";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
import type { RootTab } from "@/types/catalog";
@@ -164,6 +168,12 @@ function CatalogManagerInner({
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
// Server totals are the baseline; after an import the live store holds
// counts that are newer than both the prop and its 30s server cache.
const liveTotals = useLiveCatalog().totals[catalogType];
useEffect(() => {
seedCatalogTotals(catalogType, totals);
}, [catalogType, totals]);
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const searchNavigation = useLatestRequest();
@@ -518,7 +528,7 @@ function CatalogManagerInner({
</div>
</div>
<StatsBar totals={totals} />
<StatsBar totals={liveTotals ?? totals} />
</TooltipProvider>
);
}
@@ -17,11 +17,11 @@ import {
Wand2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
type OrganizedPageChange,
type OrganizeImportGroup as OrganizeGroupPayload,
organizeImportFurni,
} from "@/actions/catalog";
@@ -51,6 +51,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
@@ -155,7 +156,6 @@ export function OrganizeImportsDialog({
onOpenChange,
}: OrganizeImportsDialogProps) {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
@@ -683,8 +683,7 @@ export function OrganizeImportsDialog({
const runs = chunkGroups(payload);
setIsCreating(true);
try {
const created: Array<{ pageId: number; moved: number; added: number }> =
[];
const created: OrganizedPageChange[] = [];
let failedRuns = 0;
for (const run of runs) {
const result = await organizeImportFurni({
@@ -707,6 +706,34 @@ export function OrganizeImportsDialog({
} catch {
// ignore storage errors
}
// Fold the result straight into the mounted catalog tree instead of
// router.refresh()-ing the route, which would rebuild the whole
// workspace and throw away scroll position and open panels.
applyCatalogDelta({
pages: created
.filter((c) => c.isNew)
.map((c) => ({
id: c.pageId,
caption: c.caption,
parentId: c.parentId,
pageLayout: c.pageLayout,
iconImage: c.iconImage,
iconColor: c.iconColor,
orderNum: c.orderNum,
enabled: c.enabled,
visible: c.visible,
})),
addedItems: created
.filter((c) => c.added > 0)
.map((c) => ({ pageId: c.pageId, count: c.added })),
movedItems: created.flatMap((c) =>
c.movedFrom.map((m) => ({
fromPageId: m.fromPageId,
toPageId: c.pageId,
count: m.count,
})),
),
});
}
if (failedRuns > 0) {
toast.error(
@@ -733,7 +760,6 @@ export function OrganizeImportsDialog({
);
}
onOpenChange(false);
router.refresh();
} catch (error) {
console.error("[OrganizeImports] create failed:", error);
toast.error(t("errorFailed"));
+13 -5
View File
@@ -63,11 +63,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
ensureCatalogTreeLoaded,
refreshCatalogTree,
useLiveCatalog,
} from "@/features/catalog/client/use-live-catalog";
import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
@@ -169,12 +173,10 @@ const TABLE_ROW_H = 40;
export function StudioClient({
actorId,
source,
initialTree,
defaultTranslate,
}: {
actorId?: number;
source: FurniImportSource;
initialTree: TreeNode[];
defaultTranslate: boolean;
}) {
const [review, setReview] = useState<{
@@ -191,12 +193,14 @@ export function StudioClient({
actorId,
);
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
// Catalog tree — read from the live store so pages created by an import show
// up without re-rendering the route.
const tree = useLiveCatalog().tree;
const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(false);
useEffect(() => {
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
void ensureCatalogTreeLoaded();
}, []);
// Search & list
@@ -957,6 +961,10 @@ export function StudioClient({
setBatchCancelling(false);
setSelected(new Set());
fetchStats();
// The importers derive their "Imported Furniture" category pages from
// furnidata, so the client cannot predict which pages appeared. Pull the
// tree over the API instead of re-rendering the route.
void refreshCatalogTree();
if (!batchDoneRef.current) markBatchDone();
}
}
@@ -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);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
assertParent,
collectSubtree,
nextFreeSiblingOrder,
validateSiblingOrder,
} from "./hierarchy";
@@ -66,3 +67,37 @@ it("orders mixed root conventions with a stable ID tie breaker", () => {
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
});
describe("nextFreeSiblingOrder", () => {
it("keeps an explicit order that no sibling holds", () => {
expect(nextFreeSiblingOrder(pages, 1, 7)).toBe(7);
// Parent 1's siblings sit at order 1 and 2, so 0 is still free.
expect(nextFreeSiblingOrder(pages, 1, 0)).toBe(0);
});
it("appends instead of duplicating a taken order", () => {
// Requesting an order a sibling already holds would make the emulator
// reject the whole catalog, so the page lands after the last sibling.
expect(nextFreeSiblingOrder(pages, 1, 1)).toBe(3);
expect(nextFreeSiblingOrder(pages, 1, 2)).toBe(3);
expect(nextFreeSiblingOrder(pages, 2, 1)).toBe(2);
});
it("appends when the caller gives no order at all", () => {
expect(nextFreeSiblingOrder(pages, 1)).toBe(3);
expect(nextFreeSiblingOrder(pages, 2)).toBe(2);
});
it("counts parent -1 and parent 0 as the same sibling set", () => {
expect(
nextFreeSiblingOrder([{ id: 2, parentId: 0, orderNum: 1 }], -1, 1),
).toBe(2);
expect(
nextFreeSiblingOrder([{ id: 2, parentId: -1, orderNum: 1 }], 0, 1),
).toBe(2);
});
it("treats a missing orderNum as 0 when looking for collisions", () => {
expect(nextFreeSiblingOrder([{ id: 5, parentId: 1 }], 1, 0)).toBe(1);
});
it("gives the first child of an empty parent the requested order", () => {
expect(nextFreeSiblingOrder([], -1, 0)).toBe(0);
expect(nextFreeSiblingOrder([], -1)).toBe(1);
});
});
+31 -5
View File
@@ -3,6 +3,12 @@ export interface HierarchyPage {
parentId: number;
orderNum?: number;
}
/** Root pages live under parent -1 or 0; both address the same sibling set. */
function isSiblingOf(page: HierarchyPage, parentId: number) {
return parentId <= 0
? page.parentId === -1 || page.parentId === 0
: page.parentId === parentId;
}
export class CatalogConflict extends Error {}
export class CatalogInputError extends Error {}
export class CatalogNotFound extends Error {}
@@ -58,11 +64,7 @@ export function validateSiblingOrder(
expected: readonly number[],
) {
const current = pages
.filter((page) =>
parentId <= 0
? page.parentId === -1 || page.parentId === 0
: page.parentId === parentId,
)
.filter((page) => isSiblingOf(page, parentId))
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
.map((page) => page.id);
if (
@@ -81,3 +83,27 @@ export function validateSiblingOrder(
"A reorder must include every sibling exactly once",
);
}
/**
* Resolve the order number a newly created page should take under `parentId`.
*
* The emulator refuses to load a catalog whose siblings share an order number
* ("sibling order N is used more than once"), yet nearly every creation path
* passes a fixed 0. Honour an explicit request while it is still free, and
* otherwise append after the last sibling so creating a page can never
* introduce a duplicate in the first place.
*/
export function nextFreeSiblingOrder(
pages: readonly HierarchyPage[],
parentId: number,
requested?: number,
): number {
const orders = pages
.filter((page) => isSiblingOf(page, parentId))
.map((page) => page.orderNum ?? 0);
const highest = orders.length ? Math.max(...orders) : 0;
if (requested === undefined || !Number.isFinite(requested))
return highest + 1;
const wanted = Number(requested);
return orders.includes(wanted) ? highest + 1 : wanted;
}
+6 -1
View File
@@ -9,6 +9,7 @@ import {
CatalogNotFound,
collectSubtree,
type HierarchyPage,
nextFreeSiblingOrder,
positiveId,
validateSiblingOrder,
} from "../domain/hierarchy";
@@ -102,9 +103,13 @@ export async function createPageCommand(
if (!data.caption) throw new CatalogInputError("Category name is required");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
assertParent(rows, 0, data.parentId ?? -1);
const parentId = data.parentId ?? -1;
assertParent(rows, 0, parentId);
const assignments = fieldsSql(kind, {
...data,
// Never trust a caller-supplied order number: the emulator rejects a
// catalog where two siblings share one.
orderNum: nextFreeSiblingOrder(rows, parentId, data.orderNum),
captionSave: data.captionSave ?? data.caption?.slice(0, 25),
});
const [result] = await tx.execute(