@growthub/cli 0.9.8 โ†’ 0.9.10

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 (19) hide show
  1. package/README.md +23 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +8 -2
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/.env.example +8 -8
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +9 -7
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/integrations/route.js +2 -2
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +4 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1264 -19
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +111 -77
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1691 -138
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +8 -3
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/env.js +9 -7
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +10 -10
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +2 -2
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +62 -7
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +220 -2
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +10 -64
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +3 -0
  19. package/package.json +1 -1
@@ -2,8 +2,50 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import {
6
+ BarChart3,
7
+ Bolt,
8
+ ChevronDown,
9
+ Code2,
10
+ Columns3,
11
+ Copy,
12
+ Database,
13
+ Download,
14
+ ExternalLink,
15
+ FileText,
16
+ Filter,
17
+ Gauge,
18
+ Grid2X2,
19
+ Home,
20
+ Import,
21
+ Italic,
22
+ LayoutDashboard,
23
+ Link as LinkIcon,
24
+ List,
25
+ Maximize2,
26
+ Pencil,
27
+ PieChart,
28
+ Plus,
29
+ Quote,
30
+ Rows3,
31
+ Save,
32
+ Search,
33
+ Settings,
34
+ Sigma,
35
+ SlidersHorizontal,
36
+ Sparkles,
37
+ Table2,
38
+ Trash2,
39
+ Type,
40
+ X
41
+ } from "lucide-react";
5
42
  import {
6
43
  DASHBOARD_TEMPLATES,
44
+ KNOWN_AGGREGATIONS,
45
+ KNOWN_CHART_TYPES,
46
+ KNOWN_FILTER_CONJUNCTIONS,
47
+ KNOWN_FILTER_OPERATORS,
48
+ KNOWN_SORT_DIRECTIONS,
7
49
  SAMPLE_DATA_BINDINGS,
8
50
  SAMPLE_VIEW_ROWS,
9
51
  cloneTemplateToDashboard,
@@ -14,6 +56,60 @@ import {
14
56
  validateWorkspaceConfig,
15
57
  wrapWorkspaceTemplateExport
16
58
  } from "@/lib/workspace-schema";
59
+ import { governedWorkspaceIntegrationCatalog } from "@/lib/domain/integrations";
60
+
61
+ const DEFAULT_CHART_TYPE = "bar-vertical";
62
+ const DEFAULT_FILTER_OP = "and";
63
+ const DEFAULT_FILTER_OPERATOR = "contains";
64
+ const DEFAULT_SORT_DIRECTION = "asc";
65
+ const SUB_PANEL_ROOT = "root";
66
+
67
+ const CHART_TYPE_LABELS = {
68
+ "bar-vertical": "Vertical Bar",
69
+ "bar-horizontal": "Horizontal Bar",
70
+ "pie": "Pie",
71
+ "sum": "Sum",
72
+ "gauge": "Gauge"
73
+ };
74
+
75
+ const CHART_TYPE_ICONS = {
76
+ "bar-vertical": BarChart3,
77
+ "bar-horizontal": Rows3,
78
+ "pie": PieChart,
79
+ "sum": Sigma,
80
+ "gauge": Gauge
81
+ };
82
+
83
+ const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
84
+
85
+ const WIDGET_KIND_ICONS = {
86
+ chart: BarChart3,
87
+ view: Table2,
88
+ iframe: Code2,
89
+ "rich-text": FileText
90
+ };
91
+
92
+ const FILTER_OPERATOR_LABELS = {
93
+ eq: "equals",
94
+ ne: "does not equal",
95
+ contains: "contains",
96
+ gt: "is greater than",
97
+ lt: "is less than",
98
+ isEmpty: "is empty",
99
+ isNotEmpty: "is not empty"
100
+ };
101
+
102
+ const COLUMN_ICON_FOR = (name) => {
103
+ const lower = String(name || "").toLowerCase();
104
+ if (lower.includes("name") || lower.includes("title")) return "๐Ÿ›";
105
+ if (lower.includes("domain") || lower.includes("url") || lower.includes("link")) return "๐Ÿ”—";
106
+ if (lower.includes("address") || lower.includes("location")) return "๐Ÿ—บ";
107
+ if (lower.includes("employee") || lower.includes("people") || lower.includes("user")) return "๐Ÿ‘ฅ";
108
+ if (lower.includes("linkedin")) return "in";
109
+ if (lower.includes("twitter") || lower === "x") return "๐•";
110
+ if (lower.includes("date") || lower.includes("created") || lower.includes("updated")) return "๐Ÿ“…";
111
+ return "โ–ฆ";
112
+ };
17
113
 
18
114
  const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
19
115
  const GRID_COLUMNS = 12;
@@ -76,7 +172,7 @@ function commitTabs(canvas, tabs, activeTabId) {
76
172
  }
77
173
 
78
174
  function createDashboardRecord(name = "Untitled") {
79
- const tab = createEmptyTab(name);
175
+ const tab = createEmptyTab("Tab 1");
80
176
  return {
81
177
  id: generateId("dashboard"),
82
178
  name,
@@ -140,7 +236,7 @@ function updateDashboardCanvas(dashboard, canvas) {
140
236
  }
141
237
 
142
238
  function createDashboardFromTab(name, tab, source = {}) {
143
- const clonedTab = cloneTabForDashboard(tab, name);
239
+ const clonedTab = cloneTabForDashboard(tab, tab?.name || "Tab 1");
144
240
  return {
145
241
  ...source,
146
242
  id: generateId("dashboard"),
@@ -189,6 +285,24 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
189
285
  };
190
286
  }
191
287
 
288
+ function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
289
+ const nextName = name.trim() || "Untitled";
290
+ const prevDashboards = config.dashboards || [];
291
+ const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
292
+ if (index < 0) return config;
293
+ const nextDashboardsWithTabs = prevDashboards.map((dashboard, dashboardIndex) => {
294
+ if (dashboard.id !== dashboardId) return dashboard;
295
+ const normalized = normalizeDashboard(dashboard, dashboardIndex === 0 ? config.canvas : undefined);
296
+ return { ...normalized, name: nextName, updatedAt: "new" };
297
+ });
298
+ const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
299
+ return {
300
+ ...config,
301
+ dashboards: nextDashboardsWithTabs,
302
+ canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], config.canvas)
303
+ };
304
+ }
305
+
192
306
  function findFreePosition(widgets) {
193
307
  const occupied = new Set();
194
308
  for (const widget of widgets) {
@@ -245,6 +359,18 @@ function clampPositionToFreeSpace(position, widgets) {
245
359
  return collides ? findFreePosition(widgets) : bounded;
246
360
  }
247
361
 
362
+ function clampWidgetMovePosition(position, widget, widgets) {
363
+ const bounded = {
364
+ ...widget.position,
365
+ x: Math.max(0, Math.min(position.x, GRID_COLUMNS - widget.position.w)),
366
+ y: Math.max(0, Math.min(position.y, GRID_ROWS - widget.position.h))
367
+ };
368
+ const otherWidgets = widgets.filter((item) => item.id !== widget.id);
369
+ return otherWidgets.some((item) => positionsOverlap(bounded, item.position))
370
+ ? widget.position
371
+ : bounded;
372
+ }
373
+
248
374
  function cellIndexFromGridPointer(event, gridElement) {
249
375
  if (!gridElement) return null;
250
376
  const rect = gridElement.getBoundingClientRect();
@@ -264,6 +390,13 @@ function cellIndexFromGridPointer(event, gridElement) {
264
390
  return row * GRID_COLUMNS + column;
265
391
  }
266
392
 
393
+ function cellPointFromIndex(index) {
394
+ return {
395
+ x: index % GRID_COLUMNS,
396
+ y: Math.floor(index / GRID_COLUMNS)
397
+ };
398
+ }
399
+
267
400
  function resizePositionFromCell(position, corner, index) {
268
401
  const cellX = index % GRID_COLUMNS;
269
402
  const cellY = Math.floor(index / GRID_COLUMNS);
@@ -313,10 +446,48 @@ function widgetKindLabel(kind) {
313
446
  return kind.charAt(0).toUpperCase() + kind.slice(1);
314
447
  }
315
448
 
449
+ function IconGlyph({ icon: Icon, size = 16 }) {
450
+ if (!Icon) return null;
451
+ return <Icon aria-hidden="true" size={size} strokeWidth={1.9} />;
452
+ }
453
+
454
+ function isLikelyHttpUrl(value) {
455
+ if (typeof value !== "string" || !value.trim()) return false;
456
+ try {
457
+ const url = new URL(value.trim());
458
+ return url.protocol === "http:" || url.protocol === "https:";
459
+ } catch {
460
+ return false;
461
+ }
462
+ }
463
+
316
464
  function cloneConfig(value) {
317
465
  return JSON.parse(JSON.stringify(value));
318
466
  }
319
467
 
468
+ function escapeHtml(value) {
469
+ return String(value || "")
470
+ .replaceAll("&", "&amp;")
471
+ .replaceAll("<", "&lt;")
472
+ .replaceAll(">", "&gt;")
473
+ .replaceAll('"', "&quot;")
474
+ .replaceAll("'", "&#039;");
475
+ }
476
+
477
+ function richTextToHtml(value) {
478
+ const escaped = escapeHtml(value || "Start writing...");
479
+ return escaped
480
+ .replace(/^### (.*)$/gm, "<h3>$1</h3>")
481
+ .replace(/^## (.*)$/gm, "<h2>$1</h2>")
482
+ .replace(/^# (.*)$/gm, "<h1>$1</h1>")
483
+ .replace(/^> (.*)$/gm, "<blockquote>$1</blockquote>")
484
+ .replace(/^- (.*)$/gm, "<li>$1</li>")
485
+ .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
486
+ .replace(/\*(.*?)\*/g, "<em>$1</em>")
487
+ .replace(/\[(.*?)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
488
+ .replace(/\n/g, "<br />");
489
+ }
490
+
320
491
  function normalizeChartValues(value) {
321
492
  return String(value)
322
493
  .split(",")
@@ -361,6 +532,129 @@ function serializeManualRows(rows, columns) {
361
532
  .join("\n");
362
533
  }
363
534
 
535
+ function getColumnList(widget) {
536
+ return Array.isArray(widget?.config?.columns) ? widget.config.columns : [];
537
+ }
538
+
539
+ function getOrderedColumns(widget) {
540
+ const columns = getColumnList(widget);
541
+ const order = Array.isArray(widget?.config?.fieldSettings?.order) ? widget.config.fieldSettings.order : [];
542
+ if (!order.length) return columns;
543
+ const known = new Set(columns);
544
+ const ordered = order.filter((name) => known.has(name));
545
+ const remaining = columns.filter((name) => !ordered.includes(name));
546
+ return [...ordered, ...remaining];
547
+ }
548
+
549
+ function getHiddenColumnSet(widget) {
550
+ const hidden = Array.isArray(widget?.config?.fieldSettings?.hidden) ? widget.config.fieldSettings.hidden : [];
551
+ return new Set(hidden);
552
+ }
553
+
554
+ function getVisibleColumns(widget) {
555
+ const ordered = getOrderedColumns(widget);
556
+ const hidden = getHiddenColumnSet(widget);
557
+ return ordered.filter((name) => !hidden.has(name));
558
+ }
559
+
560
+ function withFieldSettings(config, patch) {
561
+ const current = isPlainConfigObject(config?.fieldSettings) ? config.fieldSettings : { hidden: [], order: [] };
562
+ return {
563
+ ...config,
564
+ fieldSettings: {
565
+ hidden: Array.isArray(current.hidden) ? [...current.hidden] : [],
566
+ order: Array.isArray(current.order) ? [...current.order] : [],
567
+ ...patch
568
+ }
569
+ };
570
+ }
571
+
572
+ function isPlainConfigObject(value) {
573
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
574
+ }
575
+
576
+ function reorderColumn(widget, fieldId, direction) {
577
+ const ordered = getOrderedColumns(widget);
578
+ const index = ordered.indexOf(fieldId);
579
+ if (index < 0) return widget.config?.fieldSettings;
580
+ const target = direction === "up" ? index - 1 : index + 1;
581
+ if (target < 0 || target >= ordered.length) return widget.config?.fieldSettings;
582
+ const next = [...ordered];
583
+ const [moved] = next.splice(index, 1);
584
+ next.splice(target, 0, moved);
585
+ return { ...(widget.config?.fieldSettings || {}), order: next };
586
+ }
587
+
588
+ function toggleColumnHidden(widget, fieldId) {
589
+ const hidden = getHiddenColumnSet(widget);
590
+ if (hidden.has(fieldId)) hidden.delete(fieldId);
591
+ else hidden.add(fieldId);
592
+ return { ...(widget.config?.fieldSettings || {}), hidden: Array.from(hidden) };
593
+ }
594
+
595
+ function getSortClauses(widget) {
596
+ return Array.isArray(widget?.config?.sort) ? widget.config.sort : [];
597
+ }
598
+
599
+ function getFilterConfig(widget) {
600
+ const filter = widget?.config?.filter;
601
+ if (!isPlainConfigObject(filter)) return { op: DEFAULT_FILTER_OP, clauses: [] };
602
+ return {
603
+ op: KNOWN_FILTER_CONJUNCTIONS.includes(filter.op) ? filter.op : DEFAULT_FILTER_OP,
604
+ clauses: Array.isArray(filter.clauses) ? filter.clauses : []
605
+ };
606
+ }
607
+
608
+ function getChartType(widget) {
609
+ const chartType = widget?.config?.chartType;
610
+ return KNOWN_CHART_TYPES.includes(chartType) ? chartType : DEFAULT_CHART_TYPE;
611
+ }
612
+
613
+ function getChartAxis(widget, axisKey) {
614
+ const axis = widget?.config?.[axisKey];
615
+ return isPlainConfigObject(axis) ? axis : {};
616
+ }
617
+
618
+ function getChartStyle(widget) {
619
+ const style = widget?.config?.style;
620
+ return isPlainConfigObject(style) ? style : {};
621
+ }
622
+
623
+ function summarizeSource(widget) {
624
+ const binding = widget?.config?.binding;
625
+ if (binding?.mode === "integration" && binding.source) return binding.source;
626
+ if (widget?.config?.source) return widget.config.source;
627
+ return "Static";
628
+ }
629
+
630
+ function summarizeFields(widget) {
631
+ const total = getColumnList(widget).length;
632
+ const hidden = getHiddenColumnSet(widget).size;
633
+ if (!total) return "0 shown";
634
+ return hidden ? `${total - hidden} of ${total} shown` : `${total} shown`;
635
+ }
636
+
637
+ function summarizeSort(widget) {
638
+ const sort = getSortClauses(widget);
639
+ if (!sort.length) return "โ€บ";
640
+ if (sort.length === 1) {
641
+ const [first] = sort;
642
+ return `${first.fieldId} ${first.direction || DEFAULT_SORT_DIRECTION}`;
643
+ }
644
+ return `${sort.length} sorts`;
645
+ }
646
+
647
+ function summarizeFilter(widget) {
648
+ const filter = getFilterConfig(widget);
649
+ const count = filter.clauses.length;
650
+ if (!count) return "โ€บ";
651
+ return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
652
+ }
653
+
654
+ function describeIntegrationLane(integration) {
655
+ return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
656
+ }
657
+
364
658
  const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
365
659
  ...normalizeWorkspaceTemplate(template),
366
660
  widgets: template.widgets
@@ -404,9 +698,36 @@ function TemplateGallery({
404
698
  onPreview,
405
699
  onClose,
406
700
  onApplyToCurrentTab,
407
- onCloneAsDashboard
701
+ onCloneAsDashboard,
702
+ filter,
703
+ onFilterChange
408
704
  }) {
705
+ const categories = useMemo(() => {
706
+ const set = new Set();
707
+ templates.forEach((template) => {
708
+ if (template.category) set.add(template.category);
709
+ });
710
+ return ["all", ...Array.from(set)];
711
+ }, [templates]);
712
+ const tags = useMemo(() => {
713
+ const set = new Set();
714
+ templates.forEach((template) => {
715
+ (template.tags || []).forEach((tag) => set.add(tag));
716
+ });
717
+ return ["all", ...Array.from(set)];
718
+ }, [templates]);
719
+ const filtered = useMemo(() => {
720
+ const query = (filter?.query || "").trim().toLowerCase();
721
+ return templates.filter((template) => {
722
+ if (filter?.category && filter.category !== "all" && template.category !== filter.category) return false;
723
+ if (filter?.tag && filter.tag !== "all" && !(template.tags || []).includes(filter.tag)) return false;
724
+ if (!query) return true;
725
+ const haystack = `${template.name} ${template.description} ${(template.tags || []).join(" ")} ${(template.bestFor || []).join(" ")}`.toLowerCase();
726
+ return haystack.includes(query);
727
+ });
728
+ }, [templates, filter]);
409
729
  const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
730
+ const setFilter = (patch) => onFilterChange({ ...(filter || {}), ...patch });
410
731
  return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
411
732
  <div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
412
733
  <section className="template-gallery-panel">
@@ -417,8 +738,31 @@ function TemplateGallery({
417
738
  </div>
418
739
  <button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
419
740
  </header>
741
+ <div className="template-gallery-filters">
742
+ <input
743
+ aria-label="Search templates"
744
+ placeholder="Search templatesโ€ฆ"
745
+ value={filter?.query || ""}
746
+ onChange={(event) => setFilter({ query: event.target.value })}
747
+ />
748
+ <select
749
+ aria-label="Filter by category"
750
+ value={filter?.category || "all"}
751
+ onChange={(event) => setFilter({ category: event.target.value })}
752
+ >
753
+ {categories.map((category) => <option key={category} value={category}>{category === "all" ? "All categories" : category}</option>)}
754
+ </select>
755
+ <select
756
+ aria-label="Filter by tag"
757
+ value={filter?.tag || "all"}
758
+ onChange={(event) => setFilter({ tag: event.target.value })}
759
+ >
760
+ {tags.map((tag) => <option key={tag} value={tag}>{tag === "all" ? "All tags" : `#${tag}`}</option>)}
761
+ </select>
762
+ </div>
420
763
  <div className="template-gallery-grid">
421
- {templates.map((template) => {
764
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No templates match those filters.</p> : null}
765
+ {filtered.map((template) => {
422
766
  const isPreviewing = previewTemplate?.id === template.id;
423
767
  return <article
424
768
  className={`template-card${isPreviewing ? " previewing" : ""}`}
@@ -441,8 +785,8 @@ function TemplateGallery({
441
785
  </div> : null}
442
786
  <div className="template-card-actions">
443
787
  <button type="button" onClick={() => onPreview(template.id)}>{isPreviewing ? "Previewing" : "Preview"}</button>
444
- <button type="button" onClick={() => onApplyToCurrentTab(template.id)}>Apply to Current Tab</button>
445
- <button type="button" onClick={() => onCloneAsDashboard(template.id)}>Clone as New Dashboard</button>
788
+ <button type="button" className="primary" onClick={() => onApplyToCurrentTab(template.id)}>Use Here</button>
789
+ <button type="button" onClick={() => onCloneAsDashboard(template.id)}>New Dashboard</button>
446
790
  </div>
447
791
  </article>;
448
792
  })}
@@ -455,10 +799,631 @@ function TemplateGallery({
455
799
  </div>;
456
800
  }
457
801
 
458
- function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
459
- const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
802
+ function SubPanelHeader({ title, breadcrumb, onBack }) {
803
+ return <div className="workspace-widget-subpanel-header">
804
+ <button type="button" className="workspace-widget-subpanel-back" aria-label={`Back from ${title}`} onClick={onBack}>โ€น</button>
805
+ <div>
806
+ {breadcrumb ? <p>{breadcrumb}</p> : null}
807
+ <strong>{title}</strong>
808
+ </div>
809
+ </div>;
810
+ }
811
+
812
+ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
813
+ const binding = widget.config?.binding || {};
814
+ const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
815
+ const [query, setQuery] = useState("");
816
+ const [laneFilter, setLaneFilter] = useState("all");
817
+ const groups = useMemo(() => {
818
+ const list = Array.isArray(integrations) ? integrations : [];
819
+ const filtered = list.filter((item) => {
820
+ if (laneFilter !== "all" && item.lane !== laneFilter) return false;
821
+ const text = `${item.label} ${item.provider} ${item.description}`.toLowerCase();
822
+ return !query.trim() || text.includes(query.trim().toLowerCase());
823
+ });
824
+ return {
825
+ "data-source": filtered.filter((item) => item.lane === "data-source"),
826
+ "workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
827
+ };
828
+ }, [integrations, laneFilter, query]);
829
+ const selectStatic = useCallback(() => {
830
+ if (widget.kind === "chart") {
831
+ onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
832
+ } else {
833
+ onChange({
834
+ ...widget.config,
835
+ source: widget.config?.source || "Companies",
836
+ binding: SAMPLE_DATA_BINDINGS.companiesManual
837
+ });
838
+ }
839
+ }, [onChange, widget.config, widget.kind]);
840
+ const selectIntegration = useCallback((integration) => {
841
+ onChange({
842
+ ...widget.config,
843
+ source: integration.label,
844
+ binding: {
845
+ mode: "integration",
846
+ source: integration.label,
847
+ integrationId: integration.id,
848
+ lane: integration.lane
849
+ }
850
+ });
851
+ }, [onChange, widget.config]);
852
+ return <section className="workspace-widget-subpanel">
853
+ <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
854
+ <div className="workspace-source-controls">
855
+ <label>
856
+ <Search size={14} aria-hidden="true" />
857
+ <input
858
+ aria-label="Search sources"
859
+ placeholder="Search sources"
860
+ value={query}
861
+ onChange={(event) => setQuery(event.target.value)}
862
+ />
863
+ </label>
864
+ <label>
865
+ <Database size={14} aria-hidden="true" />
866
+ <select
867
+ aria-label="Filter source type"
868
+ value={laneFilter}
869
+ onChange={(event) => setLaneFilter(event.target.value)}
870
+ >
871
+ <option value="all">All source types</option>
872
+ <option value="data-source">Data sources</option>
873
+ <option value="workspace-integration">Workspace tools</option>
874
+ </select>
875
+ <ChevronDown size={14} aria-hidden="true" />
876
+ </label>
877
+ </div>
878
+ <p className="workspace-panel-label">Static data</p>
879
+ <div className="workspace-source-list">
880
+ <button
881
+ type="button"
882
+ className={`workspace-source-row${currentMode !== "integration" ? " active" : ""}`}
883
+ onClick={selectStatic}
884
+ >
885
+ <span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
886
+ <span className="workspace-source-meta">
887
+ <strong>Static rows</strong>
888
+ <em>Inline data โ€” no external authority required.</em>
889
+ </span>
890
+ {currentMode !== "integration" ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
891
+ </button>
892
+ </div>
893
+ {Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
894
+ <p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
895
+ <div className="workspace-source-list">
896
+ {items.map((integration) => {
897
+ const isActive = currentMode === "integration" && binding.integrationId === integration.id;
898
+ const connected = integration.isConnected || integration.status === "connected";
899
+ return <button
900
+ key={integration.id}
901
+ type="button"
902
+ className={`workspace-source-row${isActive ? " active" : ""}`}
903
+ onClick={() => selectIntegration(integration)}
904
+ >
905
+ <span className="workspace-source-icon" aria-hidden="true">{integration.icon || integration.label?.[0] || "โ€ข"}</span>
906
+ <span className="workspace-source-meta">
907
+ <strong>{integration.label}</strong>
908
+ <em>{describeIntegrationLane(integration)} ยท {connected ? "connected" : "needs connection"}</em>
909
+ </span>
910
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
911
+ </button>;
912
+ })}
913
+ </div>
914
+ </div> : null)}
915
+ <p className="workspace-panel-hint">
916
+ Selecting a source writes a binding reference only. The browser does not query integrations or store tokens.
917
+ </p>
918
+ </section>;
919
+ }
920
+
921
+ function FieldsSubPanel({ widget, onChange, onBack }) {
922
+ const ordered = getOrderedColumns(widget);
923
+ const hidden = getHiddenColumnSet(widget);
924
+ const visible = ordered.filter((name) => !hidden.has(name));
925
+ const hiddenList = ordered.filter((name) => hidden.has(name));
926
+ const [hiddenOpen, setHiddenOpen] = useState(true);
927
+ const [draftField, setDraftField] = useState("");
928
+ const move = (fieldId, direction) => {
929
+ const next = reorderColumn(widget, fieldId, direction);
930
+ onChange({ ...widget.config, fieldSettings: next });
931
+ };
932
+ const toggle = (fieldId) => {
933
+ const next = toggleColumnHidden(widget, fieldId);
934
+ onChange({ ...widget.config, fieldSettings: next });
935
+ };
936
+ const removeColumn = (fieldId) => {
937
+ const nextColumns = ordered.filter((name) => name !== fieldId);
938
+ const fs = widget.config?.fieldSettings || {};
939
+ onChange({
940
+ ...widget.config,
941
+ columns: nextColumns,
942
+ fieldSettings: {
943
+ hidden: (fs.hidden || []).filter((name) => name !== fieldId),
944
+ order: (fs.order || []).filter((name) => name !== fieldId)
945
+ }
946
+ });
947
+ };
948
+ const addColumn = () => {
949
+ const trimmed = draftField.trim();
950
+ if (!trimmed || ordered.includes(trimmed)) return;
951
+ onChange({ ...widget.config, columns: [...ordered, trimmed] });
952
+ setDraftField("");
953
+ };
954
+ return <section className="workspace-widget-subpanel">
955
+ <SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
956
+ <p className="workspace-panel-label">Visible fields</p>
957
+ <div className="workspace-field-rows">
958
+ {visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
959
+ {visible.map((name, index) => <div key={name} className="workspace-field-row">
960
+ <span className="workspace-field-row-handle" aria-hidden="true">::</span>
961
+ <span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
962
+ <span className="workspace-field-row-name">{name}</span>
963
+ <span className="workspace-field-row-actions">
964
+ <button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>โ†‘</button>
965
+ <button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>โ†“</button>
966
+ <button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>๐Ÿ‘</button>
967
+ <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>โœ•</button>
968
+ </span>
969
+ </div>)}
970
+ </div>
971
+ <button
972
+ type="button"
973
+ className="workspace-hidden-fields-toggle"
974
+ onClick={() => setHiddenOpen((value) => !value)}
975
+ aria-expanded={hiddenOpen}
976
+ >
977
+ <span>๐Ÿ‘โ€๐Ÿ—จ Hidden Fields</span>
978
+ <span aria-hidden="true">{hiddenOpen ? "โˆ’" : "+"}</span>
979
+ </button>
980
+ {hiddenOpen ? <div className="workspace-field-rows workspace-hidden-fields">
981
+ {hiddenList.length === 0 ? <p className="workspace-panel-hint">No hidden fields.</p> : null}
982
+ {hiddenList.map((name) => <div key={name} className="workspace-field-row workspace-field-row-hidden">
983
+ <span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
984
+ <span className="workspace-field-row-name">{name}</span>
985
+ <span className="workspace-field-row-actions">
986
+ <button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>๐Ÿ‘</button>
987
+ <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>โœ•</button>
988
+ </span>
989
+ </div>)}
990
+ </div> : null}
991
+ <div className="workspace-field-add">
992
+ <input
993
+ aria-label="New field name"
994
+ value={draftField}
995
+ placeholder="Add fieldโ€ฆ"
996
+ onChange={(event) => setDraftField(event.target.value)}
997
+ onKeyDown={(event) => {
998
+ if (event.key === "Enter") {
999
+ event.preventDefault();
1000
+ addColumn();
1001
+ }
1002
+ }}
1003
+ />
1004
+ <button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
1005
+ </div>
1006
+ </section>;
1007
+ }
1008
+
1009
+ function SortSubPanel({ widget, onChange, onBack }) {
1010
+ const sort = getSortClauses(widget);
1011
+ const columns = getColumnList(widget);
1012
+ const updateSort = (next) => onChange({ ...widget.config, sort: next });
1013
+ const addClause = () => {
1014
+ const fieldId = columns[0] || "";
1015
+ if (!fieldId) return;
1016
+ updateSort([...sort, { fieldId, direction: DEFAULT_SORT_DIRECTION }]);
1017
+ };
1018
+ const updateClause = (index, patch) => {
1019
+ updateSort(sort.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause));
1020
+ };
1021
+ const removeClause = (index) => updateSort(sort.filter((_, idx) => idx !== index));
1022
+ return <section className="workspace-widget-subpanel">
1023
+ <SubPanelHeader title="Sorts" breadcrumb={widget.title} onBack={onBack} />
1024
+ <p className="workspace-panel-label">Sorts</p>
1025
+ <div className="workspace-sort-list">
1026
+ {sort.length === 0 ? <p className="workspace-panel-hint">No sorts applied.</p> : null}
1027
+ {sort.map((clause, index) => <div key={index} className="workspace-sort-row">
1028
+ <select
1029
+ aria-label={`Sort ${index + 1} field`}
1030
+ value={clause.fieldId}
1031
+ onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1032
+ >
1033
+ {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1034
+ {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1035
+ </select>
1036
+ <select
1037
+ aria-label={`Sort ${index + 1} direction`}
1038
+ value={clause.direction || DEFAULT_SORT_DIRECTION}
1039
+ onChange={(event) => updateClause(index, { direction: event.target.value })}
1040
+ >
1041
+ {KNOWN_SORT_DIRECTIONS.map((dir) => <option key={dir} value={dir}>{dir === "asc" ? "Ascending" : "Descending"}</option>)}
1042
+ </select>
1043
+ <button type="button" aria-label={`Remove sort ${index + 1}`} onClick={() => removeClause(index)}>โœ•</button>
1044
+ </div>)}
1045
+ </div>
1046
+ <button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
1047
+ + Add sort
1048
+ </button>
1049
+ <p className="workspace-panel-hint">
1050
+ Sort metadata persists with the widget. Live integrations are not queried from the browser.
1051
+ </p>
1052
+ </section>;
1053
+ }
1054
+
1055
+ function FilterSubPanel({ widget, onChange, onBack }) {
1056
+ const filter = getFilterConfig(widget);
1057
+ const columns = getColumnList(widget);
1058
+ const setFilter = (next) => onChange({ ...widget.config, filter: next });
1059
+ const setOp = (op) => setFilter({ ...filter, op });
1060
+ const addClause = () => {
1061
+ const fieldId = columns[0] || "";
1062
+ if (!fieldId) return;
1063
+ setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: DEFAULT_FILTER_OPERATOR, value: "" }] });
1064
+ };
1065
+ const updateClause = (index, patch) => {
1066
+ setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
1067
+ };
1068
+ const removeClause = (index) => {
1069
+ setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
1070
+ };
1071
+ return <section className="workspace-widget-subpanel">
1072
+ <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1073
+ <div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
1074
+ {KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
1075
+ key={op}
1076
+ type="button"
1077
+ role="radio"
1078
+ aria-checked={filter.op === op}
1079
+ className={filter.op === op ? "active" : ""}
1080
+ onClick={() => setOp(op)}
1081
+ >{op.toUpperCase()}</button>)}
1082
+ </div>
1083
+ <div className="workspace-filter-list">
1084
+ {filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
1085
+ {filter.clauses.map((clause, index) => {
1086
+ const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
1087
+ return <div key={index} className="workspace-filter-clause">
1088
+ <select
1089
+ aria-label={`Filter ${index + 1} field`}
1090
+ value={clause.fieldId}
1091
+ onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1092
+ >
1093
+ {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1094
+ {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1095
+ </select>
1096
+ <select
1097
+ aria-label={`Filter ${index + 1} operator`}
1098
+ value={clause.operator || DEFAULT_FILTER_OPERATOR}
1099
+ onChange={(event) => updateClause(index, { operator: event.target.value })}
1100
+ >
1101
+ {KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
1102
+ </select>
1103
+ {!valueless ? <input
1104
+ aria-label={`Filter ${index + 1} value`}
1105
+ value={clause.value ?? ""}
1106
+ placeholder="value"
1107
+ onChange={(event) => updateClause(index, { value: event.target.value })}
1108
+ /> : <span className="workspace-filter-clause-empty">โ€”</span>}
1109
+ <button type="button" aria-label={`Remove filter ${index + 1}`} onClick={() => removeClause(index)}>โœ•</button>
1110
+ </div>;
1111
+ })}
1112
+ </div>
1113
+ <button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
1114
+ + Add filter
1115
+ </button>
1116
+ <p className="workspace-panel-hint">
1117
+ Filter metadata persists with the widget. Live integration queries stay in the CLI / hosted layers.
1118
+ </p>
1119
+ </section>;
1120
+ }
1121
+
1122
+ function ChartConfigPanel({ widget, onChange, onSubPage }) {
1123
+ const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
1124
+ const xAxis = getChartAxis(widget, "xAxis");
1125
+ const yAxis = getChartAxis(widget, "yAxis");
1126
+ const style = getChartStyle(widget);
1127
+ const setChartType = (type) => onChange({ ...widget.config, chartType: type });
1128
+ const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
1129
+ const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
1130
+ const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
1131
+ return <section className="workspace-chart-config">
1132
+ <p className="workspace-panel-label">Chart type</p>
1133
+ <div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
1134
+ {VISIBLE_CHART_TYPES.map((type) => {
1135
+ const TypeIcon = CHART_TYPE_ICONS[type];
1136
+ return <button
1137
+ key={type}
1138
+ type="button"
1139
+ role="tab"
1140
+ aria-selected={chartType === type}
1141
+ className={chartType === type ? "active" : ""}
1142
+ onClick={() => setChartType(type)}
1143
+ title={CHART_TYPE_LABELS[type]}
1144
+ >
1145
+ <IconGlyph icon={TypeIcon} size={17} />
1146
+ <em>{CHART_TYPE_LABELS[type]}</em>
1147
+ </button>;
1148
+ })}
1149
+ </div>
1150
+ <button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
1151
+ <span>Source</span><code>{summarizeSource(widget)}</code>
1152
+ </button>
1153
+ <button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
1154
+ <span>Filter</span><code>{summarizeFilter(widget)}</code>
1155
+ </button>
1156
+ <p className="workspace-panel-label">X axis</p>
1157
+ <label>
1158
+ <span>Data on display</span>
1159
+ <input
1160
+ value={xAxis.field || ""}
1161
+ placeholder="Stage"
1162
+ onChange={(event) => setXAxis({ field: event.target.value })}
1163
+ />
1164
+ </label>
1165
+ <label>
1166
+ <span>Sort by</span>
1167
+ <select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
1168
+ <option value="position">Stage position ascending</option>
1169
+ <option value="asc">Value ascending</option>
1170
+ <option value="desc">Value descending</option>
1171
+ </select>
1172
+ </label>
1173
+ <label className="workspace-toggle-row">
1174
+ <span>Omit zero values</span>
1175
+ <input
1176
+ type="checkbox"
1177
+ checked={Boolean(xAxis.omitZero)}
1178
+ onChange={(event) => setXAxis({ omitZero: event.target.checked })}
1179
+ />
1180
+ </label>
1181
+ <p className="workspace-panel-label">Y axis</p>
1182
+ <label>
1183
+ <span>Aggregation</span>
1184
+ <select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
1185
+ {KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
1186
+ </select>
1187
+ </label>
1188
+ <label>
1189
+ <span>Data on display</span>
1190
+ <input
1191
+ value={yAxis.field || ""}
1192
+ placeholder="Amount"
1193
+ onChange={(event) => setYAxis({ field: event.target.value })}
1194
+ />
1195
+ </label>
1196
+ <label>
1197
+ <span>Group by</span>
1198
+ <input
1199
+ value={yAxis.groupBy || ""}
1200
+ placeholder="โ€”"
1201
+ onChange={(event) => setYAxis({ groupBy: event.target.value })}
1202
+ />
1203
+ </label>
1204
+ <div className="workspace-axis-range">
1205
+ <label>
1206
+ <span>Min range</span>
1207
+ <input
1208
+ value={yAxis.min ?? ""}
1209
+ placeholder="Min"
1210
+ onChange={(event) => setYAxis({ min: event.target.value })}
1211
+ />
1212
+ </label>
1213
+ <label>
1214
+ <span>Max range</span>
1215
+ <input
1216
+ value={yAxis.max ?? ""}
1217
+ placeholder="Max"
1218
+ onChange={(event) => setYAxis({ max: event.target.value })}
1219
+ />
1220
+ </label>
1221
+ </div>
1222
+ <p className="workspace-panel-label">Style</p>
1223
+ <label>
1224
+ <span>Colors</span>
1225
+ <select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
1226
+ <option value="auto">Auto</option>
1227
+ <option value="accent">Accent</option>
1228
+ <option value="manual">Manual</option>
1229
+ </select>
1230
+ </label>
1231
+ {style.colors === "manual" ? <div className="workspace-color-picker-row">
1232
+ <label>
1233
+ <span>Manual color</span>
1234
+ <input
1235
+ type="color"
1236
+ value={style.manualColor || "#38bdf8"}
1237
+ onChange={(event) => setStyle({ manualColor: event.target.value })}
1238
+ />
1239
+ </label>
1240
+ <input
1241
+ aria-label="Manual color hex"
1242
+ value={style.manualColor || "#38bdf8"}
1243
+ onChange={(event) => setStyle({ manualColor: event.target.value })}
1244
+ />
1245
+ </div> : null}
1246
+ <label>
1247
+ <span>Axis name</span>
1248
+ <input
1249
+ value={style.axisName || ""}
1250
+ placeholder="None"
1251
+ onChange={(event) => setStyle({ axisName: event.target.value })}
1252
+ />
1253
+ </label>
1254
+ <label className="workspace-toggle-row">
1255
+ <span>Data labels</span>
1256
+ <input
1257
+ type="checkbox"
1258
+ checked={Boolean(style.dataLabels)}
1259
+ onChange={(event) => setStyle({ dataLabels: event.target.checked })}
1260
+ />
1261
+ </label>
1262
+ </section>;
1263
+ }
1264
+
1265
+ function CommandPalette({ commands, onClose }) {
1266
+ const [query, setQuery] = useState("");
1267
+ const inputRef = useRef(null);
1268
+ const [highlight, setHighlight] = useState(0);
1269
+ useEffect(() => {
1270
+ inputRef.current?.focus();
1271
+ }, []);
1272
+ const filtered = useMemo(() => {
1273
+ const trimmed = query.trim().toLowerCase();
1274
+ if (!trimmed) return commands;
1275
+ return commands.filter((command) => {
1276
+ const haystack = `${command.label} ${command.group || ""} ${(command.aliases || []).join(" ")}`.toLowerCase();
1277
+ return haystack.includes(trimmed);
1278
+ });
1279
+ }, [commands, query]);
1280
+ useEffect(() => {
1281
+ setHighlight((value) => Math.min(value, Math.max(0, filtered.length - 1)));
1282
+ }, [filtered.length]);
1283
+ const handleKeyDown = (event) => {
1284
+ if (event.key === "ArrowDown") {
1285
+ event.preventDefault();
1286
+ setHighlight((value) => Math.min(filtered.length - 1, value + 1));
1287
+ } else if (event.key === "ArrowUp") {
1288
+ event.preventDefault();
1289
+ setHighlight((value) => Math.max(0, value - 1));
1290
+ } else if (event.key === "Enter") {
1291
+ event.preventDefault();
1292
+ const command = filtered[highlight];
1293
+ if (command && !command.disabled) {
1294
+ command.run();
1295
+ onClose();
1296
+ }
1297
+ } else if (event.key === "Escape") {
1298
+ event.preventDefault();
1299
+ onClose();
1300
+ }
1301
+ };
1302
+ const groups = useMemo(() => {
1303
+ const map = new Map();
1304
+ filtered.forEach((command) => {
1305
+ const key = command.group || "General";
1306
+ if (!map.has(key)) map.set(key, []);
1307
+ map.get(key).push(command);
1308
+ });
1309
+ return Array.from(map.entries());
1310
+ }, [filtered]);
1311
+ return <div className="workspace-command-palette" role="dialog" aria-modal="true" aria-label="Command palette">
1312
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1313
+ <section className="workspace-command-palette-panel" onKeyDown={handleKeyDown}>
1314
+ <header className="workspace-command-palette-input">
1315
+ <span aria-hidden="true">โŒ˜</span>
1316
+ <input
1317
+ ref={inputRef}
1318
+ value={query}
1319
+ onChange={(event) => setQuery(event.target.value)}
1320
+ placeholder="Type a commandโ€ฆ"
1321
+ aria-label="Command palette search"
1322
+ />
1323
+ <kbd>esc</kbd>
1324
+ </header>
1325
+ <div className="workspace-command-palette-list" role="listbox">
1326
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No matching commands.</p> : null}
1327
+ {groups.map(([group, items]) => <div key={group} className="workspace-command-palette-group">
1328
+ <p className="workspace-panel-label">{group}</p>
1329
+ {items.map((command) => {
1330
+ const globalIndex = filtered.indexOf(command);
1331
+ const isHighlighted = globalIndex === highlight;
1332
+ return <button
1333
+ key={command.id}
1334
+ type="button"
1335
+ role="option"
1336
+ aria-selected={isHighlighted}
1337
+ className={`workspace-command-palette-item${isHighlighted ? " active" : ""}${command.disabled ? " disabled" : ""}`}
1338
+ disabled={command.disabled}
1339
+ onMouseEnter={() => setHighlight(globalIndex)}
1340
+ onClick={() => {
1341
+ if (command.disabled) return;
1342
+ command.run();
1343
+ onClose();
1344
+ }}
1345
+ >
1346
+ <span aria-hidden="true">{typeof command.icon === "string" ? command.icon : <IconGlyph icon={command.icon} size={15} />}</span>
1347
+ <span className="workspace-command-palette-label">{command.label}</span>
1348
+ {command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
1349
+ </button>;
1350
+ })}
1351
+ </div>)}
1352
+ </div>
1353
+ <footer className="workspace-command-palette-footer">
1354
+ <span>โ†‘ โ†“ navigate</span>
1355
+ <span>โ†ต run</span>
1356
+ <span>esc close</span>
1357
+ </footer>
1358
+ </section>
1359
+ </div>;
1360
+ }
1361
+
1362
+ function RichTextEditor({ value, onChange }) {
1363
+ const textareaRef = useRef(null);
1364
+ const insert = useCallback((prefix, suffix = "", placeholder = "text") => {
1365
+ const textarea = textareaRef.current;
1366
+ const current = value || "";
1367
+ const start = textarea?.selectionStart ?? current.length;
1368
+ const end = textarea?.selectionEnd ?? current.length;
1369
+ const selected = current.slice(start, end) || placeholder;
1370
+ const next = `${current.slice(0, start)}${prefix}${selected}${suffix}${current.slice(end)}`;
1371
+ onChange(next);
1372
+ requestAnimationFrame(() => {
1373
+ textarea?.focus();
1374
+ const cursor = start + prefix.length + selected.length + suffix.length;
1375
+ textarea?.setSelectionRange(cursor, cursor);
1376
+ });
1377
+ }, [onChange, value]);
1378
+ return <div className="workspace-rich-text-editor">
1379
+ <div className="workspace-rich-text-toolbar" role="toolbar" aria-label="Rich text controls">
1380
+ <button type="button" aria-label="Heading" onClick={() => insert("## ", "", "Heading")}><Type size={14} /></button>
1381
+ <button type="button" aria-label="Bold" onClick={() => insert("**", "**")}><strong>B</strong></button>
1382
+ <button type="button" aria-label="Italic" onClick={() => insert("*", "*")}><Italic size={14} /></button>
1383
+ <button type="button" aria-label="Quote" onClick={() => insert("> ", "", "Quote")}><Quote size={14} /></button>
1384
+ <button type="button" aria-label="Bullet list" onClick={() => insert("- ", "", "List item")}><List size={14} /></button>
1385
+ <button type="button" aria-label="Link" onClick={() => insert("[", "](https://)", "Link")}><LinkIcon size={14} /></button>
1386
+ </div>
1387
+ <textarea
1388
+ ref={textareaRef}
1389
+ placeholder="Write text..."
1390
+ value={value || ""}
1391
+ onChange={(event) => onChange(event.target.value)}
1392
+ />
1393
+ </div>;
1394
+ }
1395
+
1396
+ function IframePreviewModal({ widget, onClose }) {
1397
+ const url = widget?.config?.url || "";
1398
+ const valid = isLikelyHttpUrl(url);
1399
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label={`${widget?.title || "iFrame"} expanded preview`}>
1400
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1401
+ <section className="workspace-iframe-modal">
1402
+ <header>
1403
+ <div>
1404
+ <p>iFrame preview</p>
1405
+ <h2>{widget?.title || "Untitled iFrame"}</h2>
1406
+ </div>
1407
+ <div>
1408
+ {valid ? <a href={url} target="_blank" rel="noreferrer"><ExternalLink size={15} /> Open</a> : null}
1409
+ <button type="button" aria-label="Close iFrame preview" onClick={onClose}><X size={16} /></button>
1410
+ </div>
1411
+ </header>
1412
+ {valid ? <iframe title={widget?.title || "iFrame preview"} src={url} /> : <div className="workspace-iframe-invalid">Enter a valid http(s) URL to preview this iFrame.</div>}
1413
+ </section>
1414
+ </div>;
1415
+ }
1416
+
1417
+ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1418
+ const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
1419
+ const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
1420
+ const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
460
1421
  const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
461
1422
  const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
1423
+ const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
1424
+ const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
1425
+ const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
1426
+ const chartColor = chartStyle.colors === "manual" && chartStyle.manualColor ? chartStyle.manualColor : undefined;
462
1427
  return <article
463
1428
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
464
1429
  onClick={onSelect}
@@ -468,7 +1433,11 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
468
1433
  }}
469
1434
  >
470
1435
  <div className="workspace-widget-preview-title">
471
- <span aria-hidden="true">::</span>
1436
+ <span
1437
+ aria-hidden="true"
1438
+ className="workspace-widget-drag-handle"
1439
+ onPointerDown={(event) => onMoveStart(event)}
1440
+ >::</span>
472
1441
  <strong>{widget.title}</strong>
473
1442
  <button
474
1443
  aria-label={`Remove ${widget.title}`}
@@ -477,7 +1446,7 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
477
1446
  onRemove();
478
1447
  }}
479
1448
  type="button"
480
- >x</button>
1449
+ ><X size={13} /></button>
481
1450
  </div>
482
1451
  {widget.kind === "view" ? <div
483
1452
  className="workspace-view-table"
@@ -491,11 +1460,23 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
491
1460
  <footer>Calculate</footer>
492
1461
  </div> : null}
493
1462
  {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
494
- {widget.config?.url ? <span>{widget.config.url}</span> : <span>Invalid URL</span>}
1463
+ {isLikelyHttpUrl(widget.config?.url) ? <iframe title={`${widget.title} preview`} src={widget.config.url} /> : <span>Enter a valid http(s) URL</span>}
1464
+ <button type="button" onClick={(event) => {
1465
+ event.stopPropagation();
1466
+ onExpandIframe(widget);
1467
+ }}><Maximize2 size={14} /> Expand</button>
495
1468
  </div> : null}
496
- {widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
497
- {widget.kind === "chart" ? <div className="workspace-chart-preview">
498
- {chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }} />)}
1469
+ {widget.kind === "rich-text" ? <div className="workspace-rich-text-preview" dangerouslySetInnerHTML={{ __html: richTextToHtml(widget.config?.text) }} /> : null}
1470
+ {widget.kind === "chart" ? <div className={`workspace-chart-preview kind-${chartType}`} data-data-labels={dataLabels ? "true" : "false"} style={chartColor ? { "--chart-accent": chartColor } : undefined}>
1471
+ {chartType === "sum" ? <strong className="workspace-chart-sum">{chartValues.reduce((acc, v) => acc + v, 0)}</strong> : null}
1472
+ {chartType === "gauge" ? <span className="workspace-chart-gauge" style={{ "--gauge-fill": `${Math.min(100, chartValues[chartValues.length - 1] || 0)}%` }} /> : null}
1473
+ {chartType === "pie" ? <span className="workspace-chart-pie" aria-hidden="true" /> : null}
1474
+ {chartType === "bar-horizontal" ? chartValues.map((height, index) => <span key={index} className="workspace-chart-bar-h" style={{ width: `${Math.max(5, Math.min(100, height))}%` }}>
1475
+ {dataLabels ? <em>{height}</em> : null}
1476
+ </span>) : null}
1477
+ {chartType === "bar-vertical" || !chartType ? chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }}>
1478
+ {dataLabels ? <em>{height}</em> : null}
1479
+ </span>) : null}
499
1480
  </div> : null}
500
1481
  {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
501
1482
  aria-label={`Resize ${widget.title} from ${corner} corner`}
@@ -507,7 +1488,170 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
507
1488
  </article>;
508
1489
  }
509
1490
 
510
- function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
1491
+ const DEFAULT_PERSISTENCE = {
1492
+ mode: "filesystem",
1493
+ adapter: "filesystem",
1494
+ canSave: true,
1495
+ saveLabel: "Save writes growthub.config.json on disk.",
1496
+ reason: "Local development",
1497
+ nextAction: null,
1498
+ guidance: null
1499
+ };
1500
+
1501
+ function countCanvasWidgets(canvas) {
1502
+ if (!canvas) return 0;
1503
+ if (Array.isArray(canvas.tabs) && canvas.tabs.length) {
1504
+ return canvas.tabs.reduce((acc, tab) => acc + (Array.isArray(tab.widgets) ? tab.widgets.length : 0), 0);
1505
+ }
1506
+ return Array.isArray(canvas.widgets) ? canvas.widgets.length : 0;
1507
+ }
1508
+
1509
+ function countCanvasTabs(canvas) {
1510
+ if (!canvas) return 0;
1511
+ if (Array.isArray(canvas.tabs) && canvas.tabs.length) return canvas.tabs.length;
1512
+ return 1;
1513
+ }
1514
+
1515
+ function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integrationAdapter, onClose }) {
1516
+ const branding = (config && config.branding) || {};
1517
+ const dashboards = Array.isArray(config?.dashboards) ? config.dashboards : [];
1518
+ const tabCount = countCanvasTabs(config?.canvas);
1519
+ const widgetCount = countCanvasWidgets(config?.canvas);
1520
+ const persist = persistence || DEFAULT_PERSISTENCE;
1521
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace settings">
1522
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1523
+ <section className="workspace-overlay-panel">
1524
+ <header className="workspace-overlay-header">
1525
+ <div>
1526
+ <p>Workspace</p>
1527
+ <h2>Workspace Settings</h2>
1528
+ </div>
1529
+ <button type="button" aria-label="Close workspace settings" onClick={onClose} autoFocus>x</button>
1530
+ </header>
1531
+ <p className="workspace-overlay-note">
1532
+ Inspect-only. Sourced from <code>growthub.config.json</code> + <code>GET /api/workspace</code>.
1533
+ Edit branding by updating <code>growthub.config.json</code> inside your governed fork.
1534
+ The builder itself never holds tokens, never executes hosted workflows, and never bypasses the PATCH allowlist
1535
+ (<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>).
1536
+ </p>
1537
+ <div className="workspace-readiness">
1538
+ <article className="workspace-readiness-section">
1539
+ <h3>Identity</h3>
1540
+ <div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
1541
+ <div className="workspace-readiness-row"><span>Workspace ID</span><code>{config?.id || "Unknown"}</code></div>
1542
+ <div className="workspace-readiness-row"><span>Brand name</span><strong>{branding.name || "Unknown"}</strong></div>
1543
+ <div className="workspace-readiness-row"><span>Logo URL</span><code>{branding.logoUrl || "โ€”"}</code></div>
1544
+ <div className="workspace-readiness-row"><span>Accent</span>
1545
+ <span className="workspace-readiness-badge" style={{ background: branding.accent || "#3f68ff" }}>{branding.accent || "โ€”"}</span>
1546
+ </div>
1547
+ </article>
1548
+ <article className="workspace-readiness-section">
1549
+ <h3>Persistence</h3>
1550
+ <div className="workspace-readiness-row"><span>Mode</span>
1551
+ <span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
1552
+ </div>
1553
+ <div className="workspace-readiness-row"><span>Adapter</span><code>{persist.adapter}</code></div>
1554
+ <div className="workspace-readiness-row"><span>Can save</span>
1555
+ <span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
1556
+ </div>
1557
+ <div className="workspace-readiness-row"><span>Save behavior</span><strong>{persist.saveLabel}</strong></div>
1558
+ <div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
1559
+ {persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
1560
+ {persist.nextAction ? <div className="workspace-readiness-row"><span>Next action</span><em>{persist.nextAction}</em></div> : null}
1561
+ </article>
1562
+ <article className="workspace-readiness-section">
1563
+ <h3>Integrations</h3>
1564
+ <div className="workspace-readiness-row"><span>Integration adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
1565
+ <div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
1566
+ <div className="workspace-readiness-row"><span>Bridge</span>
1567
+ <span className={`workspace-readiness-badge ${adapterConfig.growthubBridge?.hasAccessToken ? "good" : ""}`}>
1568
+ {adapterConfig.growthubBridge?.hasAccessToken ? "token configured" : "no token"}
1569
+ </span>
1570
+ </div>
1571
+ <div className="workspace-readiness-row"><span>Bridge base URL</span><code>{adapterConfig.growthubBridge?.baseUrl || "โ€”"}</code></div>
1572
+ <div className="workspace-readiness-row"><span>Authority</span><strong>{integrationAdapter.authority}</strong></div>
1573
+ </article>
1574
+ <article className="workspace-readiness-section">
1575
+ <h3>Counts</h3>
1576
+ <div className="workspace-readiness-row"><span>Dashboards</span><strong>{dashboards.length}</strong></div>
1577
+ <div className="workspace-readiness-row"><span>Tabs (active canvas)</span><strong>{tabCount}</strong></div>
1578
+ <div className="workspace-readiness-row"><span>Widgets (active canvas)</span><strong>{widgetCount}</strong></div>
1579
+ <div className="workspace-readiness-row"><span>Template format</span><code>growthub-workspace-template</code></div>
1580
+ </article>
1581
+ </div>
1582
+ </section>
1583
+ </div>;
1584
+ }
1585
+
1586
+ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose }) {
1587
+ const persist = persistence || DEFAULT_PERSISTENCE;
1588
+ const pipelines = Array.isArray(config?.pipelines) ? config.pipelines : [];
1589
+ const integrations = Array.isArray(config?.integrations) ? config.integrations : [];
1590
+ const capabilities = Array.isArray(config?.capabilities) ? config.capabilities : [];
1591
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace management">
1592
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
1593
+ <section className="workspace-overlay-panel">
1594
+ <header className="workspace-overlay-header">
1595
+ <div>
1596
+ <p>Workspace</p>
1597
+ <h2>Management</h2>
1598
+ </div>
1599
+ <button type="button" aria-label="Close management panel" onClick={onClose} autoFocus>x</button>
1600
+ </header>
1601
+ <p className="workspace-overlay-note">
1602
+ Inspect-only. Workflow execution stays in <code>growthub workflow</code> / <code>growthub bridge</code>; this panel does not
1603
+ execute, does not call hosted endpoints, and does not expose tokens.
1604
+ </p>
1605
+ <div className="workspace-readiness">
1606
+ <article className="workspace-readiness-section">
1607
+ <h3>Workspace</h3>
1608
+ <div className="workspace-readiness-row"><span>ID</span><code>{config?.id || "Unknown"}</code></div>
1609
+ <div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
1610
+ <div className="workspace-readiness-row"><span>Capabilities</span>
1611
+ <span>{capabilities.length ? capabilities.join(", ") : "none"}</span>
1612
+ </div>
1613
+ </article>
1614
+ <article className="workspace-readiness-section">
1615
+ <h3>API</h3>
1616
+ <div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas</code></div>
1617
+ <div className="workspace-readiness-row"><span>Unknown field</span><code>400</code></div>
1618
+ <div className="workspace-readiness-row"><span>Read-only runtime</span><code>409 + guidance</code></div>
1619
+ <div className="workspace-readiness-row"><span>Can save now</span>
1620
+ <span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
1621
+ </div>
1622
+ </article>
1623
+ <article className="workspace-readiness-section">
1624
+ <h3>Workflows</h3>
1625
+ {pipelines.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
1626
+ <em>No workflows declared in growthub.config.json. Connect via <code>growthub workflow</code> after Bridge auth.</em>
1627
+ </div> : pipelines.map((pipeline, index) => <div className="workspace-readiness-row" key={pipeline.id || index}>
1628
+ <span>{pipeline.id || `pipeline-${index}`}</span><strong>{pipeline.name || "Untitled"}</strong>
1629
+ </div>)}
1630
+ </article>
1631
+ <article className="workspace-readiness-section">
1632
+ <h3>Integrations</h3>
1633
+ <div className="workspace-readiness-row"><span>Adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
1634
+ <div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
1635
+ {integrations.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
1636
+ <em>No static integrations declared. Use <code>growthub bridge agents bind</code> for hosted bindings.</em>
1637
+ </div> : integrations.map((integration, index) => <div className="workspace-readiness-row" key={integration.id || index}>
1638
+ <span>{integration.id || `integration-${index}`}</span><strong>{integration.name || "Untitled"}</strong>
1639
+ </div>)}
1640
+ </article>
1641
+ <article className="workspace-readiness-section">
1642
+ <h3>Persistence</h3>
1643
+ <div className="workspace-readiness-row"><span>Mode</span>
1644
+ <span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
1645
+ </div>
1646
+ <div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
1647
+ {persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
1648
+ </article>
1649
+ </div>
1650
+ </section>
1651
+ </div>;
1652
+ }
1653
+
1654
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
511
1655
  const [config, setConfig] = useState(() => {
512
1656
  const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
513
1657
  ? initialConfig.dashboards.map((dashboard, index) =>
@@ -523,8 +1667,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
523
1667
  const [saving, setSaving] = useState(false);
524
1668
  const [panelOpen, setPanelOpen] = useState(true);
525
1669
  const [templateGalleryOpen, setTemplateGalleryOpen] = useState(false);
1670
+ const [settingsOpen, setSettingsOpen] = useState(false);
1671
+ const [managementOpen, setManagementOpen] = useState(false);
526
1672
  const [previewTemplateId, setPreviewTemplateId] = useState(null);
527
1673
  const [editingDashboardId, setEditingDashboardId] = useState(null);
1674
+ const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
1675
+ const [workspaceView, setWorkspaceView] = useState("dashboards");
528
1676
  const [activeDashboardId, setActiveDashboardId] = useState(() =>
529
1677
  getActiveDashboardId(
530
1678
  Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
@@ -541,13 +1689,20 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
541
1689
  const activeTabId = getActiveTabId(canvas);
542
1690
  const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
543
1691
  const activeWidgets = activeTab.widgets || [];
1692
+ const activeDashboard = dashboards[resolvedActiveDashboardIndex] || dashboards[0] || null;
544
1693
  const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
545
1694
  const [selectedWidgetId, setSelectedWidgetId] = useState(null);
546
1695
  const [dragStartCell, setDragStartCell] = useState(null);
547
1696
  const [dragPreview, setDragPreview] = useState(null);
548
1697
  const [resizeDrag, setResizeDrag] = useState(null);
1698
+ const [moveDrag, setMoveDrag] = useState(null);
549
1699
  const [configMessage, setConfigMessage] = useState("");
1700
+ const [inspectorPath, setInspectorPath] = useState(SUB_PANEL_ROOT);
1701
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
1702
+ const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
1703
+ const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
550
1704
  const resizeDragRef = useRef(null);
1705
+ const moveDragRef = useRef(null);
551
1706
  const importInputRef = useRef(null);
552
1707
  const addSlot = dragPreview || selectedPosition;
553
1708
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
@@ -633,7 +1788,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
633
1788
  setSelectedPosition({ ...DEFAULT_POSITION });
634
1789
  setDragPreview(null);
635
1790
  setEditingDashboardId(dashboard.id);
1791
+ setEditingDashboardDraft(dashboard.name);
636
1792
  setActiveDashboardId(dashboard.id);
1793
+ setWorkspaceView("builder");
637
1794
  setConfigMessage(`Created ${name}`);
638
1795
  return {
639
1796
  ...synced,
@@ -653,8 +1810,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
653
1810
  setSelectedWidgetId(null);
654
1811
  setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
655
1812
  setDragPreview(null);
656
- setEditingDashboardId(dashboard.id);
1813
+ setEditingDashboardId(null);
1814
+ setEditingDashboardDraft("");
657
1815
  setActiveDashboardId(dashboard.id);
1816
+ setWorkspaceView("builder");
658
1817
  setConfigMessage(`Editing ${dashboard.name}`);
659
1818
  return {
660
1819
  ...synced,
@@ -664,32 +1823,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
664
1823
  });
665
1824
  }, [activeDashboardId]);
666
1825
 
667
- const renameDashboard = useCallback((dashboardId, name) => {
668
- const nextName = name.trimStart();
669
- setConfig((prev) => {
670
- const prevDashboards = prev.dashboards || [];
671
- const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
672
- if (index < 0) return prev;
673
- const displayName = nextName || "Untitled";
674
- const nextDashboards = prevDashboards.map((dashboard) =>
675
- dashboard.id === dashboardId ? { ...dashboard, name: displayName, updatedAt: "new" } : dashboard
676
- );
677
- const nextDashboardsWithTabs = nextDashboards.map((dashboard, dashboardIndex) => {
678
- if (dashboardIndex !== index) return dashboard;
679
- const normalized = normalizeDashboard(dashboard, index === 0 ? prev.canvas : undefined);
680
- const renamedTabs = normalized.tabs.map((tab, tabIndex) =>
681
- tabIndex === 0 ? { ...tab, name: displayName } : tab
682
- );
683
- return { ...normalized, tabs: renamedTabs };
684
- });
685
- const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
686
- return {
687
- ...prev,
688
- dashboards: nextDashboardsWithTabs,
689
- canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], prev.canvas)
690
- };
691
- });
692
- }, [activeDashboardId]);
1826
+ const enterDashboardTitleEdit = useCallback((dashboard) => {
1827
+ if (!dashboard) return;
1828
+ setEditingDashboardId(dashboard.id);
1829
+ setEditingDashboardDraft(dashboard.name);
1830
+ setWorkspaceView("dashboards");
1831
+ }, []);
693
1832
 
694
1833
  const updateDashboardStatus = useCallback((dashboardId, status) => {
695
1834
  setConfig((prev) => ({
@@ -714,9 +1853,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
714
1853
  name,
715
1854
  updatedAt: "new",
716
1855
  status: "draft",
717
- tabs: normalizedSource.tabs.map((tab, tabIndex) =>
718
- cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
719
- )
1856
+ tabs: normalizedSource.tabs.map((tab) => cloneTabForDashboard(tab, tab.name || "Tab 1"))
720
1857
  };
721
1858
  dashboard.activeTabId = dashboard.tabs[0].id;
722
1859
  setSelectedWidgetId(null);
@@ -724,6 +1861,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
724
1861
  setDragPreview(null);
725
1862
  setEditingDashboardId(dashboard.id);
726
1863
  setActiveDashboardId(dashboard.id);
1864
+ setWorkspaceView("builder");
727
1865
  setConfigMessage(`Cloned ${sourceDashboard.name}`);
728
1866
  return {
729
1867
  ...synced,
@@ -840,7 +1978,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
840
1978
  const nextCanvas = commitTabs(prev.canvas, nextTabs, prevActiveId);
841
1979
  const nextDashboards = (prev.dashboards || []).map((dashboard, index) =>
842
1980
  index === dashboardIndex
843
- ? updateDashboardCanvas({ ...dashboard, name: template.name, updatedAt: "new", status: "draft" }, nextCanvas)
1981
+ ? updateDashboardCanvas({ ...dashboard, updatedAt: "new", status: "draft" }, nextCanvas)
844
1982
  : dashboard
845
1983
  );
846
1984
  return {
@@ -937,14 +2075,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
937
2075
  }
938
2076
  }, []);
939
2077
 
940
- const save = useCallback(async () => {
2078
+ const persistWorkspaceConfig = useCallback(async (nextConfig, nextActiveDashboardId = activeDashboardId) => {
941
2079
  if (saving) return;
942
2080
  setSaving(true);
943
2081
  try {
944
2082
  const stamp = todayIsoDate();
945
- const syncedConfig = syncActiveDashboard(config, activeDashboardId);
2083
+ const syncedConfig = syncActiveDashboard(nextConfig, nextActiveDashboardId);
946
2084
  const updatedDashboards = (syncedConfig.dashboards || []).map((dashboard) =>
947
- dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], activeDashboardId)
2085
+ dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], nextActiveDashboardId)
948
2086
  ? { ...dashboard, updatedAt: stamp }
949
2087
  : dashboard
950
2088
  );
@@ -962,13 +2100,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
962
2100
  const savedDashboards = (payload.workspaceConfig.dashboards || []).map((dashboard, index) =>
963
2101
  normalizeDashboard(dashboard, index === 0 ? payload.workspaceConfig.canvas : undefined)
964
2102
  );
965
- const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === activeDashboardId) || savedDashboards[0];
2103
+ const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === nextActiveDashboardId) || savedDashboards[0];
966
2104
  setConfig({
967
2105
  ...payload.workspaceConfig,
968
2106
  dashboards: savedDashboards,
969
2107
  canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
970
2108
  });
971
- setConfigMessage("Saved dashboard config");
972
2109
  } else {
973
2110
  setConfigMessage(payload.error || "Save failed");
974
2111
  }
@@ -979,7 +2116,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
979
2116
  }
980
2117
  }, [activeDashboardId, saving, config]);
981
2118
 
982
- const reopenPanel = useCallback(() => setPanelOpen(true), []);
2119
+ const save = useCallback(async () => {
2120
+ await persistWorkspaceConfig(config, activeDashboardId);
2121
+ }, [activeDashboardId, config, persistWorkspaceConfig]);
2122
+
2123
+ const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
2124
+ const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
2125
+ setEditingDashboardId(null);
2126
+ setEditingDashboardDraft("");
2127
+ setConfig(nextConfig);
2128
+ await persistWorkspaceConfig(nextConfig, activeDashboardId);
2129
+ }, [activeDashboardId, config, editingDashboardDraft, persistWorkspaceConfig]);
2130
+
2131
+ const cancelDashboardTitleEdit = useCallback((dashboard) => {
2132
+ if (!dashboard) return;
2133
+ if (editingDashboardDraft.trim() !== dashboard.name) {
2134
+ const discard = window.confirm("Discard dashboard title changes?");
2135
+ if (!discard) {
2136
+ requestAnimationFrame(() => {
2137
+ document.querySelector(`[data-dashboard-title-input="${dashboard.id}"]`)?.focus();
2138
+ });
2139
+ return;
2140
+ }
2141
+ }
2142
+ setEditingDashboardId(null);
2143
+ setEditingDashboardDraft("");
2144
+ }, [editingDashboardDraft]);
2145
+
983
2146
  const closePanel = useCallback(() => setPanelOpen(false), []);
984
2147
  const beginCellDrag = useCallback((index, event) => {
985
2148
  const x = index % GRID_COLUMNS;
@@ -1052,10 +2215,77 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1052
2215
  resizeDragRef.current = null;
1053
2216
  setResizeDrag(null);
1054
2217
  }, []);
2218
+ const beginMoveDrag = useCallback((widget, event) => {
2219
+ event.preventDefault();
2220
+ event.stopPropagation();
2221
+ event.currentTarget.setPointerCapture?.(event.pointerId);
2222
+ const pointerIndex = cellIndexFromGridPointer(event, gridRef.current);
2223
+ const pointerCell = pointerIndex === null ? { x: widget.position.x, y: widget.position.y } : cellPointFromIndex(pointerIndex);
2224
+ const nextMoveDrag = {
2225
+ widgetId: widget.id,
2226
+ offsetX: Math.max(0, Math.min(widget.position.w - 1, pointerCell.x - widget.position.x)),
2227
+ offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
2228
+ };
2229
+ setSelectedWidgetId(widget.id);
2230
+ setPanelOpen(true);
2231
+ moveDragRef.current = nextMoveDrag;
2232
+ setMoveDrag(nextMoveDrag);
2233
+ }, []);
2234
+ const updateMoveDrag = useCallback((event) => {
2235
+ const activeMoveDrag = moveDragRef.current;
2236
+ if (!activeMoveDrag) return;
2237
+ event.preventDefault();
2238
+ const index = cellIndexFromGridPointer(event, gridRef.current);
2239
+ if (index === null) return;
2240
+ const point = cellPointFromIndex(index);
2241
+ const movingWidget = activeWidgets.find((widget) => widget.id === activeMoveDrag.widgetId);
2242
+ if (!movingWidget) return;
2243
+ const nextPosition = clampWidgetMovePosition({
2244
+ x: point.x - activeMoveDrag.offsetX,
2245
+ y: point.y - activeMoveDrag.offsetY
2246
+ }, movingWidget, activeWidgets);
2247
+ setConfig((prev) => {
2248
+ const prevTabs = getTabs(prev.canvas);
2249
+ const prevActiveId = getActiveTabId(prev.canvas);
2250
+ const nextTabs = prevTabs.map((tab) => {
2251
+ if (tab.id !== prevActiveId) return tab;
2252
+ return {
2253
+ ...tab,
2254
+ widgets: (tab.widgets || []).map((widget) =>
2255
+ widget.id === activeMoveDrag.widgetId ? { ...widget, position: nextPosition } : widget
2256
+ )
2257
+ };
2258
+ });
2259
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
2260
+ });
2261
+ }, [activeDashboardId, activeWidgets]);
2262
+ const finishMoveDrag = useCallback(() => {
2263
+ if (!moveDragRef.current) return;
2264
+ moveDragRef.current = null;
2265
+ setMoveDrag(null);
2266
+ }, []);
1055
2267
  const selectWidget = useCallback((widgetId) => {
1056
2268
  setSelectedWidgetId(widgetId);
2269
+ setInspectorPath(SUB_PANEL_ROOT);
1057
2270
  setPanelOpen(true);
1058
2271
  }, []);
2272
+ const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
2273
+ if (!selectedWidgetId) return;
2274
+ setConfig((prev) => {
2275
+ const prevTabs = getTabs(prev.canvas);
2276
+ const prevActiveId = getActiveTabId(prev.canvas);
2277
+ const nextTabs = prevTabs.map((tab) => {
2278
+ if (tab.id !== prevActiveId) return tab;
2279
+ return {
2280
+ ...tab,
2281
+ widgets: (tab.widgets || []).map((widget) =>
2282
+ widget.id === selectedWidgetId ? { ...widget, config: nextConfig } : widget
2283
+ )
2284
+ };
2285
+ });
2286
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
2287
+ });
2288
+ }, [activeDashboardId, selectedWidgetId]);
1059
2289
  const updateSelectedWidget = useCallback((updates) => {
1060
2290
  if (!selectedWidgetId) return;
1061
2291
  setConfig((prev) => {
@@ -1092,10 +2322,56 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1092
2322
  });
1093
2323
  }, [activeDashboardId]);
1094
2324
 
2325
+ const duplicateSelectedWidget = useCallback(() => {
2326
+ if (!selectedWidget) return;
2327
+ setConfig((prev) => {
2328
+ const prevTabs = getTabs(prev.canvas);
2329
+ const prevActiveId = getActiveTabId(prev.canvas);
2330
+ const tabWidgets = prevTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
2331
+ const position = clampPositionToFreeSpace(
2332
+ { ...selectedWidget.position, x: selectedWidget.position.x, y: selectedWidget.position.y },
2333
+ tabWidgets
2334
+ );
2335
+ const cloned = {
2336
+ ...cloneConfig(selectedWidget),
2337
+ id: generateId("widget"),
2338
+ title: `${selectedWidget.title} Copy`,
2339
+ position
2340
+ };
2341
+ const nextTabs = prevTabs.map((tab) => {
2342
+ if (tab.id !== prevActiveId) return tab;
2343
+ return { ...tab, widgets: [...(tab.widgets || []), cloned] };
2344
+ });
2345
+ setSelectedWidgetId(cloned.id);
2346
+ setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
2347
+ setConfigMessage(`Duplicated ${selectedWidget.title}`);
2348
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
2349
+ });
2350
+ }, [activeDashboardId, selectedWidget]);
2351
+
1095
2352
  const closeTemplateGallery = useCallback(() => {
1096
2353
  setTemplateGalleryOpen(false);
1097
2354
  setPreviewTemplateId(null);
1098
2355
  }, []);
2356
+ const closeSettings = useCallback(() => setSettingsOpen(false), []);
2357
+ const closeManagement = useCallback(() => setManagementOpen(false), []);
2358
+ const resetWidgetSelection = useCallback(() => {
2359
+ setSelectedWidgetId(null);
2360
+ setPanelOpen(true);
2361
+ }, []);
2362
+ const showDashboardHome = useCallback(() => {
2363
+ setEditingDashboardId(null);
2364
+ setEditingDashboardDraft("");
2365
+ setWorkspaceView("dashboards");
2366
+ }, []);
2367
+ const resetWidgetSelectionOnOutsidePointer = useCallback((event) => {
2368
+ if (!selectedWidgetId) return;
2369
+ if (resizeDragRef.current || moveDragRef.current) return;
2370
+ const target = event.target;
2371
+ if (!(target instanceof Element)) return;
2372
+ if (target.closest(".workspace-widget-preview, .workspace-widget-panel, .workspace-overlay, .template-gallery")) return;
2373
+ resetWidgetSelection();
2374
+ }, [resetWidgetSelection, selectedWidgetId]);
1099
2375
 
1100
2376
  useEffect(() => {
1101
2377
  if (!templateGalleryOpen) return undefined;
@@ -1106,20 +2382,212 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1106
2382
  return () => window.removeEventListener("keydown", handler);
1107
2383
  }, [templateGalleryOpen, closeTemplateGallery]);
1108
2384
 
1109
- const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
2385
+ useEffect(() => {
2386
+ if (!settingsOpen) return undefined;
2387
+ const handler = (event) => {
2388
+ if (event.key === "Escape") closeSettings();
2389
+ };
2390
+ window.addEventListener("keydown", handler);
2391
+ return () => window.removeEventListener("keydown", handler);
2392
+ }, [settingsOpen, closeSettings]);
2393
+
2394
+ useEffect(() => {
2395
+ if (!managementOpen) return undefined;
2396
+ const handler = (event) => {
2397
+ if (event.key === "Escape") closeManagement();
2398
+ };
2399
+ window.addEventListener("keydown", handler);
2400
+ return () => window.removeEventListener("keydown", handler);
2401
+ }, [managementOpen, closeManagement]);
2402
+
2403
+ useEffect(() => {
2404
+ const handler = (event) => {
2405
+ if (commandPaletteOpen) return;
2406
+ const target = event.target;
2407
+ const isEditable = target instanceof HTMLElement && (
2408
+ target.tagName === "INPUT" ||
2409
+ target.tagName === "TEXTAREA" ||
2410
+ target.isContentEditable
2411
+ );
2412
+ const meta = event.metaKey || event.ctrlKey;
2413
+ if (meta && (event.key === "k" || event.key === "K")) {
2414
+ event.preventDefault();
2415
+ setCommandPaletteOpen(true);
2416
+ return;
2417
+ }
2418
+ if (event.key === "/" && !isEditable && !templateGalleryOpen && !settingsOpen && !managementOpen) {
2419
+ event.preventDefault();
2420
+ setCommandPaletteOpen(true);
2421
+ return;
2422
+ }
2423
+ if (!isEditable && workspaceView === "builder" && panelOpen && !commandPaletteOpen && !templateGalleryOpen && !settingsOpen && !managementOpen) {
2424
+ const quickMap = { c: "chart", v: "view", i: "iframe", t: "rich-text" };
2425
+ const kind = quickMap[event.key.toLowerCase()];
2426
+ if (kind) {
2427
+ event.preventDefault();
2428
+ addWidget(kind);
2429
+ }
2430
+ }
2431
+ };
2432
+ window.addEventListener("keydown", handler);
2433
+ return () => window.removeEventListener("keydown", handler);
2434
+ }, [addWidget, commandPaletteOpen, managementOpen, panelOpen, settingsOpen, templateGalleryOpen, workspaceView]);
2435
+
2436
+ const builderStyle = workspaceView === "dashboards" || !panelOpen
2437
+ ? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
2438
+ : undefined;
2439
+
2440
+ const closeCommandPalette = useCallback(() => setCommandPaletteOpen(false), []);
1110
2441
 
1111
- return <main className="workspace-builder" style={builderStyle}>
2442
+ const paletteCommands = useMemo(() => {
2443
+ const list = [];
2444
+ list.push({
2445
+ id: "dashboard.new", group: "Dashboard", icon: Plus, label: "Create dashboard", shortcut: "N",
2446
+ run: () => addDashboard()
2447
+ });
2448
+ list.push({
2449
+ id: "dashboard.duplicate", group: "Dashboard", icon: Copy, label: "Duplicate dashboard",
2450
+ run: () => duplicateDashboard(),
2451
+ disabled: !activeDashboard
2452
+ });
2453
+ list.push({
2454
+ id: "dashboard.delete", group: "Dashboard", icon: Trash2, label: "Delete dashboard",
2455
+ disabled: !activeDashboard,
2456
+ run: () => {
2457
+ if (resolvedActiveDashboardIndex >= 0) deleteDashboard(resolvedActiveDashboardIndex);
2458
+ }
2459
+ });
2460
+ list.push({
2461
+ id: "dashboard.export", group: "Dashboard", icon: Download, label: "Export dashboard",
2462
+ run: () => exportConfig()
2463
+ });
2464
+ list.push({
2465
+ id: "dashboard.import", group: "Dashboard", icon: Import, label: "Import dashboards",
2466
+ run: () => importInputRef.current?.click()
2467
+ });
2468
+ list.push({
2469
+ id: "dashboard.templates", group: "Dashboard", icon: Grid2X2, label: "Open template gallery",
2470
+ run: () => setTemplateGalleryOpen(true)
2471
+ });
2472
+ list.push({
2473
+ id: "tab.new", group: "Tab", icon: Plus, label: "New tab",
2474
+ run: () => addTab()
2475
+ });
2476
+ list.push({
2477
+ id: "tab.duplicate", group: "Tab", icon: Copy, label: "Duplicate tab",
2478
+ run: () => duplicateTab()
2479
+ });
2480
+ [
2481
+ ["chart", "Add chart widget", "C"],
2482
+ ["view", "Add view widget", "V"],
2483
+ ["iframe", "Add iFrame widget", "I"],
2484
+ ["rich-text", "Add rich text widget", "T"]
2485
+ ].forEach(([kind, label, shortcut]) => {
2486
+ list.push({
2487
+ id: `widget.add.${kind}`,
2488
+ group: "Widget Add",
2489
+ icon: WIDGET_KIND_ICONS[kind],
2490
+ label,
2491
+ shortcut,
2492
+ disabled: workspaceView !== "builder",
2493
+ run: () => addWidget(kind)
2494
+ });
2495
+ });
2496
+ list.push({
2497
+ id: "widget.duplicate", group: "Widget", icon: Copy, label: "Duplicate selected widget",
2498
+ disabled: !selectedWidget,
2499
+ run: () => duplicateSelectedWidget()
2500
+ });
2501
+ list.push({
2502
+ id: "widget.remove", group: "Widget", icon: Trash2, label: "Remove selected widget",
2503
+ disabled: !selectedWidget,
2504
+ run: () => selectedWidget && removeSelectedWidget(selectedWidget.id)
2505
+ });
2506
+ list.push({
2507
+ id: "widget.source", group: "Widget", icon: Database, label: "Open widget source",
2508
+ disabled: !selectedWidget,
2509
+ run: () => {
2510
+ setPanelOpen(true);
2511
+ setInspectorPath("source");
2512
+ }
2513
+ });
2514
+ list.push({
2515
+ id: "widget.fields", group: "Widget", icon: Columns3, label: "Open widget fields",
2516
+ disabled: !(selectedWidget && selectedWidget.kind === "view"),
2517
+ run: () => {
2518
+ setPanelOpen(true);
2519
+ setInspectorPath("fields");
2520
+ }
2521
+ });
2522
+ list.push({
2523
+ id: "widget.sort", group: "Widget", icon: SlidersHorizontal, label: "Open widget sorts",
2524
+ disabled: !(selectedWidget && selectedWidget.kind === "view"),
2525
+ run: () => {
2526
+ setPanelOpen(true);
2527
+ setInspectorPath("sort");
2528
+ }
2529
+ });
2530
+ list.push({
2531
+ id: "widget.filter", group: "Widget", icon: Filter, label: "Open widget filter",
2532
+ disabled: !selectedWidget,
2533
+ run: () => {
2534
+ setPanelOpen(true);
2535
+ setInspectorPath("filter");
2536
+ }
2537
+ });
2538
+ list.push({
2539
+ id: "workspace.save", group: "Workspace", icon: Save, label: saving ? "Saving..." : "Save workspace",
2540
+ disabled: saving,
2541
+ shortcut: "S",
2542
+ run: () => save()
2543
+ });
2544
+ list.push({
2545
+ id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
2546
+ run: () => setSettingsOpen(true)
2547
+ });
2548
+ list.push({
2549
+ id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
2550
+ run: () => setManagementOpen(true)
2551
+ });
2552
+ list.push({
2553
+ id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
2554
+ run: () => showDashboardHome()
2555
+ });
2556
+ list.push({
2557
+ id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
2558
+ run: () => { window.location.href = "/settings/integrations"; }
2559
+ });
2560
+ return list;
2561
+ }, [
2562
+ activeDashboard,
2563
+ addWidget,
2564
+ addDashboard,
2565
+ addTab,
2566
+ deleteDashboard,
2567
+ duplicateDashboard,
2568
+ duplicateSelectedWidget,
2569
+ duplicateTab,
2570
+ exportConfig,
2571
+ removeSelectedWidget,
2572
+ resolvedActiveDashboardIndex,
2573
+ save,
2574
+ saving,
2575
+ selectedWidget,
2576
+ showDashboardHome,
2577
+ workspaceView
2578
+ ]);
2579
+
2580
+ return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
1112
2581
  <aside className="workspace-rail" aria-label="Workspace navigation">
1113
2582
  <div className="workspace-brand">
1114
2583
  <span className="workspace-mark">G</span>
1115
2584
  <span>Growthub Workspace</span>
1116
2585
  </div>
1117
2586
  <nav className="workspace-nav">
1118
- <a className="active" href="#dashboards">Dashboards</a>
1119
- <a href="#canvas">Canvas</a>
1120
- <a href="#widgets" onClick={reopenPanel}>Widgets</a>
1121
- <a href="#bindings" onClick={reopenPanel}>Bindings</a>
2587
+ <button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
1122
2588
  <Link href="/settings/integrations">Integrations</Link>
2589
+ <button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
2590
+ <button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
1123
2591
  </nav>
1124
2592
  <div className="workspace-rail-status">
1125
2593
  <span className="status-dot" />
@@ -1130,16 +2598,21 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1130
2598
  <section className="workspace-surface">
1131
2599
  <header className="workspace-toolbar">
1132
2600
  <div>
1133
- <p>Official starter</p>
1134
- <h1>{config.name}</h1>
2601
+ {workspaceView === "builder" ? <>
2602
+ <p>{activeTab?.name || "Tab 1"}</p>
2603
+ <h1>{activeDashboard?.name || "Untitled"}</h1>
2604
+ </> : <>
2605
+ <p>Workspace home</p>
2606
+ <h1>Dashboards</h1>
2607
+ </>}
1135
2608
  </div>
1136
2609
  <div className="workspace-toolbar-actions">
1137
- <button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
1138
- <button type="button" onClick={addDashboard}>New Dashboard</button>
1139
- <button type="button" onClick={duplicateDashboard}>Duplicate Dashboard</button>
1140
- <button type="button" onClick={() => importInputRef.current?.click()}>Import</button>
1141
- <button type="button" onClick={exportConfig}>Export</button>
1142
- <button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
2610
+ <button type="button" onClick={() => setTemplateGalleryOpen(true)}><Grid2X2 size={15} />Templates</button>
2611
+ <button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
2612
+ <button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
2613
+ <button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
2614
+ <button type="button" onClick={exportConfig}><Download size={15} />Export</button>
2615
+ <button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
1143
2616
  </div>
1144
2617
  <input
1145
2618
  ref={importInputRef}
@@ -1149,12 +2622,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1149
2622
  onChange={importConfig}
1150
2623
  />
1151
2624
  </header>
1152
- {configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
1153
2625
 
1154
- <section className="workspace-table" id="dashboards" aria-label="Dashboards">
2626
+ {workspaceView === "dashboards" ? <section className="workspace-table" id="dashboards" aria-label="Dashboards">
1155
2627
  <div className="workspace-table-heading">
1156
2628
  <strong>Dashboards</strong>
1157
- <span>{dashboards.length} template</span>
2629
+ <span>{dashboards.length} dashboard{dashboards.length === 1 ? "" : "s"}</span>
1158
2630
  </div>
1159
2631
  <div className="workspace-table-row workspace-table-head">
1160
2632
  <span>Title</span>
@@ -1165,19 +2637,35 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1165
2637
  </div>
1166
2638
  {dashboards.map((dashboard, index) => <div className="workspace-table-row" key={dashboard.id}>
1167
2639
  <span className="workspace-dashboard-title">
1168
- {editingDashboardId === dashboard.id ? <input
1169
- aria-label={`Rename ${dashboard.name}`}
1170
- onBlur={() => setEditingDashboardId(null)}
1171
- onChange={(event) => renameDashboard(dashboard.id, event.target.value)}
1172
- onKeyDown={(event) => {
1173
- if (event.key === "Enter" || event.key === "Escape") {
1174
- event.currentTarget.blur();
1175
- }
1176
- }}
1177
- value={dashboard.name}
1178
- /> : <button
2640
+ {editingDashboardId === dashboard.id ? <span className="workspace-dashboard-title-editor">
2641
+ <input
2642
+ aria-label={`Rename ${dashboard.name}`}
2643
+ autoFocus
2644
+ data-dashboard-title-input={dashboard.id}
2645
+ onBlur={() => cancelDashboardTitleEdit(dashboard)}
2646
+ onChange={(event) => setEditingDashboardDraft(event.target.value)}
2647
+ onKeyDown={(event) => {
2648
+ if (event.key === "Enter") {
2649
+ event.preventDefault();
2650
+ confirmDashboardTitleEdit(dashboard.id);
2651
+ }
2652
+ if (event.key === "Escape") {
2653
+ event.preventDefault();
2654
+ cancelDashboardTitleEdit(dashboard);
2655
+ }
2656
+ }}
2657
+ value={editingDashboardDraft}
2658
+ />
2659
+ <button
2660
+ aria-label={`Confirm ${dashboard.name} title`}
2661
+ className="workspace-dashboard-title-confirm"
2662
+ onMouseDown={(event) => event.preventDefault()}
2663
+ onClick={() => confirmDashboardTitleEdit(dashboard.id)}
2664
+ type="button"
2665
+ >โœ“</button>
2666
+ </span> : <button
1179
2667
  className={index === resolvedActiveDashboardIndex ? "active" : ""}
1180
- onClick={() => selectDashboard(index)}
2668
+ onClick={() => enterDashboardTitleEdit(dashboard)}
1181
2669
  type="button"
1182
2670
  >{dashboard.name}</button>}
1183
2671
  </span>
@@ -1196,14 +2684,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1196
2684
  </span>
1197
2685
  <span className="workspace-dashboard-actions">
1198
2686
  <button type="button" onClick={() => selectDashboard(index)}>Edit</button>
1199
- <button type="button" onClick={() => setEditingDashboardId(dashboard.id)}>Rename</button>
2687
+ <button type="button" onClick={() => enterDashboardTitleEdit(dashboard)}>Rename</button>
1200
2688
  <button type="button" onClick={() => cloneDashboard(index)}>Clone</button>
1201
2689
  <button type="button" onClick={() => deleteDashboard(index)}>Delete</button>
1202
2690
  </span>
1203
2691
  </div>)}
1204
- </section>
2692
+ </section> : null}
1205
2693
 
1206
- <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
2694
+ {workspaceView === "builder" ? <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
1207
2695
  <div className="workspace-tabs">
1208
2696
  {tabs.map((tab) => <button
1209
2697
  key={tab.id}
@@ -1230,19 +2718,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1230
2718
  tabIndex={0}
1231
2719
  >x</span>
1232
2720
  </button>)}
1233
- <button type="button" onClick={addTab}>New Tab</button>
1234
- <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
2721
+ <button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
2722
+ <button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
1235
2723
  </div>
1236
2724
  <div
1237
- className="workspace-grid"
2725
+ className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
1238
2726
  ref={gridRef}
1239
2727
  onPointerMove={updatePointerDrag}
1240
2728
  onPointerUp={(event) => {
1241
2729
  finishPointerDrag(event);
1242
2730
  finishResizeDrag();
2731
+ finishMoveDrag();
2732
+ }}
2733
+ onPointerLeave={() => {
2734
+ finishResizeDrag();
2735
+ finishMoveDrag();
2736
+ }}
2737
+ onPointerMoveCapture={(event) => {
2738
+ updateResizeDrag(event);
2739
+ updateMoveDrag(event);
1243
2740
  }}
1244
- onPointerLeave={finishResizeDrag}
1245
- onPointerMoveCapture={updateResizeDrag}
1246
2741
  style={{ "--workspace-columns": canvas.layout.columns, "--workspace-rows": GRID_ROWS }}
1247
2742
  >
1248
2743
  {Array.from({ length: GRID_CELL_COUNT }).map((_, index) => {
@@ -1274,14 +2769,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1274
2769
  </button>
1275
2770
  {activeWidgets.map((widget) => <WidgetPreview
1276
2771
  key={widget.id}
2772
+ onMoveStart={(event) => beginMoveDrag(widget, event)}
1277
2773
  onRemove={() => removeSelectedWidget(widget.id)}
1278
2774
  onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
1279
2775
  onSelect={() => selectWidget(widget.id)}
2776
+ onExpandIframe={setExpandedIframeWidget}
1280
2777
  selected={widget.id === selectedWidgetId}
1281
2778
  widget={widget}
1282
2779
  />)}
1283
2780
  </div>
1284
- </section>
2781
+ </section> : null}
1285
2782
  </section>
1286
2783
 
1287
2784
  {templateGalleryOpen ? <TemplateGallery
@@ -1291,20 +2788,67 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1291
2788
  onClose={closeTemplateGallery}
1292
2789
  onApplyToCurrentTab={applyTemplateToCurrentTab}
1293
2790
  onCloneAsDashboard={cloneTemplateAsDashboard}
2791
+ filter={templateFilter}
2792
+ onFilterChange={setTemplateFilter}
2793
+ /> : null}
2794
+
2795
+ {settingsOpen ? <WorkspaceSettingsPanel
2796
+ config={config}
2797
+ persistence={persistence}
2798
+ adapterConfig={adapterConfig}
2799
+ integrationAdapter={integrationAdapter}
2800
+ onClose={closeSettings}
2801
+ /> : null}
2802
+
2803
+ {managementOpen ? <WorkspaceManagementPanel
2804
+ config={config}
2805
+ persistence={persistence}
2806
+ adapterConfig={adapterConfig}
2807
+ onClose={closeManagement}
1294
2808
  /> : null}
1295
2809
 
1296
- {panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
2810
+ {workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
1297
2811
  <div className="workspace-panel-title">
1298
2812
  <button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
1299
2813
  <span aria-hidden="true">+</span>
1300
2814
  <strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
1301
2815
  {selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
1302
2816
  </div>
1303
- {selectedWidget ? <section className="workspace-widget-settings">
2817
+ {selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
2818
+ <button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
2819
+ <button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
2820
+ </div> : null}
2821
+ {selectedWidget && inspectorPath === "source" ? <SourceSubPanel
2822
+ widget={selectedWidget}
2823
+ integrations={governedWorkspaceIntegrationCatalog}
2824
+ onChange={replaceSelectedWidgetConfig}
2825
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2826
+ /> : null}
2827
+ {selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
2828
+ widget={selectedWidget}
2829
+ onChange={replaceSelectedWidgetConfig}
2830
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2831
+ /> : null}
2832
+ {selectedWidget && inspectorPath === "sort" ? <SortSubPanel
2833
+ widget={selectedWidget}
2834
+ onChange={replaceSelectedWidgetConfig}
2835
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2836
+ /> : null}
2837
+ {selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
2838
+ widget={selectedWidget}
2839
+ onChange={replaceSelectedWidgetConfig}
2840
+ onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2841
+ /> : null}
2842
+ {selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
1304
2843
  <label>
1305
2844
  <span>Title</span>
1306
2845
  <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
1307
2846
  </label>
2847
+ {selectedWidget.kind === "chart" ? <ChartConfigPanel
2848
+ widget={selectedWidget}
2849
+ onChange={replaceSelectedWidgetConfig}
2850
+ onSubPage={(name) => setInspectorPath(name)}
2851
+ /> : null}
1308
2852
  {selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
1309
2853
  <label>
1310
2854
  <span>Sample Values</span>
@@ -1323,40 +2867,36 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1323
2867
  >
1324
2868
  <option value="json">Sample JSON</option>
1325
2869
  <option value="csv">Sample CSV</option>
2870
+ {selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
1326
2871
  </select>
1327
2872
  </label>
1328
2873
  </section> : null}
1329
- {selectedWidget.kind === "iframe" ? <label>
2874
+ {selectedWidget.kind === "iframe" ? <label className="workspace-field-with-hint">
1330
2875
  <span>URL to Embed</span>
1331
2876
  <input
1332
2877
  placeholder="https://example.com/embed"
1333
2878
  value={selectedWidget.config?.url || ""}
1334
2879
  onChange={(event) => updateSelectedWidgetConfig({ url: event.target.value })}
1335
2880
  />
2881
+ <small className={isLikelyHttpUrl(selectedWidget.config?.url) ? "workspace-field-hint good" : "workspace-field-hint warn"}>
2882
+ {isLikelyHttpUrl(selectedWidget.config?.url)
2883
+ ? "Looks like a valid http(s) URL"
2884
+ : selectedWidget.config?.url
2885
+ ? "URL must start with http:// or https://"
2886
+ : "Add an http(s) URL to embed"}
2887
+ </small>
1336
2888
  </label> : null}
1337
- {selectedWidget.kind === "rich-text" ? <label>
2889
+ {selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
1338
2890
  <span>Content</span>
1339
- <textarea
1340
- placeholder="Write text..."
2891
+ <RichTextEditor
1341
2892
  value={selectedWidget.config?.text || ""}
1342
- onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
2893
+ onChange={(text) => updateSelectedWidgetConfig({ text })}
1343
2894
  />
2895
+ <small className="workspace-field-hint">
2896
+ {(selectedWidget.config?.text || "").length} characters ยท markdown controls
2897
+ </small>
1344
2898
  </label> : null}
1345
2899
  {selectedWidget.kind === "view" ? <section className="workspace-field-stack">
1346
- <label>
1347
- <span>Source</span>
1348
- <input
1349
- value={selectedWidget.config?.source || ""}
1350
- onChange={(event) => updateSelectedWidgetConfig({ source: event.target.value })}
1351
- />
1352
- </label>
1353
- <label>
1354
- <span>Columns</span>
1355
- <input
1356
- value={serializeLineList(selectedWidget.config?.columns || [])}
1357
- onChange={(event) => updateSelectedWidgetConfig({ columns: parseLineList(event.target.value) })}
1358
- />
1359
- </label>
1360
2900
  <label>
1361
2901
  <span>Manual Rows</span>
1362
2902
  <textarea
@@ -1370,26 +2910,23 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1370
2910
  }}
1371
2911
  />
1372
2912
  </label>
1373
- <label>
1374
- <span>Static Binding</span>
1375
- <select
1376
- value={selectedWidget.config?.binding?.mode || "manual"}
1377
- onChange={(event) => {
1378
- const binding = event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.companiesManual;
1379
- updateSelectedWidgetConfig({ binding });
1380
- }}
1381
- >
1382
- <option value="manual">Manual Rows</option>
1383
- <option value="csv">Sample CSV</option>
1384
- </select>
1385
- </label>
1386
- <div className="workspace-settings-list">
1387
- <p className="workspace-panel-label">Settings</p>
1388
- <div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
1389
- <div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
1390
- <div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
1391
- <div><span>Filter</span><code>โ€บ</code></div>
1392
- <div><span>Sort</span><code>โ€บ</code></div>
2913
+ <div className="workspace-settings-list" role="group" aria-label="View widget settings">
2914
+ <p className="workspace-panel-label">Settings</p>
2915
+ <button type="button" className="workspace-settings-row" disabled>
2916
+ <span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code>
2917
+ </button>
2918
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("source")}>
2919
+ <span>Source</span><code>{summarizeSource(selectedWidget)}</code>
2920
+ </button>
2921
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
2922
+ <span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
2923
+ </button>
2924
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
2925
+ <span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
2926
+ </button>
2927
+ <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
2928
+ <span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
2929
+ </button>
1393
2930
  </div>
1394
2931
  </section> : null}
1395
2932
  {selectedWidget.kind === "rich-text" ? <label>
@@ -1409,16 +2946,30 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1409
2946
  <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
1410
2947
  <div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
1411
2948
  </div>
1412
- </section> : <section>
2949
+ </section> : null}
2950
+ {!selectedWidget ? <section>
2951
+ <div className="workspace-widget-empty">
2952
+ <strong>Pick a widget kind</strong>
2953
+ <p>
2954
+ Widgets snap to the 12-column ร— 16-row grid. {addSlot.w} ร— {addSlot.h} cells
2955
+ selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
2956
+ canvas to reshape the placement. Press <kbd>โŒ˜K</kbd> for the command palette.
2957
+ </p>
2958
+ </div>
1413
2959
  <p className="workspace-panel-label">Widget type</p>
1414
2960
  <div className="workspace-widget-types">
1415
- {widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
1416
- <span>{widget.icon}</span>
2961
+ {widgetTypes.map((widget) => {
2962
+ const KindIcon = WIDGET_KIND_ICONS[widget.kind];
2963
+ const shortcut = widget.kind === "chart" ? "C" : widget.kind === "view" ? "V" : widget.kind === "iframe" ? "I" : "T";
2964
+ return <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
2965
+ <span><IconGlyph icon={KindIcon} size={15} /></span>
1417
2966
  {widget.label}
1418
- </button>)}
2967
+ <kbd>{shortcut}</kbd>
2968
+ </button>;
2969
+ })}
1419
2970
  </div>
1420
- </section>}
1421
- <section className="workspace-bindings" id="bindings">
2971
+ </section> : null}
2972
+ {inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-bindings" id="bindings">
1422
2973
  <p className="workspace-panel-label">Config bindings</p>
1423
2974
  {Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
1424
2975
  <span>{key}</span>
@@ -1428,8 +2979,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1428
2979
  <span>integrationAdapter</span>
1429
2980
  <code>{adapterConfig.integrationAdapter}</code>
1430
2981
  </div>
1431
- </section>
2982
+ </section> : null}
1432
2983
  </aside> : null}
2984
+ {expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
2985
+ {commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
1433
2986
  </main>;
1434
2987
  }
1435
2988