@growthub/cli 0.9.5 → 0.9.6

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.
@@ -544,6 +544,24 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
544
544
  background: #f3f3f3;
545
545
  color: #222;
546
546
  }
547
+ .workspace-toolbar-actions select,
548
+ .workspace-widget-settings select {
549
+ min-height: 32px;
550
+ border: 1px solid #dcdcdc;
551
+ border-radius: 6px;
552
+ background: #ffffff;
553
+ color: #444;
554
+ font: inherit;
555
+ padding: 0 8px;
556
+ }
557
+ .workspace-hidden-input {
558
+ display: none;
559
+ }
560
+ .workspace-config-message {
561
+ margin: -8px 0 10px;
562
+ color: #777;
563
+ font-size: 12px;
564
+ }
547
565
  .workspace-grid {
548
566
  position: relative;
549
567
  display: grid;
@@ -687,7 +705,7 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
687
705
  }
688
706
  .workspace-view-table div {
689
707
  display: grid;
690
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
708
+ grid-template-columns: repeat(var(--workspace-view-columns, 2), minmax(0, 1fr));
691
709
  min-height: 25px;
692
710
  border-bottom: 1px solid #eeeeee;
693
711
  }
@@ -791,7 +809,6 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
791
809
  padding: 0 8px;
792
810
  text-align: left;
793
811
  }
794
- .workspace-widget-types button:first-child,
795
812
  .workspace-widget-types button:hover {
796
813
  background: #f2f2f2;
797
814
  }
@@ -827,6 +844,10 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
827
844
  gap: 14px;
828
845
  padding-top: 14px;
829
846
  }
847
+ .workspace-field-stack {
848
+ display: grid;
849
+ gap: 12px;
850
+ }
830
851
  .workspace-widget-settings label {
831
852
  display: grid;
832
853
  gap: 6px;
@@ -2,6 +2,13 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useCallback, useMemo, useRef, useState } from "react";
5
+ import {
6
+ DASHBOARD_TEMPLATES,
7
+ SAMPLE_DATA_BINDINGS,
8
+ SAMPLE_VIEW_ROWS,
9
+ defaultConfigFor,
10
+ validateWorkspaceConfig
11
+ } from "@/lib/workspace-schema";
5
12
 
6
13
  const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
7
14
  const GRID_COLUMNS = 12;
@@ -27,19 +34,6 @@ function defaultTitleFor(kind) {
27
34
  }
28
35
  }
29
36
 
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
37
  function getTabs(canvas) {
44
38
  if (Array.isArray(canvas?.tabs) && canvas.tabs.length > 0) {
45
39
  return canvas.tabs;
@@ -200,7 +194,69 @@ function widgetKindLabel(kind) {
200
194
  return kind.charAt(0).toUpperCase() + kind.slice(1);
201
195
  }
202
196
 
197
+ function cloneConfig(value) {
198
+ return JSON.parse(JSON.stringify(value));
199
+ }
200
+
201
+ function normalizeChartValues(value) {
202
+ return String(value)
203
+ .split(",")
204
+ .map((item) => Number(item.trim()))
205
+ .filter((item) => Number.isFinite(item));
206
+ }
207
+
208
+ function serializeChartValues(values) {
209
+ return (Array.isArray(values) ? values : []).join(", ");
210
+ }
211
+
212
+ function parseLineList(value) {
213
+ return String(value)
214
+ .split(",")
215
+ .map((item) => item.trim())
216
+ .filter(Boolean);
217
+ }
218
+
219
+ function serializeLineList(values) {
220
+ return (Array.isArray(values) ? values : []).join(", ");
221
+ }
222
+
223
+ function parseManualRows(value, columns) {
224
+ const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
225
+ return String(value)
226
+ .split("\n")
227
+ .map((row) => row.trim())
228
+ .filter(Boolean)
229
+ .map((row) => {
230
+ const values = row.split("|").map((item) => item.trim());
231
+ return activeColumns.reduce((record, column, index) => {
232
+ record[column] = values[index] || "";
233
+ return record;
234
+ }, {});
235
+ });
236
+ }
237
+
238
+ function serializeManualRows(rows, columns) {
239
+ const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
240
+ return (Array.isArray(rows) ? rows : [])
241
+ .map((row) => activeColumns.map((column) => row?.[column] || "").join(" | "))
242
+ .join("\n");
243
+ }
244
+
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
+ };
254
+ }
255
+
203
256
  function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
257
+ const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
258
+ const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
259
+ const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
204
260
  return <article
205
261
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
206
262
  onClick={onSelect}
@@ -221,16 +277,15 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
221
277
  type="button"
222
278
  >x</button>
223
279
  </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>)}
280
+ {widget.kind === "view" ? <div
281
+ className="workspace-view-table"
282
+ aria-label={`${widget.title} preview`}
283
+ style={{ "--workspace-view-columns": viewColumns.length }}
284
+ >
285
+ <div>{viewColumns.map((column) => <span key={column}>{column}</span>)}</div>
286
+ {viewRows.slice(0, 6).map((row, rowIndex) => <div key={rowIndex}>
287
+ {viewColumns.map((column) => <span key={column}>{row?.[column] || ""}</span>)}
288
+ </div>)}
234
289
  <footer>Calculate</footer>
235
290
  </div> : null}
236
291
  {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
@@ -238,7 +293,7 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
238
293
  </div> : null}
239
294
  {widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
240
295
  {widget.kind === "chart" ? <div className="workspace-chart-preview">
241
- {[58, 36, 72, 48, 64].map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
296
+ {chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }} />)}
242
297
  </div> : null}
243
298
  {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
244
299
  aria-label={`Resize ${widget.title} from ${corner} corner`}
@@ -267,7 +322,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
267
322
  const [dragStartCell, setDragStartCell] = useState(null);
268
323
  const [dragPreview, setDragPreview] = useState(null);
269
324
  const [resizeDrag, setResizeDrag] = useState(null);
325
+ const [configMessage, setConfigMessage] = useState("");
270
326
  const resizeDragRef = useRef(null);
327
+ const importInputRef = useRef(null);
271
328
  const addSlot = dragPreview || selectedPosition;
272
329
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
273
330
  const occupiedCells = useMemo(() => {
@@ -358,6 +415,121 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
358
415
  }));
359
416
  }, []);
360
417
 
418
+ const duplicateDashboard = useCallback(() => {
419
+ setConfig((prev) => {
420
+ const source = prev.dashboards?.[0] || {
421
+ name: "Untitled",
422
+ createdBy: "Workspace owner",
423
+ updatedAt: "new",
424
+ status: "draft"
425
+ };
426
+ 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
+ ]
438
+ };
439
+ });
440
+ }, []);
441
+
442
+ const duplicateTab = useCallback(() => {
443
+ setConfig((prev) => {
444
+ const prevTabs = getTabs(prev.canvas);
445
+ const prevActiveId = getActiveTabId(prev.canvas);
446
+ const source = prevTabs.find((tab) => tab.id === prevActiveId) || prevTabs[0];
447
+ const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
448
+ ? { ...prevTabs[0], id: generateId("tab") }
449
+ : prevTabs[0];
450
+ const stableTabs = prevTabs.length === 1 ? [stableFirst] : prevTabs;
451
+ const cloned = {
452
+ id: generateId("tab"),
453
+ name: `${source.name} Copy`,
454
+ widgets: (source.widgets || []).map((widget) => ({
455
+ ...cloneConfig(widget),
456
+ id: generateId("widget")
457
+ }))
458
+ };
459
+ const nextTabs = [...stableTabs, cloned];
460
+ setSelectedWidgetId(null);
461
+ setSelectedPosition(findFreePosition(cloned.widgets));
462
+ setDragPreview(null);
463
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, cloned.id) };
464
+ });
465
+ }, []);
466
+
467
+ const applyTemplate = useCallback((templateId) => {
468
+ const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
469
+ if (!template) return;
470
+ setConfig((prev) => {
471
+ const hydrated = hydrateTemplate(template);
472
+ const prevTabs = getTabs(prev.canvas);
473
+ const prevActiveId = getActiveTabId(prev.canvas);
474
+ const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
475
+ ? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
476
+ : prevTabs;
477
+ const nextTabs = stableTabs.map((tab) =>
478
+ tab.id === prevActiveId ? { ...tab, name: hydrated.name, widgets: hydrated.widgets } : tab
479
+ );
480
+ setSelectedWidgetId(null);
481
+ setSelectedPosition(findFreePosition(hydrated.widgets));
482
+ setDragPreview(null);
483
+ setConfigMessage(`Applied ${template.name}`);
484
+ return {
485
+ ...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)
490
+ };
491
+ });
492
+ }, []);
493
+
494
+ 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" });
500
+ const url = URL.createObjectURL(blob);
501
+ const anchor = document.createElement("a");
502
+ anchor.href = url;
503
+ anchor.download = "growthub-dashboard.config.json";
504
+ anchor.click();
505
+ URL.revokeObjectURL(url);
506
+ setConfigMessage("Exported dashboard config");
507
+ }, [config]);
508
+
509
+ const importConfig = useCallback(async (event) => {
510
+ const file = event.target.files?.[0];
511
+ if (!file) return;
512
+ try {
513
+ const imported = JSON.parse(await file.text());
514
+ validateWorkspaceConfig(imported);
515
+ setConfig((prev) => ({
516
+ ...prev,
517
+ dashboards: imported.dashboards,
518
+ widgetTypes: imported.widgetTypes,
519
+ canvas: imported.canvas
520
+ }));
521
+ const importedTabs = getTabs(imported.canvas);
522
+ setSelectedWidgetId(null);
523
+ setSelectedPosition(findFreePosition(importedTabs[0]?.widgets || []));
524
+ setDragPreview(null);
525
+ setConfigMessage(`Imported ${file.name}`);
526
+ } catch (error) {
527
+ setConfigMessage(error.message || "Import failed");
528
+ } finally {
529
+ event.target.value = "";
530
+ }
531
+ }, []);
532
+
361
533
  const save = useCallback(async () => {
362
534
  if (saving) return;
363
535
  setSaving(true);
@@ -378,7 +550,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
378
550
  const payload = await response.json();
379
551
  if (response.ok && payload.workspaceConfig) {
380
552
  setConfig(payload.workspaceConfig);
553
+ setConfigMessage("Saved dashboard config");
554
+ } else {
555
+ setConfigMessage(payload.error || "Save failed");
381
556
  }
557
+ } catch (error) {
558
+ setConfigMessage(error.message || "Save failed");
382
559
  } finally {
383
560
  setSaving(false);
384
561
  }
@@ -525,10 +702,28 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
525
702
  <h1>{config.name}</h1>
526
703
  </div>
527
704
  <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>
528
712
  <button type="button" onClick={addDashboard}>New Dashboard</button>
713
+ <button type="button" onClick={duplicateDashboard}>Duplicate Dashboard</button>
714
+ <button type="button" onClick={() => importInputRef.current?.click()}>Import</button>
715
+ <button type="button" onClick={exportConfig}>Export</button>
529
716
  <button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
530
717
  </div>
718
+ <input
719
+ ref={importInputRef}
720
+ type="file"
721
+ accept="application/json,.json"
722
+ className="workspace-hidden-input"
723
+ onChange={importConfig}
724
+ />
531
725
  </header>
726
+ {configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
532
727
 
533
728
  <section className="workspace-table" id="dashboards" aria-label="Dashboards">
534
729
  <div className="workspace-table-heading">
@@ -558,6 +753,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
558
753
  onClick={() => switchTab(tab.id)}
559
754
  >{tab.name}</button>)}
560
755
  <button type="button" onClick={addTab}>New Tab</button>
756
+ <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
561
757
  </div>
562
758
  <div
563
759
  className="workspace-grid"
@@ -622,6 +818,27 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
622
818
  <span>Title</span>
623
819
  <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
624
820
  </label>
821
+ {selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
822
+ <label>
823
+ <span>Sample Values</span>
824
+ <input
825
+ value={serializeChartValues(selectedWidget.config?.values || [])}
826
+ onChange={(event) => updateSelectedWidgetConfig({ values: normalizeChartValues(event.target.value) })}
827
+ />
828
+ </label>
829
+ <label>
830
+ <span>Static Binding</span>
831
+ <select
832
+ value={selectedWidget.config?.binding?.mode || "json"}
833
+ onChange={(event) => updateSelectedWidgetConfig({
834
+ binding: event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.reportingJson
835
+ })}
836
+ >
837
+ <option value="json">Sample JSON</option>
838
+ <option value="csv">Sample CSV</option>
839
+ </select>
840
+ </label>
841
+ </section> : null}
625
842
  {selectedWidget.kind === "iframe" ? <label>
626
843
  <span>URL to Embed</span>
627
844
  <input
@@ -638,14 +855,68 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
638
855
  onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
639
856
  />
640
857
  </label> : null}
641
- {selectedWidget.kind === "view" ? <div className="workspace-settings-list">
858
+ {selectedWidget.kind === "view" ? <section className="workspace-field-stack">
859
+ <label>
860
+ <span>Source</span>
861
+ <input
862
+ value={selectedWidget.config?.source || ""}
863
+ onChange={(event) => updateSelectedWidgetConfig({ source: event.target.value })}
864
+ />
865
+ </label>
866
+ <label>
867
+ <span>Columns</span>
868
+ <input
869
+ value={serializeLineList(selectedWidget.config?.columns || [])}
870
+ onChange={(event) => updateSelectedWidgetConfig({ columns: parseLineList(event.target.value) })}
871
+ />
872
+ </label>
873
+ <label>
874
+ <span>Manual Rows</span>
875
+ <textarea
876
+ value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
877
+ onChange={(event) => {
878
+ const columns = selectedWidget.config?.columns?.length ? selectedWidget.config.columns : ["Name", "Domain Name"];
879
+ updateSelectedWidgetConfig({
880
+ rows: parseManualRows(event.target.value, columns),
881
+ binding: { mode: "manual", source: "Manual rows", rows: parseManualRows(event.target.value, columns) }
882
+ });
883
+ }}
884
+ />
885
+ </label>
886
+ <label>
887
+ <span>Static Binding</span>
888
+ <select
889
+ value={selectedWidget.config?.binding?.mode || "manual"}
890
+ onChange={(event) => {
891
+ const binding = event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.companiesManual;
892
+ updateSelectedWidgetConfig({ binding });
893
+ }}
894
+ >
895
+ <option value="manual">Manual Rows</option>
896
+ <option value="csv">Sample CSV</option>
897
+ </select>
898
+ </label>
899
+ <div className="workspace-settings-list">
642
900
  <p className="workspace-panel-label">Settings</p>
643
901
  <div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
644
902
  <div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
645
- <div><span>Fields</span><code>6 shown</code></div>
903
+ <div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
646
904
  <div><span>Filter</span><code>›</code></div>
647
905
  <div><span>Sort</span><code>›</code></div>
648
- </div> : null}
906
+ </div>
907
+ </section> : null}
908
+ {selectedWidget.kind === "rich-text" ? <label>
909
+ <span>Static Binding</span>
910
+ <select
911
+ value={selectedWidget.config?.binding?.mode || "manual"}
912
+ onChange={(event) => updateSelectedWidgetConfig({
913
+ binding: { mode: event.target.value, source: event.target.value === "manual" ? "Manual text" : "Sample JSON", rows: [] }
914
+ })}
915
+ >
916
+ <option value="manual">Manual Text</option>
917
+ <option value="json">Sample JSON</option>
918
+ </select>
919
+ </label> : null}
649
920
  <div className="workspace-settings-list">
650
921
  <p className="workspace-panel-label">Placement</p>
651
922
  <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
@@ -1,11 +1,12 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { readAdapterConfig } from "@/lib/adapters/env";
4
-
5
- const KNOWN_FIELDS = ["dashboards", "widgetTypes", "canvas"];
6
- const KNOWN_WIDGET_KINDS = ["chart", "view", "iframe", "rich-text"];
7
- const GRID_COLUMNS = 12;
8
- const GRID_ROWS = 16;
4
+ import {
5
+ GRID_COLUMNS,
6
+ GRID_ROWS,
7
+ KNOWN_WIDGET_KINDS,
8
+ validateWorkspaceConfig
9
+ } from "@/lib/workspace-schema";
9
10
 
10
11
  function resolveWorkspaceConfigPath() {
11
12
  return path.resolve(/*turbopackIgnore: true*/ process.cwd(), "growthub.config.json");
@@ -36,156 +37,35 @@ function describePersistenceMode() {
36
37
  return { mode: "filesystem", reason: "Local development" };
37
38
  }
38
39
 
39
- function isFiniteInt(value) {
40
- return typeof value === "number" && Number.isFinite(value) && Math.floor(value) === value;
41
- }
42
-
43
- function validateWidgetArray(widgets, contextPath, errors, seenIds) {
44
- if (!Array.isArray(widgets)) {
45
- errors.push(`${contextPath} must be an array`);
46
- return;
47
- }
48
- const occupied = new Map();
49
- widgets.forEach((widget, index) => {
50
- const prefix = `${contextPath}[${index}]`;
51
- if (!widget || typeof widget !== "object" || Array.isArray(widget)) {
52
- errors.push(`${prefix} must be an object`);
53
- return;
54
- }
55
- if (typeof widget.id !== "string" || !widget.id) {
56
- errors.push(`${prefix}.id must be a non-empty string`);
57
- } else if (seenIds.has(widget.id)) {
58
- errors.push(`${prefix}.id duplicates an earlier widget id`);
59
- } else {
60
- seenIds.add(widget.id);
61
- }
62
- if (!KNOWN_WIDGET_KINDS.includes(widget.kind)) {
63
- errors.push(`${prefix}.kind must be one of ${KNOWN_WIDGET_KINDS.join(", ")}`);
64
- }
65
- if (!widget.position || typeof widget.position !== "object" || Array.isArray(widget.position)) {
66
- errors.push(`${prefix}.position must be an object`);
67
- return;
68
- }
69
- for (const k of ["x", "y", "w", "h"]) {
70
- if (!isFiniteInt(widget.position[k])) {
71
- errors.push(`${prefix}.position.${k} must be a finite integer`);
72
- }
73
- }
74
- if (
75
- isFiniteInt(widget.position.x) &&
76
- isFiniteInt(widget.position.w) &&
77
- (widget.position.x < 0 || widget.position.w < 1 || widget.position.x + widget.position.w > GRID_COLUMNS)
78
- ) {
79
- errors.push(`${prefix} x/w out of [0..${GRID_COLUMNS}] grid`);
80
- }
81
- if (
82
- isFiniteInt(widget.position.y) &&
83
- isFiniteInt(widget.position.h) &&
84
- (widget.position.y < 0 || widget.position.h < 1 || widget.position.y + widget.position.h > GRID_ROWS)
85
- ) {
86
- errors.push(`${prefix} y/h out of [0..${GRID_ROWS}] grid`);
87
- }
88
- if (
89
- isFiniteInt(widget.position.x) &&
90
- isFiniteInt(widget.position.y) &&
91
- isFiniteInt(widget.position.w) &&
92
- isFiniteInt(widget.position.h)
93
- ) {
94
- for (let dx = 0; dx < widget.position.w; dx += 1) {
95
- for (let dy = 0; dy < widget.position.h; dy += 1) {
96
- const cell = `${widget.position.x + dx}:${widget.position.y + dy}`;
97
- const previous = occupied.get(cell);
98
- if (previous) {
99
- errors.push(`${prefix} overlaps ${previous} at grid cell ${cell}`);
100
- } else {
101
- occupied.set(cell, `${prefix}.position`);
102
- }
103
- }
104
- }
105
- }
106
- });
107
- }
108
-
109
- function validateWorkspaceConfig(nextConfig) {
110
- if (!nextConfig || typeof nextConfig !== "object" || Array.isArray(nextConfig)) {
111
- const error = new Error("workspace config must be a plain object");
112
- error.code = "INVALID_WORKSPACE_CONFIG";
113
- error.details = ["root must be a plain object"];
114
- throw error;
115
- }
116
- const errors = [];
117
- for (const key of Object.keys(nextConfig)) {
118
- if (!KNOWN_FIELDS.includes(key)) {
119
- errors.push(`unknown top-level field: ${key}`);
120
- }
121
- }
122
- if (nextConfig.dashboards !== undefined && !Array.isArray(nextConfig.dashboards)) {
123
- errors.push("dashboards must be an array");
124
- }
125
- if (nextConfig.widgetTypes !== undefined && !Array.isArray(nextConfig.widgetTypes)) {
126
- errors.push("widgetTypes must be an array");
127
- }
128
- if (nextConfig.canvas !== undefined) {
129
- if (typeof nextConfig.canvas !== "object" || Array.isArray(nextConfig.canvas) || nextConfig.canvas === null) {
130
- errors.push("canvas must be a plain object");
131
- } else {
132
- const seenWidgetIds = new Set();
133
- if (nextConfig.canvas.widgets !== undefined) {
134
- validateWidgetArray(nextConfig.canvas.widgets, "canvas.widgets", errors, seenWidgetIds);
135
- }
136
- if (nextConfig.canvas.tabs !== undefined) {
137
- if (!Array.isArray(nextConfig.canvas.tabs)) {
138
- errors.push("canvas.tabs must be an array");
139
- } else {
140
- const seenTabIds = new Set();
141
- nextConfig.canvas.tabs.forEach((tab, index) => {
142
- const tabPrefix = `canvas.tabs[${index}]`;
143
- if (!tab || typeof tab !== "object" || Array.isArray(tab)) {
144
- errors.push(`${tabPrefix} must be an object`);
145
- return;
146
- }
147
- if (typeof tab.id !== "string" || !tab.id) {
148
- errors.push(`${tabPrefix}.id must be a non-empty string`);
149
- } else if (seenTabIds.has(tab.id)) {
150
- errors.push(`${tabPrefix}.id duplicates an earlier tab id`);
151
- } else {
152
- seenTabIds.add(tab.id);
153
- }
154
- if (typeof tab.name !== "string" || !tab.name) {
155
- errors.push(`${tabPrefix}.name must be a non-empty string`);
156
- }
157
- if (tab.widgets !== undefined) {
158
- validateWidgetArray(tab.widgets, `${tabPrefix}.widgets`, errors, seenWidgetIds);
159
- }
160
- });
161
- }
162
- }
163
- if (nextConfig.canvas.activeTabId !== undefined && typeof nextConfig.canvas.activeTabId !== "string") {
164
- errors.push("canvas.activeTabId must be a string");
165
- }
166
- }
167
- }
168
- if (errors.length) {
169
- const error = new Error(`invalid workspace config: ${errors.join("; ")}`);
170
- error.code = "INVALID_WORKSPACE_CONFIG";
171
- error.details = errors;
172
- throw error;
173
- }
174
- }
175
-
176
40
  function applyPatch(currentConfig, patch) {
177
41
  const next = { ...currentConfig };
178
42
  if (patch.dashboards !== undefined) next.dashboards = patch.dashboards;
179
43
  if (patch.widgetTypes !== undefined) next.widgetTypes = patch.widgetTypes;
180
44
  if (patch.canvas !== undefined && patch.canvas !== null) {
45
+ const patchCanvas = { ...patch.canvas };
46
+ if (Array.isArray(patchCanvas.tabs)) {
47
+ delete patchCanvas.widgets;
48
+ delete patchCanvas.name;
49
+ } else if (Array.isArray(patchCanvas.widgets)) {
50
+ delete patchCanvas.tabs;
51
+ delete patchCanvas.activeTabId;
52
+ }
181
53
  next.canvas = {
182
54
  ...currentConfig.canvas,
183
- ...patch.canvas,
184
- layout: { ...(currentConfig.canvas?.layout || {}), ...(patch.canvas.layout || {}) },
185
- bindings: { ...(currentConfig.canvas?.bindings || {}), ...(patch.canvas.bindings || {}) }
55
+ ...patchCanvas,
56
+ layout: { ...(currentConfig.canvas?.layout || {}), ...(patchCanvas.layout || {}) },
57
+ bindings: { ...(currentConfig.canvas?.bindings || {}), ...(patchCanvas.bindings || {}) }
186
58
  };
59
+ if (Array.isArray(patch.canvas.tabs)) {
60
+ delete next.canvas.widgets;
61
+ delete next.canvas.name;
62
+ }
63
+ if (Array.isArray(patch.canvas.widgets)) {
64
+ delete next.canvas.tabs;
65
+ delete next.canvas.activeTabId;
66
+ }
187
67
  for (const key of ["widgets", "tabs", "activeTabId", "name"]) {
188
- if (Object.prototype.hasOwnProperty.call(patch.canvas, key) && patch.canvas[key] === null) {
68
+ if (Object.prototype.hasOwnProperty.call(patchCanvas, key) && patchCanvas[key] === null) {
189
69
  delete next.canvas[key];
190
70
  }
191
71
  }