@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.
Files changed (21) hide show
  1. package/.medusa/server/src/admin/components/category-tree/canvas-dock.d.ts +5 -0
  2. package/.medusa/server/src/admin/components/category-tree/canvas-dock.js +4 -2
  3. package/.medusa/server/src/admin/components/category-tree/category-node.js +12 -2
  4. package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +42 -3
  5. package/.medusa/server/src/admin/components/category-tree/edit-category-drawer.js +5 -1
  6. package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.d.ts +9 -1
  7. package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.js +116 -30
  8. package/.medusa/server/src/admin/components/category-tree/lib/tree-state.d.ts +14 -0
  9. package/.medusa/server/src/admin/components/category-tree/lib/tree-state.js +95 -0
  10. package/.medusa/server/src/admin/components/category-tree/tier-bars.d.ts +4 -2
  11. package/.medusa/server/src/admin/components/category-tree/tier-bars.js +9 -4
  12. package/.medusa/server/src/admin/components/category-tree/types.d.ts +4 -0
  13. package/package.json +1 -1
  14. package/src/admin/components/category-tree/canvas-dock.tsx +71 -18
  15. package/src/admin/components/category-tree/category-node.tsx +48 -9
  16. package/src/admin/components/category-tree/category-tree-canvas.tsx +56 -2
  17. package/src/admin/components/category-tree/edit-category-drawer.tsx +54 -0
  18. package/src/admin/components/category-tree/lib/layout-engine.ts +152 -25
  19. package/src/admin/components/category-tree/lib/tree-state.ts +112 -0
  20. package/src/admin/components/category-tree/tier-bars.tsx +12 -5
  21. 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
- export function computeDagreLayout(
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 g = new dagre.graphlib.Graph()
22
- g.setDefaultEdgeLabel(() => ({}))
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
- g.setGraph({
25
- rankdir: direction,
26
- nodesep: options.nodeSep ?? (isTB ? 45 : 32),
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
- nodes.forEach((node) => {
33
- g.setNode(node.id, { width: CARD_WIDTH, height: CARD_HEIGHT })
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
- edges.forEach((edge) => {
37
- g.setEdge(edge.source, edge.target)
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
- dagre.layout(g)
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
- ...node,
46
- targetPosition: isTB ? Position.Top : Position.Left,
47
- sourcePosition: isTB ? Position.Bottom : Position.Right,
48
- position: {
49
- x: pos.x - CARD_WIDTH / 2,
50
- y: pos.y - CARD_HEIGHT / 2,
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: layoutedNodes, edges }
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 black bars on the left edge of each category card,
9
- * separated by crisp white spacing, providing an immediate tactile hierarchy indicator:
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-[3px] bg-neutral-950 dark:bg-neutral-100 rounded-full shadow-sm"
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
  }