@growthub/cli 0.9.7 → 0.9.9
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/README.md +23 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +8 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/.env.example +8 -8
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +9 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/integrations/route.js +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +4 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +554 -19
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +111 -77
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +485 -77
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +8 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/env.js +9 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +10 -10
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +62 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +38 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +3 -0
- package/package.json +1 -1
|
@@ -76,7 +76,7 @@ function commitTabs(canvas, tabs, activeTabId) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function createDashboardRecord(name = "Untitled") {
|
|
79
|
-
const tab = createEmptyTab(
|
|
79
|
+
const tab = createEmptyTab("Tab 1");
|
|
80
80
|
return {
|
|
81
81
|
id: generateId("dashboard"),
|
|
82
82
|
name,
|
|
@@ -140,7 +140,7 @@ function updateDashboardCanvas(dashboard, canvas) {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
function createDashboardFromTab(name, tab, source = {}) {
|
|
143
|
-
const clonedTab = cloneTabForDashboard(tab, name);
|
|
143
|
+
const clonedTab = cloneTabForDashboard(tab, tab?.name || "Tab 1");
|
|
144
144
|
return {
|
|
145
145
|
...source,
|
|
146
146
|
id: generateId("dashboard"),
|
|
@@ -189,6 +189,24 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
|
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
|
|
193
|
+
const nextName = name.trim() || "Untitled";
|
|
194
|
+
const prevDashboards = config.dashboards || [];
|
|
195
|
+
const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
|
|
196
|
+
if (index < 0) return config;
|
|
197
|
+
const nextDashboardsWithTabs = prevDashboards.map((dashboard, dashboardIndex) => {
|
|
198
|
+
if (dashboard.id !== dashboardId) return dashboard;
|
|
199
|
+
const normalized = normalizeDashboard(dashboard, dashboardIndex === 0 ? config.canvas : undefined);
|
|
200
|
+
return { ...normalized, name: nextName, updatedAt: "new" };
|
|
201
|
+
});
|
|
202
|
+
const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
|
|
203
|
+
return {
|
|
204
|
+
...config,
|
|
205
|
+
dashboards: nextDashboardsWithTabs,
|
|
206
|
+
canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], config.canvas)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
192
210
|
function findFreePosition(widgets) {
|
|
193
211
|
const occupied = new Set();
|
|
194
212
|
for (const widget of widgets) {
|
|
@@ -245,6 +263,18 @@ function clampPositionToFreeSpace(position, widgets) {
|
|
|
245
263
|
return collides ? findFreePosition(widgets) : bounded;
|
|
246
264
|
}
|
|
247
265
|
|
|
266
|
+
function clampWidgetMovePosition(position, widget, widgets) {
|
|
267
|
+
const bounded = {
|
|
268
|
+
...widget.position,
|
|
269
|
+
x: Math.max(0, Math.min(position.x, GRID_COLUMNS - widget.position.w)),
|
|
270
|
+
y: Math.max(0, Math.min(position.y, GRID_ROWS - widget.position.h))
|
|
271
|
+
};
|
|
272
|
+
const otherWidgets = widgets.filter((item) => item.id !== widget.id);
|
|
273
|
+
return otherWidgets.some((item) => positionsOverlap(bounded, item.position))
|
|
274
|
+
? widget.position
|
|
275
|
+
: bounded;
|
|
276
|
+
}
|
|
277
|
+
|
|
248
278
|
function cellIndexFromGridPointer(event, gridElement) {
|
|
249
279
|
if (!gridElement) return null;
|
|
250
280
|
const rect = gridElement.getBoundingClientRect();
|
|
@@ -264,6 +294,13 @@ function cellIndexFromGridPointer(event, gridElement) {
|
|
|
264
294
|
return row * GRID_COLUMNS + column;
|
|
265
295
|
}
|
|
266
296
|
|
|
297
|
+
function cellPointFromIndex(index) {
|
|
298
|
+
return {
|
|
299
|
+
x: index % GRID_COLUMNS,
|
|
300
|
+
y: Math.floor(index / GRID_COLUMNS)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
267
304
|
function resizePositionFromCell(position, corner, index) {
|
|
268
305
|
const cellX = index % GRID_COLUMNS;
|
|
269
306
|
const cellY = Math.floor(index / GRID_COLUMNS);
|
|
@@ -313,6 +350,16 @@ function widgetKindLabel(kind) {
|
|
|
313
350
|
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
314
351
|
}
|
|
315
352
|
|
|
353
|
+
function isLikelyHttpUrl(value) {
|
|
354
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
355
|
+
try {
|
|
356
|
+
const url = new URL(value.trim());
|
|
357
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
316
363
|
function cloneConfig(value) {
|
|
317
364
|
return JSON.parse(JSON.stringify(value));
|
|
318
365
|
}
|
|
@@ -441,8 +488,8 @@ function TemplateGallery({
|
|
|
441
488
|
</div> : null}
|
|
442
489
|
<div className="template-card-actions">
|
|
443
490
|
<button type="button" onClick={() => onPreview(template.id)}>{isPreviewing ? "Previewing" : "Preview"}</button>
|
|
444
|
-
<button type="button" onClick={() => onApplyToCurrentTab(template.id)}>
|
|
445
|
-
<button type="button" onClick={() => onCloneAsDashboard(template.id)}>
|
|
491
|
+
<button type="button" className="primary" onClick={() => onApplyToCurrentTab(template.id)}>Use Here</button>
|
|
492
|
+
<button type="button" onClick={() => onCloneAsDashboard(template.id)}>New Dashboard</button>
|
|
446
493
|
</div>
|
|
447
494
|
</article>;
|
|
448
495
|
})}
|
|
@@ -455,7 +502,7 @@ function TemplateGallery({
|
|
|
455
502
|
</div>;
|
|
456
503
|
}
|
|
457
504
|
|
|
458
|
-
function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
|
|
505
|
+
function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart }) {
|
|
459
506
|
const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
|
|
460
507
|
const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
|
|
461
508
|
const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
|
|
@@ -468,7 +515,11 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
468
515
|
}}
|
|
469
516
|
>
|
|
470
517
|
<div className="workspace-widget-preview-title">
|
|
471
|
-
<span
|
|
518
|
+
<span
|
|
519
|
+
aria-hidden="true"
|
|
520
|
+
className="workspace-widget-drag-handle"
|
|
521
|
+
onPointerDown={(event) => onMoveStart(event)}
|
|
522
|
+
>::</span>
|
|
472
523
|
<strong>{widget.title}</strong>
|
|
473
524
|
<button
|
|
474
525
|
aria-label={`Remove ${widget.title}`}
|
|
@@ -507,7 +558,170 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
507
558
|
</article>;
|
|
508
559
|
}
|
|
509
560
|
|
|
510
|
-
|
|
561
|
+
const DEFAULT_PERSISTENCE = {
|
|
562
|
+
mode: "filesystem",
|
|
563
|
+
adapter: "filesystem",
|
|
564
|
+
canSave: true,
|
|
565
|
+
saveLabel: "Save writes growthub.config.json on disk.",
|
|
566
|
+
reason: "Local development",
|
|
567
|
+
nextAction: null,
|
|
568
|
+
guidance: null
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
function countCanvasWidgets(canvas) {
|
|
572
|
+
if (!canvas) return 0;
|
|
573
|
+
if (Array.isArray(canvas.tabs) && canvas.tabs.length) {
|
|
574
|
+
return canvas.tabs.reduce((acc, tab) => acc + (Array.isArray(tab.widgets) ? tab.widgets.length : 0), 0);
|
|
575
|
+
}
|
|
576
|
+
return Array.isArray(canvas.widgets) ? canvas.widgets.length : 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function countCanvasTabs(canvas) {
|
|
580
|
+
if (!canvas) return 0;
|
|
581
|
+
if (Array.isArray(canvas.tabs) && canvas.tabs.length) return canvas.tabs.length;
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integrationAdapter, onClose }) {
|
|
586
|
+
const branding = (config && config.branding) || {};
|
|
587
|
+
const dashboards = Array.isArray(config?.dashboards) ? config.dashboards : [];
|
|
588
|
+
const tabCount = countCanvasTabs(config?.canvas);
|
|
589
|
+
const widgetCount = countCanvasWidgets(config?.canvas);
|
|
590
|
+
const persist = persistence || DEFAULT_PERSISTENCE;
|
|
591
|
+
return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace settings">
|
|
592
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
593
|
+
<section className="workspace-overlay-panel">
|
|
594
|
+
<header className="workspace-overlay-header">
|
|
595
|
+
<div>
|
|
596
|
+
<p>Workspace</p>
|
|
597
|
+
<h2>Workspace Settings</h2>
|
|
598
|
+
</div>
|
|
599
|
+
<button type="button" aria-label="Close workspace settings" onClick={onClose} autoFocus>x</button>
|
|
600
|
+
</header>
|
|
601
|
+
<p className="workspace-overlay-note">
|
|
602
|
+
Inspect-only. Sourced from <code>growthub.config.json</code> + <code>GET /api/workspace</code>.
|
|
603
|
+
Edit branding by updating <code>growthub.config.json</code> inside your governed fork.
|
|
604
|
+
The builder itself never holds tokens, never executes hosted workflows, and never bypasses the PATCH allowlist
|
|
605
|
+
(<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>).
|
|
606
|
+
</p>
|
|
607
|
+
<div className="workspace-readiness">
|
|
608
|
+
<article className="workspace-readiness-section">
|
|
609
|
+
<h3>Identity</h3>
|
|
610
|
+
<div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
|
|
611
|
+
<div className="workspace-readiness-row"><span>Workspace ID</span><code>{config?.id || "Unknown"}</code></div>
|
|
612
|
+
<div className="workspace-readiness-row"><span>Brand name</span><strong>{branding.name || "Unknown"}</strong></div>
|
|
613
|
+
<div className="workspace-readiness-row"><span>Logo URL</span><code>{branding.logoUrl || "—"}</code></div>
|
|
614
|
+
<div className="workspace-readiness-row"><span>Accent</span>
|
|
615
|
+
<span className="workspace-readiness-badge" style={{ background: branding.accent || "#3f68ff" }}>{branding.accent || "—"}</span>
|
|
616
|
+
</div>
|
|
617
|
+
</article>
|
|
618
|
+
<article className="workspace-readiness-section">
|
|
619
|
+
<h3>Persistence</h3>
|
|
620
|
+
<div className="workspace-readiness-row"><span>Mode</span>
|
|
621
|
+
<span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
|
|
622
|
+
</div>
|
|
623
|
+
<div className="workspace-readiness-row"><span>Adapter</span><code>{persist.adapter}</code></div>
|
|
624
|
+
<div className="workspace-readiness-row"><span>Can save</span>
|
|
625
|
+
<span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
|
|
626
|
+
</div>
|
|
627
|
+
<div className="workspace-readiness-row"><span>Save behavior</span><strong>{persist.saveLabel}</strong></div>
|
|
628
|
+
<div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
|
|
629
|
+
{persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
|
|
630
|
+
{persist.nextAction ? <div className="workspace-readiness-row"><span>Next action</span><em>{persist.nextAction}</em></div> : null}
|
|
631
|
+
</article>
|
|
632
|
+
<article className="workspace-readiness-section">
|
|
633
|
+
<h3>Integrations</h3>
|
|
634
|
+
<div className="workspace-readiness-row"><span>Integration adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
|
|
635
|
+
<div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
|
|
636
|
+
<div className="workspace-readiness-row"><span>Bridge</span>
|
|
637
|
+
<span className={`workspace-readiness-badge ${adapterConfig.growthubBridge?.hasAccessToken ? "good" : ""}`}>
|
|
638
|
+
{adapterConfig.growthubBridge?.hasAccessToken ? "token configured" : "no token"}
|
|
639
|
+
</span>
|
|
640
|
+
</div>
|
|
641
|
+
<div className="workspace-readiness-row"><span>Bridge base URL</span><code>{adapterConfig.growthubBridge?.baseUrl || "—"}</code></div>
|
|
642
|
+
<div className="workspace-readiness-row"><span>Authority</span><strong>{integrationAdapter.authority}</strong></div>
|
|
643
|
+
</article>
|
|
644
|
+
<article className="workspace-readiness-section">
|
|
645
|
+
<h3>Counts</h3>
|
|
646
|
+
<div className="workspace-readiness-row"><span>Dashboards</span><strong>{dashboards.length}</strong></div>
|
|
647
|
+
<div className="workspace-readiness-row"><span>Tabs (active canvas)</span><strong>{tabCount}</strong></div>
|
|
648
|
+
<div className="workspace-readiness-row"><span>Widgets (active canvas)</span><strong>{widgetCount}</strong></div>
|
|
649
|
+
<div className="workspace-readiness-row"><span>Template format</span><code>growthub-workspace-template</code></div>
|
|
650
|
+
</article>
|
|
651
|
+
</div>
|
|
652
|
+
</section>
|
|
653
|
+
</div>;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose }) {
|
|
657
|
+
const persist = persistence || DEFAULT_PERSISTENCE;
|
|
658
|
+
const pipelines = Array.isArray(config?.pipelines) ? config.pipelines : [];
|
|
659
|
+
const integrations = Array.isArray(config?.integrations) ? config.integrations : [];
|
|
660
|
+
const capabilities = Array.isArray(config?.capabilities) ? config.capabilities : [];
|
|
661
|
+
return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace management">
|
|
662
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
663
|
+
<section className="workspace-overlay-panel">
|
|
664
|
+
<header className="workspace-overlay-header">
|
|
665
|
+
<div>
|
|
666
|
+
<p>Workspace</p>
|
|
667
|
+
<h2>Management</h2>
|
|
668
|
+
</div>
|
|
669
|
+
<button type="button" aria-label="Close management panel" onClick={onClose} autoFocus>x</button>
|
|
670
|
+
</header>
|
|
671
|
+
<p className="workspace-overlay-note">
|
|
672
|
+
Inspect-only. Workflow execution stays in <code>growthub workflow</code> / <code>growthub bridge</code>; this panel does not
|
|
673
|
+
execute, does not call hosted endpoints, and does not expose tokens.
|
|
674
|
+
</p>
|
|
675
|
+
<div className="workspace-readiness">
|
|
676
|
+
<article className="workspace-readiness-section">
|
|
677
|
+
<h3>Workspace</h3>
|
|
678
|
+
<div className="workspace-readiness-row"><span>ID</span><code>{config?.id || "Unknown"}</code></div>
|
|
679
|
+
<div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
|
|
680
|
+
<div className="workspace-readiness-row"><span>Capabilities</span>
|
|
681
|
+
<span>{capabilities.length ? capabilities.join(", ") : "none"}</span>
|
|
682
|
+
</div>
|
|
683
|
+
</article>
|
|
684
|
+
<article className="workspace-readiness-section">
|
|
685
|
+
<h3>API</h3>
|
|
686
|
+
<div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas</code></div>
|
|
687
|
+
<div className="workspace-readiness-row"><span>Unknown field</span><code>400</code></div>
|
|
688
|
+
<div className="workspace-readiness-row"><span>Read-only runtime</span><code>409 + guidance</code></div>
|
|
689
|
+
<div className="workspace-readiness-row"><span>Can save now</span>
|
|
690
|
+
<span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
|
|
691
|
+
</div>
|
|
692
|
+
</article>
|
|
693
|
+
<article className="workspace-readiness-section">
|
|
694
|
+
<h3>Workflows</h3>
|
|
695
|
+
{pipelines.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
|
|
696
|
+
<em>No workflows declared in growthub.config.json. Connect via <code>growthub workflow</code> after Bridge auth.</em>
|
|
697
|
+
</div> : pipelines.map((pipeline, index) => <div className="workspace-readiness-row" key={pipeline.id || index}>
|
|
698
|
+
<span>{pipeline.id || `pipeline-${index}`}</span><strong>{pipeline.name || "Untitled"}</strong>
|
|
699
|
+
</div>)}
|
|
700
|
+
</article>
|
|
701
|
+
<article className="workspace-readiness-section">
|
|
702
|
+
<h3>Integrations</h3>
|
|
703
|
+
<div className="workspace-readiness-row"><span>Adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
|
|
704
|
+
<div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
|
|
705
|
+
{integrations.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
|
|
706
|
+
<em>No static integrations declared. Use <code>growthub bridge agents bind</code> for hosted bindings.</em>
|
|
707
|
+
</div> : integrations.map((integration, index) => <div className="workspace-readiness-row" key={integration.id || index}>
|
|
708
|
+
<span>{integration.id || `integration-${index}`}</span><strong>{integration.name || "Untitled"}</strong>
|
|
709
|
+
</div>)}
|
|
710
|
+
</article>
|
|
711
|
+
<article className="workspace-readiness-section">
|
|
712
|
+
<h3>Persistence</h3>
|
|
713
|
+
<div className="workspace-readiness-row"><span>Mode</span>
|
|
714
|
+
<span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
|
|
715
|
+
</div>
|
|
716
|
+
<div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
|
|
717
|
+
{persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
|
|
718
|
+
</article>
|
|
719
|
+
</div>
|
|
720
|
+
</section>
|
|
721
|
+
</div>;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
|
|
511
725
|
const [config, setConfig] = useState(() => {
|
|
512
726
|
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
513
727
|
? initialConfig.dashboards.map((dashboard, index) =>
|
|
@@ -523,8 +737,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
523
737
|
const [saving, setSaving] = useState(false);
|
|
524
738
|
const [panelOpen, setPanelOpen] = useState(true);
|
|
525
739
|
const [templateGalleryOpen, setTemplateGalleryOpen] = useState(false);
|
|
740
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
741
|
+
const [managementOpen, setManagementOpen] = useState(false);
|
|
526
742
|
const [previewTemplateId, setPreviewTemplateId] = useState(null);
|
|
527
743
|
const [editingDashboardId, setEditingDashboardId] = useState(null);
|
|
744
|
+
const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
|
|
745
|
+
const [workspaceView, setWorkspaceView] = useState("dashboards");
|
|
528
746
|
const [activeDashboardId, setActiveDashboardId] = useState(() =>
|
|
529
747
|
getActiveDashboardId(
|
|
530
748
|
Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
|
|
@@ -541,13 +759,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
541
759
|
const activeTabId = getActiveTabId(canvas);
|
|
542
760
|
const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
|
|
543
761
|
const activeWidgets = activeTab.widgets || [];
|
|
762
|
+
const activeDashboard = dashboards[resolvedActiveDashboardIndex] || dashboards[0] || null;
|
|
544
763
|
const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
|
|
545
764
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null);
|
|
546
765
|
const [dragStartCell, setDragStartCell] = useState(null);
|
|
547
766
|
const [dragPreview, setDragPreview] = useState(null);
|
|
548
767
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
768
|
+
const [moveDrag, setMoveDrag] = useState(null);
|
|
549
769
|
const [configMessage, setConfigMessage] = useState("");
|
|
550
770
|
const resizeDragRef = useRef(null);
|
|
771
|
+
const moveDragRef = useRef(null);
|
|
551
772
|
const importInputRef = useRef(null);
|
|
552
773
|
const addSlot = dragPreview || selectedPosition;
|
|
553
774
|
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
|
|
@@ -633,7 +854,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
633
854
|
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
634
855
|
setDragPreview(null);
|
|
635
856
|
setEditingDashboardId(dashboard.id);
|
|
857
|
+
setEditingDashboardDraft(dashboard.name);
|
|
636
858
|
setActiveDashboardId(dashboard.id);
|
|
859
|
+
setWorkspaceView("builder");
|
|
637
860
|
setConfigMessage(`Created ${name}`);
|
|
638
861
|
return {
|
|
639
862
|
...synced,
|
|
@@ -653,8 +876,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
653
876
|
setSelectedWidgetId(null);
|
|
654
877
|
setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
|
|
655
878
|
setDragPreview(null);
|
|
656
|
-
setEditingDashboardId(
|
|
879
|
+
setEditingDashboardId(null);
|
|
880
|
+
setEditingDashboardDraft("");
|
|
657
881
|
setActiveDashboardId(dashboard.id);
|
|
882
|
+
setWorkspaceView("builder");
|
|
658
883
|
setConfigMessage(`Editing ${dashboard.name}`);
|
|
659
884
|
return {
|
|
660
885
|
...synced,
|
|
@@ -664,32 +889,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
664
889
|
});
|
|
665
890
|
}, [activeDashboardId]);
|
|
666
891
|
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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]);
|
|
892
|
+
const enterDashboardTitleEdit = useCallback((dashboard) => {
|
|
893
|
+
if (!dashboard) return;
|
|
894
|
+
setEditingDashboardId(dashboard.id);
|
|
895
|
+
setEditingDashboardDraft(dashboard.name);
|
|
896
|
+
setWorkspaceView("dashboards");
|
|
897
|
+
}, []);
|
|
693
898
|
|
|
694
899
|
const updateDashboardStatus = useCallback((dashboardId, status) => {
|
|
695
900
|
setConfig((prev) => ({
|
|
@@ -714,9 +919,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
714
919
|
name,
|
|
715
920
|
updatedAt: "new",
|
|
716
921
|
status: "draft",
|
|
717
|
-
tabs: normalizedSource.tabs.map((tab,
|
|
718
|
-
cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
|
|
719
|
-
)
|
|
922
|
+
tabs: normalizedSource.tabs.map((tab) => cloneTabForDashboard(tab, tab.name || "Tab 1"))
|
|
720
923
|
};
|
|
721
924
|
dashboard.activeTabId = dashboard.tabs[0].id;
|
|
722
925
|
setSelectedWidgetId(null);
|
|
@@ -724,6 +927,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
724
927
|
setDragPreview(null);
|
|
725
928
|
setEditingDashboardId(dashboard.id);
|
|
726
929
|
setActiveDashboardId(dashboard.id);
|
|
930
|
+
setWorkspaceView("builder");
|
|
727
931
|
setConfigMessage(`Cloned ${sourceDashboard.name}`);
|
|
728
932
|
return {
|
|
729
933
|
...synced,
|
|
@@ -840,7 +1044,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
840
1044
|
const nextCanvas = commitTabs(prev.canvas, nextTabs, prevActiveId);
|
|
841
1045
|
const nextDashboards = (prev.dashboards || []).map((dashboard, index) =>
|
|
842
1046
|
index === dashboardIndex
|
|
843
|
-
? updateDashboardCanvas({ ...dashboard,
|
|
1047
|
+
? updateDashboardCanvas({ ...dashboard, updatedAt: "new", status: "draft" }, nextCanvas)
|
|
844
1048
|
: dashboard
|
|
845
1049
|
);
|
|
846
1050
|
return {
|
|
@@ -937,14 +1141,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
937
1141
|
}
|
|
938
1142
|
}, []);
|
|
939
1143
|
|
|
940
|
-
const
|
|
1144
|
+
const persistWorkspaceConfig = useCallback(async (nextConfig, nextActiveDashboardId = activeDashboardId) => {
|
|
941
1145
|
if (saving) return;
|
|
942
1146
|
setSaving(true);
|
|
943
1147
|
try {
|
|
944
1148
|
const stamp = todayIsoDate();
|
|
945
|
-
const syncedConfig = syncActiveDashboard(
|
|
1149
|
+
const syncedConfig = syncActiveDashboard(nextConfig, nextActiveDashboardId);
|
|
946
1150
|
const updatedDashboards = (syncedConfig.dashboards || []).map((dashboard) =>
|
|
947
|
-
dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [],
|
|
1151
|
+
dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], nextActiveDashboardId)
|
|
948
1152
|
? { ...dashboard, updatedAt: stamp }
|
|
949
1153
|
: dashboard
|
|
950
1154
|
);
|
|
@@ -962,13 +1166,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
962
1166
|
const savedDashboards = (payload.workspaceConfig.dashboards || []).map((dashboard, index) =>
|
|
963
1167
|
normalizeDashboard(dashboard, index === 0 ? payload.workspaceConfig.canvas : undefined)
|
|
964
1168
|
);
|
|
965
|
-
const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id ===
|
|
1169
|
+
const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === nextActiveDashboardId) || savedDashboards[0];
|
|
966
1170
|
setConfig({
|
|
967
1171
|
...payload.workspaceConfig,
|
|
968
1172
|
dashboards: savedDashboards,
|
|
969
1173
|
canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
|
|
970
1174
|
});
|
|
971
|
-
setConfigMessage("Saved dashboard config");
|
|
972
1175
|
} else {
|
|
973
1176
|
setConfigMessage(payload.error || "Save failed");
|
|
974
1177
|
}
|
|
@@ -979,7 +1182,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
979
1182
|
}
|
|
980
1183
|
}, [activeDashboardId, saving, config]);
|
|
981
1184
|
|
|
982
|
-
const
|
|
1185
|
+
const save = useCallback(async () => {
|
|
1186
|
+
await persistWorkspaceConfig(config, activeDashboardId);
|
|
1187
|
+
}, [activeDashboardId, config, persistWorkspaceConfig]);
|
|
1188
|
+
|
|
1189
|
+
const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
|
|
1190
|
+
const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
|
|
1191
|
+
setEditingDashboardId(null);
|
|
1192
|
+
setEditingDashboardDraft("");
|
|
1193
|
+
setConfig(nextConfig);
|
|
1194
|
+
await persistWorkspaceConfig(nextConfig, activeDashboardId);
|
|
1195
|
+
}, [activeDashboardId, config, editingDashboardDraft, persistWorkspaceConfig]);
|
|
1196
|
+
|
|
1197
|
+
const cancelDashboardTitleEdit = useCallback((dashboard) => {
|
|
1198
|
+
if (!dashboard) return;
|
|
1199
|
+
if (editingDashboardDraft.trim() !== dashboard.name) {
|
|
1200
|
+
const discard = window.confirm("Discard dashboard title changes?");
|
|
1201
|
+
if (!discard) {
|
|
1202
|
+
requestAnimationFrame(() => {
|
|
1203
|
+
document.querySelector(`[data-dashboard-title-input="${dashboard.id}"]`)?.focus();
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
setEditingDashboardId(null);
|
|
1209
|
+
setEditingDashboardDraft("");
|
|
1210
|
+
}, [editingDashboardDraft]);
|
|
1211
|
+
|
|
983
1212
|
const closePanel = useCallback(() => setPanelOpen(false), []);
|
|
984
1213
|
const beginCellDrag = useCallback((index, event) => {
|
|
985
1214
|
const x = index % GRID_COLUMNS;
|
|
@@ -1052,6 +1281,55 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1052
1281
|
resizeDragRef.current = null;
|
|
1053
1282
|
setResizeDrag(null);
|
|
1054
1283
|
}, []);
|
|
1284
|
+
const beginMoveDrag = useCallback((widget, event) => {
|
|
1285
|
+
event.preventDefault();
|
|
1286
|
+
event.stopPropagation();
|
|
1287
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
1288
|
+
const pointerIndex = cellIndexFromGridPointer(event, gridRef.current);
|
|
1289
|
+
const pointerCell = pointerIndex === null ? { x: widget.position.x, y: widget.position.y } : cellPointFromIndex(pointerIndex);
|
|
1290
|
+
const nextMoveDrag = {
|
|
1291
|
+
widgetId: widget.id,
|
|
1292
|
+
offsetX: Math.max(0, Math.min(widget.position.w - 1, pointerCell.x - widget.position.x)),
|
|
1293
|
+
offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
|
|
1294
|
+
};
|
|
1295
|
+
setSelectedWidgetId(widget.id);
|
|
1296
|
+
setPanelOpen(true);
|
|
1297
|
+
moveDragRef.current = nextMoveDrag;
|
|
1298
|
+
setMoveDrag(nextMoveDrag);
|
|
1299
|
+
}, []);
|
|
1300
|
+
const updateMoveDrag = useCallback((event) => {
|
|
1301
|
+
const activeMoveDrag = moveDragRef.current;
|
|
1302
|
+
if (!activeMoveDrag) return;
|
|
1303
|
+
event.preventDefault();
|
|
1304
|
+
const index = cellIndexFromGridPointer(event, gridRef.current);
|
|
1305
|
+
if (index === null) return;
|
|
1306
|
+
const point = cellPointFromIndex(index);
|
|
1307
|
+
const movingWidget = activeWidgets.find((widget) => widget.id === activeMoveDrag.widgetId);
|
|
1308
|
+
if (!movingWidget) return;
|
|
1309
|
+
const nextPosition = clampWidgetMovePosition({
|
|
1310
|
+
x: point.x - activeMoveDrag.offsetX,
|
|
1311
|
+
y: point.y - activeMoveDrag.offsetY
|
|
1312
|
+
}, movingWidget, activeWidgets);
|
|
1313
|
+
setConfig((prev) => {
|
|
1314
|
+
const prevTabs = getTabs(prev.canvas);
|
|
1315
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
1316
|
+
const nextTabs = prevTabs.map((tab) => {
|
|
1317
|
+
if (tab.id !== prevActiveId) return tab;
|
|
1318
|
+
return {
|
|
1319
|
+
...tab,
|
|
1320
|
+
widgets: (tab.widgets || []).map((widget) =>
|
|
1321
|
+
widget.id === activeMoveDrag.widgetId ? { ...widget, position: nextPosition } : widget
|
|
1322
|
+
)
|
|
1323
|
+
};
|
|
1324
|
+
});
|
|
1325
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
1326
|
+
});
|
|
1327
|
+
}, [activeDashboardId, activeWidgets]);
|
|
1328
|
+
const finishMoveDrag = useCallback(() => {
|
|
1329
|
+
if (!moveDragRef.current) return;
|
|
1330
|
+
moveDragRef.current = null;
|
|
1331
|
+
setMoveDrag(null);
|
|
1332
|
+
}, []);
|
|
1055
1333
|
const selectWidget = useCallback((widgetId) => {
|
|
1056
1334
|
setSelectedWidgetId(widgetId);
|
|
1057
1335
|
setPanelOpen(true);
|
|
@@ -1092,10 +1370,56 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1092
1370
|
});
|
|
1093
1371
|
}, [activeDashboardId]);
|
|
1094
1372
|
|
|
1373
|
+
const duplicateSelectedWidget = useCallback(() => {
|
|
1374
|
+
if (!selectedWidget) return;
|
|
1375
|
+
setConfig((prev) => {
|
|
1376
|
+
const prevTabs = getTabs(prev.canvas);
|
|
1377
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
1378
|
+
const tabWidgets = prevTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
|
|
1379
|
+
const position = clampPositionToFreeSpace(
|
|
1380
|
+
{ ...selectedWidget.position, x: selectedWidget.position.x, y: selectedWidget.position.y },
|
|
1381
|
+
tabWidgets
|
|
1382
|
+
);
|
|
1383
|
+
const cloned = {
|
|
1384
|
+
...cloneConfig(selectedWidget),
|
|
1385
|
+
id: generateId("widget"),
|
|
1386
|
+
title: `${selectedWidget.title} Copy`,
|
|
1387
|
+
position
|
|
1388
|
+
};
|
|
1389
|
+
const nextTabs = prevTabs.map((tab) => {
|
|
1390
|
+
if (tab.id !== prevActiveId) return tab;
|
|
1391
|
+
return { ...tab, widgets: [...(tab.widgets || []), cloned] };
|
|
1392
|
+
});
|
|
1393
|
+
setSelectedWidgetId(cloned.id);
|
|
1394
|
+
setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
|
|
1395
|
+
setConfigMessage(`Duplicated ${selectedWidget.title}`);
|
|
1396
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
1397
|
+
});
|
|
1398
|
+
}, [activeDashboardId, selectedWidget]);
|
|
1399
|
+
|
|
1095
1400
|
const closeTemplateGallery = useCallback(() => {
|
|
1096
1401
|
setTemplateGalleryOpen(false);
|
|
1097
1402
|
setPreviewTemplateId(null);
|
|
1098
1403
|
}, []);
|
|
1404
|
+
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
|
1405
|
+
const closeManagement = useCallback(() => setManagementOpen(false), []);
|
|
1406
|
+
const resetWidgetSelection = useCallback(() => {
|
|
1407
|
+
setSelectedWidgetId(null);
|
|
1408
|
+
setPanelOpen(true);
|
|
1409
|
+
}, []);
|
|
1410
|
+
const showDashboardHome = useCallback(() => {
|
|
1411
|
+
setEditingDashboardId(null);
|
|
1412
|
+
setEditingDashboardDraft("");
|
|
1413
|
+
setWorkspaceView("dashboards");
|
|
1414
|
+
}, []);
|
|
1415
|
+
const resetWidgetSelectionOnOutsidePointer = useCallback((event) => {
|
|
1416
|
+
if (!selectedWidgetId) return;
|
|
1417
|
+
if (resizeDragRef.current || moveDragRef.current) return;
|
|
1418
|
+
const target = event.target;
|
|
1419
|
+
if (!(target instanceof Element)) return;
|
|
1420
|
+
if (target.closest(".workspace-widget-preview, .workspace-widget-panel, .workspace-overlay, .template-gallery")) return;
|
|
1421
|
+
resetWidgetSelection();
|
|
1422
|
+
}, [resetWidgetSelection, selectedWidgetId]);
|
|
1099
1423
|
|
|
1100
1424
|
useEffect(() => {
|
|
1101
1425
|
if (!templateGalleryOpen) return undefined;
|
|
@@ -1106,20 +1430,39 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1106
1430
|
return () => window.removeEventListener("keydown", handler);
|
|
1107
1431
|
}, [templateGalleryOpen, closeTemplateGallery]);
|
|
1108
1432
|
|
|
1109
|
-
|
|
1433
|
+
useEffect(() => {
|
|
1434
|
+
if (!settingsOpen) return undefined;
|
|
1435
|
+
const handler = (event) => {
|
|
1436
|
+
if (event.key === "Escape") closeSettings();
|
|
1437
|
+
};
|
|
1438
|
+
window.addEventListener("keydown", handler);
|
|
1439
|
+
return () => window.removeEventListener("keydown", handler);
|
|
1440
|
+
}, [settingsOpen, closeSettings]);
|
|
1110
1441
|
|
|
1111
|
-
|
|
1442
|
+
useEffect(() => {
|
|
1443
|
+
if (!managementOpen) return undefined;
|
|
1444
|
+
const handler = (event) => {
|
|
1445
|
+
if (event.key === "Escape") closeManagement();
|
|
1446
|
+
};
|
|
1447
|
+
window.addEventListener("keydown", handler);
|
|
1448
|
+
return () => window.removeEventListener("keydown", handler);
|
|
1449
|
+
}, [managementOpen, closeManagement]);
|
|
1450
|
+
|
|
1451
|
+
const builderStyle = workspaceView === "dashboards" || !panelOpen
|
|
1452
|
+
? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
|
|
1453
|
+
: undefined;
|
|
1454
|
+
|
|
1455
|
+
return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
|
|
1112
1456
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
1113
1457
|
<div className="workspace-brand">
|
|
1114
1458
|
<span className="workspace-mark">G</span>
|
|
1115
1459
|
<span>Growthub Workspace</span>
|
|
1116
1460
|
</div>
|
|
1117
1461
|
<nav className="workspace-nav">
|
|
1118
|
-
<
|
|
1119
|
-
<a href="#canvas">Canvas</a>
|
|
1120
|
-
<a href="#widgets" onClick={reopenPanel}>Widgets</a>
|
|
1121
|
-
<a href="#bindings" onClick={reopenPanel}>Bindings</a>
|
|
1462
|
+
<button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
|
|
1122
1463
|
<Link href="/settings/integrations">Integrations</Link>
|
|
1464
|
+
<button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
|
|
1465
|
+
<button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
|
|
1123
1466
|
</nav>
|
|
1124
1467
|
<div className="workspace-rail-status">
|
|
1125
1468
|
<span className="status-dot" />
|
|
@@ -1130,8 +1473,13 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1130
1473
|
<section className="workspace-surface">
|
|
1131
1474
|
<header className="workspace-toolbar">
|
|
1132
1475
|
<div>
|
|
1133
|
-
|
|
1134
|
-
|
|
1476
|
+
{workspaceView === "builder" ? <>
|
|
1477
|
+
<p>{activeTab?.name || "Tab 1"}</p>
|
|
1478
|
+
<h1>{activeDashboard?.name || "Untitled"}</h1>
|
|
1479
|
+
</> : <>
|
|
1480
|
+
<p>Workspace home</p>
|
|
1481
|
+
<h1>Dashboards</h1>
|
|
1482
|
+
</>}
|
|
1135
1483
|
</div>
|
|
1136
1484
|
<div className="workspace-toolbar-actions">
|
|
1137
1485
|
<button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
|
|
@@ -1149,12 +1497,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1149
1497
|
onChange={importConfig}
|
|
1150
1498
|
/>
|
|
1151
1499
|
</header>
|
|
1152
|
-
{configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
|
|
1153
1500
|
|
|
1154
|
-
<section className="workspace-table" id="dashboards" aria-label="Dashboards">
|
|
1501
|
+
{workspaceView === "dashboards" ? <section className="workspace-table" id="dashboards" aria-label="Dashboards">
|
|
1155
1502
|
<div className="workspace-table-heading">
|
|
1156
1503
|
<strong>Dashboards</strong>
|
|
1157
|
-
<span>{dashboards.length}
|
|
1504
|
+
<span>{dashboards.length} dashboard{dashboards.length === 1 ? "" : "s"}</span>
|
|
1158
1505
|
</div>
|
|
1159
1506
|
<div className="workspace-table-row workspace-table-head">
|
|
1160
1507
|
<span>Title</span>
|
|
@@ -1165,19 +1512,35 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1165
1512
|
</div>
|
|
1166
1513
|
{dashboards.map((dashboard, index) => <div className="workspace-table-row" key={dashboard.id}>
|
|
1167
1514
|
<span className="workspace-dashboard-title">
|
|
1168
|
-
{editingDashboardId === dashboard.id ? <
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1515
|
+
{editingDashboardId === dashboard.id ? <span className="workspace-dashboard-title-editor">
|
|
1516
|
+
<input
|
|
1517
|
+
aria-label={`Rename ${dashboard.name}`}
|
|
1518
|
+
autoFocus
|
|
1519
|
+
data-dashboard-title-input={dashboard.id}
|
|
1520
|
+
onBlur={() => cancelDashboardTitleEdit(dashboard)}
|
|
1521
|
+
onChange={(event) => setEditingDashboardDraft(event.target.value)}
|
|
1522
|
+
onKeyDown={(event) => {
|
|
1523
|
+
if (event.key === "Enter") {
|
|
1524
|
+
event.preventDefault();
|
|
1525
|
+
confirmDashboardTitleEdit(dashboard.id);
|
|
1526
|
+
}
|
|
1527
|
+
if (event.key === "Escape") {
|
|
1528
|
+
event.preventDefault();
|
|
1529
|
+
cancelDashboardTitleEdit(dashboard);
|
|
1530
|
+
}
|
|
1531
|
+
}}
|
|
1532
|
+
value={editingDashboardDraft}
|
|
1533
|
+
/>
|
|
1534
|
+
<button
|
|
1535
|
+
aria-label={`Confirm ${dashboard.name} title`}
|
|
1536
|
+
className="workspace-dashboard-title-confirm"
|
|
1537
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1538
|
+
onClick={() => confirmDashboardTitleEdit(dashboard.id)}
|
|
1539
|
+
type="button"
|
|
1540
|
+
>✓</button>
|
|
1541
|
+
</span> : <button
|
|
1179
1542
|
className={index === resolvedActiveDashboardIndex ? "active" : ""}
|
|
1180
|
-
onClick={() =>
|
|
1543
|
+
onClick={() => enterDashboardTitleEdit(dashboard)}
|
|
1181
1544
|
type="button"
|
|
1182
1545
|
>{dashboard.name}</button>}
|
|
1183
1546
|
</span>
|
|
@@ -1196,14 +1559,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1196
1559
|
</span>
|
|
1197
1560
|
<span className="workspace-dashboard-actions">
|
|
1198
1561
|
<button type="button" onClick={() => selectDashboard(index)}>Edit</button>
|
|
1199
|
-
<button type="button" onClick={() =>
|
|
1562
|
+
<button type="button" onClick={() => enterDashboardTitleEdit(dashboard)}>Rename</button>
|
|
1200
1563
|
<button type="button" onClick={() => cloneDashboard(index)}>Clone</button>
|
|
1201
1564
|
<button type="button" onClick={() => deleteDashboard(index)}>Delete</button>
|
|
1202
1565
|
</span>
|
|
1203
1566
|
</div>)}
|
|
1204
|
-
</section>
|
|
1567
|
+
</section> : null}
|
|
1205
1568
|
|
|
1206
|
-
<section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
|
|
1569
|
+
{workspaceView === "builder" ? <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
|
|
1207
1570
|
<div className="workspace-tabs">
|
|
1208
1571
|
{tabs.map((tab) => <button
|
|
1209
1572
|
key={tab.id}
|
|
@@ -1234,15 +1597,22 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1234
1597
|
<button type="button" onClick={duplicateTab}>Duplicate Tab</button>
|
|
1235
1598
|
</div>
|
|
1236
1599
|
<div
|
|
1237
|
-
className=
|
|
1600
|
+
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
1238
1601
|
ref={gridRef}
|
|
1239
1602
|
onPointerMove={updatePointerDrag}
|
|
1240
1603
|
onPointerUp={(event) => {
|
|
1241
1604
|
finishPointerDrag(event);
|
|
1242
1605
|
finishResizeDrag();
|
|
1606
|
+
finishMoveDrag();
|
|
1607
|
+
}}
|
|
1608
|
+
onPointerLeave={() => {
|
|
1609
|
+
finishResizeDrag();
|
|
1610
|
+
finishMoveDrag();
|
|
1611
|
+
}}
|
|
1612
|
+
onPointerMoveCapture={(event) => {
|
|
1613
|
+
updateResizeDrag(event);
|
|
1614
|
+
updateMoveDrag(event);
|
|
1243
1615
|
}}
|
|
1244
|
-
onPointerLeave={finishResizeDrag}
|
|
1245
|
-
onPointerMoveCapture={updateResizeDrag}
|
|
1246
1616
|
style={{ "--workspace-columns": canvas.layout.columns, "--workspace-rows": GRID_ROWS }}
|
|
1247
1617
|
>
|
|
1248
1618
|
{Array.from({ length: GRID_CELL_COUNT }).map((_, index) => {
|
|
@@ -1274,6 +1644,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1274
1644
|
</button>
|
|
1275
1645
|
{activeWidgets.map((widget) => <WidgetPreview
|
|
1276
1646
|
key={widget.id}
|
|
1647
|
+
onMoveStart={(event) => beginMoveDrag(widget, event)}
|
|
1277
1648
|
onRemove={() => removeSelectedWidget(widget.id)}
|
|
1278
1649
|
onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
|
|
1279
1650
|
onSelect={() => selectWidget(widget.id)}
|
|
@@ -1281,7 +1652,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1281
1652
|
widget={widget}
|
|
1282
1653
|
/>)}
|
|
1283
1654
|
</div>
|
|
1284
|
-
</section>
|
|
1655
|
+
</section> : null}
|
|
1285
1656
|
</section>
|
|
1286
1657
|
|
|
1287
1658
|
{templateGalleryOpen ? <TemplateGallery
|
|
@@ -1293,13 +1664,32 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1293
1664
|
onCloneAsDashboard={cloneTemplateAsDashboard}
|
|
1294
1665
|
/> : null}
|
|
1295
1666
|
|
|
1296
|
-
{
|
|
1667
|
+
{settingsOpen ? <WorkspaceSettingsPanel
|
|
1668
|
+
config={config}
|
|
1669
|
+
persistence={persistence}
|
|
1670
|
+
adapterConfig={adapterConfig}
|
|
1671
|
+
integrationAdapter={integrationAdapter}
|
|
1672
|
+
onClose={closeSettings}
|
|
1673
|
+
/> : null}
|
|
1674
|
+
|
|
1675
|
+
{managementOpen ? <WorkspaceManagementPanel
|
|
1676
|
+
config={config}
|
|
1677
|
+
persistence={persistence}
|
|
1678
|
+
adapterConfig={adapterConfig}
|
|
1679
|
+
onClose={closeManagement}
|
|
1680
|
+
/> : null}
|
|
1681
|
+
|
|
1682
|
+
{workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
1297
1683
|
<div className="workspace-panel-title">
|
|
1298
1684
|
<button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
|
|
1299
1685
|
<span aria-hidden="true">+</span>
|
|
1300
1686
|
<strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
|
|
1301
1687
|
{selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
|
|
1302
1688
|
</div>
|
|
1689
|
+
{selectedWidget ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
1690
|
+
<button type="button" onClick={duplicateSelectedWidget}>Duplicate</button>
|
|
1691
|
+
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}>Remove</button>
|
|
1692
|
+
</div> : null}
|
|
1303
1693
|
{selectedWidget ? <section className="workspace-widget-settings">
|
|
1304
1694
|
<label>
|
|
1305
1695
|
<span>Title</span>
|
|
@@ -1326,21 +1716,31 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1326
1716
|
</select>
|
|
1327
1717
|
</label>
|
|
1328
1718
|
</section> : null}
|
|
1329
|
-
{selectedWidget.kind === "iframe" ? <label>
|
|
1719
|
+
{selectedWidget.kind === "iframe" ? <label className="workspace-field-with-hint">
|
|
1330
1720
|
<span>URL to Embed</span>
|
|
1331
1721
|
<input
|
|
1332
1722
|
placeholder="https://example.com/embed"
|
|
1333
1723
|
value={selectedWidget.config?.url || ""}
|
|
1334
1724
|
onChange={(event) => updateSelectedWidgetConfig({ url: event.target.value })}
|
|
1335
1725
|
/>
|
|
1726
|
+
<small className={isLikelyHttpUrl(selectedWidget.config?.url) ? "workspace-field-hint good" : "workspace-field-hint warn"}>
|
|
1727
|
+
{isLikelyHttpUrl(selectedWidget.config?.url)
|
|
1728
|
+
? "Looks like a valid http(s) URL"
|
|
1729
|
+
: selectedWidget.config?.url
|
|
1730
|
+
? "URL must start with http:// or https://"
|
|
1731
|
+
: "Add an http(s) URL to embed"}
|
|
1732
|
+
</small>
|
|
1336
1733
|
</label> : null}
|
|
1337
|
-
{selectedWidget.kind === "rich-text" ? <label>
|
|
1734
|
+
{selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
|
|
1338
1735
|
<span>Content</span>
|
|
1339
1736
|
<textarea
|
|
1340
1737
|
placeholder="Write text..."
|
|
1341
1738
|
value={selectedWidget.config?.text || ""}
|
|
1342
1739
|
onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
|
|
1343
1740
|
/>
|
|
1741
|
+
<small className="workspace-field-hint">
|
|
1742
|
+
{(selectedWidget.config?.text || "").length} characters · plain text only at V1
|
|
1743
|
+
</small>
|
|
1344
1744
|
</label> : null}
|
|
1345
1745
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
1346
1746
|
<label>
|
|
@@ -1410,6 +1810,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1410
1810
|
<div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
|
|
1411
1811
|
</div>
|
|
1412
1812
|
</section> : <section>
|
|
1813
|
+
<div className="workspace-widget-empty">
|
|
1814
|
+
<strong>Pick a widget kind</strong>
|
|
1815
|
+
<p>
|
|
1816
|
+
Widgets snap to the 12-column × 16-row grid. {addSlot.w} × {addSlot.h} cells
|
|
1817
|
+
selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
|
|
1818
|
+
canvas to reshape the placement.
|
|
1819
|
+
</p>
|
|
1820
|
+
</div>
|
|
1413
1821
|
<p className="workspace-panel-label">Widget type</p>
|
|
1414
1822
|
<div className="workspace-widget-types">
|
|
1415
1823
|
{widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
|