@growthub/cli 0.12.2 โ 0.13.1
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/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +50 -25
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +38 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +556 -248
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +242 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +52 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +1203 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +163 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +190 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +64 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolDraftPanel.jsx +376 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/dm-shared.jsx +8 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +6 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +2897 -934
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +10 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/views/[viewId]/page.jsx +206 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +906 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/page.jsx +12 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +493 -28
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +1363 -8
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/nav-workflows.js +54 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +322 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +734 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +73 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-sidecar-routing.js +24 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +13 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +96 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +122 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +1 -0
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* โ [๐ Home] [๐ฌ Chat] [โถ+ Ask helper] โ Tab row
|
|
13
13
|
* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
|
14
14
|
* โ HOME tab body: CHAT tab body: โ
|
|
15
|
-
* โ
|
|
15
|
+
* โ Builder Latest โ
|
|
16
16
|
* โ Data Model ๐ฌ Best Skills โ
|
|
17
17
|
* โ Management ๐ฌ Casual greet โ
|
|
18
18
|
* โ Workspace Settings (โฆ more threads) โ
|
|
@@ -26,26 +26,47 @@
|
|
|
26
26
|
*
|
|
27
27
|
* Surface-specific slots (`dashboardsSlot`, `dataModelSlot`,
|
|
28
28
|
* `managementSlot`, `settingsSlot`) let the page inject its own
|
|
29
|
-
*
|
|
29
|
+
* Builder / Management / Workspace Settings behaviour
|
|
30
30
|
* while keeping the visual treatment identical across every page.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
33
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
34
|
+
import { createPortal } from "react-dom";
|
|
34
35
|
import Link from "next/link";
|
|
35
|
-
import { usePathname, useRouter } from "next/navigation";
|
|
36
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
36
37
|
import {
|
|
37
38
|
Archive,
|
|
38
39
|
ChevronDown,
|
|
40
|
+
ChevronRight,
|
|
41
|
+
Folder,
|
|
42
|
+
FolderPlus,
|
|
43
|
+
GitBranch,
|
|
39
44
|
Home,
|
|
45
|
+
LayoutDashboard,
|
|
40
46
|
MessageCircle,
|
|
41
47
|
MessageCirclePlus,
|
|
42
48
|
MoreHorizontal,
|
|
49
|
+
MoreVertical,
|
|
43
50
|
PanelLeftClose,
|
|
44
51
|
Pencil,
|
|
52
|
+
Plus,
|
|
45
53
|
Search,
|
|
54
|
+
Settings,
|
|
55
|
+
SlidersHorizontal,
|
|
56
|
+
Table as TableIcon,
|
|
46
57
|
Trash2,
|
|
47
58
|
X,
|
|
48
59
|
} from "lucide-react";
|
|
60
|
+
import {
|
|
61
|
+
NAV_FOLDERS_OBJECT_ID,
|
|
62
|
+
NAV_FOLDER_NAME_MAX,
|
|
63
|
+
NAV_ITEM_LABEL_MAX,
|
|
64
|
+
ensureNavFoldersObject,
|
|
65
|
+
nextNavFolderId,
|
|
66
|
+
nextNavItemId,
|
|
67
|
+
} from "@/lib/workspace-helper-apply";
|
|
68
|
+
import { listAvailableWorkflows } from "@/lib/nav-workflows";
|
|
69
|
+
import { ICON_PICKER_SET, LucideIcon } from "./data-model/components/dm-shared.jsx";
|
|
49
70
|
|
|
50
71
|
function textColorForAccent(accent) {
|
|
51
72
|
const hex = String(accent || "").replace("#", "");
|
|
@@ -93,6 +114,42 @@ function deriveThreadTitle(row) {
|
|
|
93
114
|
return INTENT_LABEL[row?.intent] || "Helper conversation";
|
|
94
115
|
}
|
|
95
116
|
|
|
117
|
+
function getNavFolderRows(workspaceConfig) {
|
|
118
|
+
const obj = (workspaceConfig?.dataModel?.objects || []).find((o) => o?.id === NAV_FOLDERS_OBJECT_ID);
|
|
119
|
+
const rows = Array.isArray(obj?.rows) ? obj.rows : [];
|
|
120
|
+
return rows
|
|
121
|
+
.slice()
|
|
122
|
+
.sort((a, b) => {
|
|
123
|
+
const oa = Number.isFinite(a?.order) ? a.order : 0;
|
|
124
|
+
const ob = Number.isFinite(b?.order) ? b.order : 0;
|
|
125
|
+
return oa - ob;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function listAvailableDashboards(workspaceConfig) {
|
|
130
|
+
const dashboards = Array.isArray(workspaceConfig?.dashboards) ? workspaceConfig.dashboards : [];
|
|
131
|
+
return dashboards
|
|
132
|
+
.filter((d) => d && d.id && d.name)
|
|
133
|
+
.map((d) => ({ id: d.id, name: d.name }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listAvailableObjectsForViews(workspaceConfig) {
|
|
137
|
+
// Mirror the user-facing object set surfaced in the data-model UI:
|
|
138
|
+
// exclude the helper-owned hidden custom objects (helper sandbox,
|
|
139
|
+
// nav-folders). Everything else โ including helper-threads and the
|
|
140
|
+
// six core governed business objects โ is fair game for a folder
|
|
141
|
+
// view item.
|
|
142
|
+
const HIDDEN = new Set(["workspace-helper-sandbox", NAV_FOLDERS_OBJECT_ID]);
|
|
143
|
+
const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
|
|
144
|
+
return objects
|
|
145
|
+
.filter((o) => o && o.id && o.label && !HIDDEN.has(o.id))
|
|
146
|
+
.map((o) => ({
|
|
147
|
+
id: o.id,
|
|
148
|
+
label: o.label,
|
|
149
|
+
columns: Array.isArray(o.columns) ? o.columns : [],
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
96
153
|
function getHelperThreadRows(workspaceConfig) {
|
|
97
154
|
const objects = workspaceConfig?.dataModel?.objects || [];
|
|
98
155
|
const ht = objects.find((o) => o?.id === "helper-threads");
|
|
@@ -103,6 +160,1248 @@ function getHelperThreadRows(workspaceConfig) {
|
|
|
103
160
|
.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
|
104
161
|
}
|
|
105
162
|
|
|
163
|
+
/** Preset swatches for folder / nav-item icon badges (Twenty-style). */
|
|
164
|
+
const NAV_COLOR_SWATCHES = [
|
|
165
|
+
{ color: "#f97316", iconBg: "#fff7ed", label: "Orange" },
|
|
166
|
+
{ color: "#3b82f6", iconBg: "#eff6ff", label: "Blue" },
|
|
167
|
+
{ color: "#14b8a6", iconBg: "#f0fdfa", label: "Teal" },
|
|
168
|
+
{ color: "#8b5cf6", iconBg: "#f5f3ff", label: "Violet" },
|
|
169
|
+
{ color: "#ec4899", iconBg: "#fdf2f8", label: "Pink" },
|
|
170
|
+
{ color: "#64748b", iconBg: "#f8fafc", label: "Slate" },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const NAV_FOLDER_STYLE_DEFAULT = { icon: "Folder", color: "#f97316", iconBg: "#fff7ed" };
|
|
174
|
+
const NAV_ITEM_STYLE_DEFAULT = {
|
|
175
|
+
dashboard: { icon: "LayoutDashboard", color: "#3b82f6", iconBg: "#eff6ff" },
|
|
176
|
+
view: { icon: "Table", color: "#14b8a6", iconBg: "#f0fdfa" },
|
|
177
|
+
workflow: { icon: "GitBranch", color: "#8b5cf6", iconBg: "#f5f3ff" },
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Default visible rows before scroll โ keeps the rail from growing unbounded. */
|
|
181
|
+
const NAV_MAX_VISIBLE_FOLDERS = 10;
|
|
182
|
+
const NAV_MAX_VISIBLE_ITEMS = 10;
|
|
183
|
+
|
|
184
|
+
function navFolderStyle(folder) {
|
|
185
|
+
return {
|
|
186
|
+
icon: folder?.icon || NAV_FOLDER_STYLE_DEFAULT.icon,
|
|
187
|
+
color: folder?.color || NAV_FOLDER_STYLE_DEFAULT.color,
|
|
188
|
+
iconBg: folder?.iconBg || NAV_FOLDER_STYLE_DEFAULT.iconBg,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function navItemStyle(item) {
|
|
193
|
+
const base = NAV_ITEM_STYLE_DEFAULT[item?.type] || NAV_ITEM_STYLE_DEFAULT.view;
|
|
194
|
+
return {
|
|
195
|
+
icon: item?.icon || base.icon,
|
|
196
|
+
color: item?.color || base.color,
|
|
197
|
+
iconBg: item?.iconBg || base.iconBg,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function filterNavFolderRows(rows, query, typeFilter) {
|
|
202
|
+
const q = query.trim().toLowerCase();
|
|
203
|
+
const typeActive = typeFilter !== "all";
|
|
204
|
+
if (!q && !typeActive) {
|
|
205
|
+
return rows.map((folder) => ({
|
|
206
|
+
folder,
|
|
207
|
+
items: Array.isArray(folder.items) ? folder.items : [],
|
|
208
|
+
expand: false,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
return rows.flatMap((folder) => {
|
|
212
|
+
const items = Array.isArray(folder.items) ? folder.items : [];
|
|
213
|
+
const folderNameMatch = !q || String(folder.name || "").toLowerCase().includes(q);
|
|
214
|
+
const filteredItems = items.filter((item) => {
|
|
215
|
+
if (typeActive && item.type !== typeFilter) return false;
|
|
216
|
+
if (!q || folderNameMatch) return true;
|
|
217
|
+
const label = String(item.label || item.refId || item.objectId || "").toLowerCase();
|
|
218
|
+
return label.includes(q);
|
|
219
|
+
});
|
|
220
|
+
if (typeActive && !filteredItems.length && !folderNameMatch) return [];
|
|
221
|
+
if (q && !folderNameMatch && !filteredItems.length) return [];
|
|
222
|
+
return [{
|
|
223
|
+
folder,
|
|
224
|
+
items: typeActive || q ? filteredItems : items,
|
|
225
|
+
expand: Boolean(q || typeActive),
|
|
226
|
+
}];
|
|
227
|
+
}).map((entry, index, list) => {
|
|
228
|
+
if (!entry.expand) return entry;
|
|
229
|
+
const firstExpandIdx = list.findIndex((e) => e.expand);
|
|
230
|
+
if (index !== firstExpandIdx) return { ...entry, expand: false };
|
|
231
|
+
return entry;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hexToTintBg(hex, alpha = 0.1) {
|
|
236
|
+
const h = String(hex || "").replace("#", "");
|
|
237
|
+
if (!/^[0-9a-f]{6}$/i.test(h)) return "#f5f5f5";
|
|
238
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
239
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
240
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
241
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function navCustomizeDirty(snapshot, draft) {
|
|
245
|
+
return (
|
|
246
|
+
snapshot.name !== draft.name
|
|
247
|
+
|| snapshot.icon !== draft.icon
|
|
248
|
+
|| snapshot.color !== draft.color
|
|
249
|
+
|| snapshot.iconBg !== draft.iconBg
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function NavIconBadge({ icon, color, iconBg }) {
|
|
254
|
+
return (
|
|
255
|
+
<span
|
|
256
|
+
className="workspace-rail-nav-icon-badge"
|
|
257
|
+
style={{ background: iconBg, color }}
|
|
258
|
+
>
|
|
259
|
+
<LucideIcon name={icon} size={14} />
|
|
260
|
+
</span>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function NavColorPicker({ color, iconBg, onPick }) {
|
|
265
|
+
return (
|
|
266
|
+
<div className="workspace-rail-nav-color-picker">
|
|
267
|
+
<div className="workspace-rail-nav-color-swatches" role="listbox" aria-label="Icon color">
|
|
268
|
+
{NAV_COLOR_SWATCHES.map((swatch) => (
|
|
269
|
+
<button
|
|
270
|
+
key={swatch.color}
|
|
271
|
+
type="button"
|
|
272
|
+
role="option"
|
|
273
|
+
aria-selected={color === swatch.color}
|
|
274
|
+
className={"workspace-rail-nav-color-swatch" + (color === swatch.color ? " active" : "")}
|
|
275
|
+
title={swatch.label}
|
|
276
|
+
style={{ background: swatch.iconBg, color: swatch.color }}
|
|
277
|
+
onClick={() => onPick(swatch)}
|
|
278
|
+
>
|
|
279
|
+
<span className="workspace-rail-nav-color-swatch-dot" style={{ background: swatch.color }} />
|
|
280
|
+
</button>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
<label className="workspace-rail-nav-color-custom">
|
|
284
|
+
<span>Custom</span>
|
|
285
|
+
<input
|
|
286
|
+
type="color"
|
|
287
|
+
value={color}
|
|
288
|
+
onChange={(e) => {
|
|
289
|
+
const hex = e.target.value;
|
|
290
|
+
onPick({ color: hex, iconBg: hexToTintBg(hex) });
|
|
291
|
+
}}
|
|
292
|
+
/>
|
|
293
|
+
</label>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function NavCustomizePanel({
|
|
299
|
+
nameLabel,
|
|
300
|
+
nameMax,
|
|
301
|
+
draft,
|
|
302
|
+
setDraft,
|
|
303
|
+
discardWarn,
|
|
304
|
+
onSave,
|
|
305
|
+
onCancel,
|
|
306
|
+
}) {
|
|
307
|
+
return (
|
|
308
|
+
<div className="workspace-rail-nav-customize" onClick={(e) => e.stopPropagation()}>
|
|
309
|
+
{discardWarn ? (
|
|
310
|
+
<p className="workspace-rail-nav-discard-warn" role="status">
|
|
311
|
+
Unsaved changes. Click outside again to discard, or save below.
|
|
312
|
+
</p>
|
|
313
|
+
) : null}
|
|
314
|
+
<label className="workspace-rail-nav-customize-field">
|
|
315
|
+
<span>{nameLabel}</span>
|
|
316
|
+
<input
|
|
317
|
+
type="text"
|
|
318
|
+
value={draft.name}
|
|
319
|
+
maxLength={nameMax}
|
|
320
|
+
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
|
321
|
+
onKeyDown={(e) => {
|
|
322
|
+
if (e.key === "Enter") { e.preventDefault(); onSave(); }
|
|
323
|
+
if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
</label>
|
|
327
|
+
<span className="workspace-rail-nav-customize-label">Icon</span>
|
|
328
|
+
<div className="dm-icon-picker workspace-rail-nav-icon-picker">
|
|
329
|
+
{ICON_PICKER_SET.map((name) => (
|
|
330
|
+
<button
|
|
331
|
+
key={name}
|
|
332
|
+
type="button"
|
|
333
|
+
className={"dm-icon-picker-btn" + (draft.icon === name ? " active" : "")}
|
|
334
|
+
title={name}
|
|
335
|
+
onClick={() => setDraft((d) => ({ ...d, icon: name }))}
|
|
336
|
+
>
|
|
337
|
+
<LucideIcon name={name} size={16} />
|
|
338
|
+
</button>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
<span className="workspace-rail-nav-customize-label">Color</span>
|
|
342
|
+
<NavColorPicker
|
|
343
|
+
color={draft.color}
|
|
344
|
+
iconBg={draft.iconBg}
|
|
345
|
+
onPick={(swatch) => setDraft((d) => ({
|
|
346
|
+
...d,
|
|
347
|
+
color: swatch.color,
|
|
348
|
+
iconBg: swatch.iconBg || d.iconBg,
|
|
349
|
+
}))}
|
|
350
|
+
/>
|
|
351
|
+
<div className="workspace-rail-nav-customize-actions">
|
|
352
|
+
<button type="button" className="workspace-rail-nav-btn-ghost" onClick={onCancel}>
|
|
353
|
+
Cancel
|
|
354
|
+
</button>
|
|
355
|
+
<button type="button" className="workspace-rail-nav-btn-primary" onClick={onSave}>
|
|
356
|
+
Save
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function NavCustomizeOverlay({ children, panelRef }) {
|
|
364
|
+
if (typeof document === "undefined") return null;
|
|
365
|
+
return createPortal(
|
|
366
|
+
<div
|
|
367
|
+
className="workspace-rail-thread-menu workspace-rail-nav-menu workspace-rail-nav-menu-stack is-customize"
|
|
368
|
+
role="menu"
|
|
369
|
+
ref={panelRef}
|
|
370
|
+
>
|
|
371
|
+
{children}
|
|
372
|
+
</div>,
|
|
373
|
+
document.body,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function NavFolderPickerOverlay({ children, onClose }) {
|
|
378
|
+
if (typeof document === "undefined") return null;
|
|
379
|
+
return createPortal(
|
|
380
|
+
<div className="workspace-rail-folder-picker-backdrop" onClick={onClose}>
|
|
381
|
+
{children}
|
|
382
|
+
</div>,
|
|
383
|
+
document.body,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Custom Folders Navigation Module โ mirrors Twenty CRM's drag-to-reorder,
|
|
389
|
+
* user-named folders that group Dashboard links and lightweight Views of
|
|
390
|
+
* governed Data Model objects. Persists in the well-known nav-folders
|
|
391
|
+
* Data Model object via the same PATCH allowlist used by the rest of the
|
|
392
|
+
* rail; if the object is absent the section silently shows zero folders.
|
|
393
|
+
*/
|
|
394
|
+
function NavFoldersSection({
|
|
395
|
+
workspaceConfig,
|
|
396
|
+
pathname,
|
|
397
|
+
onPatchNavFolders,
|
|
398
|
+
}) {
|
|
399
|
+
const router = useRouter();
|
|
400
|
+
const searchParams = useSearchParams();
|
|
401
|
+
const [creating, setCreating] = useState(false);
|
|
402
|
+
const [createDraft, setCreateDraft] = useState("");
|
|
403
|
+
const [createDiscardWarn, setCreateDiscardWarn] = useState(false);
|
|
404
|
+
const [openMenuId, setOpenMenuId] = useState(null); // folderId or `${folderId}::${itemId}`
|
|
405
|
+
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
406
|
+
const [customizeTarget, setCustomizeTarget] = useState(null);
|
|
407
|
+
const [discardWarn, setDiscardWarn] = useState(false);
|
|
408
|
+
const [addPickerFor, setAddPickerFor] = useState(null); // { folderId, kind: "dashboard"|"view"|"workflow" }
|
|
409
|
+
const [filterQuery, setFilterQuery] = useState("");
|
|
410
|
+
const [filterType, setFilterType] = useState("all"); // all | dashboard | view | workflow
|
|
411
|
+
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
|
412
|
+
const [sectionCollapsed, setSectionCollapsed] = useState(true);
|
|
413
|
+
|
|
414
|
+
const rows = useMemo(() => getNavFolderRows(workspaceConfig), [workspaceConfig]);
|
|
415
|
+
const dashboards = useMemo(() => listAvailableDashboards(workspaceConfig), [workspaceConfig]);
|
|
416
|
+
const viewableObjects = useMemo(() => listAvailableObjectsForViews(workspaceConfig), [workspaceConfig]);
|
|
417
|
+
const workflows = useMemo(() => listAvailableWorkflows(workspaceConfig), [workspaceConfig]);
|
|
418
|
+
const filteredEntries = useMemo(
|
|
419
|
+
() => filterNavFolderRows(rows, filterQuery, filterType),
|
|
420
|
+
[rows, filterQuery, filterType],
|
|
421
|
+
);
|
|
422
|
+
const filterActive = Boolean(filterQuery.trim()) || filterType !== "all";
|
|
423
|
+
const menuWrapRef = useRef(null);
|
|
424
|
+
const filterMenuRef = useRef(null);
|
|
425
|
+
const customizePanelRef = useRef(null);
|
|
426
|
+
const createInputRef = useRef(null);
|
|
427
|
+
const dragState = useRef(null);
|
|
428
|
+
|
|
429
|
+
const closeCustomize = useCallback(() => {
|
|
430
|
+
setCustomizeTarget(null);
|
|
431
|
+
setDiscardWarn(false);
|
|
432
|
+
setOpenMenuId(null);
|
|
433
|
+
setMenuAnchor(null);
|
|
434
|
+
}, []);
|
|
435
|
+
|
|
436
|
+
const openAnchoredMenu = useCallback((menuId, trigger) => {
|
|
437
|
+
const rect = trigger.getBoundingClientRect();
|
|
438
|
+
const menuWidth = 188;
|
|
439
|
+
const top = Math.min(rect.bottom + 4, window.innerHeight - 86);
|
|
440
|
+
const left = Math.max(10, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8));
|
|
441
|
+
setMenuAnchor({ top, left });
|
|
442
|
+
setOpenMenuId(menuId);
|
|
443
|
+
}, []);
|
|
444
|
+
|
|
445
|
+
const requestDiscardCustomize = useCallback(() => {
|
|
446
|
+
if (!customizeTarget) return;
|
|
447
|
+
if (!navCustomizeDirty(customizeTarget.snapshot, customizeTarget.draft)) {
|
|
448
|
+
closeCustomize();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!discardWarn) {
|
|
452
|
+
setDiscardWarn(true);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
closeCustomize();
|
|
456
|
+
}, [customizeTarget, discardWarn, closeCustomize]);
|
|
457
|
+
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (!openMenuId && !customizeTarget) return undefined;
|
|
460
|
+
const onPointerDown = (e) => {
|
|
461
|
+
if (menuWrapRef.current?.contains(e.target)) return;
|
|
462
|
+
if (customizePanelRef.current?.contains(e.target)) return;
|
|
463
|
+
if (customizeTarget) {
|
|
464
|
+
requestDiscardCustomize();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
setOpenMenuId(null);
|
|
468
|
+
setMenuAnchor(null);
|
|
469
|
+
};
|
|
470
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
471
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
472
|
+
}, [openMenuId, customizeTarget, requestDiscardCustomize]);
|
|
473
|
+
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
if (!filterMenuOpen) return undefined;
|
|
476
|
+
const onPointerDown = (e) => {
|
|
477
|
+
if (filterMenuRef.current?.contains(e.target)) return;
|
|
478
|
+
setFilterMenuOpen(false);
|
|
479
|
+
};
|
|
480
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
481
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
482
|
+
}, [filterMenuOpen]);
|
|
483
|
+
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (!creating) {
|
|
486
|
+
setCreateDiscardWarn(false);
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
const onPointerDown = (e) => {
|
|
490
|
+
if (createInputRef.current?.contains(e.target)) return;
|
|
491
|
+
const trimmed = createDraft.trim();
|
|
492
|
+
if (!trimmed) {
|
|
493
|
+
setCreating(false);
|
|
494
|
+
setCreateDraft("");
|
|
495
|
+
setCreateDiscardWarn(false);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (!createDiscardWarn) {
|
|
499
|
+
setCreateDiscardWarn(true);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
setCreating(false);
|
|
503
|
+
setCreateDraft("");
|
|
504
|
+
setCreateDiscardWarn(false);
|
|
505
|
+
};
|
|
506
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
507
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
508
|
+
}, [creating, createDraft, createDiscardWarn]);
|
|
509
|
+
|
|
510
|
+
useEffect(() => {
|
|
511
|
+
if (!addPickerFor) return undefined;
|
|
512
|
+
const onKey = (e) => { if (e.key === "Escape") setAddPickerFor(null); };
|
|
513
|
+
document.addEventListener("keydown", onKey);
|
|
514
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
515
|
+
}, [addPickerFor]);
|
|
516
|
+
|
|
517
|
+
const writeRows = useCallback(async (nextRows) => {
|
|
518
|
+
await onPatchNavFolders(nextRows.map((row, i) => ({ ...row, order: i })));
|
|
519
|
+
}, [onPatchNavFolders]);
|
|
520
|
+
|
|
521
|
+
const createFolder = useCallback(async () => {
|
|
522
|
+
const trimmed = createDraft.trim();
|
|
523
|
+
if (!trimmed) {
|
|
524
|
+
setCreating(false);
|
|
525
|
+
setCreateDraft("");
|
|
526
|
+
setCreateDiscardWarn(false);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const name = trimmed.length > NAV_FOLDER_NAME_MAX ? trimmed.slice(0, NAV_FOLDER_NAME_MAX) : trimmed;
|
|
530
|
+
const next = [
|
|
531
|
+
...rows,
|
|
532
|
+
{
|
|
533
|
+
id: nextNavFolderId(),
|
|
534
|
+
name,
|
|
535
|
+
order: rows.length,
|
|
536
|
+
collapsed: false,
|
|
537
|
+
items: [],
|
|
538
|
+
...NAV_FOLDER_STYLE_DEFAULT,
|
|
539
|
+
},
|
|
540
|
+
];
|
|
541
|
+
setCreating(false);
|
|
542
|
+
setCreateDraft("");
|
|
543
|
+
setCreateDiscardWarn(false);
|
|
544
|
+
await writeRows(next);
|
|
545
|
+
}, [createDraft, rows, writeRows]);
|
|
546
|
+
|
|
547
|
+
const startCustomizeFolder = useCallback((folder) => {
|
|
548
|
+
const style = navFolderStyle(folder);
|
|
549
|
+
const snapshot = {
|
|
550
|
+
name: folder.name,
|
|
551
|
+
icon: style.icon,
|
|
552
|
+
color: style.color,
|
|
553
|
+
iconBg: style.iconBg,
|
|
554
|
+
};
|
|
555
|
+
setCustomizeTarget({
|
|
556
|
+
scope: "folder",
|
|
557
|
+
folderId: folder.id,
|
|
558
|
+
snapshot,
|
|
559
|
+
draft: { ...snapshot },
|
|
560
|
+
});
|
|
561
|
+
setDiscardWarn(false);
|
|
562
|
+
setOpenMenuId(folder.id);
|
|
563
|
+
}, []);
|
|
564
|
+
|
|
565
|
+
const startCustomizeItem = useCallback((folder, item) => {
|
|
566
|
+
const style = navItemStyle(item);
|
|
567
|
+
const snapshot = {
|
|
568
|
+
name: item.label || item.refId || item.objectId || "",
|
|
569
|
+
icon: style.icon,
|
|
570
|
+
color: style.color,
|
|
571
|
+
iconBg: style.iconBg,
|
|
572
|
+
};
|
|
573
|
+
const composedId = `${folder.id}::${item.id}`;
|
|
574
|
+
setCustomizeTarget({
|
|
575
|
+
scope: "item",
|
|
576
|
+
folderId: folder.id,
|
|
577
|
+
itemId: item.id,
|
|
578
|
+
snapshot,
|
|
579
|
+
draft: { ...snapshot },
|
|
580
|
+
});
|
|
581
|
+
setDiscardWarn(false);
|
|
582
|
+
setOpenMenuId(composedId);
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
585
|
+
const saveCustomize = useCallback(async () => {
|
|
586
|
+
if (!customizeTarget) return;
|
|
587
|
+
const trimmed = customizeTarget.draft.name.trim();
|
|
588
|
+
if (!trimmed) return;
|
|
589
|
+
if (customizeTarget.scope === "folder") {
|
|
590
|
+
const next = rows.map((row) => (row.id === customizeTarget.folderId
|
|
591
|
+
? {
|
|
592
|
+
...row,
|
|
593
|
+
name: trimmed.slice(0, NAV_FOLDER_NAME_MAX),
|
|
594
|
+
icon: customizeTarget.draft.icon,
|
|
595
|
+
color: customizeTarget.draft.color,
|
|
596
|
+
iconBg: customizeTarget.draft.iconBg,
|
|
597
|
+
}
|
|
598
|
+
: row));
|
|
599
|
+
await writeRows(next);
|
|
600
|
+
} else {
|
|
601
|
+
const label = trimmed.slice(0, NAV_ITEM_LABEL_MAX);
|
|
602
|
+
const next = rows.map((row) => {
|
|
603
|
+
if (row.id !== customizeTarget.folderId) return row;
|
|
604
|
+
return {
|
|
605
|
+
...row,
|
|
606
|
+
items: (row.items || []).map((it) => (it.id === customizeTarget.itemId
|
|
607
|
+
? {
|
|
608
|
+
...it,
|
|
609
|
+
label,
|
|
610
|
+
icon: customizeTarget.draft.icon,
|
|
611
|
+
color: customizeTarget.draft.color,
|
|
612
|
+
iconBg: customizeTarget.draft.iconBg,
|
|
613
|
+
}
|
|
614
|
+
: it)),
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
await writeRows(next);
|
|
618
|
+
}
|
|
619
|
+
closeCustomize();
|
|
620
|
+
}, [customizeTarget, rows, writeRows, closeCustomize]);
|
|
621
|
+
|
|
622
|
+
const toggleCollapsed = useCallback(async (folderId) => {
|
|
623
|
+
const target = rows.find((row) => row.id === folderId);
|
|
624
|
+
const isOpen = target && !target.collapsed;
|
|
625
|
+
const next = isOpen
|
|
626
|
+
? rows.map((row) => (row.id === folderId ? { ...row, collapsed: true } : row))
|
|
627
|
+
: rows.map((row) => ({ ...row, collapsed: row.id !== folderId }));
|
|
628
|
+
await writeRows(next);
|
|
629
|
+
}, [rows, writeRows]);
|
|
630
|
+
|
|
631
|
+
const deleteFolder = useCallback(async (folderId) => {
|
|
632
|
+
setOpenMenuId(null);
|
|
633
|
+
await writeRows(rows.filter((row) => row.id !== folderId));
|
|
634
|
+
}, [rows, writeRows]);
|
|
635
|
+
|
|
636
|
+
const deleteItem = useCallback(async (folderId, itemId) => {
|
|
637
|
+
setOpenMenuId(null);
|
|
638
|
+
const next = rows.map((row) => {
|
|
639
|
+
if (row.id !== folderId) return row;
|
|
640
|
+
return { ...row, items: (row.items || []).filter((it) => it.id !== itemId) };
|
|
641
|
+
});
|
|
642
|
+
await writeRows(next);
|
|
643
|
+
}, [rows, writeRows]);
|
|
644
|
+
|
|
645
|
+
const addDashboardItem = useCallback(async (folderId, dashboard) => {
|
|
646
|
+
setAddPickerFor(null);
|
|
647
|
+
setOpenMenuId(null);
|
|
648
|
+
const style = NAV_ITEM_STYLE_DEFAULT.dashboard;
|
|
649
|
+
const item = {
|
|
650
|
+
id: nextNavItemId(),
|
|
651
|
+
type: "dashboard",
|
|
652
|
+
refId: dashboard.id,
|
|
653
|
+
label: dashboard.name,
|
|
654
|
+
icon: style.icon,
|
|
655
|
+
color: style.color,
|
|
656
|
+
iconBg: style.iconBg,
|
|
657
|
+
};
|
|
658
|
+
const next = rows.map((row) => {
|
|
659
|
+
if (row.id !== folderId) return row;
|
|
660
|
+
return { ...row, items: [...(row.items || []), item] };
|
|
661
|
+
});
|
|
662
|
+
await writeRows(next);
|
|
663
|
+
}, [rows, writeRows]);
|
|
664
|
+
|
|
665
|
+
const addViewItem = useCallback(async (folderId, dmObject) => {
|
|
666
|
+
setAddPickerFor(null);
|
|
667
|
+
setOpenMenuId(null);
|
|
668
|
+
const style = NAV_ITEM_STYLE_DEFAULT.view;
|
|
669
|
+
const item = {
|
|
670
|
+
id: nextNavItemId(),
|
|
671
|
+
type: "view",
|
|
672
|
+
objectId: dmObject.id,
|
|
673
|
+
label: dmObject.label,
|
|
674
|
+
icon: style.icon,
|
|
675
|
+
color: style.color,
|
|
676
|
+
iconBg: style.iconBg,
|
|
677
|
+
viewConfig: {
|
|
678
|
+
columns: dmObject.columns,
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
const next = rows.map((row) => {
|
|
682
|
+
if (row.id !== folderId) return row;
|
|
683
|
+
return { ...row, items: [...(row.items || []), item] };
|
|
684
|
+
});
|
|
685
|
+
await writeRows(next);
|
|
686
|
+
}, [rows, writeRows]);
|
|
687
|
+
|
|
688
|
+
const addWorkflowItem = useCallback(async (folderId, workflow) => {
|
|
689
|
+
setAddPickerFor(null);
|
|
690
|
+
setOpenMenuId(null);
|
|
691
|
+
const style = NAV_ITEM_STYLE_DEFAULT.workflow;
|
|
692
|
+
const item = {
|
|
693
|
+
id: nextNavItemId(),
|
|
694
|
+
type: "workflow",
|
|
695
|
+
objectId: workflow.objectId,
|
|
696
|
+
rowId: workflow.rowId,
|
|
697
|
+
fieldName: "orchestrationGraph",
|
|
698
|
+
label: workflow.label,
|
|
699
|
+
icon: style.icon,
|
|
700
|
+
color: style.color,
|
|
701
|
+
iconBg: style.iconBg,
|
|
702
|
+
};
|
|
703
|
+
const next = rows.map((row) => {
|
|
704
|
+
if (row.id !== folderId) return row;
|
|
705
|
+
return { ...row, items: [...(row.items || []), item] };
|
|
706
|
+
});
|
|
707
|
+
await writeRows(next);
|
|
708
|
+
}, [rows, writeRows]);
|
|
709
|
+
|
|
710
|
+
// โโ Drag-and-drop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
711
|
+
//
|
|
712
|
+
// HTML5 DnD with a tiny in-ref state machine; mirrors Twenty's
|
|
713
|
+
// reorder-folders + reorder-items + move-between-folders behaviour.
|
|
714
|
+
//
|
|
715
|
+
// - drag a folder row โ reorder among folders
|
|
716
|
+
// - drag an item row โ reorder inside its folder, or drop into a
|
|
717
|
+
// different folder (folder header or another folder's body)
|
|
718
|
+
|
|
719
|
+
const handleFolderDragStart = (e, folderId) => {
|
|
720
|
+
dragState.current = { kind: "folder", folderId };
|
|
721
|
+
e.dataTransfer.effectAllowed = "move";
|
|
722
|
+
try { e.dataTransfer.setData("text/plain", folderId); } catch { /* noop */ }
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const handleItemDragStart = (e, folderId, itemId) => {
|
|
726
|
+
dragState.current = { kind: "item", folderId, itemId };
|
|
727
|
+
e.dataTransfer.effectAllowed = "move";
|
|
728
|
+
try { e.dataTransfer.setData("text/plain", `${folderId}::${itemId}`); } catch { /* noop */ }
|
|
729
|
+
e.stopPropagation();
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const handleDragEnd = () => { dragState.current = null; };
|
|
733
|
+
|
|
734
|
+
const handleDragOver = (e) => {
|
|
735
|
+
if (!dragState.current) return;
|
|
736
|
+
e.preventDefault();
|
|
737
|
+
e.dataTransfer.dropEffect = "move";
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const handleFolderDrop = async (e, targetFolderId) => {
|
|
741
|
+
if (!dragState.current) return;
|
|
742
|
+
e.preventDefault();
|
|
743
|
+
const drag = dragState.current;
|
|
744
|
+
dragState.current = null;
|
|
745
|
+
if (drag.kind === "folder") {
|
|
746
|
+
if (!drag.folderId || drag.folderId === targetFolderId) return;
|
|
747
|
+
const fromIdx = rows.findIndex((r) => r.id === drag.folderId);
|
|
748
|
+
const toIdx = rows.findIndex((r) => r.id === targetFolderId);
|
|
749
|
+
if (fromIdx < 0 || toIdx < 0) return;
|
|
750
|
+
const next = rows.slice();
|
|
751
|
+
const [moved] = next.splice(fromIdx, 1);
|
|
752
|
+
next.splice(toIdx, 0, moved);
|
|
753
|
+
await writeRows(next);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (drag.kind === "item") {
|
|
757
|
+
if (drag.folderId === targetFolderId) return;
|
|
758
|
+
const next = rows.map((row) => ({ ...row, items: Array.isArray(row.items) ? row.items.slice() : [] }));
|
|
759
|
+
const fromRow = next.find((r) => r.id === drag.folderId);
|
|
760
|
+
const toRow = next.find((r) => r.id === targetFolderId);
|
|
761
|
+
if (!fromRow || !toRow) return;
|
|
762
|
+
const itemIdx = fromRow.items.findIndex((it) => it.id === drag.itemId);
|
|
763
|
+
if (itemIdx < 0) return;
|
|
764
|
+
const [item] = fromRow.items.splice(itemIdx, 1);
|
|
765
|
+
toRow.items.push(item);
|
|
766
|
+
await writeRows(next);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const handleItemDrop = async (e, targetFolderId, targetItemId) => {
|
|
771
|
+
if (!dragState.current) return;
|
|
772
|
+
e.preventDefault();
|
|
773
|
+
e.stopPropagation();
|
|
774
|
+
const drag = dragState.current;
|
|
775
|
+
dragState.current = null;
|
|
776
|
+
if (drag.kind !== "item") {
|
|
777
|
+
// A folder dragged onto an item is treated as a folder drop on the
|
|
778
|
+
// target item's folder, keeping behaviour predictable.
|
|
779
|
+
if (drag.kind === "folder") {
|
|
780
|
+
await handleFolderDrop(e, targetFolderId);
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const next = rows.map((row) => ({ ...row, items: Array.isArray(row.items) ? row.items.slice() : [] }));
|
|
785
|
+
const fromRow = next.find((r) => r.id === drag.folderId);
|
|
786
|
+
const toRow = next.find((r) => r.id === targetFolderId);
|
|
787
|
+
if (!fromRow || !toRow) return;
|
|
788
|
+
const fromIdx = fromRow.items.findIndex((it) => it.id === drag.itemId);
|
|
789
|
+
if (fromIdx < 0) return;
|
|
790
|
+
const [item] = fromRow.items.splice(fromIdx, 1);
|
|
791
|
+
const toIdx = toRow.items.findIndex((it) => it.id === targetItemId);
|
|
792
|
+
const insertAt = toIdx < 0 ? toRow.items.length : toIdx;
|
|
793
|
+
toRow.items.splice(insertAt, 0, item);
|
|
794
|
+
await writeRows(next);
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const openDashboardItem = (item) => {
|
|
798
|
+
// Builder items are top-level surfaces; the builder reads the active
|
|
799
|
+
// dashboard from query params if present. Other surfaces simply
|
|
800
|
+
// navigate home; the user lands on the Builder list. This keeps
|
|
801
|
+
// the rail itself agnostic of surface-specific routing.
|
|
802
|
+
router.push(`/?dashboard=${encodeURIComponent(item.refId)}`);
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const openViewItem = (item) => {
|
|
806
|
+
router.push(`/data-model?object=${encodeURIComponent(item.objectId || "")}`);
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const openWorkflowItem = (item) => {
|
|
810
|
+
const objectId = encodeURIComponent(item.objectId || "");
|
|
811
|
+
const row = encodeURIComponent(item.rowId || "");
|
|
812
|
+
const field = encodeURIComponent(item.fieldName || "orchestrationGraph");
|
|
813
|
+
router.push(`/workflows?object=${objectId}&row=${row}&field=${field}`);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const renderItemMenu = (folder, item) => {
|
|
817
|
+
const composedId = `${folder.id}::${item.id}`;
|
|
818
|
+
const isMenuOpen = openMenuId === composedId;
|
|
819
|
+
const isCustomizing = customizeTarget?.scope === "item"
|
|
820
|
+
&& customizeTarget.folderId === folder.id
|
|
821
|
+
&& customizeTarget.itemId === item.id;
|
|
822
|
+
return (
|
|
823
|
+
<div className="workspace-rail-thread-menu-wrap workspace-rail-nav-menu-wrap"
|
|
824
|
+
ref={isMenuOpen ? menuWrapRef : null}
|
|
825
|
+
>
|
|
826
|
+
<button
|
|
827
|
+
type="button"
|
|
828
|
+
className="workspace-rail-thread-menu-btn workspace-rail-nav-menu-btn"
|
|
829
|
+
aria-label={`Actions for ${item.label || "item"}`}
|
|
830
|
+
aria-haspopup="menu"
|
|
831
|
+
aria-expanded={isMenuOpen}
|
|
832
|
+
onClick={(e) => {
|
|
833
|
+
e.stopPropagation();
|
|
834
|
+
if (isMenuOpen) {
|
|
835
|
+
if (isCustomizing) requestDiscardCustomize();
|
|
836
|
+
else {
|
|
837
|
+
setOpenMenuId(null);
|
|
838
|
+
setMenuAnchor(null);
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
openAnchoredMenu(composedId, e.currentTarget);
|
|
842
|
+
}
|
|
843
|
+
}}
|
|
844
|
+
>
|
|
845
|
+
<MoreVertical size={14} />
|
|
846
|
+
</button>
|
|
847
|
+
{isMenuOpen && (isCustomizing ? (
|
|
848
|
+
<NavCustomizeOverlay panelRef={customizePanelRef}>
|
|
849
|
+
<NavCustomizePanel
|
|
850
|
+
nameLabel="Display name"
|
|
851
|
+
nameMax={NAV_ITEM_LABEL_MAX}
|
|
852
|
+
draft={customizeTarget.draft}
|
|
853
|
+
setDraft={(updater) => setCustomizeTarget((t) => ({
|
|
854
|
+
...t,
|
|
855
|
+
draft: typeof updater === "function" ? updater(t.draft) : updater,
|
|
856
|
+
}))}
|
|
857
|
+
discardWarn={discardWarn}
|
|
858
|
+
onSave={saveCustomize}
|
|
859
|
+
onCancel={() => {
|
|
860
|
+
if (navCustomizeDirty(customizeTarget.snapshot, customizeTarget.draft) && !discardWarn) {
|
|
861
|
+
setDiscardWarn(true);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
closeCustomize();
|
|
865
|
+
}}
|
|
866
|
+
/>
|
|
867
|
+
</NavCustomizeOverlay>
|
|
868
|
+
) : (
|
|
869
|
+
<div
|
|
870
|
+
className="workspace-rail-thread-menu workspace-rail-nav-menu workspace-rail-nav-menu-stack"
|
|
871
|
+
role="menu"
|
|
872
|
+
style={menuAnchor ? { top: `${menuAnchor.top}px`, left: `${menuAnchor.left}px` } : undefined}
|
|
873
|
+
>
|
|
874
|
+
<button
|
|
875
|
+
type="button"
|
|
876
|
+
role="menuitem"
|
|
877
|
+
className="workspace-rail-thread-menu-item"
|
|
878
|
+
onClick={() => startCustomizeItem(folder, item)}
|
|
879
|
+
>
|
|
880
|
+
<Pencil size={13} aria-hidden="true" /> Customize
|
|
881
|
+
</button>
|
|
882
|
+
<button
|
|
883
|
+
type="button"
|
|
884
|
+
role="menuitem"
|
|
885
|
+
className="workspace-rail-thread-menu-item is-destructive"
|
|
886
|
+
onClick={() => deleteItem(folder.id, item.id)}
|
|
887
|
+
>
|
|
888
|
+
<Trash2 size={13} aria-hidden="true" /> Remove
|
|
889
|
+
</button>
|
|
890
|
+
</div>
|
|
891
|
+
))}
|
|
892
|
+
</div>
|
|
893
|
+
);
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const renderItemRow = (folder, item) => {
|
|
897
|
+
const composedId = `${folder.id}::${item.id}`;
|
|
898
|
+
const isMenuOpen = openMenuId === composedId;
|
|
899
|
+
const isActive = item.type === "view" && pathname.startsWith(`/views/${encodeURIComponent(item.id)}`)
|
|
900
|
+
|| (item.type === "workflow"
|
|
901
|
+
&& pathname.startsWith("/workflows")
|
|
902
|
+
&& searchParams.get("object") === item.objectId
|
|
903
|
+
&& searchParams.get("row") === item.rowId);
|
|
904
|
+
const style = navItemStyle(item);
|
|
905
|
+
const typeHint = item.type === "dashboard" ? "Dashboard" : item.type === "workflow" ? "Workflow" : "View";
|
|
906
|
+
return (
|
|
907
|
+
<li
|
|
908
|
+
key={item.id}
|
|
909
|
+
className={`workspace-rail-folder-item workspace-rail-nav-row${isActive ? " is-active" : ""}${isMenuOpen ? " is-menu-open" : ""}`}
|
|
910
|
+
draggable
|
|
911
|
+
onDragStart={(e) => handleItemDragStart(e, folder.id, item.id)}
|
|
912
|
+
onDragEnd={handleDragEnd}
|
|
913
|
+
onDragOver={handleDragOver}
|
|
914
|
+
onDrop={(e) => handleItemDrop(e, folder.id, item.id)}
|
|
915
|
+
>
|
|
916
|
+
<span className="workspace-rail-tree-guide" aria-hidden="true" />
|
|
917
|
+
<div className="workspace-rail-nav-row-body">
|
|
918
|
+
<button
|
|
919
|
+
type="button"
|
|
920
|
+
className="workspace-rail-nav-row-main"
|
|
921
|
+
onClick={() => {
|
|
922
|
+
if (item.type === "dashboard") openDashboardItem(item);
|
|
923
|
+
else if (item.type === "workflow") openWorkflowItem(item);
|
|
924
|
+
else openViewItem(item);
|
|
925
|
+
}}
|
|
926
|
+
title={`${item.label || item.refId || item.objectId} ยท ${typeHint}`}
|
|
927
|
+
>
|
|
928
|
+
<NavIconBadge icon={style.icon} color={style.color} iconBg={style.iconBg} />
|
|
929
|
+
<span className="workspace-rail-nav-row-text">
|
|
930
|
+
<span className="workspace-rail-folder-item-label">{item.label || item.refId || item.objectId}</span>
|
|
931
|
+
<span className="workspace-rail-nav-row-meta">{typeHint}</span>
|
|
932
|
+
</span>
|
|
933
|
+
</button>
|
|
934
|
+
{renderItemMenu(folder, item)}
|
|
935
|
+
</div>
|
|
936
|
+
</li>
|
|
937
|
+
);
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const renderFolderMenu = (folder) => {
|
|
941
|
+
const isMenuOpen = openMenuId === folder.id;
|
|
942
|
+
const isCustomizing = customizeTarget?.scope === "folder" && customizeTarget.folderId === folder.id;
|
|
943
|
+
return (
|
|
944
|
+
<div className="workspace-rail-thread-menu-wrap workspace-rail-nav-menu-wrap"
|
|
945
|
+
ref={isMenuOpen ? menuWrapRef : null}
|
|
946
|
+
>
|
|
947
|
+
<button
|
|
948
|
+
type="button"
|
|
949
|
+
className="workspace-rail-thread-menu-btn workspace-rail-nav-menu-btn"
|
|
950
|
+
aria-label={`Actions for folder ${folder.name}`}
|
|
951
|
+
aria-haspopup="menu"
|
|
952
|
+
aria-expanded={isMenuOpen}
|
|
953
|
+
onClick={(e) => {
|
|
954
|
+
e.stopPropagation();
|
|
955
|
+
if (isMenuOpen) {
|
|
956
|
+
if (isCustomizing) requestDiscardCustomize();
|
|
957
|
+
else {
|
|
958
|
+
setOpenMenuId(null);
|
|
959
|
+
setMenuAnchor(null);
|
|
960
|
+
}
|
|
961
|
+
} else {
|
|
962
|
+
openAnchoredMenu(folder.id, e.currentTarget);
|
|
963
|
+
}
|
|
964
|
+
}}
|
|
965
|
+
>
|
|
966
|
+
<MoreVertical size={14} />
|
|
967
|
+
</button>
|
|
968
|
+
{isMenuOpen && (isCustomizing ? (
|
|
969
|
+
<NavCustomizeOverlay panelRef={customizePanelRef}>
|
|
970
|
+
<NavCustomizePanel
|
|
971
|
+
nameLabel="Folder name"
|
|
972
|
+
nameMax={NAV_FOLDER_NAME_MAX}
|
|
973
|
+
draft={customizeTarget.draft}
|
|
974
|
+
setDraft={(updater) => setCustomizeTarget((t) => ({
|
|
975
|
+
...t,
|
|
976
|
+
draft: typeof updater === "function" ? updater(t.draft) : updater,
|
|
977
|
+
}))}
|
|
978
|
+
discardWarn={discardWarn}
|
|
979
|
+
onSave={saveCustomize}
|
|
980
|
+
onCancel={() => {
|
|
981
|
+
if (navCustomizeDirty(customizeTarget.snapshot, customizeTarget.draft) && !discardWarn) {
|
|
982
|
+
setDiscardWarn(true);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
closeCustomize();
|
|
986
|
+
}}
|
|
987
|
+
/>
|
|
988
|
+
</NavCustomizeOverlay>
|
|
989
|
+
) : (
|
|
990
|
+
<div
|
|
991
|
+
className="workspace-rail-thread-menu workspace-rail-nav-menu workspace-rail-nav-menu-stack"
|
|
992
|
+
role="menu"
|
|
993
|
+
style={menuAnchor ? { top: `${menuAnchor.top}px`, left: `${menuAnchor.left}px` } : undefined}
|
|
994
|
+
>
|
|
995
|
+
<button
|
|
996
|
+
type="button"
|
|
997
|
+
role="menuitem"
|
|
998
|
+
className="workspace-rail-thread-menu-item"
|
|
999
|
+
onClick={() => startCustomizeFolder(folder)}
|
|
1000
|
+
>
|
|
1001
|
+
<Pencil size={13} aria-hidden="true" /> Customize
|
|
1002
|
+
</button>
|
|
1003
|
+
<button
|
|
1004
|
+
type="button"
|
|
1005
|
+
role="menuitem"
|
|
1006
|
+
className="workspace-rail-thread-menu-item"
|
|
1007
|
+
disabled={dashboards.length === 0}
|
|
1008
|
+
onClick={() => {
|
|
1009
|
+
setOpenMenuId(null);
|
|
1010
|
+
setMenuAnchor(null);
|
|
1011
|
+
setAddPickerFor({ folderId: folder.id, kind: "dashboard" });
|
|
1012
|
+
}}
|
|
1013
|
+
>
|
|
1014
|
+
<LayoutDashboard size={13} aria-hidden="true" /> Add dashboard
|
|
1015
|
+
</button>
|
|
1016
|
+
<button
|
|
1017
|
+
type="button"
|
|
1018
|
+
role="menuitem"
|
|
1019
|
+
className="workspace-rail-thread-menu-item"
|
|
1020
|
+
disabled={viewableObjects.length === 0}
|
|
1021
|
+
onClick={() => {
|
|
1022
|
+
setOpenMenuId(null);
|
|
1023
|
+
setMenuAnchor(null);
|
|
1024
|
+
setAddPickerFor({ folderId: folder.id, kind: "view" });
|
|
1025
|
+
}}
|
|
1026
|
+
>
|
|
1027
|
+
<TableIcon size={13} aria-hidden="true" /> Add view
|
|
1028
|
+
</button>
|
|
1029
|
+
<button
|
|
1030
|
+
type="button"
|
|
1031
|
+
role="menuitem"
|
|
1032
|
+
className="workspace-rail-thread-menu-item"
|
|
1033
|
+
disabled={workflows.length === 0}
|
|
1034
|
+
onClick={() => {
|
|
1035
|
+
setOpenMenuId(null);
|
|
1036
|
+
setMenuAnchor(null);
|
|
1037
|
+
setAddPickerFor({ folderId: folder.id, kind: "workflow" });
|
|
1038
|
+
}}
|
|
1039
|
+
>
|
|
1040
|
+
<GitBranch size={13} aria-hidden="true" /> Add workflow
|
|
1041
|
+
</button>
|
|
1042
|
+
<button
|
|
1043
|
+
type="button"
|
|
1044
|
+
role="menuitem"
|
|
1045
|
+
className="workspace-rail-thread-menu-item is-destructive"
|
|
1046
|
+
onClick={() => deleteFolder(folder.id)}
|
|
1047
|
+
>
|
|
1048
|
+
<Trash2 size={13} aria-hidden="true" /> Delete
|
|
1049
|
+
</button>
|
|
1050
|
+
</div>
|
|
1051
|
+
))}
|
|
1052
|
+
</div>
|
|
1053
|
+
);
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const renderFolder = (entry) => {
|
|
1057
|
+
const { folder, items, expand: forceExpand } = entry;
|
|
1058
|
+
const isMenuOpen = openMenuId === folder.id;
|
|
1059
|
+
const isCustomizing = customizeTarget?.scope === "folder" && customizeTarget.folderId === folder.id;
|
|
1060
|
+
const collapsed = Boolean(folder.collapsed) && !forceExpand;
|
|
1061
|
+
const isExpanded = !collapsed;
|
|
1062
|
+
const style = navFolderStyle(folder);
|
|
1063
|
+
const visibleItems = items;
|
|
1064
|
+
const itemOverflow = visibleItems.length > NAV_MAX_VISIBLE_ITEMS;
|
|
1065
|
+
return (
|
|
1066
|
+
<li
|
|
1067
|
+
key={folder.id}
|
|
1068
|
+
className={
|
|
1069
|
+
"workspace-rail-folder workspace-rail-nav-row"
|
|
1070
|
+
+ (isExpanded ? " is-expanded" : "")
|
|
1071
|
+
+ (isMenuOpen ? " is-menu-open" : "")
|
|
1072
|
+
}
|
|
1073
|
+
draggable={!isCustomizing}
|
|
1074
|
+
onDragStart={(e) => handleFolderDragStart(e, folder.id)}
|
|
1075
|
+
onDragEnd={handleDragEnd}
|
|
1076
|
+
onDragOver={handleDragOver}
|
|
1077
|
+
onDrop={(e) => handleFolderDrop(e, folder.id)}
|
|
1078
|
+
>
|
|
1079
|
+
<div className="workspace-rail-nav-row-body workspace-rail-folder-header">
|
|
1080
|
+
<button
|
|
1081
|
+
type="button"
|
|
1082
|
+
className="workspace-rail-nav-row-main workspace-rail-folder-toggle"
|
|
1083
|
+
aria-expanded={isExpanded}
|
|
1084
|
+
aria-label={collapsed ? `Expand ${folder.name}` : `Collapse ${folder.name}`}
|
|
1085
|
+
onClick={() => toggleCollapsed(folder.id)}
|
|
1086
|
+
>
|
|
1087
|
+
<NavIconBadge icon={style.icon} color={style.color} iconBg={style.iconBg} />
|
|
1088
|
+
<span className="workspace-rail-folder-name">{folder.name}</span>
|
|
1089
|
+
</button>
|
|
1090
|
+
{renderFolderMenu(folder)}
|
|
1091
|
+
</div>
|
|
1092
|
+
<div
|
|
1093
|
+
className="workspace-rail-folder-accordion-panel"
|
|
1094
|
+
aria-hidden={collapsed}
|
|
1095
|
+
>
|
|
1096
|
+
<div className="workspace-rail-folder-accordion-inner">
|
|
1097
|
+
<ul
|
|
1098
|
+
className={"workspace-rail-folder-items" + (itemOverflow ? " is-scrollable" : "")}
|
|
1099
|
+
role="list"
|
|
1100
|
+
aria-label={`Items in ${folder.name}`}
|
|
1101
|
+
>
|
|
1102
|
+
{visibleItems.length === 0 ? (
|
|
1103
|
+
<li className="workspace-rail-folder-empty-spacer" aria-hidden="true" />
|
|
1104
|
+
) : (
|
|
1105
|
+
visibleItems.map((item) => renderItemRow(folder, item))
|
|
1106
|
+
)}
|
|
1107
|
+
</ul>
|
|
1108
|
+
</div>
|
|
1109
|
+
</div>
|
|
1110
|
+
</li>
|
|
1111
|
+
);
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const picker = addPickerFor ? (
|
|
1115
|
+
<NavFolderPickerOverlay onClose={() => setAddPickerFor(null)}>
|
|
1116
|
+
<div className="workspace-rail-folder-picker" onClick={(e) => e.stopPropagation()}>
|
|
1117
|
+
<div className="workspace-rail-folder-picker-head">
|
|
1118
|
+
<strong>
|
|
1119
|
+
{addPickerFor.kind === "dashboard"
|
|
1120
|
+
? "Add dashboard"
|
|
1121
|
+
: addPickerFor.kind === "workflow"
|
|
1122
|
+
? "Add workflow"
|
|
1123
|
+
: "Add view"}
|
|
1124
|
+
</strong>
|
|
1125
|
+
<button
|
|
1126
|
+
type="button"
|
|
1127
|
+
className="workspace-rail-folder-picker-close"
|
|
1128
|
+
aria-label="Close picker"
|
|
1129
|
+
onClick={() => setAddPickerFor(null)}
|
|
1130
|
+
>
|
|
1131
|
+
<X size={14} />
|
|
1132
|
+
</button>
|
|
1133
|
+
</div>
|
|
1134
|
+
<ul className="workspace-rail-folder-picker-list" role="list">
|
|
1135
|
+
{addPickerFor.kind === "dashboard"
|
|
1136
|
+
? dashboards.map((d) => (
|
|
1137
|
+
<li key={d.id}>
|
|
1138
|
+
<button
|
|
1139
|
+
type="button"
|
|
1140
|
+
className="workspace-rail-folder-picker-item"
|
|
1141
|
+
onClick={() => addDashboardItem(addPickerFor.folderId, d)}
|
|
1142
|
+
>
|
|
1143
|
+
<NavIconBadge
|
|
1144
|
+
icon={NAV_ITEM_STYLE_DEFAULT.dashboard.icon}
|
|
1145
|
+
color={NAV_ITEM_STYLE_DEFAULT.dashboard.color}
|
|
1146
|
+
iconBg={NAV_ITEM_STYLE_DEFAULT.dashboard.iconBg}
|
|
1147
|
+
/>
|
|
1148
|
+
<span>{d.name}</span>
|
|
1149
|
+
</button>
|
|
1150
|
+
</li>
|
|
1151
|
+
))
|
|
1152
|
+
: addPickerFor.kind === "workflow"
|
|
1153
|
+
? workflows.map((w) => (
|
|
1154
|
+
<li key={`${w.objectId}:${w.rowId}`}>
|
|
1155
|
+
<button
|
|
1156
|
+
type="button"
|
|
1157
|
+
className="workspace-rail-folder-picker-item"
|
|
1158
|
+
onClick={() => addWorkflowItem(addPickerFor.folderId, w)}
|
|
1159
|
+
>
|
|
1160
|
+
<NavIconBadge
|
|
1161
|
+
icon={NAV_ITEM_STYLE_DEFAULT.workflow.icon}
|
|
1162
|
+
color={NAV_ITEM_STYLE_DEFAULT.workflow.color}
|
|
1163
|
+
iconBg={NAV_ITEM_STYLE_DEFAULT.workflow.iconBg}
|
|
1164
|
+
/>
|
|
1165
|
+
<span className="workspace-rail-folder-picker-item-text">
|
|
1166
|
+
<span>{w.label}</span>
|
|
1167
|
+
<span className="workspace-rail-folder-picker-hint">
|
|
1168
|
+
{w.objectLabel} ยท {w.status} ยท {w.graphNodeCount} node{w.graphNodeCount === 1 ? "" : "s"}
|
|
1169
|
+
</span>
|
|
1170
|
+
</span>
|
|
1171
|
+
</button>
|
|
1172
|
+
</li>
|
|
1173
|
+
))
|
|
1174
|
+
: viewableObjects.map((o) => (
|
|
1175
|
+
<li key={o.id}>
|
|
1176
|
+
<button
|
|
1177
|
+
type="button"
|
|
1178
|
+
className="workspace-rail-folder-picker-item"
|
|
1179
|
+
onClick={() => addViewItem(addPickerFor.folderId, o)}
|
|
1180
|
+
>
|
|
1181
|
+
<NavIconBadge
|
|
1182
|
+
icon={NAV_ITEM_STYLE_DEFAULT.view.icon}
|
|
1183
|
+
color={NAV_ITEM_STYLE_DEFAULT.view.color}
|
|
1184
|
+
iconBg={NAV_ITEM_STYLE_DEFAULT.view.iconBg}
|
|
1185
|
+
/>
|
|
1186
|
+
<span>{o.label}</span>
|
|
1187
|
+
<span className="workspace-rail-folder-picker-hint">{o.columns.length} field{o.columns.length === 1 ? "" : "s"}</span>
|
|
1188
|
+
</button>
|
|
1189
|
+
</li>
|
|
1190
|
+
))}
|
|
1191
|
+
</ul>
|
|
1192
|
+
</div>
|
|
1193
|
+
</NavFolderPickerOverlay>
|
|
1194
|
+
) : null;
|
|
1195
|
+
|
|
1196
|
+
const folderOverflow = filteredEntries.length > NAV_MAX_VISIBLE_FOLDERS;
|
|
1197
|
+
|
|
1198
|
+
return (
|
|
1199
|
+
<div
|
|
1200
|
+
className={"workspace-rail-folders" + (sectionCollapsed ? " is-section-collapsed" : "")}
|
|
1201
|
+
aria-label="Custom folders"
|
|
1202
|
+
>
|
|
1203
|
+
<div className="workspace-rail-folders-head">
|
|
1204
|
+
<button
|
|
1205
|
+
type="button"
|
|
1206
|
+
className="workspace-rail-folders-section-toggle"
|
|
1207
|
+
aria-expanded={!sectionCollapsed}
|
|
1208
|
+
onClick={(e) => {
|
|
1209
|
+
e.currentTarget.blur();
|
|
1210
|
+
setSectionCollapsed((v) => !v);
|
|
1211
|
+
}}
|
|
1212
|
+
>
|
|
1213
|
+
<span className="workspace-rail-section-label">Folders</span>
|
|
1214
|
+
{sectionCollapsed
|
|
1215
|
+
? <ChevronRight size={12} className="workspace-rail-folders-section-chevron" aria-hidden="true" />
|
|
1216
|
+
: <ChevronDown size={12} className="workspace-rail-folders-section-chevron" aria-hidden="true" />}
|
|
1217
|
+
</button>
|
|
1218
|
+
<button
|
|
1219
|
+
type="button"
|
|
1220
|
+
className="workspace-rail-folders-add-btn"
|
|
1221
|
+
aria-label="Create folder"
|
|
1222
|
+
title="New folder"
|
|
1223
|
+
onClick={() => {
|
|
1224
|
+
setSectionCollapsed(false);
|
|
1225
|
+
setCreating(true);
|
|
1226
|
+
setCreateDraft("");
|
|
1227
|
+
}}
|
|
1228
|
+
>
|
|
1229
|
+
<FolderPlus size={13} aria-hidden="true" />
|
|
1230
|
+
</button>
|
|
1231
|
+
</div>
|
|
1232
|
+
{!sectionCollapsed ? (
|
|
1233
|
+
<>
|
|
1234
|
+
<div className="workspace-rail-folders-filters">
|
|
1235
|
+
<div className="workspace-rail-folders-search">
|
|
1236
|
+
<Search size={12} aria-hidden="true" />
|
|
1237
|
+
<input
|
|
1238
|
+
type="search"
|
|
1239
|
+
className="workspace-rail-folders-search-input"
|
|
1240
|
+
placeholder="Filter folders & shortcuts"
|
|
1241
|
+
value={filterQuery}
|
|
1242
|
+
onChange={(e) => setFilterQuery(e.target.value)}
|
|
1243
|
+
aria-label="Filter folders and views by name"
|
|
1244
|
+
/>
|
|
1245
|
+
{filterQuery ? (
|
|
1246
|
+
<button
|
|
1247
|
+
type="button"
|
|
1248
|
+
className="workspace-rail-chat-search-clear"
|
|
1249
|
+
onClick={() => setFilterQuery("")}
|
|
1250
|
+
aria-label="Clear filter"
|
|
1251
|
+
>
|
|
1252
|
+
<X size={11} />
|
|
1253
|
+
</button>
|
|
1254
|
+
) : null}
|
|
1255
|
+
</div>
|
|
1256
|
+
<div className="workspace-rail-folders-filter-menu-wrap" ref={filterMenuRef}>
|
|
1257
|
+
<button
|
|
1258
|
+
type="button"
|
|
1259
|
+
className={
|
|
1260
|
+
"workspace-rail-folders-filter-btn"
|
|
1261
|
+
+ (filterMenuOpen ? " is-open" : "")
|
|
1262
|
+
+ (filterActive ? " has-live-state" : "")
|
|
1263
|
+
+ (rows.length === 0 ? " is-disabled" : "")
|
|
1264
|
+
}
|
|
1265
|
+
aria-label="Folder display options"
|
|
1266
|
+
aria-haspopup="menu"
|
|
1267
|
+
aria-expanded={filterMenuOpen}
|
|
1268
|
+
aria-disabled={rows.length === 0}
|
|
1269
|
+
title={rows.length === 0
|
|
1270
|
+
? "No folders yet. Create one to organize dashboards and table views."
|
|
1271
|
+
: "Folder display options"}
|
|
1272
|
+
onClick={(e) => {
|
|
1273
|
+
e.currentTarget.blur();
|
|
1274
|
+
if (rows.length === 0) return;
|
|
1275
|
+
setFilterMenuOpen((v) => !v);
|
|
1276
|
+
}}
|
|
1277
|
+
>
|
|
1278
|
+
<SlidersHorizontal size={14} aria-hidden="true" />
|
|
1279
|
+
{filterActive ? <span className="workspace-rail-folders-filter-state-dot" aria-hidden="true" /> : null}
|
|
1280
|
+
</button>
|
|
1281
|
+
{filterMenuOpen && rows.length > 0 ? (
|
|
1282
|
+
<div className="workspace-rail-folders-filter-menu" role="menu">
|
|
1283
|
+
<div className="workspace-rail-folders-filter-menu-group">
|
|
1284
|
+
<p className="workspace-rail-folders-filter-menu-label">Type</p>
|
|
1285
|
+
{[
|
|
1286
|
+
{ id: "all", label: "All" },
|
|
1287
|
+
{ id: "dashboard", label: "Dashboards" },
|
|
1288
|
+
{ id: "view", label: "Views" },
|
|
1289
|
+
{ id: "workflow", label: "Workflows" },
|
|
1290
|
+
].map((opt) => (
|
|
1291
|
+
<button
|
|
1292
|
+
key={opt.id}
|
|
1293
|
+
type="button"
|
|
1294
|
+
role="menuitemradio"
|
|
1295
|
+
aria-checked={filterType === opt.id}
|
|
1296
|
+
className="workspace-rail-folders-filter-menu-item"
|
|
1297
|
+
onClick={() => {
|
|
1298
|
+
setFilterType(opt.id);
|
|
1299
|
+
setFilterMenuOpen(false);
|
|
1300
|
+
}}
|
|
1301
|
+
>
|
|
1302
|
+
<span>{opt.label}</span>
|
|
1303
|
+
<span className="workspace-rail-folders-filter-menu-value">
|
|
1304
|
+
{filterType === opt.id ? "Active" : ""}
|
|
1305
|
+
</span>
|
|
1306
|
+
<ChevronRight size={13} aria-hidden="true" />
|
|
1307
|
+
</button>
|
|
1308
|
+
))}
|
|
1309
|
+
</div>
|
|
1310
|
+
{filterActive ? (
|
|
1311
|
+
<div className="workspace-rail-folders-filter-menu-group">
|
|
1312
|
+
<button
|
|
1313
|
+
type="button"
|
|
1314
|
+
role="menuitem"
|
|
1315
|
+
className="workspace-rail-folders-filter-menu-item is-reset"
|
|
1316
|
+
onClick={() => {
|
|
1317
|
+
setFilterQuery("");
|
|
1318
|
+
setFilterType("all");
|
|
1319
|
+
setFilterMenuOpen(false);
|
|
1320
|
+
}}
|
|
1321
|
+
>
|
|
1322
|
+
<span>Clear folder config</span>
|
|
1323
|
+
<span className="workspace-rail-folders-filter-menu-value">Reset</span>
|
|
1324
|
+
<X size={13} aria-hidden="true" />
|
|
1325
|
+
</button>
|
|
1326
|
+
</div>
|
|
1327
|
+
) : null}
|
|
1328
|
+
<div className="workspace-rail-folders-filter-menu-group">
|
|
1329
|
+
<button type="button" role="menuitem" className="workspace-rail-folders-filter-menu-item">
|
|
1330
|
+
<span>Group by</span>
|
|
1331
|
+
<span className="workspace-rail-folders-filter-menu-value">Folder</span>
|
|
1332
|
+
<ChevronRight size={13} aria-hidden="true" />
|
|
1333
|
+
</button>
|
|
1334
|
+
<button type="button" role="menuitem" className="workspace-rail-folders-filter-menu-item">
|
|
1335
|
+
<span>Sort by</span>
|
|
1336
|
+
<span className="workspace-rail-folders-filter-menu-value">Custom order</span>
|
|
1337
|
+
<ChevronRight size={13} aria-hidden="true" />
|
|
1338
|
+
</button>
|
|
1339
|
+
</div>
|
|
1340
|
+
</div>
|
|
1341
|
+
) : null}
|
|
1342
|
+
</div>
|
|
1343
|
+
</div>
|
|
1344
|
+
{creating ? (
|
|
1345
|
+
<div className="workspace-rail-folder-create">
|
|
1346
|
+
<NavIconBadge
|
|
1347
|
+
icon={NAV_FOLDER_STYLE_DEFAULT.icon}
|
|
1348
|
+
color={NAV_FOLDER_STYLE_DEFAULT.color}
|
|
1349
|
+
iconBg={NAV_FOLDER_STYLE_DEFAULT.iconBg}
|
|
1350
|
+
/>
|
|
1351
|
+
<input
|
|
1352
|
+
ref={createInputRef}
|
|
1353
|
+
autoFocus
|
|
1354
|
+
className="workspace-rail-thread-rename"
|
|
1355
|
+
value={createDraft}
|
|
1356
|
+
onChange={(e) => {
|
|
1357
|
+
setCreateDraft(e.target.value);
|
|
1358
|
+
setCreateDiscardWarn(false);
|
|
1359
|
+
}}
|
|
1360
|
+
placeholder="Folder name"
|
|
1361
|
+
onKeyDown={(e) => {
|
|
1362
|
+
if (e.key === "Enter") { e.preventDefault(); createFolder(); }
|
|
1363
|
+
if (e.key === "Escape") {
|
|
1364
|
+
setCreating(false);
|
|
1365
|
+
setCreateDraft("");
|
|
1366
|
+
setCreateDiscardWarn(false);
|
|
1367
|
+
}
|
|
1368
|
+
}}
|
|
1369
|
+
/>
|
|
1370
|
+
<button type="button" className="workspace-rail-nav-btn-primary is-compact" onClick={createFolder}>
|
|
1371
|
+
Save
|
|
1372
|
+
</button>
|
|
1373
|
+
{createDiscardWarn ? (
|
|
1374
|
+
<p className="workspace-rail-nav-discard-warn is-inline" role="status">
|
|
1375
|
+
Click outside again to discard
|
|
1376
|
+
</p>
|
|
1377
|
+
) : null}
|
|
1378
|
+
</div>
|
|
1379
|
+
) : null}
|
|
1380
|
+
{rows.length === 0 && !creating ? null : filteredEntries.length === 0 ? (
|
|
1381
|
+
<p className="workspace-rail-folders-empty">No folders or views match this filter.</p>
|
|
1382
|
+
) : (
|
|
1383
|
+
<div
|
|
1384
|
+
className={"workspace-rail-folders-scroll" + (folderOverflow ? " is-scrollable" : "")}
|
|
1385
|
+
role="region"
|
|
1386
|
+
aria-label="Folder list"
|
|
1387
|
+
>
|
|
1388
|
+
<ul className="workspace-rail-folders-list" role="list">
|
|
1389
|
+
{filteredEntries.map(renderFolder)}
|
|
1390
|
+
</ul>
|
|
1391
|
+
{folderOverflow ? (
|
|
1392
|
+
<p className="workspace-rail-folders-scroll-hint">
|
|
1393
|
+
{filteredEntries.length} folders ยท scroll for more
|
|
1394
|
+
</p>
|
|
1395
|
+
) : null}
|
|
1396
|
+
</div>
|
|
1397
|
+
)}
|
|
1398
|
+
{picker}
|
|
1399
|
+
</>
|
|
1400
|
+
) : null}
|
|
1401
|
+
</div>
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
106
1405
|
export function WorkspaceRail({
|
|
107
1406
|
workspaceConfig,
|
|
108
1407
|
authority,
|
|
@@ -124,6 +1423,7 @@ export function WorkspaceRail({
|
|
|
124
1423
|
const router = useRouter();
|
|
125
1424
|
|
|
126
1425
|
const [activeTab, setActiveTab] = useState("home");
|
|
1426
|
+
const [railCollapsed, setRailCollapsed] = useState(false);
|
|
127
1427
|
const [openMenuId, setOpenMenuId] = useState(null);
|
|
128
1428
|
const [renamingId, setRenamingId] = useState(null);
|
|
129
1429
|
const [renameDraft, setRenameDraft] = useState("");
|
|
@@ -144,6 +1444,12 @@ export function WorkspaceRail({
|
|
|
144
1444
|
|
|
145
1445
|
const threads = useMemo(() => getHelperThreadRows(workspaceConfig), [workspaceConfig]);
|
|
146
1446
|
|
|
1447
|
+
useEffect(() => {
|
|
1448
|
+
if (typeof document === "undefined") return undefined;
|
|
1449
|
+
document.body.classList.toggle("workspace-rail-collapsed", railCollapsed);
|
|
1450
|
+
return () => document.body.classList.remove("workspace-rail-collapsed");
|
|
1451
|
+
}, [railCollapsed]);
|
|
1452
|
+
|
|
147
1453
|
const handleAskHelperClick = () => {
|
|
148
1454
|
if (onOpenHelper) {
|
|
149
1455
|
onOpenHelper();
|
|
@@ -160,6 +1466,31 @@ export function WorkspaceRail({
|
|
|
160
1466
|
router.push(`/data-model?thread=${encodeURIComponent(row.id)}`);
|
|
161
1467
|
};
|
|
162
1468
|
|
|
1469
|
+
async function patchNavFolders(updatedRows) {
|
|
1470
|
+
const seeded = ensureNavFoldersObject(workspaceConfig);
|
|
1471
|
+
const dm = seeded.dataModel;
|
|
1472
|
+
const objects = Array.isArray(dm.objects) ? dm.objects.slice() : [];
|
|
1473
|
+
const idx = objects.findIndex((o) => o?.id === NAV_FOLDERS_OBJECT_ID);
|
|
1474
|
+
if (idx === -1) return;
|
|
1475
|
+
objects[idx] = { ...objects[idx], rows: updatedRows };
|
|
1476
|
+
const nextDataModel = { ...dm, objects };
|
|
1477
|
+
try {
|
|
1478
|
+
const res = await fetch("/api/workspace", {
|
|
1479
|
+
method: "PATCH",
|
|
1480
|
+
headers: { "content-type": "application/json" },
|
|
1481
|
+
body: JSON.stringify({ dataModel: nextDataModel }),
|
|
1482
|
+
});
|
|
1483
|
+
if (res.ok) {
|
|
1484
|
+
const body = await res.json().catch(() => ({}));
|
|
1485
|
+
if (body?.workspaceConfig && onConfigChange) {
|
|
1486
|
+
onConfigChange(body.workspaceConfig);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
} catch {
|
|
1490
|
+
// Best-effort: read-only runtimes return 409; the user can retry.
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
163
1494
|
async function patchHelperThreads(updatedRows) {
|
|
164
1495
|
const dm = workspaceConfig?.dataModel || {};
|
|
165
1496
|
const objects = Array.isArray(dm.objects) ? dm.objects.slice() : [];
|
|
@@ -215,7 +1546,7 @@ export function WorkspaceRail({
|
|
|
215
1546
|
};
|
|
216
1547
|
|
|
217
1548
|
return (
|
|
218
|
-
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
1549
|
+
<aside className={"workspace-rail" + (railCollapsed ? " is-collapsed" : "")} aria-label="Workspace navigation">
|
|
219
1550
|
{/* Row 1: brand + utility actions */}
|
|
220
1551
|
<div className="workspace-rail-topbar">
|
|
221
1552
|
<button type="button" className="workspace-rail-brand-button" aria-label={`Workspace ${workspaceName}`}>
|
|
@@ -252,11 +1583,22 @@ export function WorkspaceRail({
|
|
|
252
1583
|
<button
|
|
253
1584
|
type="button"
|
|
254
1585
|
className="workspace-rail-icon-btn"
|
|
255
|
-
aria-label="Collapse sidebar"
|
|
256
|
-
title="Collapse sidebar"
|
|
1586
|
+
aria-label={railCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
1587
|
+
title={railCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
1588
|
+
aria-pressed={railCollapsed}
|
|
1589
|
+
onClick={() => setRailCollapsed((v) => !v)}
|
|
257
1590
|
>
|
|
258
1591
|
<PanelLeftClose size={13} />
|
|
259
1592
|
</button>
|
|
1593
|
+
<button
|
|
1594
|
+
type="button"
|
|
1595
|
+
className="workspace-rail-icon-btn"
|
|
1596
|
+
aria-label="Workspace settings"
|
|
1597
|
+
title="Workspace settings"
|
|
1598
|
+
onClick={() => router.push("/settings/general")}
|
|
1599
|
+
>
|
|
1600
|
+
<Settings size={13} />
|
|
1601
|
+
</button>
|
|
260
1602
|
</div>
|
|
261
1603
|
</div>
|
|
262
1604
|
|
|
@@ -300,6 +1642,19 @@ export function WorkspaceRail({
|
|
|
300
1642
|
</button>
|
|
301
1643
|
</div>
|
|
302
1644
|
|
|
1645
|
+
{/* Custom Folders Navigation Module โ sits directly below the tab
|
|
1646
|
+
row and above both the Home nav items and the Chat thread list,
|
|
1647
|
+
mirroring Twenty CRM's drag-to-reorder sidebar folders. The
|
|
1648
|
+
module is fully backwards compatible: with no `nav-folders`
|
|
1649
|
+
object (or zero rows) it shows a thin empty-state hint and
|
|
1650
|
+
renders nothing in the body of the rail it would otherwise
|
|
1651
|
+
push down. */}
|
|
1652
|
+
<NavFoldersSection
|
|
1653
|
+
workspaceConfig={workspaceConfig}
|
|
1654
|
+
pathname={pathname}
|
|
1655
|
+
onPatchNavFolders={patchNavFolders}
|
|
1656
|
+
/>
|
|
1657
|
+
|
|
303
1658
|
{/* Body: switches by tab. The legacy `Management` nav item now
|
|
304
1659
|
lives as the 4th Workspace Settings tab (`/settings/ownership`).
|
|
305
1660
|
The Data Model link is renamed to `Management` since the data
|
|
@@ -308,7 +1663,7 @@ export function WorkspaceRail({
|
|
|
308
1663
|
<nav className="workspace-nav" aria-label="Workspace pages">
|
|
309
1664
|
{dashboardsSlot ?? (
|
|
310
1665
|
<Link href="/" className={pathname === "/" ? "active" : undefined}>
|
|
311
|
-
|
|
1666
|
+
Builder
|
|
312
1667
|
</Link>
|
|
313
1668
|
)}
|
|
314
1669
|
{dataModelSlot ?? (
|