@growthub/cli 0.9.6 → 0.9.8

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.
@@ -1,13 +1,18 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { useCallback, useMemo, useRef, useState } from "react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import {
6
6
  DASHBOARD_TEMPLATES,
7
7
  SAMPLE_DATA_BINDINGS,
8
8
  SAMPLE_VIEW_ROWS,
9
+ cloneTemplateToDashboard,
10
+ cloneTemplateToTab,
9
11
  defaultConfigFor,
10
- validateWorkspaceConfig
12
+ normalizeWorkspaceTemplate,
13
+ unwrapWorkspaceTemplateImport,
14
+ validateWorkspaceConfig,
15
+ wrapWorkspaceTemplateExport
11
16
  } from "@/lib/workspace-schema";
12
17
 
13
18
  const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
@@ -70,6 +75,120 @@ function commitTabs(canvas, tabs, activeTabId) {
70
75
  return next;
71
76
  }
72
77
 
78
+ function createDashboardRecord(name = "Untitled") {
79
+ const tab = createEmptyTab(name);
80
+ return {
81
+ id: generateId("dashboard"),
82
+ name,
83
+ createdBy: "Workspace owner",
84
+ updatedAt: "new",
85
+ status: "draft",
86
+ tabs: [tab],
87
+ activeTabId: tab.id
88
+ };
89
+ }
90
+
91
+ function createEmptyTab(name = "Untitled") {
92
+ return {
93
+ id: generateId("tab"),
94
+ name,
95
+ widgets: []
96
+ };
97
+ }
98
+
99
+ function cloneTabForDashboard(tab, name) {
100
+ return {
101
+ id: generateId("tab"),
102
+ name,
103
+ widgets: (tab?.widgets || []).map((widget) => ({
104
+ ...cloneConfig(widget),
105
+ id: generateId("widget")
106
+ }))
107
+ };
108
+ }
109
+
110
+ function normalizeDashboard(dashboard, fallbackCanvas) {
111
+ const tabs = Array.isArray(dashboard?.tabs) && dashboard.tabs.length
112
+ ? dashboard.tabs
113
+ : getTabs(fallbackCanvas).map((tab) => ({
114
+ ...tab,
115
+ id: tab.id === DEFAULT_TAB_ID ? generateId("tab") : tab.id
116
+ }));
117
+ const activeTabId = dashboard?.activeTabId && tabs.some((tab) => tab.id === dashboard.activeTabId)
118
+ ? dashboard.activeTabId
119
+ : tabs[0].id;
120
+ return {
121
+ ...dashboard,
122
+ tabs,
123
+ activeTabId
124
+ };
125
+ }
126
+
127
+ function dashboardCanvasFrom(dashboard, baseCanvas) {
128
+ const normalized = normalizeDashboard(dashboard, baseCanvas);
129
+ return commitTabs(baseCanvas, normalized.tabs, normalized.activeTabId);
130
+ }
131
+
132
+ function updateDashboardCanvas(dashboard, canvas) {
133
+ const tabs = getTabs(canvas);
134
+ const activeTabId = getActiveTabId(canvas);
135
+ return {
136
+ ...dashboard,
137
+ tabs,
138
+ activeTabId
139
+ };
140
+ }
141
+
142
+ function createDashboardFromTab(name, tab, source = {}) {
143
+ const clonedTab = cloneTabForDashboard(tab, name);
144
+ return {
145
+ ...source,
146
+ id: generateId("dashboard"),
147
+ name,
148
+ createdBy: source.createdBy || "Workspace owner",
149
+ updatedAt: "new",
150
+ status: "draft",
151
+ tabs: [clonedTab],
152
+ activeTabId: clonedTab.id
153
+ };
154
+ }
155
+
156
+ function getActiveDashboardId(dashboards, fallback) {
157
+ if (fallback && dashboards.some((dashboard) => dashboard.id === fallback)) {
158
+ return fallback;
159
+ }
160
+ return dashboards[0]?.id || null;
161
+ }
162
+
163
+ function activeDashboardIndex(dashboards, activeDashboardId) {
164
+ const index = dashboards.findIndex((dashboard) => dashboard.id === activeDashboardId);
165
+ return index >= 0 ? index : 0;
166
+ }
167
+
168
+ function syncActiveDashboard(config, activeDashboardId) {
169
+ const dashboards = config.dashboards || [];
170
+ const resolvedId = getActiveDashboardId(dashboards, activeDashboardId);
171
+ if (!resolvedId) return config;
172
+ return {
173
+ ...config,
174
+ dashboards: dashboards.map((dashboard) =>
175
+ dashboard.id === resolvedId ? updateDashboardCanvas(dashboard, config.canvas) : dashboard
176
+ )
177
+ };
178
+ }
179
+
180
+ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
181
+ const dashboards = config.dashboards || [];
182
+ const resolvedId = getActiveDashboardId(dashboards, activeDashboardId);
183
+ return {
184
+ ...config,
185
+ dashboards: dashboards.map((dashboard) =>
186
+ dashboard.id === resolvedId ? updateDashboardCanvas(dashboard, nextCanvas) : dashboard
187
+ ),
188
+ canvas: nextCanvas
189
+ };
190
+ }
191
+
73
192
  function findFreePosition(widgets) {
74
193
  const occupied = new Set();
75
194
  for (const widget of widgets) {
@@ -242,15 +361,98 @@ function serializeManualRows(rows, columns) {
242
361
  .join("\n");
243
362
  }
244
363
 
245
- function hydrateTemplate(template) {
246
- return {
247
- name: template.name,
248
- widgets: template.widgets.map((widget) => ({
249
- ...cloneConfig(widget),
250
- id: generateId("widget"),
251
- config: cloneConfig(widget.config || defaultConfigFor(widget.kind))
252
- }))
253
- };
364
+ const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
365
+ ...normalizeWorkspaceTemplate(template),
366
+ widgets: template.widgets
367
+ }));
368
+
369
+ function widgetKindFill(kind) {
370
+ switch (kind) {
371
+ case "chart": return "#dbeafe";
372
+ case "view": return "#fef3c7";
373
+ case "iframe": return "#ede9fe";
374
+ case "rich-text": return "#dcfce7";
375
+ default: return "#e5e7eb";
376
+ }
377
+ }
378
+
379
+ function TemplateMiniGrid({ template }) {
380
+ const widgets = Array.isArray(template?.widgets) ? template.widgets : [];
381
+ return <div
382
+ className="template-mini-grid"
383
+ aria-hidden="true"
384
+ style={{
385
+ "--template-mini-columns": GRID_COLUMNS,
386
+ "--template-mini-rows": GRID_ROWS
387
+ }}
388
+ >
389
+ {widgets.map((widget, index) => <span
390
+ className={`template-mini-widget kind-${widget.kind}`}
391
+ key={`${widget.kind}-${index}`}
392
+ style={{
393
+ gridColumn: `${widget.position.x + 1} / span ${widget.position.w}`,
394
+ gridRow: `${widget.position.y + 1} / span ${widget.position.h}`,
395
+ background: widgetKindFill(widget.kind)
396
+ }}
397
+ />)}
398
+ </div>;
399
+ }
400
+
401
+ function TemplateGallery({
402
+ templates,
403
+ previewTemplateId,
404
+ onPreview,
405
+ onClose,
406
+ onApplyToCurrentTab,
407
+ onCloneAsDashboard
408
+ }) {
409
+ const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
410
+ return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
411
+ <div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
412
+ <section className="template-gallery-panel">
413
+ <header className="template-gallery-header">
414
+ <div>
415
+ <p>Workspace templates</p>
416
+ <h2>Pick a starting layout</h2>
417
+ </div>
418
+ <button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
419
+ </header>
420
+ <div className="template-gallery-grid">
421
+ {templates.map((template) => {
422
+ const isPreviewing = previewTemplate?.id === template.id;
423
+ return <article
424
+ className={`template-card${isPreviewing ? " previewing" : ""}`}
425
+ key={template.id}
426
+ >
427
+ <div className="template-card-header">
428
+ <strong>{template.name}</strong>
429
+ <span className="template-card-category">{template.category}</span>
430
+ </div>
431
+ <p className="template-card-description">{template.description}</p>
432
+ <div className="template-card-preview">
433
+ <TemplateMiniGrid template={template} />
434
+ </div>
435
+ <div className="template-card-meta">
436
+ <span>{template.widgetCount} widget{template.widgetCount === 1 ? "" : "s"}</span>
437
+ {template.bestFor.length ? <span>· Best for: {template.bestFor.join(", ")}</span> : null}
438
+ </div>
439
+ {template.tags.length ? <div className="template-card-tags">
440
+ {template.tags.map((tag) => <span key={tag}>#{tag}</span>)}
441
+ </div> : null}
442
+ <div className="template-card-actions">
443
+ <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>
446
+ </div>
447
+ </article>;
448
+ })}
449
+ </div>
450
+ {previewTemplate ? <footer className="template-gallery-footer" aria-live="polite">
451
+ <strong>{previewTemplate.name}</strong>
452
+ <span>{previewTemplate.preview?.summary || previewTemplate.description}</span>
453
+ </footer> : null}
454
+ </section>
455
+ </div>;
254
456
  }
255
457
 
256
458
  function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
@@ -306,12 +508,34 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
306
508
  }
307
509
 
308
510
  function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
309
- const [config, setConfig] = useState(initialConfig);
511
+ const [config, setConfig] = useState(() => {
512
+ const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
513
+ ? initialConfig.dashboards.map((dashboard, index) =>
514
+ normalizeDashboard(dashboard, index === 0 ? initialConfig.canvas : undefined)
515
+ )
516
+ : [createDashboardRecord("Untitled")];
517
+ return {
518
+ ...initialConfig,
519
+ dashboards,
520
+ canvas: dashboardCanvasFrom(dashboards[0], initialConfig.canvas)
521
+ };
522
+ });
310
523
  const [saving, setSaving] = useState(false);
311
524
  const [panelOpen, setPanelOpen] = useState(true);
525
+ const [templateGalleryOpen, setTemplateGalleryOpen] = useState(false);
526
+ const [previewTemplateId, setPreviewTemplateId] = useState(null);
527
+ const [editingDashboardId, setEditingDashboardId] = useState(null);
528
+ const [activeDashboardId, setActiveDashboardId] = useState(() =>
529
+ getActiveDashboardId(
530
+ Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
531
+ null
532
+ )
533
+ );
312
534
  const gridRef = useRef(null);
313
535
  const canvas = config.canvas;
314
- const dashboards = config.dashboards;
536
+ const dashboards = config.dashboards || [];
537
+ const resolvedActiveDashboardId = getActiveDashboardId(dashboards, activeDashboardId);
538
+ const resolvedActiveDashboardIndex = activeDashboardIndex(dashboards, resolvedActiveDashboardId);
315
539
  const widgetTypes = config.widgetTypes;
316
540
  const tabs = getTabs(canvas);
317
541
  const activeTabId = getActiveTabId(canvas);
@@ -361,9 +585,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
361
585
  setSelectedWidgetId(widget.id);
362
586
  setSelectedPosition(findFreePosition([...existingWidgets, widget]));
363
587
  setDragPreview(null);
364
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
588
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
365
589
  });
366
- }, [addSlot]);
590
+ }, [activeDashboardId, addSlot]);
367
591
 
368
592
  const switchTab = useCallback((tabId) => {
369
593
  setConfig((prev) => {
@@ -374,9 +598,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
374
598
  setSelectedWidgetId(null);
375
599
  setSelectedPosition(findFreePosition(nextTab?.widgets || []));
376
600
  setDragPreview(null);
377
- return { ...prev, canvas: commitTabs(prev.canvas, prevTabs, tabId) };
601
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, prevTabs, tabId));
378
602
  });
379
- }, []);
603
+ }, [activeDashboardId]);
380
604
 
381
605
  const addTab = useCallback(() => {
382
606
  setConfig((prev) => {
@@ -395,49 +619,160 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
395
619
  setSelectedWidgetId(null);
396
620
  setSelectedPosition({ ...DEFAULT_POSITION });
397
621
  setDragPreview(null);
398
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, newTab.id) };
622
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, newTab.id));
399
623
  });
400
- }, []);
624
+ }, [activeDashboardId]);
401
625
 
402
626
  const addDashboard = useCallback(() => {
627
+ setConfig((prev) => {
628
+ const synced = syncActiveDashboard(prev, activeDashboardId);
629
+ const prevDashboards = synced.dashboards || [];
630
+ const name = `Dashboard ${prevDashboards.length + 1}`;
631
+ const dashboard = createDashboardRecord(name);
632
+ setSelectedWidgetId(null);
633
+ setSelectedPosition({ ...DEFAULT_POSITION });
634
+ setDragPreview(null);
635
+ setEditingDashboardId(dashboard.id);
636
+ setActiveDashboardId(dashboard.id);
637
+ setConfigMessage(`Created ${name}`);
638
+ return {
639
+ ...synced,
640
+ dashboards: [...prevDashboards, dashboard],
641
+ canvas: dashboardCanvasFrom(dashboard, synced.canvas)
642
+ };
643
+ });
644
+ }, [activeDashboardId]);
645
+
646
+ const selectDashboard = useCallback((index) => {
647
+ setConfig((prev) => {
648
+ const synced = syncActiveDashboard(prev, activeDashboardId);
649
+ const prevDashboards = synced.dashboards || [];
650
+ const dashboard = prevDashboards[index];
651
+ if (!dashboard) return prev;
652
+ const normalized = normalizeDashboard(dashboard, index === 0 ? synced.canvas : undefined);
653
+ setSelectedWidgetId(null);
654
+ setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
655
+ setDragPreview(null);
656
+ setEditingDashboardId(dashboard.id);
657
+ setActiveDashboardId(dashboard.id);
658
+ setConfigMessage(`Editing ${dashboard.name}`);
659
+ return {
660
+ ...synced,
661
+ dashboards: prevDashboards.map((item) => item.id === dashboard.id ? normalized : item),
662
+ canvas: dashboardCanvasFrom(normalized, synced.canvas)
663
+ };
664
+ });
665
+ }, [activeDashboardId]);
666
+
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]);
693
+
694
+ const updateDashboardStatus = useCallback((dashboardId, status) => {
403
695
  setConfig((prev) => ({
404
696
  ...prev,
405
- dashboards: [
406
- ...(prev.dashboards || []),
407
- {
408
- id: generateId("dashboard"),
409
- name: "Untitled",
410
- createdBy: "Workspace owner",
411
- updatedAt: "new",
412
- status: "draft"
413
- }
414
- ]
697
+ dashboards: (prev.dashboards || []).map((dashboard) =>
698
+ dashboard.id === dashboardId ? { ...dashboard, status, updatedAt: "new" } : dashboard
699
+ )
415
700
  }));
416
701
  }, []);
417
702
 
418
- const duplicateDashboard = useCallback(() => {
703
+ const cloneDashboard = useCallback((index) => {
419
704
  setConfig((prev) => {
420
- const source = prev.dashboards?.[0] || {
421
- name: "Untitled",
422
- createdBy: "Workspace owner",
705
+ const synced = syncActiveDashboard(prev, activeDashboardId);
706
+ const prevDashboards = synced.dashboards || [];
707
+ const sourceDashboard = prevDashboards[index];
708
+ if (!sourceDashboard) return prev;
709
+ const normalizedSource = normalizeDashboard(sourceDashboard, index === resolvedActiveDashboardIndex ? synced.canvas : undefined);
710
+ const name = `${sourceDashboard.name} Copy`;
711
+ const dashboard = {
712
+ ...normalizedSource,
713
+ id: generateId("dashboard"),
714
+ name,
423
715
  updatedAt: "new",
424
- status: "draft"
716
+ status: "draft",
717
+ tabs: normalizedSource.tabs.map((tab, tabIndex) =>
718
+ cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
719
+ )
425
720
  };
721
+ dashboard.activeTabId = dashboard.tabs[0].id;
722
+ setSelectedWidgetId(null);
723
+ setSelectedPosition(findFreePosition(dashboard.tabs[0].widgets));
724
+ setDragPreview(null);
725
+ setEditingDashboardId(dashboard.id);
726
+ setActiveDashboardId(dashboard.id);
727
+ setConfigMessage(`Cloned ${sourceDashboard.name}`);
426
728
  return {
427
- ...prev,
428
- dashboards: [
429
- ...(prev.dashboards || []),
430
- {
431
- ...source,
432
- id: generateId("dashboard"),
433
- name: `${source.name} Copy`,
434
- updatedAt: "new",
435
- status: "draft"
436
- }
437
- ]
729
+ ...synced,
730
+ dashboards: [...prevDashboards, dashboard],
731
+ canvas: dashboardCanvasFrom(dashboard, synced.canvas)
438
732
  };
439
733
  });
440
- }, []);
734
+ }, [activeDashboardId, resolvedActiveDashboardIndex]);
735
+
736
+ const deleteDashboard = useCallback((index) => {
737
+ setConfig((prev) => {
738
+ const synced = syncActiveDashboard(prev, activeDashboardId);
739
+ const prevDashboards = synced.dashboards || [];
740
+ if (!prevDashboards[index]) return prev;
741
+ if (prevDashboards.length <= 1) {
742
+ const dashboard = createDashboardRecord("Untitled");
743
+ setSelectedWidgetId(null);
744
+ setSelectedPosition({ ...DEFAULT_POSITION });
745
+ setDragPreview(null);
746
+ setEditingDashboardId(dashboard.id);
747
+ setActiveDashboardId(dashboard.id);
748
+ setConfigMessage("Reset dashboard");
749
+ return {
750
+ ...synced,
751
+ dashboards: [dashboard],
752
+ canvas: dashboardCanvasFrom(dashboard, synced.canvas)
753
+ };
754
+ }
755
+ const removed = prevDashboards[index];
756
+ const nextDashboards = prevDashboards.filter((_, dashboardIndex) => dashboardIndex !== index);
757
+ const nextActiveIndex = Math.min(index, nextDashboards.length - 1);
758
+ const nextActiveDashboard = normalizeDashboard(nextDashboards[nextActiveIndex], synced.canvas);
759
+ setSelectedWidgetId(null);
760
+ setSelectedPosition(findFreePosition(nextActiveDashboard.tabs[0]?.widgets || []));
761
+ setDragPreview(null);
762
+ setEditingDashboardId(nextActiveDashboard.id);
763
+ setActiveDashboardId(nextActiveDashboard.id);
764
+ setConfigMessage(`Deleted ${removed.name}`);
765
+ return {
766
+ ...synced,
767
+ dashboards: nextDashboards.map((dashboard) => dashboard.id === nextActiveDashboard.id ? nextActiveDashboard : dashboard),
768
+ canvas: dashboardCanvasFrom(nextActiveDashboard, synced.canvas)
769
+ };
770
+ });
771
+ }, [activeDashboardId]);
772
+
773
+ const duplicateDashboard = useCallback(() => {
774
+ cloneDashboard(resolvedActiveDashboardIndex);
775
+ }, [cloneDashboard, resolvedActiveDashboardIndex]);
441
776
 
442
777
  const duplicateTab = useCallback(() => {
443
778
  setConfig((prev) => {
@@ -460,65 +795,137 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
460
795
  setSelectedWidgetId(null);
461
796
  setSelectedPosition(findFreePosition(cloned.widgets));
462
797
  setDragPreview(null);
463
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, cloned.id) };
798
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, cloned.id));
464
799
  });
465
- }, []);
800
+ }, [activeDashboardId]);
801
+
802
+ const deleteTab = useCallback((tabId) => {
803
+ setConfig((prev) => {
804
+ const prevTabs = getTabs(prev.canvas);
805
+ const tab = prevTabs.find((item) => item.id === tabId);
806
+ if (!tab) return prev;
807
+ if (prevTabs.length <= 1) {
808
+ const fallback = createEmptyTab(tab.name || "Tab 1");
809
+ setSelectedWidgetId(null);
810
+ setSelectedPosition({ ...DEFAULT_POSITION });
811
+ setDragPreview(null);
812
+ setConfigMessage(`Cleared ${tab.name}`);
813
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, [fallback], fallback.id));
814
+ }
815
+ const nextTabs = prevTabs.filter((item) => item.id !== tabId);
816
+ const activeIndex = prevTabs.findIndex((item) => item.id === tabId);
817
+ const nextActiveTab = nextTabs[Math.min(activeIndex, nextTabs.length - 1)] || nextTabs[0];
818
+ setSelectedWidgetId(null);
819
+ setSelectedPosition(findFreePosition(nextActiveTab.widgets || []));
820
+ setDragPreview(null);
821
+ setConfigMessage(`Deleted ${tab.name}`);
822
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, nextActiveTab.id));
823
+ });
824
+ }, [activeDashboardId]);
466
825
 
467
- const applyTemplate = useCallback((templateId) => {
826
+ const applyTemplateToCurrentTab = useCallback((templateId) => {
468
827
  const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
469
828
  if (!template) return;
829
+ const clonedTab = cloneTemplateToTab(template, { tabName: template.name, idFactory: generateId });
470
830
  setConfig((prev) => {
471
- const hydrated = hydrateTemplate(template);
472
831
  const prevTabs = getTabs(prev.canvas);
473
832
  const prevActiveId = getActiveTabId(prev.canvas);
833
+ const dashboardIndex = activeDashboardIndex(prev.dashboards || [], activeDashboardId);
474
834
  const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
475
835
  ? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
476
836
  : prevTabs;
477
837
  const nextTabs = stableTabs.map((tab) =>
478
- tab.id === prevActiveId ? { ...tab, name: hydrated.name, widgets: hydrated.widgets } : tab
838
+ tab.id === prevActiveId ? { ...tab, name: clonedTab.name, widgets: clonedTab.widgets } : tab
839
+ );
840
+ const nextCanvas = commitTabs(prev.canvas, nextTabs, prevActiveId);
841
+ const nextDashboards = (prev.dashboards || []).map((dashboard, index) =>
842
+ index === dashboardIndex
843
+ ? updateDashboardCanvas({ ...dashboard, name: template.name, updatedAt: "new", status: "draft" }, nextCanvas)
844
+ : dashboard
479
845
  );
480
- setSelectedWidgetId(null);
481
- setSelectedPosition(findFreePosition(hydrated.widgets));
482
- setDragPreview(null);
483
- setConfigMessage(`Applied ${template.name}`);
484
846
  return {
485
847
  ...prev,
486
- dashboards: (prev.dashboards || []).map((dashboard, index) =>
487
- index === 0 ? { ...dashboard, name: template.name, updatedAt: "new", status: "draft" } : dashboard
488
- ),
489
- canvas: commitTabs(prev.canvas, nextTabs, prevActiveId)
848
+ dashboards: nextDashboards,
849
+ canvas: nextCanvas
490
850
  };
491
851
  });
492
- }, []);
852
+ setSelectedWidgetId(null);
853
+ setSelectedPosition(findFreePosition(clonedTab.widgets));
854
+ setDragPreview(null);
855
+ setConfigMessage(`Applied ${template.name} to current tab`);
856
+ setTemplateGalleryOpen(false);
857
+ setPreviewTemplateId(null);
858
+ }, [activeDashboardId]);
859
+
860
+ const cloneTemplateAsDashboard = useCallback((templateId) => {
861
+ const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
862
+ if (!template) return;
863
+ const cloned = cloneTemplateToDashboard(template, { idFactory: generateId });
864
+ setConfig((prev) => {
865
+ const synced = syncActiveDashboard(prev, activeDashboardId);
866
+ const dashboard = {
867
+ ...cloned.dashboard,
868
+ tabs: [cloned.tab],
869
+ activeTabId: cloned.tab.id
870
+ };
871
+ setActiveDashboardId(dashboard.id);
872
+ return {
873
+ ...synced,
874
+ dashboards: [...(synced.dashboards || []), dashboard],
875
+ canvas: dashboardCanvasFrom(dashboard, synced.canvas)
876
+ };
877
+ });
878
+ setSelectedWidgetId(null);
879
+ setSelectedPosition(findFreePosition(cloned.tab.widgets));
880
+ setDragPreview(null);
881
+ setConfigMessage(`Cloned ${template.name} as dashboard`);
882
+ setTemplateGalleryOpen(false);
883
+ setPreviewTemplateId(null);
884
+ }, [activeDashboardId]);
493
885
 
494
886
  const exportConfig = useCallback(() => {
495
- const blob = new Blob([`${JSON.stringify({
496
- dashboards: config.dashboards,
497
- widgetTypes: config.widgetTypes,
498
- canvas: config.canvas
499
- }, null, 2)}\n`], { type: "application/json" });
887
+ const syncedConfig = syncActiveDashboard(config, activeDashboardId);
888
+ const primaryDashboard = syncedConfig.dashboards?.[0] || {};
889
+ const wrapped = wrapWorkspaceTemplateExport(
890
+ {
891
+ dashboards: syncedConfig.dashboards,
892
+ widgetTypes: syncedConfig.widgetTypes,
893
+ canvas: syncedConfig.canvas
894
+ },
895
+ {
896
+ name: primaryDashboard.name || syncedConfig.name || "Workspace template",
897
+ description: syncedConfig.description || ""
898
+ }
899
+ );
900
+ const blob = new Blob([`${JSON.stringify(wrapped, null, 2)}\n`], { type: "application/json" });
500
901
  const url = URL.createObjectURL(blob);
501
902
  const anchor = document.createElement("a");
502
903
  anchor.href = url;
503
- anchor.download = "growthub-dashboard.config.json";
904
+ anchor.download = "growthub-dashboard.template.json";
504
905
  anchor.click();
505
906
  URL.revokeObjectURL(url);
506
- setConfigMessage("Exported dashboard config");
507
- }, [config]);
907
+ setConfigMessage("Exported workspace template");
908
+ }, [activeDashboardId, config]);
508
909
 
509
910
  const importConfig = useCallback(async (event) => {
510
911
  const file = event.target.files?.[0];
511
912
  if (!file) return;
512
913
  try {
513
- const imported = JSON.parse(await file.text());
514
- validateWorkspaceConfig(imported);
914
+ const parsed = JSON.parse(await file.text());
915
+ const payload = unwrapWorkspaceTemplateImport(parsed);
916
+ validateWorkspaceConfig(payload);
917
+ const importedDashboards = (payload.dashboards || []).map((dashboard, index) =>
918
+ normalizeDashboard(dashboard, index === 0 ? payload.canvas : undefined)
919
+ );
920
+ const importedActiveDashboard = importedDashboards[0];
515
921
  setConfig((prev) => ({
516
922
  ...prev,
517
- dashboards: imported.dashboards,
518
- widgetTypes: imported.widgetTypes,
519
- canvas: imported.canvas
923
+ dashboards: importedDashboards,
924
+ widgetTypes: payload.widgetTypes,
925
+ canvas: importedActiveDashboard ? dashboardCanvasFrom(importedActiveDashboard, payload.canvas) : payload.canvas
520
926
  }));
521
- const importedTabs = getTabs(imported.canvas);
927
+ setActiveDashboardId(importedActiveDashboard?.id || null);
928
+ const importedTabs = getTabs(importedActiveDashboard ? dashboardCanvasFrom(importedActiveDashboard, payload.canvas) : payload.canvas);
522
929
  setSelectedWidgetId(null);
523
930
  setSelectedPosition(findFreePosition(importedTabs[0]?.widgets || []));
524
931
  setDragPreview(null);
@@ -535,21 +942,32 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
535
942
  setSaving(true);
536
943
  try {
537
944
  const stamp = todayIsoDate();
538
- const updatedDashboards = (config.dashboards || []).map((dashboard, index) =>
539
- index === 0 ? { ...dashboard, updatedAt: stamp } : dashboard
945
+ const syncedConfig = syncActiveDashboard(config, activeDashboardId);
946
+ const updatedDashboards = (syncedConfig.dashboards || []).map((dashboard) =>
947
+ dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], activeDashboardId)
948
+ ? { ...dashboard, updatedAt: stamp }
949
+ : dashboard
540
950
  );
541
951
  const response = await fetch("/api/workspace", {
542
952
  method: "PATCH",
543
953
  headers: { "content-type": "application/json" },
544
954
  body: JSON.stringify({
545
955
  dashboards: updatedDashboards,
546
- widgetTypes: config.widgetTypes,
547
- canvas: config.canvas
956
+ widgetTypes: syncedConfig.widgetTypes,
957
+ canvas: syncedConfig.canvas
548
958
  })
549
959
  });
550
960
  const payload = await response.json();
551
961
  if (response.ok && payload.workspaceConfig) {
552
- setConfig(payload.workspaceConfig);
962
+ const savedDashboards = (payload.workspaceConfig.dashboards || []).map((dashboard, index) =>
963
+ normalizeDashboard(dashboard, index === 0 ? payload.workspaceConfig.canvas : undefined)
964
+ );
965
+ const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === activeDashboardId) || savedDashboards[0];
966
+ setConfig({
967
+ ...payload.workspaceConfig,
968
+ dashboards: savedDashboards,
969
+ canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
970
+ });
553
971
  setConfigMessage("Saved dashboard config");
554
972
  } else {
555
973
  setConfigMessage(payload.error || "Save failed");
@@ -559,7 +977,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
559
977
  } finally {
560
978
  setSaving(false);
561
979
  }
562
- }, [saving, config]);
980
+ }, [activeDashboardId, saving, config]);
563
981
 
564
982
  const reopenPanel = useCallback(() => setPanelOpen(true), []);
565
983
  const closePanel = useCallback(() => setPanelOpen(false), []);
@@ -626,9 +1044,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
626
1044
  )
627
1045
  };
628
1046
  });
629
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
1047
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
630
1048
  });
631
- }, [activeWidgets]);
1049
+ }, [activeDashboardId, activeWidgets]);
632
1050
  const finishResizeDrag = useCallback(() => {
633
1051
  if (!resizeDragRef.current) return;
634
1052
  resizeDragRef.current = null;
@@ -652,9 +1070,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
652
1070
  )
653
1071
  };
654
1072
  });
655
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
1073
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
656
1074
  });
657
- }, [selectedWidgetId]);
1075
+ }, [activeDashboardId, selectedWidgetId]);
658
1076
  const updateSelectedWidgetConfig = useCallback((updates) => {
659
1077
  if (!selectedWidget) return;
660
1078
  updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
@@ -670,10 +1088,24 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
670
1088
  const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
671
1089
  setSelectedWidgetId(null);
672
1090
  setSelectedPosition(findFreePosition(nextActiveWidgets));
673
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
1091
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
674
1092
  });
1093
+ }, [activeDashboardId]);
1094
+
1095
+ const closeTemplateGallery = useCallback(() => {
1096
+ setTemplateGalleryOpen(false);
1097
+ setPreviewTemplateId(null);
675
1098
  }, []);
676
1099
 
1100
+ useEffect(() => {
1101
+ if (!templateGalleryOpen) return undefined;
1102
+ const handler = (event) => {
1103
+ if (event.key === "Escape") closeTemplateGallery();
1104
+ };
1105
+ window.addEventListener("keydown", handler);
1106
+ return () => window.removeEventListener("keydown", handler);
1107
+ }, [templateGalleryOpen, closeTemplateGallery]);
1108
+
677
1109
  const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
678
1110
 
679
1111
  return <main className="workspace-builder" style={builderStyle}>
@@ -702,13 +1134,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
702
1134
  <h1>{config.name}</h1>
703
1135
  </div>
704
1136
  <div className="workspace-toolbar-actions">
705
- <select aria-label="Apply dashboard template" defaultValue="" onChange={(event) => {
706
- if (event.target.value) applyTemplate(event.target.value);
707
- event.target.value = "";
708
- }}>
709
- <option value="">Templates</option>
710
- {DASHBOARD_TEMPLATES.map((template) => <option key={template.id} value={template.id}>{template.name}</option>)}
711
- </select>
1137
+ <button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
712
1138
  <button type="button" onClick={addDashboard}>New Dashboard</button>
713
1139
  <button type="button" onClick={duplicateDashboard}>Duplicate Dashboard</button>
714
1140
  <button type="button" onClick={() => importInputRef.current?.click()}>Import</button>
@@ -735,12 +1161,45 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
735
1161
  <span>Created by</span>
736
1162
  <span>Last update</span>
737
1163
  <span>Status</span>
1164
+ <span>Actions</span>
738
1165
  </div>
739
- {dashboards.map((dashboard) => <div className="workspace-table-row" key={dashboard.id}>
740
- <span>{dashboard.name}</span>
1166
+ {dashboards.map((dashboard, index) => <div className="workspace-table-row" key={dashboard.id}>
1167
+ <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
1179
+ className={index === resolvedActiveDashboardIndex ? "active" : ""}
1180
+ onClick={() => selectDashboard(index)}
1181
+ type="button"
1182
+ >{dashboard.name}</button>}
1183
+ </span>
741
1184
  <span>{dashboard.createdBy}</span>
742
1185
  <span>{dashboard.updatedAt}</span>
743
- <code>{dashboard.status}</code>
1186
+ <span>
1187
+ <select
1188
+ aria-label={`Status for ${dashboard.name}`}
1189
+ onChange={(event) => updateDashboardStatus(dashboard.id, event.target.value)}
1190
+ value={dashboard.status}
1191
+ >
1192
+ <option value="draft">draft</option>
1193
+ <option value="active">active</option>
1194
+ <option value="archived">archived</option>
1195
+ </select>
1196
+ </span>
1197
+ <span className="workspace-dashboard-actions">
1198
+ <button type="button" onClick={() => selectDashboard(index)}>Edit</button>
1199
+ <button type="button" onClick={() => setEditingDashboardId(dashboard.id)}>Rename</button>
1200
+ <button type="button" onClick={() => cloneDashboard(index)}>Clone</button>
1201
+ <button type="button" onClick={() => deleteDashboard(index)}>Delete</button>
1202
+ </span>
744
1203
  </div>)}
745
1204
  </section>
746
1205
 
@@ -751,7 +1210,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
751
1210
  className={tab.id === activeTabId ? "active" : ""}
752
1211
  type="button"
753
1212
  onClick={() => switchTab(tab.id)}
754
- >{tab.name}</button>)}
1213
+ >
1214
+ <span>{tab.name}</span>
1215
+ <span
1216
+ aria-label={`Delete tab ${tab.name}`}
1217
+ className="workspace-tab-delete"
1218
+ onClick={(event) => {
1219
+ event.stopPropagation();
1220
+ deleteTab(tab.id);
1221
+ }}
1222
+ onKeyDown={(event) => {
1223
+ if (event.key === "Enter" || event.key === " ") {
1224
+ event.preventDefault();
1225
+ event.stopPropagation();
1226
+ deleteTab(tab.id);
1227
+ }
1228
+ }}
1229
+ role="button"
1230
+ tabIndex={0}
1231
+ >x</span>
1232
+ </button>)}
755
1233
  <button type="button" onClick={addTab}>New Tab</button>
756
1234
  <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
757
1235
  </div>
@@ -806,6 +1284,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
806
1284
  </section>
807
1285
  </section>
808
1286
 
1287
+ {templateGalleryOpen ? <TemplateGallery
1288
+ templates={NORMALIZED_TEMPLATES}
1289
+ previewTemplateId={previewTemplateId}
1290
+ onPreview={setPreviewTemplateId}
1291
+ onClose={closeTemplateGallery}
1292
+ onApplyToCurrentTab={applyTemplateToCurrentTab}
1293
+ onCloneAsDashboard={cloneTemplateAsDashboard}
1294
+ /> : null}
1295
+
809
1296
  {panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
810
1297
  <div className="workspace-panel-title">
811
1298
  <button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>