@hectasquare/medusa-plugin-category-tree 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/components/category-tree/canvas-dock.d.ts +5 -0
- package/.medusa/server/src/admin/components/category-tree/canvas-dock.js +4 -2
- package/.medusa/server/src/admin/components/category-tree/category-node.js +12 -2
- package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +42 -3
- package/.medusa/server/src/admin/components/category-tree/edit-category-drawer.js +5 -1
- package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.d.ts +9 -1
- package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.js +116 -30
- package/.medusa/server/src/admin/components/category-tree/lib/tree-state.d.ts +14 -0
- package/.medusa/server/src/admin/components/category-tree/lib/tree-state.js +95 -0
- package/.medusa/server/src/admin/components/category-tree/tier-bars.d.ts +4 -2
- package/.medusa/server/src/admin/components/category-tree/tier-bars.js +9 -4
- package/.medusa/server/src/admin/components/category-tree/types.d.ts +4 -0
- package/package.json +1 -1
- package/src/admin/components/category-tree/canvas-dock.tsx +71 -18
- package/src/admin/components/category-tree/category-node.tsx +48 -9
- package/src/admin/components/category-tree/category-tree-canvas.tsx +56 -2
- package/src/admin/components/category-tree/edit-category-drawer.tsx +54 -0
- package/src/admin/components/category-tree/lib/layout-engine.ts +152 -25
- package/src/admin/components/category-tree/lib/tree-state.ts +112 -0
- package/src/admin/components/category-tree/tier-bars.tsx +12 -5
- package/src/admin/components/category-tree/types.ts +4 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import dagre from "@dagrejs/dagre"
|
|
2
1
|
import { Node, Edge, Position } from "@xyflow/react"
|
|
3
2
|
|
|
4
3
|
export const CARD_WIDTH = 270
|
|
@@ -10,47 +9,175 @@ export interface LayoutOptions {
|
|
|
10
9
|
rankSep?: number
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
interface TreeLayoutNode {
|
|
13
|
+
id: string
|
|
14
|
+
node: Node
|
|
15
|
+
children: TreeLayoutNode[]
|
|
16
|
+
depth: number
|
|
17
|
+
span: number // width in TB, height in LR
|
|
18
|
+
primaryCoord: number // x in TB, y in LR
|
|
19
|
+
secondaryCoord: number // y in TB, x in LR
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AAA Clustered Hierarchical Tree Layout (Buchheim/Walker style)
|
|
24
|
+
* - Guarantees each parent is positioned at the exact midpoint/center of its children
|
|
25
|
+
* - Guarantees subtrees never overlap and stay tightly clustered
|
|
26
|
+
* - Completely eliminates long spanning cross-canvas threads
|
|
27
|
+
* - Sibling order is preserved strictly according to category rank
|
|
28
|
+
*/
|
|
29
|
+
export function computeTreeLayout(
|
|
14
30
|
nodes: Node[],
|
|
15
31
|
edges: Edge[],
|
|
16
32
|
options: LayoutOptions = {}
|
|
17
33
|
): { nodes: Node[]; edges: Edge[] } {
|
|
34
|
+
if (nodes.length === 0) return { nodes: [], edges: [] }
|
|
35
|
+
|
|
18
36
|
const direction = options.direction || "TB"
|
|
19
37
|
const isTB = direction === "TB"
|
|
20
38
|
|
|
21
|
-
const
|
|
22
|
-
|
|
39
|
+
const nodeSep = options.nodeSep ?? (isTB ? 40 : 28)
|
|
40
|
+
const rankSep = options.rankSep ?? (isTB ? 70 : 80)
|
|
41
|
+
const treeGap = isTB ? 80 : 64
|
|
23
42
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
ranksep: options.rankSep ?? (isTB ? 65 : 75),
|
|
28
|
-
align: "UL",
|
|
29
|
-
ranker: "network-simplex",
|
|
30
|
-
})
|
|
43
|
+
const nodeMap = new Map<string, Node>(nodes.map((n) => [n.id, n]))
|
|
44
|
+
const childrenMap = new Map<string, string[]>()
|
|
45
|
+
const parentMap = new Map<string, string>()
|
|
31
46
|
|
|
32
|
-
|
|
33
|
-
|
|
47
|
+
edges.forEach((edge) => {
|
|
48
|
+
if (nodeMap.has(edge.source) && nodeMap.has(edge.target)) {
|
|
49
|
+
const list = childrenMap.get(edge.source) || []
|
|
50
|
+
list.push(edge.target)
|
|
51
|
+
childrenMap.set(edge.source, list)
|
|
52
|
+
parentMap.set(edge.target, edge.source)
|
|
53
|
+
}
|
|
34
54
|
})
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
// Identify roots (nodes with no incoming edge in the visible graph)
|
|
57
|
+
const rootNodes = nodes.filter((n) => !parentMap.has(n.id))
|
|
58
|
+
|
|
59
|
+
// Sort children by their category rank
|
|
60
|
+
const getRank = (id: string): number => {
|
|
61
|
+
const raw = (nodeMap.get(id)?.data as any)?.category?.rank
|
|
62
|
+
return typeof raw === "number" ? raw : 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildTree(id: string, depth: number): TreeLayoutNode {
|
|
66
|
+
const rawNode = nodeMap.get(id)!
|
|
67
|
+
const childIds = (childrenMap.get(id) || []).sort(
|
|
68
|
+
(a, b) => getRank(a) - getRank(b) || a.localeCompare(b)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const children = childIds.map((childId) => buildTree(childId, depth + 1))
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
node: rawNode,
|
|
76
|
+
children,
|
|
77
|
+
depth,
|
|
78
|
+
span: 0,
|
|
79
|
+
primaryCoord: 0,
|
|
80
|
+
secondaryCoord: 0,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 1. Post-order pass: Compute subtree span (width in TB, height in LR)
|
|
85
|
+
function computeSpan(treeNode: TreeLayoutNode): number {
|
|
86
|
+
const baseSpan = isTB ? CARD_WIDTH : CARD_HEIGHT
|
|
87
|
+
if (treeNode.children.length === 0) {
|
|
88
|
+
treeNode.span = baseSpan
|
|
89
|
+
return baseSpan
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let childrenSpanTotal = 0
|
|
93
|
+
treeNode.children.forEach((child, index) => {
|
|
94
|
+
const childSpan = computeSpan(child)
|
|
95
|
+
childrenSpanTotal += childSpan + (index > 0 ? nodeSep : 0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
treeNode.span = Math.max(baseSpan, childrenSpanTotal)
|
|
99
|
+
return treeNode.span
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Pre-order pass: Position nodes
|
|
103
|
+
function assignPositions(
|
|
104
|
+
treeNode: TreeLayoutNode,
|
|
105
|
+
startOffset: number,
|
|
106
|
+
depth: number
|
|
107
|
+
) {
|
|
108
|
+
const baseSpan = isTB ? CARD_WIDTH : CARD_HEIGHT
|
|
109
|
+
const baseSecondary = isTB ? CARD_HEIGHT : CARD_WIDTH
|
|
110
|
+
|
|
111
|
+
treeNode.secondaryCoord = depth * (baseSecondary + rankSep)
|
|
112
|
+
|
|
113
|
+
if (treeNode.children.length === 0) {
|
|
114
|
+
treeNode.primaryCoord = startOffset + (treeNode.span - baseSpan) / 2
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let currentOffset = startOffset
|
|
119
|
+
treeNode.children.forEach((child) => {
|
|
120
|
+
assignPositions(child, currentOffset, depth + 1)
|
|
121
|
+
currentOffset += child.span + nodeSep
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Center parent directly between first and last child
|
|
125
|
+
const firstChild = treeNode.children[0]
|
|
126
|
+
const lastChild = treeNode.children[treeNode.children.length - 1]
|
|
127
|
+
const centerOfChildren = (firstChild.primaryCoord + lastChild.primaryCoord) / 2
|
|
128
|
+
|
|
129
|
+
treeNode.primaryCoord = centerOfChildren
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Sort roots by their rank
|
|
133
|
+
rootNodes.sort((a, b) => getRank(a.id) - getRank(b.id) || a.id.localeCompare(b.id))
|
|
134
|
+
|
|
135
|
+
const positionedNodes: Node[] = []
|
|
136
|
+
let globalOffset = 0
|
|
137
|
+
|
|
138
|
+
rootNodes.forEach((root) => {
|
|
139
|
+
const tree = buildTree(root.id, 0)
|
|
140
|
+
computeSpan(tree)
|
|
141
|
+
assignPositions(tree, globalOffset, 0)
|
|
142
|
+
globalOffset += tree.span + treeGap
|
|
143
|
+
|
|
144
|
+
// Flatten tree
|
|
145
|
+
function collect(node: TreeLayoutNode) {
|
|
146
|
+
const x = isTB ? node.primaryCoord : node.secondaryCoord
|
|
147
|
+
const y = isTB ? node.secondaryCoord : node.primaryCoord
|
|
148
|
+
|
|
149
|
+
positionedNodes.push({
|
|
150
|
+
...node.node,
|
|
151
|
+
targetPosition: isTB ? Position.Top : Position.Left,
|
|
152
|
+
sourcePosition: isTB ? Position.Bottom : Position.Right,
|
|
153
|
+
position: { x, y },
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
node.children.forEach(collect)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
collect(tree)
|
|
38
160
|
})
|
|
39
161
|
|
|
40
|
-
|
|
162
|
+
// Style edges to match source branch color if available
|
|
163
|
+
const styledEdges: Edge[] = edges.map((e) => {
|
|
164
|
+
const sourceNode = nodeMap.get(e.source)
|
|
165
|
+
const branchColor = (sourceNode?.data as any)?.branchColor || "#64748b"
|
|
41
166
|
|
|
42
|
-
const layoutedNodes: Node[] = nodes.map((node) => {
|
|
43
|
-
const pos = g.node(node.id)
|
|
44
167
|
return {
|
|
45
|
-
...
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
168
|
+
...e,
|
|
169
|
+
style: {
|
|
170
|
+
stroke: branchColor,
|
|
171
|
+
strokeWidth: 2,
|
|
172
|
+
opacity: 0.85,
|
|
173
|
+
...e.style,
|
|
51
174
|
},
|
|
52
175
|
}
|
|
53
176
|
})
|
|
54
177
|
|
|
55
|
-
return { nodes:
|
|
178
|
+
return { nodes: positionedNodes, edges: styledEdges }
|
|
56
179
|
}
|
|
180
|
+
|
|
181
|
+
// Keep computeDagreLayout as alias to guarantee backwards compatibility
|
|
182
|
+
export const computeDagreLayout = computeTreeLayout
|
|
183
|
+
|
|
@@ -165,3 +165,115 @@ export function computeMoves(
|
|
|
165
165
|
|
|
166
166
|
return moves
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
export const BRANCH_PALETTE: { name: string; hex: string }[] = [
|
|
170
|
+
{ name: "Emerald", hex: "#10b981" },
|
|
171
|
+
{ name: "Indigo", hex: "#6366f1" },
|
|
172
|
+
{ name: "Amber", hex: "#f59e0b" },
|
|
173
|
+
{ name: "Rose", hex: "#f43f5e" },
|
|
174
|
+
{ name: "Cyan", hex: "#06b6d4" },
|
|
175
|
+
{ name: "Violet", hex: "#8b5cf6" },
|
|
176
|
+
{ name: "Pink", hex: "#ec4899" },
|
|
177
|
+
{ name: "Lime", hex: "#84cc16" },
|
|
178
|
+
{ name: "Sky", hex: "#0ea5e9" },
|
|
179
|
+
{ name: "Orange", hex: "#f97316" },
|
|
180
|
+
{ name: "Teal", hex: "#14b8a6" },
|
|
181
|
+
{ name: "Purple", hex: "#a855f7" },
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Assigns a deterministic branch color to each category based on its top-level root ancestor.
|
|
186
|
+
* All descendants in a root branch share the exact same branch color.
|
|
187
|
+
*/
|
|
188
|
+
export function computeBranchColors(
|
|
189
|
+
categories: CategoryTreeNode[]
|
|
190
|
+
): Map<string, string> {
|
|
191
|
+
const catMap = new Map(categories.map((c) => [c.id, c]))
|
|
192
|
+
const rootMap = new Map<string, string>()
|
|
193
|
+
|
|
194
|
+
function findRootId(id: string, visited: Set<string>): string {
|
|
195
|
+
if (rootMap.has(id)) return rootMap.get(id)!
|
|
196
|
+
if (visited.has(id)) return id
|
|
197
|
+
visited.add(id)
|
|
198
|
+
|
|
199
|
+
const cat = catMap.get(id)
|
|
200
|
+
if (!cat || !cat.parent_category_id || !catMap.has(cat.parent_category_id)) {
|
|
201
|
+
rootMap.set(id, id)
|
|
202
|
+
return id
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const root = findRootId(cat.parent_category_id, visited)
|
|
206
|
+
rootMap.set(id, root)
|
|
207
|
+
return root
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const c of categories) {
|
|
211
|
+
findRootId(c.id, new Set())
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const rootIds = Array.from(
|
|
215
|
+
new Set(categories.filter((c) => !c.parent_category_id).map((c) => c.id))
|
|
216
|
+
).sort((a, b) => {
|
|
217
|
+
const catA = catMap.get(a)
|
|
218
|
+
const catB = catMap.get(b)
|
|
219
|
+
return (catA?.rank ?? 0) - (catB?.rank ?? 0) || a.localeCompare(b)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const rootColorMap = new Map<string, string>()
|
|
223
|
+
rootIds.forEach((id, index) => {
|
|
224
|
+
rootColorMap.set(id, BRANCH_PALETTE[index % BRANCH_PALETTE.length].hex)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const result = new Map<string, string>()
|
|
228
|
+
for (const c of categories) {
|
|
229
|
+
const rootId = rootMap.get(c.id) || c.id
|
|
230
|
+
const color = rootColorMap.get(rootId) || BRANCH_PALETTE[0].hex
|
|
231
|
+
result.set(c.id, color)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return result
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Swaps sibling ranking (up or down). Modifies rank property among siblings
|
|
239
|
+
* and returns a new categories array ready for preview and commit.
|
|
240
|
+
*/
|
|
241
|
+
export function moveSiblingRank(
|
|
242
|
+
categories: CategoryTreeNode[],
|
|
243
|
+
targetId: string,
|
|
244
|
+
direction: "up" | "down"
|
|
245
|
+
): CategoryTreeNode[] {
|
|
246
|
+
const target = categories.find((c) => c.id === targetId)
|
|
247
|
+
if (!target) return categories
|
|
248
|
+
|
|
249
|
+
const parentId = target.parent_category_id || null
|
|
250
|
+
const siblings = categories
|
|
251
|
+
.filter((c) => (c.parent_category_id || null) === parentId)
|
|
252
|
+
.sort((a, b) => a.rank - b.rank)
|
|
253
|
+
|
|
254
|
+
const currentIndex = siblings.findIndex((s) => s.id === targetId)
|
|
255
|
+
if (currentIndex === -1) return categories
|
|
256
|
+
|
|
257
|
+
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1
|
|
258
|
+
if (targetIndex < 0 || targetIndex >= siblings.length) return categories
|
|
259
|
+
|
|
260
|
+
const adjacent = siblings[targetIndex]
|
|
261
|
+
// Normalize ranks if they are identical or uninitialized
|
|
262
|
+
const updatedRanks = new Map<string, number>()
|
|
263
|
+
siblings.forEach((s, idx) => {
|
|
264
|
+
updatedRanks.set(s.id, idx * 10)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Swap target and adjacent
|
|
268
|
+
const targetNewRank = targetIndex * 10
|
|
269
|
+
const adjacentNewRank = currentIndex * 10
|
|
270
|
+
updatedRanks.set(target.id, targetNewRank)
|
|
271
|
+
updatedRanks.set(adjacent.id, adjacentNewRank)
|
|
272
|
+
|
|
273
|
+
return categories.map((c) => {
|
|
274
|
+
if (updatedRanks.has(c.id)) {
|
|
275
|
+
return { ...c, rank: updatedRanks.get(c.id)! }
|
|
276
|
+
}
|
|
277
|
+
return c
|
|
278
|
+
})
|
|
279
|
+
}
|
|
@@ -2,28 +2,35 @@ import React from "react"
|
|
|
2
2
|
|
|
3
3
|
export interface TierBarsProps {
|
|
4
4
|
tier: number
|
|
5
|
+
color?: string
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* Renders N vertical
|
|
9
|
-
*
|
|
9
|
+
* Renders N vertical colored bars on the left edge of each category card,
|
|
10
|
+
* providing an immediate tactile hierarchy indicator:
|
|
10
11
|
* Tier 1 = 1 bar (Root/Main)
|
|
11
12
|
* Tier 2 = 2 bars (Sub)
|
|
12
13
|
* Tier 3 = 3 bars (Sub-Sub)
|
|
13
14
|
* Tier N = N bars
|
|
15
|
+
* Bar color matches the branch lineage for instant visual identification.
|
|
14
16
|
*/
|
|
15
|
-
export const TierBars: React.FC<TierBarsProps> = ({ tier }) => {
|
|
17
|
+
export const TierBars: React.FC<TierBarsProps> = ({ tier, color }) => {
|
|
16
18
|
const count = Math.max(1, tier)
|
|
19
|
+
const barColor = color || "var(--fg-interactive, #3b82f6)"
|
|
17
20
|
|
|
18
21
|
return (
|
|
19
22
|
<div
|
|
20
23
|
className="flex h-full py-2 pl-2 pr-1.5 gap-[2.5px] items-stretch flex-shrink-0 select-none bg-neutral-50 dark:bg-neutral-900/60 rounded-l-xl border-r border-ui-border-base/50"
|
|
21
|
-
title={`Tier ${tier} (${tier === 1 ? "Main Root Category" : tier === 2 ? "Subcategory" : "Sub-subcategory"})`}
|
|
24
|
+
title={`Tier ${tier} (${tier === 1 ? "Main Root Category" : tier === 2 ? "Subcategory" : "Sub-subcategory"}) - Branch Color: ${barColor}`}
|
|
22
25
|
>
|
|
23
26
|
{Array.from({ length: count }).map((_, index) => (
|
|
24
27
|
<span
|
|
25
28
|
key={index}
|
|
26
|
-
className="w-[
|
|
29
|
+
className="w-[3.5px] rounded-full shadow-xs transition-colors"
|
|
30
|
+
style={{
|
|
31
|
+
backgroundColor: barColor,
|
|
32
|
+
boxShadow: `0 0 4px ${barColor}40`,
|
|
33
|
+
}}
|
|
27
34
|
/>
|
|
28
35
|
))}
|
|
29
36
|
</div>
|
|
@@ -20,6 +20,7 @@ export type CategoryTreeNode = {
|
|
|
20
20
|
sibling_rank: number
|
|
21
21
|
children_count: number
|
|
22
22
|
has_children: boolean
|
|
23
|
+
branch_color?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export type CategoryTreeResponse = {
|
|
@@ -39,6 +40,7 @@ export type CategoryNodeData = {
|
|
|
39
40
|
tier: number
|
|
40
41
|
siblingRank: number
|
|
41
42
|
superscriptRank: string
|
|
43
|
+
branchColor?: string
|
|
42
44
|
salesChannels: SalesChannelInfo[]
|
|
43
45
|
isCollapsed: boolean
|
|
44
46
|
hiddenDescendantCount: number
|
|
@@ -48,4 +50,6 @@ export type CategoryNodeData = {
|
|
|
48
50
|
onAddChild: (parent: CategoryTreeNode) => void
|
|
49
51
|
onEdit: (category: CategoryTreeNode) => void
|
|
50
52
|
onDetach: (id: string) => void
|
|
53
|
+
onMoveSiblingUp?: (id: string) => void
|
|
54
|
+
onMoveSiblingDown?: (id: string) => void
|
|
51
55
|
}
|