@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +23 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +298 -27
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +26 -146
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +461 -0
- package/dist/index.js +1824 -1061
- package/package.json +3 -2
package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css
CHANGED
|
@@ -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:
|
|
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
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
[
|
|
232
|
-
|
|
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
|
-
{
|
|
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" ? <
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
...
|
|
184
|
-
layout: { ...(currentConfig.canvas?.layout || {}), ...(
|
|
185
|
-
bindings: { ...(currentConfig.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(
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(patchCanvas, key) && patchCanvas[key] === null) {
|
|
189
69
|
delete next.canvas[key];
|
|
190
70
|
}
|
|
191
71
|
}
|