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

-1
View File
@@ -137,7 +137,6 @@ createRoot(root).render(
<OrganizeImportsLauncher /> <OrganizeImportsLauncher />
<StudioClient <StudioClient
source={buildFurniImportSource("it")} source={buildFurniImportSource("it")}
initialTree={[]}
defaultTranslate={false} defaultTranslate={false}
/> />
</> </>
+61 -16
View File
File diff suppressed because it is too large. Load diff
@@ -400,6 +400,10 @@ function FurniGrid({ source }: FurniGridProps) {
const [busyClassname, setBusyClassname] = useState<string | null>(null); const [busyClassname, setBusyClassname] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "missing" | "present">("all"); const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
const batchAbortRef = useRef<AbortController | null>(null); 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( const fetchItems = useCallback(
async ( async (
@@ -426,6 +430,11 @@ function FurniGrid({ source }: FurniGridProps) {
setItems(data.items || []); setItems(data.items || []);
setMeta(data.meta || null); setMeta(data.meta || null);
setStats(data.stats || null); setStats(data.stats || null);
presentRef.current = new Set(
(data.items || [])
.filter((it: FurniItem) => it.present)
.map((it: FurniItem) => it.classname),
);
setSelected(new Set()); setSelected(new Set());
} catch { } catch {
setError("Network error"); setError("Network error");
@@ -479,6 +488,19 @@ function FurniGrid({ source }: FurniGridProps) {
} }
function markDone(classname: string) { 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) => setItems((prev) =>
prev.map((it) => prev.map((it) =>
it.classname === classname ? { ...it, present: true } : 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 { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -14,9 +13,11 @@ export default async function StudioFurniPage(_props: {
redirect("/admin"); 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(), getHabboGamedataHotel(),
getTreeFlat(),
siteSettings.getBool("furnidata_translate_enabled", true), siteSettings.getBool("furnidata_translate_enabled", true),
]); ]);
@@ -24,7 +25,6 @@ export default async function StudioFurniPage(_props: {
<StudioClient <StudioClient
actorId={Number(session.user.id)} actorId={Number(session.user.id)}
source={buildFurniImportSource(hotel)} source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
defaultTranslate={translateEnabled} defaultTranslate={translateEnabled}
/> />
); );
@@ -12,6 +12,10 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url"; import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation"; import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request"; 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 { CatalogSearch } from "@/features/catalog/components/catalog-search";
import type { CatalogSearchResult } from "@/features/catalog/domain/search"; import type { CatalogSearchResult } from "@/features/catalog/domain/search";
import type { RootTab } from "@/types/catalog"; import type { RootTab } from "@/types/catalog";
@@ -164,6 +168,12 @@ function CatalogManagerInner({
totals?: CatalogStatsTotals | null; totals?: CatalogStatsTotals | null;
}) { }) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager(); 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 catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest(); const rootRequests = useLatestRequest();
const searchNavigation = useLatestRequest(); const searchNavigation = useLatestRequest();
@@ -518,7 +528,7 @@ function CatalogManagerInner({
</div> </div>
</div> </div>
<StatsBar totals={totals} /> <StatsBar totals={liveTotals ?? totals} />
</TooltipProvider> </TooltipProvider>
); );
} }
@@ -17,11 +17,11 @@ import {
Wand2, Wand2,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
type OrganizedPageChange,
type OrganizeImportGroup as OrganizeGroupPayload, type OrganizeImportGroup as OrganizeGroupPayload,
organizeImportFurni, organizeImportFurni,
} from "@/actions/catalog"; } from "@/actions/catalog";
@@ -51,6 +51,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
import { import {
CATALOG_LAYOUTS, CATALOG_LAYOUTS,
type CatalogLayout, type CatalogLayout,
@@ -155,7 +156,6 @@ export function OrganizeImportsDialog({
onOpenChange, onOpenChange,
}: OrganizeImportsDialogProps) { }: OrganizeImportsDialogProps) {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports"); const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
@@ -683,8 +683,7 @@ export function OrganizeImportsDialog({
const runs = chunkGroups(payload); const runs = chunkGroups(payload);
setIsCreating(true); setIsCreating(true);
try { try {
const created: Array<{ pageId: number; moved: number; added: number }> = const created: OrganizedPageChange[] = [];
[];
let failedRuns = 0; let failedRuns = 0;
for (const run of runs) { for (const run of runs) {
const result = await organizeImportFurni({ const result = await organizeImportFurni({
@@ -707,6 +706,34 @@ export function OrganizeImportsDialog({
} catch { } catch {
// ignore storage errors // 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) { if (failedRuns > 0) {
toast.error( toast.error(
@@ -733,7 +760,6 @@ export function OrganizeImportsDialog({
); );
} }
onOpenChange(false); onOpenChange(false);
router.refresh();
} catch (error) { } catch (error) {
console.error("[OrganizeImports] create failed:", error); console.error("[OrganizeImports] create failed:", error);
toast.error(t("errorFailed")); toast.error(t("errorFailed"));
+13 -5
View File
@@ -63,11 +63,15 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
ensureCatalogTreeLoaded,
refreshCatalogTree,
useLiveCatalog,
} from "@/features/catalog/client/use-live-catalog";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client"; import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress"; import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail"; import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot"; import { CheckboxDot } from "./checkbox-dot";
@@ -169,12 +173,10 @@ const TABLE_ROW_H = 40;
export function StudioClient({ export function StudioClient({
actorId, actorId,
source, source,
initialTree,
defaultTranslate, defaultTranslate,
}: { }: {
actorId?: number; actorId?: number;
source: FurniImportSource; source: FurniImportSource;
initialTree: TreeNode[];
defaultTranslate: boolean; defaultTranslate: boolean;
}) { }) {
const [review, setReview] = useState<{ const [review, setReview] = useState<{
@@ -191,12 +193,14 @@ export function StudioClient({
actorId, actorId,
); );
const [preparingReview, setPreparingReview] = useState(false); const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree // Catalog tree — read from the live store so pages created by an import show
const [tree] = useState<TreeNode[]>(initialTree); // up without re-rendering the route.
const tree = useLiveCatalog().tree;
const [selectedPage, setSelectedPage] = useState<number | null>(null); const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(false); const [railOpen, setRailOpen] = useState(false);
useEffect(() => { useEffect(() => {
setRailOpen(window.matchMedia("(min-width: 1024px)").matches); setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
void ensureCatalogTreeLoaded();
}, []); }, []);
// Search & list // Search & list
@@ -957,6 +961,10 @@ export function StudioClient({
setBatchCancelling(false); setBatchCancelling(false);
setSelected(new Set()); setSelected(new Set());
fetchStats(); 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(); if (!batchDoneRef.current) markBatchDone();
} }
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff