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
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user