@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.
Files changed (31) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +50 -25
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +38 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +556 -248
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +242 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +52 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +1203 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +163 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +190 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +64 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolDraftPanel.jsx +376 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/dm-shared.jsx +8 -2
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +6 -1
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +2897 -934
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +10 -7
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/views/[viewId]/page.jsx +206 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +906 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/page.jsx +12 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +493 -28
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +1363 -8
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/nav-workflows.js +54 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +322 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +734 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +73 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-sidecar-routing.js +24 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +13 -4
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +96 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +122 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +1 -0
  31. 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
- * โ”‚ Dashboards Latest โ”‚
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
- * Dashboards / Data Model / Management / Workspace Settings behaviour
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
- Dashboards
1666
+ Builder
312
1667
  </Link>
313
1668
  )}
314
1669
  {dataModelSlot ?? (