Fix Visual Manager empty categories: seed roots, harden tree API.
Local Build and Deploy / deploy (push) Successful in 56s
Local Build and Deploy / deploy (push) Successful in 56s
Seed root tabs from SSR, load the full tree without CLEAR_TREE races, coerce parent ids, and tolerate catalog_items page_id type mismatches so category pages actually appear. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
385cefd0fa
commit
1d8efefce4
6 files changed
+244
-146
No files matched your search
@@ -232,11 +232,9 @@ export default async function CatalogPagesPage({
|
|||||||
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Tree view data ──────────────────────────────────────────────
|
// ── Root pages (tree view + Visual Manager seed) ────────────────
|
||||||
let rootPages: TreeNodeShape[] = [];
|
|
||||||
if (viewMode === "tree") {
|
|
||||||
const rootPagesRaw = await prisma.catalogPages.findMany({
|
const rootPagesRaw = await prisma.catalogPages.findMany({
|
||||||
where: { parentId: -1 },
|
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
|
||||||
orderBy: { orderNum: "asc" },
|
orderBy: { orderNum: "asc" },
|
||||||
});
|
});
|
||||||
const rootIds = rootPagesRaw.map((p) => p.id);
|
const rootIds = rootPagesRaw.map((p) => p.id);
|
||||||
@@ -249,11 +247,13 @@ export default async function CatalogPagesPage({
|
|||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
rootIds.length > 0
|
rootIds.length > 0
|
||||||
? prisma.catalogItems.groupBy({
|
? prisma.catalogItems
|
||||||
|
.groupBy({
|
||||||
by: ["pageId"],
|
by: ["pageId"],
|
||||||
where: { pageId: { in: rootIds } },
|
where: { pageId: { in: rootIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
|
.catch(() => [])
|
||||||
: [],
|
: [],
|
||||||
]);
|
]);
|
||||||
const rootChildMap = Object.fromEntries(
|
const rootChildMap = Object.fromEntries(
|
||||||
@@ -262,7 +262,7 @@ export default async function CatalogPagesPage({
|
|||||||
const rootItemMap = Object.fromEntries(
|
const rootItemMap = Object.fromEntries(
|
||||||
rootItemCounts.map((c) => [c.pageId, c._count]),
|
rootItemCounts.map((c) => [c.pageId, c._count]),
|
||||||
);
|
);
|
||||||
rootPages = rootPagesRaw.map((p) => ({
|
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
caption: p.caption,
|
caption: p.caption,
|
||||||
parentId: p.parentId,
|
parentId: p.parentId,
|
||||||
@@ -274,7 +274,6 @@ export default async function CatalogPagesPage({
|
|||||||
childCount: rootChildMap[p.id] ?? 0,
|
childCount: rootChildMap[p.id] ?? 0,
|
||||||
itemCount: rootItemMap[p.id] ?? 0,
|
itemCount: rootItemMap[p.id] ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
// ── Table view data (skip if tree) ────────────────────────────
|
// ── Table view data (skip if tree) ────────────────────────────
|
||||||
let rows: {
|
let rows: {
|
||||||
@@ -399,7 +398,17 @@ export default async function CatalogPagesPage({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||||
{canEdit && <CatalogManagerDialog />}
|
{canEdit && (
|
||||||
|
<CatalogManagerDialog
|
||||||
|
initialRoots={rootPages.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
caption: p.caption,
|
||||||
|
iconImage: p.iconImage,
|
||||||
|
enabled: p.enabled,
|
||||||
|
orderNum: 0,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canEdit && <CatalogActions />}
|
{canEdit && <CatalogActions />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -120,26 +120,36 @@ export function SortableTree({
|
|||||||
setTreeLoading(true);
|
setTreeLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/catalog/tree?mode=full");
|
const res = await fetch("/api/admin/catalog/tree?mode=full");
|
||||||
if (!res.ok) throw new Error();
|
const data = await res.json().catch(() => ({}));
|
||||||
const data = await res.json();
|
if (!res.ok) throw new Error(data.error || "Tree request failed");
|
||||||
const pages: TreeNodeData[] = data.pages || [];
|
const pages: TreeNodeData[] = data.pages || [];
|
||||||
dispatch({ type: "SET_FULL_TREE", nodes: pages });
|
dispatch({ type: "SET_FULL_TREE", nodes: pages });
|
||||||
|
|
||||||
|
// Auto-expand first level under the active root so categories are visible.
|
||||||
|
if (activeTabId !== null) {
|
||||||
|
const firstLevel = pages
|
||||||
|
.filter((p) => Number(p.parentId) === activeTabId)
|
||||||
|
.map((p) => Number(p.id));
|
||||||
|
if (firstLevel.length > 0) {
|
||||||
|
dispatch({ type: "EXPAND_PATH", ids: firstLevel });
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to load catalog tree");
|
toast.error("Failed to load catalog tree");
|
||||||
} finally {
|
} finally {
|
||||||
setTreeLoading(false);
|
setTreeLoading(false);
|
||||||
}
|
}
|
||||||
}, [dispatch]);
|
}, [dispatch, activeTabId]);
|
||||||
|
|
||||||
// Load full tree when tab changes
|
// Load full tree when tab changes — replace atomically (no CLEAR_TREE race).
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTabId === null) return;
|
if (activeTabId === null) return;
|
||||||
dispatch({ type: "CLEAR_TREE" });
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setFilter("all");
|
setFilter("all");
|
||||||
loadFullTree();
|
dispatch({ type: "COLLAPSE_ALL" });
|
||||||
|
void loadFullTree();
|
||||||
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
@@ -198,7 +208,6 @@ export function SortableTree({
|
|||||||
for (const cid of childIds) {
|
for (const cid of childIds) {
|
||||||
const node = nodes.get(cid);
|
const node = nodes.get(cid);
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
// Apply filter
|
|
||||||
if (filter === "enabled" && node.enabled !== "1") continue;
|
if (filter === "enabled" && node.enabled !== "1") continue;
|
||||||
if (filter === "disabled" && node.enabled === "1") continue;
|
if (filter === "disabled" && node.enabled === "1") continue;
|
||||||
const isExpanded = expandedIds.has(cid);
|
const isExpanded = expandedIds.has(cid);
|
||||||
@@ -207,6 +216,20 @@ export function SortableTree({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
traverse(activeTabId, 0);
|
traverse(activeTabId, 0);
|
||||||
|
|
||||||
|
// If this root has no children yet, still show the root page itself
|
||||||
|
// so categories are editable (matches page CatalogTree expectations).
|
||||||
|
if (result.length === 0) {
|
||||||
|
const rootNode = nodes.get(activeTabId);
|
||||||
|
if (rootNode) {
|
||||||
|
if (
|
||||||
|
!(filter === "enabled" && rootNode.enabled !== "1") &&
|
||||||
|
!(filter === "disabled" && rootNode.enabled === "1")
|
||||||
|
) {
|
||||||
|
result.push({ ...rootNode, depth: 0, isExpanded: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
|
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
|
||||||
|
|
||||||
|
|||||||
@@ -142,12 +142,35 @@ function reducer(
|
|||||||
case "SET_FULL_TREE": {
|
case "SET_FULL_TREE": {
|
||||||
const nodes = new Map<number, TreeNodeData>();
|
const nodes = new Map<number, TreeNodeData>();
|
||||||
const childrenMap = new Map<number, number[]>();
|
const childrenMap = new Map<number, number[]>();
|
||||||
for (const n of action.nodes) {
|
for (const raw of action.nodes) {
|
||||||
nodes.set(n.id, n);
|
const id = Number(raw.id);
|
||||||
|
const parentId = Number(raw.parentId);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
const n: TreeNodeData = {
|
||||||
|
...raw,
|
||||||
|
id,
|
||||||
|
parentId: Number.isFinite(parentId) ? parentId : -1,
|
||||||
|
enabled: String(raw.enabled ?? "0"),
|
||||||
|
visible: String(raw.visible ?? "0"),
|
||||||
|
childCount: Number(raw.childCount) || 0,
|
||||||
|
itemCount: Number(raw.itemCount) || 0,
|
||||||
|
iconImage: Number(raw.iconImage) || 0,
|
||||||
|
iconColor: Number(raw.iconColor) || 0,
|
||||||
|
orderNum: Number(raw.orderNum) || 0,
|
||||||
|
};
|
||||||
|
nodes.set(id, n);
|
||||||
const siblings = childrenMap.get(n.parentId) || [];
|
const siblings = childrenMap.get(n.parentId) || [];
|
||||||
siblings.push(n.id);
|
siblings.push(id);
|
||||||
childrenMap.set(n.parentId, siblings);
|
childrenMap.set(n.parentId, siblings);
|
||||||
}
|
}
|
||||||
|
// Roots may be parent_id -1 or 0 — mirror both keys so tabs resolve children.
|
||||||
|
const underNeg1 = childrenMap.get(-1) || [];
|
||||||
|
const under0 = childrenMap.get(0) || [];
|
||||||
|
if (underNeg1.length || under0.length) {
|
||||||
|
const merged = [...new Set([...underNeg1, ...under0])];
|
||||||
|
childrenMap.set(-1, merged);
|
||||||
|
childrenMap.set(0, merged);
|
||||||
|
}
|
||||||
for (const [parentId, ids] of childrenMap) {
|
for (const [parentId, ids] of childrenMap) {
|
||||||
ids.sort(
|
ids.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user