@hectasquare/medusa-plugin-category-tree 1.0.0 → 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 (23) 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 +22 -13
  4. package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +43 -4
  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/.medusa/server/src/admin/routes/category-tree/page.js +2 -1
  14. package/package.json +1 -1
  15. package/src/admin/components/category-tree/canvas-dock.tsx +93 -45
  16. package/src/admin/components/category-tree/category-node.tsx +77 -41
  17. package/src/admin/components/category-tree/category-tree-canvas.tsx +62 -6
  18. package/src/admin/components/category-tree/edit-category-drawer.tsx +54 -0
  19. package/src/admin/components/category-tree/lib/layout-engine.ts +152 -25
  20. package/src/admin/components/category-tree/lib/tree-state.ts +112 -0
  21. package/src/admin/components/category-tree/tier-bars.tsx +12 -5
  22. package/src/admin/components/category-tree/types.ts +4 -0
  23. package/src/admin/routes/category-tree/page.tsx +6 -1
@@ -1,13 +1,15 @@
1
1
  import React from "react";
2
2
  export interface TierBarsProps {
3
3
  tier: number;
4
+ color?: string;
4
5
  }
5
6
  /**
6
- * Renders N vertical black bars on the left edge of each category card,
7
- * separated by crisp white spacing, providing an immediate tactile hierarchy indicator:
7
+ * Renders N vertical colored bars on the left edge of each category card,
8
+ * providing an immediate tactile hierarchy indicator:
8
9
  * Tier 1 = 1 bar (Root/Main)
9
10
  * Tier 2 = 2 bars (Sub)
10
11
  * Tier 3 = 3 bars (Sub-Sub)
11
12
  * Tier N = N bars
13
+ * Bar color matches the branch lineage for instant visual identification.
12
14
  */
13
15
  export declare const TierBars: React.FC<TierBarsProps>;
@@ -3,15 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TierBars = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  /**
6
- * Renders N vertical black bars on the left edge of each category card,
7
- * separated by crisp white spacing, providing an immediate tactile hierarchy indicator:
6
+ * Renders N vertical colored bars on the left edge of each category card,
7
+ * providing an immediate tactile hierarchy indicator:
8
8
  * Tier 1 = 1 bar (Root/Main)
9
9
  * Tier 2 = 2 bars (Sub)
10
10
  * Tier 3 = 3 bars (Sub-Sub)
11
11
  * Tier N = N bars
12
+ * Bar color matches the branch lineage for instant visual identification.
12
13
  */
13
- const TierBars = ({ tier }) => {
14
+ const TierBars = ({ tier, color }) => {
14
15
  const count = Math.max(1, tier);
15
- return ((0, jsx_runtime_1.jsx)("div", { 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", title: `Tier ${tier} (${tier === 1 ? "Main Root Category" : tier === 2 ? "Subcategory" : "Sub-subcategory"})`, children: Array.from({ length: count }).map((_, index) => ((0, jsx_runtime_1.jsx)("span", { className: "w-[3px] bg-neutral-950 dark:bg-neutral-100 rounded-full shadow-sm" }, index))) }));
16
+ const barColor = color || "var(--fg-interactive, #3b82f6)";
17
+ return ((0, jsx_runtime_1.jsx)("div", { 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", title: `Tier ${tier} (${tier === 1 ? "Main Root Category" : tier === 2 ? "Subcategory" : "Sub-subcategory"}) - Branch Color: ${barColor}`, children: Array.from({ length: count }).map((_, index) => ((0, jsx_runtime_1.jsx)("span", { className: "w-[3.5px] rounded-full shadow-xs transition-colors", style: {
18
+ backgroundColor: barColor,
19
+ boxShadow: `0 0 4px ${barColor}40`,
20
+ } }, index))) }));
16
21
  };
17
22
  exports.TierBars = TierBars;
@@ -19,6 +19,7 @@ export type CategoryTreeNode = {
19
19
  sibling_rank: number;
20
20
  children_count: number;
21
21
  has_children: boolean;
22
+ branch_color?: string;
22
23
  };
23
24
  export type CategoryTreeResponse = {
24
25
  categories: CategoryTreeNode[];
@@ -35,6 +36,7 @@ export type CategoryNodeData = {
35
36
  tier: number;
36
37
  siblingRank: number;
37
38
  superscriptRank: string;
39
+ branchColor?: string;
38
40
  salesChannels: SalesChannelInfo[];
39
41
  isCollapsed: boolean;
40
42
  hiddenDescendantCount: number;
@@ -44,4 +46,6 @@ export type CategoryNodeData = {
44
46
  onAddChild: (parent: CategoryTreeNode) => void;
45
47
  onEdit: (category: CategoryTreeNode) => void;
46
48
  onDetach: (id: string) => void;
49
+ onMoveSiblingUp?: (id: string) => void;
50
+ onMoveSiblingDown?: (id: string) => void;
47
51
  };
@@ -4,9 +4,10 @@ exports.config = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const admin_sdk_1 = require("@medusajs/admin-sdk");
6
6
  const icons_1 = require("@medusajs/icons");
7
+ const ui_1 = require("@medusajs/ui");
7
8
  const category_tree_canvas_1 = require("../../components/category-tree/category-tree-canvas");
8
9
  const CategoryTreePage = () => {
9
- return (0, jsx_runtime_1.jsx)(category_tree_canvas_1.CategoryTreeCanvas, {});
10
+ return ((0, jsx_runtime_1.jsx)(ui_1.TooltipProvider, { children: (0, jsx_runtime_1.jsx)(category_tree_canvas_1.CategoryTreeCanvas, {}) }));
10
11
  };
11
12
  exports.config = (0, admin_sdk_1.defineRouteConfig)({
12
13
  label: "Category Tree",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hectasquare/medusa-plugin-category-tree",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "AAA Interactive Category Tree & Mind Map for Medusa v2 Admin with drag-and-drop reparenting, tier indicators, superscript ranks, multi-storefront filtering, and NotebookLM-style subtree collapse.",
5
5
  "author": "HectaSquare Team",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import React from "react"
2
- import { Button, Tooltip } from "@medusajs/ui"
2
+ import { Button } from "@medusajs/ui"
3
3
  import {
4
4
  ArrowsPointingOut,
5
5
  MagnifyingGlass,
@@ -22,6 +22,13 @@ export interface CanvasDockProps {
22
22
  onCollapseAll: () => void
23
23
  onFocusRoot: () => void
24
24
 
25
+ selectedCount?: number
26
+ onExpandSelected?: () => void
27
+ onCollapseSelected?: () => void
28
+
29
+ isBoxSelect?: boolean
30
+ onToggleBoxSelect?: () => void
31
+
25
32
  onZoomIn: () => void
26
33
  onZoomOut: () => void
27
34
  onFitView: () => void
@@ -45,6 +52,11 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
45
52
  onExpandAll,
46
53
  onCollapseAll,
47
54
  onFocusRoot,
55
+ selectedCount = 0,
56
+ onExpandSelected,
57
+ onCollapseSelected,
58
+ isBoxSelect = false,
59
+ onToggleBoxSelect,
48
60
  onFitView,
49
61
  onOpenCreateRoot,
50
62
  dirtyCount,
@@ -74,18 +86,17 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
74
86
  </div>
75
87
 
76
88
  <div className="flex items-center border-r border-ui-border-base pr-2.5">
77
- <Tooltip content={direction === "TB" ? "Switch to Mind Map (Left-to-Right)" : "Switch to Vertical Tree (Top-to-Bottom)"}>
78
- <button
79
- type="button"
80
- onClick={onToggleDirection}
81
- className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base transition-colors"
82
- >
83
- <span>{direction === "TB" ? "⬇ Vertical" : "➡ Mind Map"}</span>
84
- </button>
85
- </Tooltip>
89
+ <button
90
+ type="button"
91
+ title={direction === "TB" ? "Switch to Mind Map (Left-to-Right)" : "Switch to Vertical Tree (Top-to-Bottom)"}
92
+ onClick={onToggleDirection}
93
+ className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base transition-colors"
94
+ >
95
+ <span>{direction === "TB" ? "⬇ Vertical" : "➡ Mind Map"}</span>
96
+ </button>
86
97
  </div>
87
98
 
88
- <div className="relative w-40 sm:w-48">
99
+ <div className="relative w-36 sm:w-44">
89
100
  <input
90
101
  type="text"
91
102
  placeholder="Search categories…"
@@ -96,48 +107,85 @@ export const CanvasDock: React.FC<CanvasDockProps> = ({
96
107
  <MagnifyingGlass className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-ui-fg-muted pointer-events-none" />
97
108
  </div>
98
109
 
110
+ {/* Expand / Collapse Section with Selective awareness */}
99
111
  <div className="flex items-center gap-1 border-l border-ui-border-base pl-2.5">
100
- <Tooltip content="Expand All Categories">
101
- <button
102
- type="button"
103
- onClick={onExpandAll}
104
- className="px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
105
- >
106
- Expand
107
- </button>
108
- </Tooltip>
109
-
110
- <Tooltip content="Collapse All Subcategories">
111
- <button
112
- type="button"
113
- onClick={onCollapseAll}
114
- className="px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
115
- >
116
- Collapse
117
- </button>
118
- </Tooltip>
112
+ {selectedCount > 0 ? (
113
+ <>
114
+ <button
115
+ type="button"
116
+ title={`Expand selected category (${selectedCount} selected)`}
117
+ onClick={onExpandSelected || onExpandAll}
118
+ className="px-2 py-1 text-xs font-medium bg-ui-bg-interactive text-ui-fg-on-color rounded-md transition-colors"
119
+ >
120
+ Expand ({selectedCount})
121
+ </button>
122
+ <button
123
+ type="button"
124
+ title={`Collapse selected category (${selectedCount} selected)`}
125
+ onClick={onCollapseSelected || onCollapseAll}
126
+ className="px-2 py-1 text-xs font-medium bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base rounded-md transition-colors"
127
+ >
128
+ Collapse ({selectedCount})
129
+ </button>
130
+ </>
131
+ ) : (
132
+ <>
133
+ <button
134
+ type="button"
135
+ title="Expand All Categories"
136
+ onClick={onExpandAll}
137
+ className="px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
138
+ >
139
+ Expand All
140
+ </button>
141
+ <button
142
+ type="button"
143
+ title="Collapse All Subcategories"
144
+ onClick={onCollapseAll}
145
+ className="px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
146
+ >
147
+ Collapse All
148
+ </button>
149
+ </>
150
+ )}
151
+
152
+ <button
153
+ type="button"
154
+ title="Focus on Root Categories"
155
+ onClick={onFocusRoot}
156
+ className="px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
157
+ >
158
+ Root
159
+ </button>
160
+ </div>
119
161
 
120
- <Tooltip content="Focus on Root Categories">
162
+ {/* Box Selection / Marquee mode toggle */}
163
+ {onToggleBoxSelect && (
164
+ <div className="flex items-center border-l border-ui-border-base pl-2.5">
121
165
  <button
122
166
  type="button"
123
- onClick={onFocusRoot}
124
- className="px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
167
+ title={isBoxSelect ? "Box Selection Active (Drag to outline cards). Click to switch to Pan" : "Click to enable Box Select marquee (Outline cards to move together)"}
168
+ onClick={onToggleBoxSelect}
169
+ className={`px-2 py-1 text-xs font-medium rounded-md transition-colors ${
170
+ isBoxSelect
171
+ ? "bg-blue-600 text-white shadow-xs"
172
+ : "text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle"
173
+ }`}
125
174
  >
126
- Root
175
+ ⛶ Box Select
127
176
  </button>
128
- </Tooltip>
129
- </div>
177
+ </div>
178
+ )}
130
179
 
131
180
  <div className="flex items-center gap-1 border-l border-ui-border-base pl-2.5">
132
- <Tooltip content="Fit View (Center)">
133
- <button
134
- type="button"
135
- onClick={onFitView}
136
- className="p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
137
- >
138
- <ArrowsPointingOut className="w-3.5 h-3.5" />
139
- </button>
140
- </Tooltip>
181
+ <button
182
+ type="button"
183
+ title="Fit View (Center)"
184
+ onClick={onFitView}
185
+ className="p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors"
186
+ >
187
+ <ArrowsPointingOut className="w-3.5 h-3.5" />
188
+ </button>
141
189
  </div>
142
190
 
143
191
  <div className="border-l border-ui-border-base pl-2.5">
@@ -1,6 +1,6 @@
1
1
  import React, { memo } from "react"
2
2
  import { Handle, Position } from "@xyflow/react"
3
- import { Text, Tooltip } from "@medusajs/ui"
3
+ import { Text } from "@medusajs/ui"
4
4
  import { Plus, PencilSquare, Folder, ArrowUturnLeft } from "@medusajs/icons"
5
5
  import { TierBars } from "./tier-bars"
6
6
  import { CategoryNodeData } from "./types"
@@ -15,7 +15,9 @@ export const CategoryNode = memo(({ id, data, selected }: CategoryNodeProps) =>
15
15
  const {
16
16
  category,
17
17
  tier,
18
+ siblingRank,
18
19
  superscriptRank,
20
+ branchColor,
19
21
  salesChannels,
20
22
  isCollapsed,
21
23
  hiddenDescendantCount,
@@ -25,6 +27,8 @@ export const CategoryNode = memo(({ id, data, selected }: CategoryNodeProps) =>
25
27
  onAddChild,
26
28
  onEdit,
27
29
  onDetach,
30
+ onMoveSiblingUp,
31
+ onMoveSiblingDown,
28
32
  } = data
29
33
 
30
34
  const isTB = direction === "TB"
@@ -42,6 +46,13 @@ export const CategoryNode = memo(({ id, data, selected }: CategoryNodeProps) =>
42
46
  ? "ring-2 ring-ui-border-interactive border-transparent shadow-lg"
43
47
  : "border-ui-border-base shadow-sm hover:shadow-md hover:border-ui-border-strong"
44
48
  } ${isDimmed ? "opacity-35 grayscale" : "opacity-100"}`}
49
+ style={
50
+ selected && branchColor
51
+ ? {
52
+ boxShadow: `0 0 0 2px ${branchColor}, 0 10px 15px -3px rgba(0, 0, 0, 0.1)`,
53
+ }
54
+ : undefined
55
+ }
45
56
  >
46
57
  <Handle
47
58
  type="target"
@@ -49,7 +60,7 @@ export const CategoryNode = memo(({ id, data, selected }: CategoryNodeProps) =>
49
60
  className="!w-2.5 !h-2.5 !bg-ui-border-interactive !border-2 !border-white dark:!border-neutral-900 transition-transform group-hover:scale-125"
50
61
  />
51
62
 
52
- <TierBars tier={tier} />
63
+ <TierBars tier={tier} color={branchColor} />
53
64
 
54
65
  <div className="flex flex-1 items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
55
66
  <div className="relative w-11 h-11 rounded-lg overflow-hidden flex-shrink-0 border border-ui-border-base bg-ui-bg-subtle flex items-center justify-center shadow-2xs">
@@ -74,20 +85,48 @@ export const CategoryNode = memo(({ id, data, selected }: CategoryNodeProps) =>
74
85
  </div>
75
86
 
76
87
  <div className="flex flex-col min-w-0 flex-1 justify-center">
77
- <div className="flex items-center gap-1.5 min-w-0">
88
+ <div className="flex items-center gap-1 min-w-0">
78
89
  <span
79
- className="text-xs font-semibold text-ui-fg-base truncate leading-snug"
90
+ className="text-xs font-semibold text-ui-fg-base truncate leading-snug flex-1 min-w-0"
80
91
  title={category.name}
81
92
  >
82
93
  {category.name}
83
94
  </span>
84
95
  {superscriptRank && (
85
- <span
86
- className="text-[11px] font-bold font-mono text-ui-fg-subtle px-1 py-0.5 rounded bg-ui-bg-subtle/80 flex-shrink-0"
87
- title={`Rank #${data.siblingRank} among siblings`}
88
- >
89
- {superscriptRank}
90
- </span>
96
+ <div className="flex items-center gap-0.5 flex-shrink-0">
97
+ <span
98
+ className="text-[11px] font-bold font-mono text-ui-fg-subtle px-1 py-0.5 rounded bg-ui-bg-subtle/80"
99
+ title={`Rank #${siblingRank} among siblings (Storefront sort order)`}
100
+ >
101
+ {superscriptRank}
102
+ </span>
103
+ {onMoveSiblingUp && (
104
+ <button
105
+ type="button"
106
+ title="Move Up in Storefront Order (↑)"
107
+ onClick={(e) => {
108
+ e.stopPropagation()
109
+ onMoveSiblingUp(id)
110
+ }}
111
+ className="w-3.5 h-3.5 flex items-center justify-center text-[10px] font-bold text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors"
112
+ >
113
+ ↑
114
+ </button>
115
+ )}
116
+ {onMoveSiblingDown && (
117
+ <button
118
+ type="button"
119
+ title="Move Down in Storefront Order (↓)"
120
+ onClick={(e) => {
121
+ e.stopPropagation()
122
+ onMoveSiblingDown(id)
123
+ }}
124
+ className="w-3.5 h-3.5 flex items-center justify-center text-[10px] font-bold text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors"
125
+ >
126
+ ↓
127
+ </button>
128
+ )}
129
+ </div>
91
130
  )}
92
131
  </div>
93
132
 
@@ -134,45 +173,42 @@ export const CategoryNode = memo(({ id, data, selected }: CategoryNodeProps) =>
134
173
  </div>
135
174
 
136
175
  <div className="absolute -top-3 right-2 hidden group-hover:flex items-center gap-1 px-1.5 py-0.5 bg-ui-bg-base/95 backdrop-blur-sm border border-ui-border-base rounded-full shadow-md z-20">
137
- <Tooltip content="Add Subcategory">
138
- <button
139
- type="button"
140
- onClick={(e) => {
141
- e.stopPropagation()
142
- onAddChild(category)
143
- }}
144
- className="p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors"
145
- >
146
- <Plus className="w-3.5 h-3.5" />
147
- </button>
148
- </Tooltip>
176
+ <button
177
+ type="button"
178
+ title="Add Subcategory"
179
+ onClick={(e) => {
180
+ e.stopPropagation()
181
+ onAddChild(category)
182
+ }}
183
+ className="p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors"
184
+ >
185
+ <Plus className="w-3.5 h-3.5" />
186
+ </button>
187
+
188
+ <button
189
+ type="button"
190
+ title="Edit Category"
191
+ onClick={(e) => {
192
+ e.stopPropagation()
193
+ onEdit(category)
194
+ }}
195
+ className="p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors"
196
+ >
197
+ <PencilSquare className="w-3.5 h-3.5" />
198
+ </button>
149
199
 
150
- <Tooltip content="Edit Category">
200
+ {category.parent_category_id && (
151
201
  <button
152
202
  type="button"
203
+ title="Promote to Root (Detach)"
153
204
  onClick={(e) => {
154
205
  e.stopPropagation()
155
- onEdit(category)
206
+ onDetach(id)
156
207
  }}
157
- className="p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors"
208
+ className="p-1 text-ui-fg-subtle hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/50 rounded-full transition-colors"
158
209
  >
159
- <PencilSquare className="w-3.5 h-3.5" />
210
+ <ArrowUturnLeft className="w-3.5 h-3.5" />
160
211
  </button>
161
- </Tooltip>
162
-
163
- {category.parent_category_id && (
164
- <Tooltip content="Promote to Root (Detach)">
165
- <button
166
- type="button"
167
- onClick={(e) => {
168
- e.stopPropagation()
169
- onDetach(id)
170
- }}
171
- className="p-1 text-ui-fg-subtle hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/50 rounded-full transition-colors"
172
- >
173
- <ArrowUturnLeft className="w-3.5 h-3.5" />
174
- </button>
175
- </Tooltip>
176
212
  )}
177
213
  </div>
178
214
 
@@ -11,9 +11,10 @@ import {
11
11
  Node,
12
12
  useReactFlow,
13
13
  ReactFlowProvider,
14
+ SelectionMode,
14
15
  } from "@xyflow/react"
15
16
  import "@xyflow/react/dist/style.css"
16
- import { toast } from "@medusajs/ui"
17
+ import { toast, TooltipProvider } from "@medusajs/ui"
17
18
 
18
19
  import { CategoryNode } from "./category-node"
19
20
  import { CustomSmoothStepEdge } from "./custom-edge"
@@ -24,11 +25,13 @@ import { CategoryTreeNode, SalesChannelInfo } from "./types"
24
25
  import {
25
26
  computeTiers,
26
27
  computeSiblingRanks,
28
+ computeBranchColors,
29
+ moveSiblingRank,
27
30
  wouldCreateCycle,
28
31
  getVisibleTree,
29
32
  computeMoves,
30
33
  } from "./lib/tree-state"
31
- import { computeDagreLayout } from "./lib/layout-engine"
34
+ import { computeTreeLayout } from "./lib/layout-engine"
32
35
  import { sdk } from "../../lib/sdk"
33
36
 
34
37
  const nodeTypes = {
@@ -52,6 +55,8 @@ const CanvasInner: React.FC = () => {
52
55
  const [selectedStorefront, setSelectedStorefront] = useState<string>("all")
53
56
  const [searchQuery, setSearchQuery] = useState("")
54
57
  const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set())
58
+ const [isBoxSelect, setIsBoxSelect] = useState(false)
59
+ const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([])
55
60
 
56
61
  const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
57
62
  const [createParentCategory, setCreateParentCategory] = useState<CategoryTreeNode | null>(null)
@@ -95,6 +100,7 @@ const CanvasInner: React.FC = () => {
95
100
 
96
101
  const tiers = computeTiers(categories)
97
102
  const siblingRanks = computeSiblingRanks(categories)
103
+ const branchColors = computeBranchColors(categories)
98
104
 
99
105
  const enriched: CategoryTreeNode[] = categories.map((cat) => {
100
106
  const kids = map.get(cat.id) || []
@@ -105,6 +111,7 @@ const CanvasInner: React.FC = () => {
105
111
  sibling_rank: rankInfo?.rankNum || 1,
106
112
  children_count: kids.length,
107
113
  has_children: kids.length > 0,
114
+ branch_color: branchColors.get(cat.id),
108
115
  }
109
116
  })
110
117
 
@@ -123,6 +130,32 @@ const CanvasInner: React.FC = () => {
123
130
  })
124
131
  }, [])
125
132
 
133
+ const handleExpandSelected = useCallback(() => {
134
+ if (selectedNodeIds.length === 0) return
135
+ setCollapsedIds((prev) => {
136
+ const next = new Set(prev)
137
+ selectedNodeIds.forEach((id) => next.delete(id))
138
+ return next
139
+ })
140
+ }, [selectedNodeIds])
141
+
142
+ const handleCollapseSelected = useCallback(() => {
143
+ if (selectedNodeIds.length === 0) return
144
+ setCollapsedIds((prev) => {
145
+ const next = new Set(prev)
146
+ selectedNodeIds.forEach((id) => next.add(id))
147
+ return next
148
+ })
149
+ }, [selectedNodeIds])
150
+
151
+ const handleMoveSiblingUp = useCallback((id: string) => {
152
+ setCategories((prev) => moveSiblingRank(prev, id, "up"))
153
+ }, [])
154
+
155
+ const handleMoveSiblingDown = useCallback((id: string) => {
156
+ setCategories((prev) => moveSiblingRank(prev, id, "down"))
157
+ }, [])
158
+
126
159
  const handleAddChild = useCallback((parent: CategoryTreeNode) => {
127
160
  setCreateParentCategory(parent)
128
161
  setCreateDrawerOpen(true)
@@ -171,6 +204,7 @@ const CanvasInner: React.FC = () => {
171
204
  tier: cat.tier,
172
205
  siblingRank: rankInfo?.rankNum || 1,
173
206
  superscriptRank: rankInfo?.superscript || "¹",
207
+ branchColor: cat.branch_color,
174
208
  salesChannels,
175
209
  isCollapsed: collapsedIds.has(cat.id),
176
210
  hiddenDescendantCount: cat.children_count,
@@ -180,6 +214,8 @@ const CanvasInner: React.FC = () => {
180
214
  onAddChild: handleAddChild,
181
215
  onEdit: handleEdit,
182
216
  onDetach: handleDetach,
217
+ onMoveSiblingUp: handleMoveSiblingUp,
218
+ onMoveSiblingDown: handleMoveSiblingDown,
183
219
  },
184
220
  }
185
221
  })
@@ -192,6 +228,10 @@ const CanvasInner: React.FC = () => {
192
228
  source: cat.parent_category_id,
193
229
  target: cat.id,
194
230
  type: "smoothstep",
231
+ style: {
232
+ stroke: cat.branch_color || "#64748b",
233
+ strokeWidth: 2,
234
+ },
195
235
  })
196
236
  }
197
237
  }
@@ -203,7 +243,7 @@ const CanvasInner: React.FC = () => {
203
243
  childrenMap
204
244
  )
205
245
 
206
- const { nodes: layoutedNodes, edges: layoutedEdges } = computeDagreLayout(
246
+ const { nodes: layoutedNodes, edges: layoutedEdges } = computeTreeLayout(
207
247
  visibleNodes,
208
248
  visibleEdges,
209
249
  { direction }
@@ -223,6 +263,8 @@ const CanvasInner: React.FC = () => {
223
263
  handleAddChild,
224
264
  handleEdit,
225
265
  handleDetach,
266
+ handleMoveSiblingUp,
267
+ handleMoveSiblingDown,
226
268
  setNodes,
227
269
  setEdges,
228
270
  ])
@@ -311,6 +353,11 @@ const CanvasInner: React.FC = () => {
311
353
  onExpandAll={handleExpandAll}
312
354
  onCollapseAll={handleCollapseAll}
313
355
  onFocusRoot={handleFocusRoot}
356
+ selectedCount={selectedNodeIds.length}
357
+ onExpandSelected={handleExpandSelected}
358
+ onCollapseSelected={handleCollapseSelected}
359
+ isBoxSelect={isBoxSelect}
360
+ onToggleBoxSelect={() => setIsBoxSelect((prev) => !prev)}
314
361
  onZoomIn={() => zoomIn({ duration: 300 })}
315
362
  onZoomOut={() => zoomOut({ duration: 300 })}
316
363
  onFitView={() => fitView({ padding: 0.25, duration: 400 })}
@@ -332,6 +379,13 @@ const CanvasInner: React.FC = () => {
332
379
  onEdgesChange={onEdgesChange}
333
380
  onConnect={onConnect}
334
381
  onEdgesDelete={onEdgesDelete}
382
+ onSelectionChange={useCallback(({ nodes }: { nodes: Node[] }) => {
383
+ setSelectedNodeIds(nodes.map((n) => n.id))
384
+ }, [])}
385
+ selectionMode={SelectionMode.Partial}
386
+ selectionOnDrag={isBoxSelect}
387
+ panOnDrag={isBoxSelect ? [1, 2] : true}
388
+ elementsSelectable={true}
335
389
  nodeTypes={nodeTypes}
336
390
  edgeTypes={edgeTypes}
337
391
  minZoom={0.2}
@@ -390,8 +444,10 @@ const CanvasInner: React.FC = () => {
390
444
 
391
445
  export const CategoryTreeCanvas: React.FC = () => {
392
446
  return (
393
- <ReactFlowProvider>
394
- <CanvasInner />
395
- </ReactFlowProvider>
447
+ <TooltipProvider>
448
+ <ReactFlowProvider>
449
+ <CanvasInner />
450
+ </ReactFlowProvider>
451
+ </TooltipProvider>
396
452
  )
397
453
  }