@growthub/cli 0.9.5 → 0.9.7

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,7 +1,19 @@
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
+ import {
6
+ DASHBOARD_TEMPLATES,
7
+ SAMPLE_DATA_BINDINGS,
8
+ SAMPLE_VIEW_ROWS,
9
+ cloneTemplateToDashboard,
10
+ cloneTemplateToTab,
11
+ defaultConfigFor,
12
+ normalizeWorkspaceTemplate,
13
+ unwrapWorkspaceTemplateImport,
14
+ validateWorkspaceConfig,
15
+ wrapWorkspaceTemplateExport
16
+ } from "@/lib/workspace-schema";
5
17
 
6
18
  const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
7
19
  const GRID_COLUMNS = 12;
@@ -27,19 +39,6 @@ function defaultTitleFor(kind) {
27
39
  }
28
40
  }
29
41
 
30
- function defaultConfigFor(kind) {
31
- switch (kind) {
32
- case "view":
33
- return { source: "Companies", layout: "Table" };
34
- case "iframe":
35
- return { url: "" };
36
- case "rich-text":
37
- return { text: "" };
38
- default:
39
- return {};
40
- }
41
- }
42
-
43
42
  function getTabs(canvas) {
44
43
  if (Array.isArray(canvas?.tabs) && canvas.tabs.length > 0) {
45
44
  return canvas.tabs;
@@ -76,6 +75,120 @@ function commitTabs(canvas, tabs, activeTabId) {
76
75
  return next;
77
76
  }
78
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
+
79
192
  function findFreePosition(widgets) {
80
193
  const occupied = new Set();
81
194
  for (const widget of widgets) {
@@ -200,7 +313,152 @@ function widgetKindLabel(kind) {
200
313
  return kind.charAt(0).toUpperCase() + kind.slice(1);
201
314
  }
202
315
 
316
+ function cloneConfig(value) {
317
+ return JSON.parse(JSON.stringify(value));
318
+ }
319
+
320
+ function normalizeChartValues(value) {
321
+ return String(value)
322
+ .split(",")
323
+ .map((item) => Number(item.trim()))
324
+ .filter((item) => Number.isFinite(item));
325
+ }
326
+
327
+ function serializeChartValues(values) {
328
+ return (Array.isArray(values) ? values : []).join(", ");
329
+ }
330
+
331
+ function parseLineList(value) {
332
+ return String(value)
333
+ .split(",")
334
+ .map((item) => item.trim())
335
+ .filter(Boolean);
336
+ }
337
+
338
+ function serializeLineList(values) {
339
+ return (Array.isArray(values) ? values : []).join(", ");
340
+ }
341
+
342
+ function parseManualRows(value, columns) {
343
+ const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
344
+ return String(value)
345
+ .split("\n")
346
+ .map((row) => row.trim())
347
+ .filter(Boolean)
348
+ .map((row) => {
349
+ const values = row.split("|").map((item) => item.trim());
350
+ return activeColumns.reduce((record, column, index) => {
351
+ record[column] = values[index] || "";
352
+ return record;
353
+ }, {});
354
+ });
355
+ }
356
+
357
+ function serializeManualRows(rows, columns) {
358
+ const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
359
+ return (Array.isArray(rows) ? rows : [])
360
+ .map((row) => activeColumns.map((column) => row?.[column] || "").join(" | "))
361
+ .join("\n");
362
+ }
363
+
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>;
456
+ }
457
+
203
458
  function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
459
+ const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
460
+ const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
461
+ const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
204
462
  return <article
205
463
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
206
464
  onClick={onSelect}
@@ -221,16 +479,15 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
221
479
  type="button"
222
480
  >x</button>
223
481
  </div>
224
- {widget.kind === "view" ? <div className="workspace-view-table" aria-label={`${widget.title} preview`}>
225
- <div><span>Name</span><span>Domain Name</span></div>
226
- {[
227
- ["CMWL Direct", "centerformedica"],
228
- ["Medi-Weightloss", "mediweightloss.com"],
229
- ["Optima Tyler", "optimatyler.com"],
230
- ["Balanced Hormone He...", "balancedhormor"],
231
- ["Jolie Aesthetics RVA", "jolie-aesthetics.c"],
232
- ["Livea Centers", "livea.com"]
233
- ].map(([name, domain]) => <div key={name}><span>{name}</span><span>{domain}</span></div>)}
482
+ {widget.kind === "view" ? <div
483
+ className="workspace-view-table"
484
+ aria-label={`${widget.title} preview`}
485
+ style={{ "--workspace-view-columns": viewColumns.length }}
486
+ >
487
+ <div>{viewColumns.map((column) => <span key={column}>{column}</span>)}</div>
488
+ {viewRows.slice(0, 6).map((row, rowIndex) => <div key={rowIndex}>
489
+ {viewColumns.map((column) => <span key={column}>{row?.[column] || ""}</span>)}
490
+ </div>)}
234
491
  <footer>Calculate</footer>
235
492
  </div> : null}
236
493
  {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
@@ -238,7 +495,7 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
238
495
  </div> : null}
239
496
  {widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
240
497
  {widget.kind === "chart" ? <div className="workspace-chart-preview">
241
- {[58, 36, 72, 48, 64].map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
498
+ {chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }} />)}
242
499
  </div> : null}
243
500
  {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
244
501
  aria-label={`Resize ${widget.title} from ${corner} corner`}
@@ -251,12 +508,34 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
251
508
  }
252
509
 
253
510
  function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
254
- 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
+ });
255
523
  const [saving, setSaving] = useState(false);
256
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
+ );
257
534
  const gridRef = useRef(null);
258
535
  const canvas = config.canvas;
259
- const dashboards = config.dashboards;
536
+ const dashboards = config.dashboards || [];
537
+ const resolvedActiveDashboardId = getActiveDashboardId(dashboards, activeDashboardId);
538
+ const resolvedActiveDashboardIndex = activeDashboardIndex(dashboards, resolvedActiveDashboardId);
260
539
  const widgetTypes = config.widgetTypes;
261
540
  const tabs = getTabs(canvas);
262
541
  const activeTabId = getActiveTabId(canvas);
@@ -267,7 +546,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
267
546
  const [dragStartCell, setDragStartCell] = useState(null);
268
547
  const [dragPreview, setDragPreview] = useState(null);
269
548
  const [resizeDrag, setResizeDrag] = useState(null);
549
+ const [configMessage, setConfigMessage] = useState("");
270
550
  const resizeDragRef = useRef(null);
551
+ const importInputRef = useRef(null);
271
552
  const addSlot = dragPreview || selectedPosition;
272
553
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
273
554
  const occupiedCells = useMemo(() => {
@@ -304,9 +585,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
304
585
  setSelectedWidgetId(widget.id);
305
586
  setSelectedPosition(findFreePosition([...existingWidgets, widget]));
306
587
  setDragPreview(null);
307
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
588
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
308
589
  });
309
- }, [addSlot]);
590
+ }, [activeDashboardId, addSlot]);
310
591
 
311
592
  const switchTab = useCallback((tabId) => {
312
593
  setConfig((prev) => {
@@ -317,9 +598,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
317
598
  setSelectedWidgetId(null);
318
599
  setSelectedPosition(findFreePosition(nextTab?.widgets || []));
319
600
  setDragPreview(null);
320
- return { ...prev, canvas: commitTabs(prev.canvas, prevTabs, tabId) };
601
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, prevTabs, tabId));
321
602
  });
322
- }, []);
603
+ }, [activeDashboardId]);
323
604
 
324
605
  const addTab = useCallback(() => {
325
606
  setConfig((prev) => {
@@ -338,51 +619,365 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
338
619
  setSelectedWidgetId(null);
339
620
  setSelectedPosition({ ...DEFAULT_POSITION });
340
621
  setDragPreview(null);
341
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, newTab.id) };
622
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, newTab.id));
342
623
  });
343
- }, []);
624
+ }, [activeDashboardId]);
344
625
 
345
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) => {
346
695
  setConfig((prev) => ({
347
696
  ...prev,
348
- dashboards: [
349
- ...(prev.dashboards || []),
350
- {
351
- id: generateId("dashboard"),
352
- name: "Untitled",
353
- createdBy: "Workspace owner",
354
- updatedAt: "new",
355
- status: "draft"
356
- }
357
- ]
697
+ dashboards: (prev.dashboards || []).map((dashboard) =>
698
+ dashboard.id === dashboardId ? { ...dashboard, status, updatedAt: "new" } : dashboard
699
+ )
358
700
  }));
359
701
  }, []);
360
702
 
703
+ const cloneDashboard = useCallback((index) => {
704
+ setConfig((prev) => {
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,
715
+ updatedAt: "new",
716
+ status: "draft",
717
+ tabs: normalizedSource.tabs.map((tab, tabIndex) =>
718
+ cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
719
+ )
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}`);
728
+ return {
729
+ ...synced,
730
+ dashboards: [...prevDashboards, dashboard],
731
+ canvas: dashboardCanvasFrom(dashboard, synced.canvas)
732
+ };
733
+ });
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]);
776
+
777
+ const duplicateTab = useCallback(() => {
778
+ setConfig((prev) => {
779
+ const prevTabs = getTabs(prev.canvas);
780
+ const prevActiveId = getActiveTabId(prev.canvas);
781
+ const source = prevTabs.find((tab) => tab.id === prevActiveId) || prevTabs[0];
782
+ const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
783
+ ? { ...prevTabs[0], id: generateId("tab") }
784
+ : prevTabs[0];
785
+ const stableTabs = prevTabs.length === 1 ? [stableFirst] : prevTabs;
786
+ const cloned = {
787
+ id: generateId("tab"),
788
+ name: `${source.name} Copy`,
789
+ widgets: (source.widgets || []).map((widget) => ({
790
+ ...cloneConfig(widget),
791
+ id: generateId("widget")
792
+ }))
793
+ };
794
+ const nextTabs = [...stableTabs, cloned];
795
+ setSelectedWidgetId(null);
796
+ setSelectedPosition(findFreePosition(cloned.widgets));
797
+ setDragPreview(null);
798
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, cloned.id));
799
+ });
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]);
825
+
826
+ const applyTemplateToCurrentTab = useCallback((templateId) => {
827
+ const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
828
+ if (!template) return;
829
+ const clonedTab = cloneTemplateToTab(template, { tabName: template.name, idFactory: generateId });
830
+ setConfig((prev) => {
831
+ const prevTabs = getTabs(prev.canvas);
832
+ const prevActiveId = getActiveTabId(prev.canvas);
833
+ const dashboardIndex = activeDashboardIndex(prev.dashboards || [], activeDashboardId);
834
+ const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
835
+ ? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
836
+ : prevTabs;
837
+ const nextTabs = stableTabs.map((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
845
+ );
846
+ return {
847
+ ...prev,
848
+ dashboards: nextDashboards,
849
+ canvas: nextCanvas
850
+ };
851
+ });
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]);
885
+
886
+ const exportConfig = useCallback(() => {
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" });
901
+ const url = URL.createObjectURL(blob);
902
+ const anchor = document.createElement("a");
903
+ anchor.href = url;
904
+ anchor.download = "growthub-dashboard.template.json";
905
+ anchor.click();
906
+ URL.revokeObjectURL(url);
907
+ setConfigMessage("Exported workspace template");
908
+ }, [activeDashboardId, config]);
909
+
910
+ const importConfig = useCallback(async (event) => {
911
+ const file = event.target.files?.[0];
912
+ if (!file) return;
913
+ try {
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];
921
+ setConfig((prev) => ({
922
+ ...prev,
923
+ dashboards: importedDashboards,
924
+ widgetTypes: payload.widgetTypes,
925
+ canvas: importedActiveDashboard ? dashboardCanvasFrom(importedActiveDashboard, payload.canvas) : payload.canvas
926
+ }));
927
+ setActiveDashboardId(importedActiveDashboard?.id || null);
928
+ const importedTabs = getTabs(importedActiveDashboard ? dashboardCanvasFrom(importedActiveDashboard, payload.canvas) : payload.canvas);
929
+ setSelectedWidgetId(null);
930
+ setSelectedPosition(findFreePosition(importedTabs[0]?.widgets || []));
931
+ setDragPreview(null);
932
+ setConfigMessage(`Imported ${file.name}`);
933
+ } catch (error) {
934
+ setConfigMessage(error.message || "Import failed");
935
+ } finally {
936
+ event.target.value = "";
937
+ }
938
+ }, []);
939
+
361
940
  const save = useCallback(async () => {
362
941
  if (saving) return;
363
942
  setSaving(true);
364
943
  try {
365
944
  const stamp = todayIsoDate();
366
- const updatedDashboards = (config.dashboards || []).map((dashboard, index) =>
367
- 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
368
950
  );
369
951
  const response = await fetch("/api/workspace", {
370
952
  method: "PATCH",
371
953
  headers: { "content-type": "application/json" },
372
954
  body: JSON.stringify({
373
955
  dashboards: updatedDashboards,
374
- widgetTypes: config.widgetTypes,
375
- canvas: config.canvas
956
+ widgetTypes: syncedConfig.widgetTypes,
957
+ canvas: syncedConfig.canvas
376
958
  })
377
959
  });
378
960
  const payload = await response.json();
379
961
  if (response.ok && payload.workspaceConfig) {
380
- 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
+ });
971
+ setConfigMessage("Saved dashboard config");
972
+ } else {
973
+ setConfigMessage(payload.error || "Save failed");
381
974
  }
975
+ } catch (error) {
976
+ setConfigMessage(error.message || "Save failed");
382
977
  } finally {
383
978
  setSaving(false);
384
979
  }
385
- }, [saving, config]);
980
+ }, [activeDashboardId, saving, config]);
386
981
 
387
982
  const reopenPanel = useCallback(() => setPanelOpen(true), []);
388
983
  const closePanel = useCallback(() => setPanelOpen(false), []);
@@ -449,9 +1044,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
449
1044
  )
450
1045
  };
451
1046
  });
452
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
1047
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
453
1048
  });
454
- }, [activeWidgets]);
1049
+ }, [activeDashboardId, activeWidgets]);
455
1050
  const finishResizeDrag = useCallback(() => {
456
1051
  if (!resizeDragRef.current) return;
457
1052
  resizeDragRef.current = null;
@@ -475,9 +1070,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
475
1070
  )
476
1071
  };
477
1072
  });
478
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
1073
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
479
1074
  });
480
- }, [selectedWidgetId]);
1075
+ }, [activeDashboardId, selectedWidgetId]);
481
1076
  const updateSelectedWidgetConfig = useCallback((updates) => {
482
1077
  if (!selectedWidget) return;
483
1078
  updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
@@ -493,10 +1088,24 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
493
1088
  const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
494
1089
  setSelectedWidgetId(null);
495
1090
  setSelectedPosition(findFreePosition(nextActiveWidgets));
496
- return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
1091
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
497
1092
  });
1093
+ }, [activeDashboardId]);
1094
+
1095
+ const closeTemplateGallery = useCallback(() => {
1096
+ setTemplateGalleryOpen(false);
1097
+ setPreviewTemplateId(null);
498
1098
  }, []);
499
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
+
500
1109
  const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
501
1110
 
502
1111
  return <main className="workspace-builder" style={builderStyle}>
@@ -525,10 +1134,22 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
525
1134
  <h1>{config.name}</h1>
526
1135
  </div>
527
1136
  <div className="workspace-toolbar-actions">
1137
+ <button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
528
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>
529
1142
  <button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
530
1143
  </div>
1144
+ <input
1145
+ ref={importInputRef}
1146
+ type="file"
1147
+ accept="application/json,.json"
1148
+ className="workspace-hidden-input"
1149
+ onChange={importConfig}
1150
+ />
531
1151
  </header>
1152
+ {configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
532
1153
 
533
1154
  <section className="workspace-table" id="dashboards" aria-label="Dashboards">
534
1155
  <div className="workspace-table-heading">
@@ -540,12 +1161,45 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
540
1161
  <span>Created by</span>
541
1162
  <span>Last update</span>
542
1163
  <span>Status</span>
1164
+ <span>Actions</span>
543
1165
  </div>
544
- {dashboards.map((dashboard) => <div className="workspace-table-row" key={dashboard.id}>
545
- <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>
546
1184
  <span>{dashboard.createdBy}</span>
547
1185
  <span>{dashboard.updatedAt}</span>
548
- <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>
549
1203
  </div>)}
550
1204
  </section>
551
1205
 
@@ -556,8 +1210,28 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
556
1210
  className={tab.id === activeTabId ? "active" : ""}
557
1211
  type="button"
558
1212
  onClick={() => switchTab(tab.id)}
559
- >{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>)}
560
1233
  <button type="button" onClick={addTab}>New Tab</button>
1234
+ <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
561
1235
  </div>
562
1236
  <div
563
1237
  className="workspace-grid"
@@ -610,6 +1284,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
610
1284
  </section>
611
1285
  </section>
612
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
+
613
1296
  {panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
614
1297
  <div className="workspace-panel-title">
615
1298
  <button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
@@ -622,6 +1305,27 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
622
1305
  <span>Title</span>
623
1306
  <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
624
1307
  </label>
1308
+ {selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
1309
+ <label>
1310
+ <span>Sample Values</span>
1311
+ <input
1312
+ value={serializeChartValues(selectedWidget.config?.values || [])}
1313
+ onChange={(event) => updateSelectedWidgetConfig({ values: normalizeChartValues(event.target.value) })}
1314
+ />
1315
+ </label>
1316
+ <label>
1317
+ <span>Static Binding</span>
1318
+ <select
1319
+ value={selectedWidget.config?.binding?.mode || "json"}
1320
+ onChange={(event) => updateSelectedWidgetConfig({
1321
+ binding: event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.reportingJson
1322
+ })}
1323
+ >
1324
+ <option value="json">Sample JSON</option>
1325
+ <option value="csv">Sample CSV</option>
1326
+ </select>
1327
+ </label>
1328
+ </section> : null}
625
1329
  {selectedWidget.kind === "iframe" ? <label>
626
1330
  <span>URL to Embed</span>
627
1331
  <input
@@ -638,14 +1342,68 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
638
1342
  onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
639
1343
  />
640
1344
  </label> : null}
641
- {selectedWidget.kind === "view" ? <div className="workspace-settings-list">
1345
+ {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
+ <label>
1361
+ <span>Manual Rows</span>
1362
+ <textarea
1363
+ value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
1364
+ onChange={(event) => {
1365
+ const columns = selectedWidget.config?.columns?.length ? selectedWidget.config.columns : ["Name", "Domain Name"];
1366
+ updateSelectedWidgetConfig({
1367
+ rows: parseManualRows(event.target.value, columns),
1368
+ binding: { mode: "manual", source: "Manual rows", rows: parseManualRows(event.target.value, columns) }
1369
+ });
1370
+ }}
1371
+ />
1372
+ </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">
642
1387
  <p className="workspace-panel-label">Settings</p>
643
1388
  <div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
644
1389
  <div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
645
- <div><span>Fields</span><code>6 shown</code></div>
1390
+ <div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
646
1391
  <div><span>Filter</span><code>›</code></div>
647
1392
  <div><span>Sort</span><code>›</code></div>
648
- </div> : null}
1393
+ </div>
1394
+ </section> : null}
1395
+ {selectedWidget.kind === "rich-text" ? <label>
1396
+ <span>Static Binding</span>
1397
+ <select
1398
+ value={selectedWidget.config?.binding?.mode || "manual"}
1399
+ onChange={(event) => updateSelectedWidgetConfig({
1400
+ binding: { mode: event.target.value, source: event.target.value === "manual" ? "Manual text" : "Sample JSON", rows: [] }
1401
+ })}
1402
+ >
1403
+ <option value="manual">Manual Text</option>
1404
+ <option value="json">Sample JSON</option>
1405
+ </select>
1406
+ </label> : null}
649
1407
  <div className="workspace-settings-list">
650
1408
  <p className="workspace-panel-label">Placement</p>
651
1409
  <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>