@growthub/cli 0.9.5 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +284 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +819 -61
- 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 +770 -0
- package/dist/index.js +1824 -1061
- package/package.json +3 -2
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import { useCallback, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
DASHBOARD_TEMPLATES,
|
|
7
|
+
SAMPLE_DATA_BINDINGS,
|
|
8
|
+
SAMPLE_VIEW_ROWS,
|
|
9
|
+
cloneTemplateToDashboard,
|
|
10
|
+
cloneTemplateToTab,
|
|
11
|
+
defaultConfigFor,
|
|
12
|
+
normalizeWorkspaceTemplate,
|
|
13
|
+
unwrapWorkspaceTemplateImport,
|
|
14
|
+
validateWorkspaceConfig,
|
|
15
|
+
wrapWorkspaceTemplateExport
|
|
16
|
+
} from "@/lib/workspace-schema";
|
|
5
17
|
|
|
6
18
|
const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
|
|
7
19
|
const GRID_COLUMNS = 12;
|
|
@@ -27,19 +39,6 @@ function defaultTitleFor(kind) {
|
|
|
27
39
|
}
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
function defaultConfigFor(kind) {
|
|
31
|
-
switch (kind) {
|
|
32
|
-
case "view":
|
|
33
|
-
return { source: "Companies", layout: "Table" };
|
|
34
|
-
case "iframe":
|
|
35
|
-
return { url: "" };
|
|
36
|
-
case "rich-text":
|
|
37
|
-
return { text: "" };
|
|
38
|
-
default:
|
|
39
|
-
return {};
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
42
|
function getTabs(canvas) {
|
|
44
43
|
if (Array.isArray(canvas?.tabs) && canvas.tabs.length > 0) {
|
|
45
44
|
return canvas.tabs;
|
|
@@ -76,6 +75,120 @@ function commitTabs(canvas, tabs, activeTabId) {
|
|
|
76
75
|
return next;
|
|
77
76
|
}
|
|
78
77
|
|
|
78
|
+
function createDashboardRecord(name = "Untitled") {
|
|
79
|
+
const tab = createEmptyTab(name);
|
|
80
|
+
return {
|
|
81
|
+
id: generateId("dashboard"),
|
|
82
|
+
name,
|
|
83
|
+
createdBy: "Workspace owner",
|
|
84
|
+
updatedAt: "new",
|
|
85
|
+
status: "draft",
|
|
86
|
+
tabs: [tab],
|
|
87
|
+
activeTabId: tab.id
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createEmptyTab(name = "Untitled") {
|
|
92
|
+
return {
|
|
93
|
+
id: generateId("tab"),
|
|
94
|
+
name,
|
|
95
|
+
widgets: []
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cloneTabForDashboard(tab, name) {
|
|
100
|
+
return {
|
|
101
|
+
id: generateId("tab"),
|
|
102
|
+
name,
|
|
103
|
+
widgets: (tab?.widgets || []).map((widget) => ({
|
|
104
|
+
...cloneConfig(widget),
|
|
105
|
+
id: generateId("widget")
|
|
106
|
+
}))
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeDashboard(dashboard, fallbackCanvas) {
|
|
111
|
+
const tabs = Array.isArray(dashboard?.tabs) && dashboard.tabs.length
|
|
112
|
+
? dashboard.tabs
|
|
113
|
+
: getTabs(fallbackCanvas).map((tab) => ({
|
|
114
|
+
...tab,
|
|
115
|
+
id: tab.id === DEFAULT_TAB_ID ? generateId("tab") : tab.id
|
|
116
|
+
}));
|
|
117
|
+
const activeTabId = dashboard?.activeTabId && tabs.some((tab) => tab.id === dashboard.activeTabId)
|
|
118
|
+
? dashboard.activeTabId
|
|
119
|
+
: tabs[0].id;
|
|
120
|
+
return {
|
|
121
|
+
...dashboard,
|
|
122
|
+
tabs,
|
|
123
|
+
activeTabId
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function dashboardCanvasFrom(dashboard, baseCanvas) {
|
|
128
|
+
const normalized = normalizeDashboard(dashboard, baseCanvas);
|
|
129
|
+
return commitTabs(baseCanvas, normalized.tabs, normalized.activeTabId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function updateDashboardCanvas(dashboard, canvas) {
|
|
133
|
+
const tabs = getTabs(canvas);
|
|
134
|
+
const activeTabId = getActiveTabId(canvas);
|
|
135
|
+
return {
|
|
136
|
+
...dashboard,
|
|
137
|
+
tabs,
|
|
138
|
+
activeTabId
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createDashboardFromTab(name, tab, source = {}) {
|
|
143
|
+
const clonedTab = cloneTabForDashboard(tab, name);
|
|
144
|
+
return {
|
|
145
|
+
...source,
|
|
146
|
+
id: generateId("dashboard"),
|
|
147
|
+
name,
|
|
148
|
+
createdBy: source.createdBy || "Workspace owner",
|
|
149
|
+
updatedAt: "new",
|
|
150
|
+
status: "draft",
|
|
151
|
+
tabs: [clonedTab],
|
|
152
|
+
activeTabId: clonedTab.id
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getActiveDashboardId(dashboards, fallback) {
|
|
157
|
+
if (fallback && dashboards.some((dashboard) => dashboard.id === fallback)) {
|
|
158
|
+
return fallback;
|
|
159
|
+
}
|
|
160
|
+
return dashboards[0]?.id || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function activeDashboardIndex(dashboards, activeDashboardId) {
|
|
164
|
+
const index = dashboards.findIndex((dashboard) => dashboard.id === activeDashboardId);
|
|
165
|
+
return index >= 0 ? index : 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function syncActiveDashboard(config, activeDashboardId) {
|
|
169
|
+
const dashboards = config.dashboards || [];
|
|
170
|
+
const resolvedId = getActiveDashboardId(dashboards, activeDashboardId);
|
|
171
|
+
if (!resolvedId) return config;
|
|
172
|
+
return {
|
|
173
|
+
...config,
|
|
174
|
+
dashboards: dashboards.map((dashboard) =>
|
|
175
|
+
dashboard.id === resolvedId ? updateDashboardCanvas(dashboard, config.canvas) : dashboard
|
|
176
|
+
)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
|
|
181
|
+
const dashboards = config.dashboards || [];
|
|
182
|
+
const resolvedId = getActiveDashboardId(dashboards, activeDashboardId);
|
|
183
|
+
return {
|
|
184
|
+
...config,
|
|
185
|
+
dashboards: dashboards.map((dashboard) =>
|
|
186
|
+
dashboard.id === resolvedId ? updateDashboardCanvas(dashboard, nextCanvas) : dashboard
|
|
187
|
+
),
|
|
188
|
+
canvas: nextCanvas
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
79
192
|
function findFreePosition(widgets) {
|
|
80
193
|
const occupied = new Set();
|
|
81
194
|
for (const widget of widgets) {
|
|
@@ -200,7 +313,152 @@ function widgetKindLabel(kind) {
|
|
|
200
313
|
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
201
314
|
}
|
|
202
315
|
|
|
316
|
+
function cloneConfig(value) {
|
|
317
|
+
return JSON.parse(JSON.stringify(value));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function normalizeChartValues(value) {
|
|
321
|
+
return String(value)
|
|
322
|
+
.split(",")
|
|
323
|
+
.map((item) => Number(item.trim()))
|
|
324
|
+
.filter((item) => Number.isFinite(item));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function serializeChartValues(values) {
|
|
328
|
+
return (Array.isArray(values) ? values : []).join(", ");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseLineList(value) {
|
|
332
|
+
return String(value)
|
|
333
|
+
.split(",")
|
|
334
|
+
.map((item) => item.trim())
|
|
335
|
+
.filter(Boolean);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function serializeLineList(values) {
|
|
339
|
+
return (Array.isArray(values) ? values : []).join(", ");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseManualRows(value, columns) {
|
|
343
|
+
const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
|
|
344
|
+
return String(value)
|
|
345
|
+
.split("\n")
|
|
346
|
+
.map((row) => row.trim())
|
|
347
|
+
.filter(Boolean)
|
|
348
|
+
.map((row) => {
|
|
349
|
+
const values = row.split("|").map((item) => item.trim());
|
|
350
|
+
return activeColumns.reduce((record, column, index) => {
|
|
351
|
+
record[column] = values[index] || "";
|
|
352
|
+
return record;
|
|
353
|
+
}, {});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function serializeManualRows(rows, columns) {
|
|
358
|
+
const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
|
|
359
|
+
return (Array.isArray(rows) ? rows : [])
|
|
360
|
+
.map((row) => activeColumns.map((column) => row?.[column] || "").join(" | "))
|
|
361
|
+
.join("\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
|
|
365
|
+
...normalizeWorkspaceTemplate(template),
|
|
366
|
+
widgets: template.widgets
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
function widgetKindFill(kind) {
|
|
370
|
+
switch (kind) {
|
|
371
|
+
case "chart": return "#dbeafe";
|
|
372
|
+
case "view": return "#fef3c7";
|
|
373
|
+
case "iframe": return "#ede9fe";
|
|
374
|
+
case "rich-text": return "#dcfce7";
|
|
375
|
+
default: return "#e5e7eb";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function TemplateMiniGrid({ template }) {
|
|
380
|
+
const widgets = Array.isArray(template?.widgets) ? template.widgets : [];
|
|
381
|
+
return <div
|
|
382
|
+
className="template-mini-grid"
|
|
383
|
+
aria-hidden="true"
|
|
384
|
+
style={{
|
|
385
|
+
"--template-mini-columns": GRID_COLUMNS,
|
|
386
|
+
"--template-mini-rows": GRID_ROWS
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{widgets.map((widget, index) => <span
|
|
390
|
+
className={`template-mini-widget kind-${widget.kind}`}
|
|
391
|
+
key={`${widget.kind}-${index}`}
|
|
392
|
+
style={{
|
|
393
|
+
gridColumn: `${widget.position.x + 1} / span ${widget.position.w}`,
|
|
394
|
+
gridRow: `${widget.position.y + 1} / span ${widget.position.h}`,
|
|
395
|
+
background: widgetKindFill(widget.kind)
|
|
396
|
+
}}
|
|
397
|
+
/>)}
|
|
398
|
+
</div>;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function TemplateGallery({
|
|
402
|
+
templates,
|
|
403
|
+
previewTemplateId,
|
|
404
|
+
onPreview,
|
|
405
|
+
onClose,
|
|
406
|
+
onApplyToCurrentTab,
|
|
407
|
+
onCloneAsDashboard
|
|
408
|
+
}) {
|
|
409
|
+
const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
|
|
410
|
+
return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
|
|
411
|
+
<div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
|
|
412
|
+
<section className="template-gallery-panel">
|
|
413
|
+
<header className="template-gallery-header">
|
|
414
|
+
<div>
|
|
415
|
+
<p>Workspace templates</p>
|
|
416
|
+
<h2>Pick a starting layout</h2>
|
|
417
|
+
</div>
|
|
418
|
+
<button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
|
|
419
|
+
</header>
|
|
420
|
+
<div className="template-gallery-grid">
|
|
421
|
+
{templates.map((template) => {
|
|
422
|
+
const isPreviewing = previewTemplate?.id === template.id;
|
|
423
|
+
return <article
|
|
424
|
+
className={`template-card${isPreviewing ? " previewing" : ""}`}
|
|
425
|
+
key={template.id}
|
|
426
|
+
>
|
|
427
|
+
<div className="template-card-header">
|
|
428
|
+
<strong>{template.name}</strong>
|
|
429
|
+
<span className="template-card-category">{template.category}</span>
|
|
430
|
+
</div>
|
|
431
|
+
<p className="template-card-description">{template.description}</p>
|
|
432
|
+
<div className="template-card-preview">
|
|
433
|
+
<TemplateMiniGrid template={template} />
|
|
434
|
+
</div>
|
|
435
|
+
<div className="template-card-meta">
|
|
436
|
+
<span>{template.widgetCount} widget{template.widgetCount === 1 ? "" : "s"}</span>
|
|
437
|
+
{template.bestFor.length ? <span>· Best for: {template.bestFor.join(", ")}</span> : null}
|
|
438
|
+
</div>
|
|
439
|
+
{template.tags.length ? <div className="template-card-tags">
|
|
440
|
+
{template.tags.map((tag) => <span key={tag}>#{tag}</span>)}
|
|
441
|
+
</div> : null}
|
|
442
|
+
<div className="template-card-actions">
|
|
443
|
+
<button type="button" onClick={() => onPreview(template.id)}>{isPreviewing ? "Previewing" : "Preview"}</button>
|
|
444
|
+
<button type="button" onClick={() => onApplyToCurrentTab(template.id)}>Apply to Current Tab</button>
|
|
445
|
+
<button type="button" onClick={() => onCloneAsDashboard(template.id)}>Clone as New Dashboard</button>
|
|
446
|
+
</div>
|
|
447
|
+
</article>;
|
|
448
|
+
})}
|
|
449
|
+
</div>
|
|
450
|
+
{previewTemplate ? <footer className="template-gallery-footer" aria-live="polite">
|
|
451
|
+
<strong>{previewTemplate.name}</strong>
|
|
452
|
+
<span>{previewTemplate.preview?.summary || previewTemplate.description}</span>
|
|
453
|
+
</footer> : null}
|
|
454
|
+
</section>
|
|
455
|
+
</div>;
|
|
456
|
+
}
|
|
457
|
+
|
|
203
458
|
function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
|
|
459
|
+
const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
|
|
460
|
+
const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
|
|
461
|
+
const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
|
|
204
462
|
return <article
|
|
205
463
|
className={`workspace-widget-preview${selected ? " selected" : ""}`}
|
|
206
464
|
onClick={onSelect}
|
|
@@ -221,16 +479,15 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
221
479
|
type="button"
|
|
222
480
|
>x</button>
|
|
223
481
|
</div>
|
|
224
|
-
{widget.kind === "view" ? <div
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
[
|
|
232
|
-
|
|
233
|
-
].map(([name, domain]) => <div key={name}><span>{name}</span><span>{domain}</span></div>)}
|
|
482
|
+
{widget.kind === "view" ? <div
|
|
483
|
+
className="workspace-view-table"
|
|
484
|
+
aria-label={`${widget.title} preview`}
|
|
485
|
+
style={{ "--workspace-view-columns": viewColumns.length }}
|
|
486
|
+
>
|
|
487
|
+
<div>{viewColumns.map((column) => <span key={column}>{column}</span>)}</div>
|
|
488
|
+
{viewRows.slice(0, 6).map((row, rowIndex) => <div key={rowIndex}>
|
|
489
|
+
{viewColumns.map((column) => <span key={column}>{row?.[column] || ""}</span>)}
|
|
490
|
+
</div>)}
|
|
234
491
|
<footer>Calculate</footer>
|
|
235
492
|
</div> : null}
|
|
236
493
|
{widget.kind === "iframe" ? <div className="workspace-iframe-preview">
|
|
@@ -238,7 +495,7 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
238
495
|
</div> : null}
|
|
239
496
|
{widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
|
|
240
497
|
{widget.kind === "chart" ? <div className="workspace-chart-preview">
|
|
241
|
-
{
|
|
498
|
+
{chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }} />)}
|
|
242
499
|
</div> : null}
|
|
243
500
|
{selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
|
|
244
501
|
aria-label={`Resize ${widget.title} from ${corner} corner`}
|
|
@@ -251,12 +508,34 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
251
508
|
}
|
|
252
509
|
|
|
253
510
|
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
|
|
254
|
-
const [config, setConfig] = useState(
|
|
511
|
+
const [config, setConfig] = useState(() => {
|
|
512
|
+
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
513
|
+
? initialConfig.dashboards.map((dashboard, index) =>
|
|
514
|
+
normalizeDashboard(dashboard, index === 0 ? initialConfig.canvas : undefined)
|
|
515
|
+
)
|
|
516
|
+
: [createDashboardRecord("Untitled")];
|
|
517
|
+
return {
|
|
518
|
+
...initialConfig,
|
|
519
|
+
dashboards,
|
|
520
|
+
canvas: dashboardCanvasFrom(dashboards[0], initialConfig.canvas)
|
|
521
|
+
};
|
|
522
|
+
});
|
|
255
523
|
const [saving, setSaving] = useState(false);
|
|
256
524
|
const [panelOpen, setPanelOpen] = useState(true);
|
|
525
|
+
const [templateGalleryOpen, setTemplateGalleryOpen] = useState(false);
|
|
526
|
+
const [previewTemplateId, setPreviewTemplateId] = useState(null);
|
|
527
|
+
const [editingDashboardId, setEditingDashboardId] = useState(null);
|
|
528
|
+
const [activeDashboardId, setActiveDashboardId] = useState(() =>
|
|
529
|
+
getActiveDashboardId(
|
|
530
|
+
Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
|
|
531
|
+
null
|
|
532
|
+
)
|
|
533
|
+
);
|
|
257
534
|
const gridRef = useRef(null);
|
|
258
535
|
const canvas = config.canvas;
|
|
259
|
-
const dashboards = config.dashboards;
|
|
536
|
+
const dashboards = config.dashboards || [];
|
|
537
|
+
const resolvedActiveDashboardId = getActiveDashboardId(dashboards, activeDashboardId);
|
|
538
|
+
const resolvedActiveDashboardIndex = activeDashboardIndex(dashboards, resolvedActiveDashboardId);
|
|
260
539
|
const widgetTypes = config.widgetTypes;
|
|
261
540
|
const tabs = getTabs(canvas);
|
|
262
541
|
const activeTabId = getActiveTabId(canvas);
|
|
@@ -267,7 +546,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
267
546
|
const [dragStartCell, setDragStartCell] = useState(null);
|
|
268
547
|
const [dragPreview, setDragPreview] = useState(null);
|
|
269
548
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
549
|
+
const [configMessage, setConfigMessage] = useState("");
|
|
270
550
|
const resizeDragRef = useRef(null);
|
|
551
|
+
const importInputRef = useRef(null);
|
|
271
552
|
const addSlot = dragPreview || selectedPosition;
|
|
272
553
|
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
|
|
273
554
|
const occupiedCells = useMemo(() => {
|
|
@@ -304,9 +585,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
304
585
|
setSelectedWidgetId(widget.id);
|
|
305
586
|
setSelectedPosition(findFreePosition([...existingWidgets, widget]));
|
|
306
587
|
setDragPreview(null);
|
|
307
|
-
return
|
|
588
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
308
589
|
});
|
|
309
|
-
}, [addSlot]);
|
|
590
|
+
}, [activeDashboardId, addSlot]);
|
|
310
591
|
|
|
311
592
|
const switchTab = useCallback((tabId) => {
|
|
312
593
|
setConfig((prev) => {
|
|
@@ -317,9 +598,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
317
598
|
setSelectedWidgetId(null);
|
|
318
599
|
setSelectedPosition(findFreePosition(nextTab?.widgets || []));
|
|
319
600
|
setDragPreview(null);
|
|
320
|
-
return
|
|
601
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, prevTabs, tabId));
|
|
321
602
|
});
|
|
322
|
-
}, []);
|
|
603
|
+
}, [activeDashboardId]);
|
|
323
604
|
|
|
324
605
|
const addTab = useCallback(() => {
|
|
325
606
|
setConfig((prev) => {
|
|
@@ -338,51 +619,365 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
338
619
|
setSelectedWidgetId(null);
|
|
339
620
|
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
340
621
|
setDragPreview(null);
|
|
341
|
-
return
|
|
622
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, newTab.id));
|
|
342
623
|
});
|
|
343
|
-
}, []);
|
|
624
|
+
}, [activeDashboardId]);
|
|
344
625
|
|
|
345
626
|
const addDashboard = useCallback(() => {
|
|
627
|
+
setConfig((prev) => {
|
|
628
|
+
const synced = syncActiveDashboard(prev, activeDashboardId);
|
|
629
|
+
const prevDashboards = synced.dashboards || [];
|
|
630
|
+
const name = `Dashboard ${prevDashboards.length + 1}`;
|
|
631
|
+
const dashboard = createDashboardRecord(name);
|
|
632
|
+
setSelectedWidgetId(null);
|
|
633
|
+
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
634
|
+
setDragPreview(null);
|
|
635
|
+
setEditingDashboardId(dashboard.id);
|
|
636
|
+
setActiveDashboardId(dashboard.id);
|
|
637
|
+
setConfigMessage(`Created ${name}`);
|
|
638
|
+
return {
|
|
639
|
+
...synced,
|
|
640
|
+
dashboards: [...prevDashboards, dashboard],
|
|
641
|
+
canvas: dashboardCanvasFrom(dashboard, synced.canvas)
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
}, [activeDashboardId]);
|
|
645
|
+
|
|
646
|
+
const selectDashboard = useCallback((index) => {
|
|
647
|
+
setConfig((prev) => {
|
|
648
|
+
const synced = syncActiveDashboard(prev, activeDashboardId);
|
|
649
|
+
const prevDashboards = synced.dashboards || [];
|
|
650
|
+
const dashboard = prevDashboards[index];
|
|
651
|
+
if (!dashboard) return prev;
|
|
652
|
+
const normalized = normalizeDashboard(dashboard, index === 0 ? synced.canvas : undefined);
|
|
653
|
+
setSelectedWidgetId(null);
|
|
654
|
+
setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
|
|
655
|
+
setDragPreview(null);
|
|
656
|
+
setEditingDashboardId(dashboard.id);
|
|
657
|
+
setActiveDashboardId(dashboard.id);
|
|
658
|
+
setConfigMessage(`Editing ${dashboard.name}`);
|
|
659
|
+
return {
|
|
660
|
+
...synced,
|
|
661
|
+
dashboards: prevDashboards.map((item) => item.id === dashboard.id ? normalized : item),
|
|
662
|
+
canvas: dashboardCanvasFrom(normalized, synced.canvas)
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
}, [activeDashboardId]);
|
|
666
|
+
|
|
667
|
+
const renameDashboard = useCallback((dashboardId, name) => {
|
|
668
|
+
const nextName = name.trimStart();
|
|
669
|
+
setConfig((prev) => {
|
|
670
|
+
const prevDashboards = prev.dashboards || [];
|
|
671
|
+
const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
|
|
672
|
+
if (index < 0) return prev;
|
|
673
|
+
const displayName = nextName || "Untitled";
|
|
674
|
+
const nextDashboards = prevDashboards.map((dashboard) =>
|
|
675
|
+
dashboard.id === dashboardId ? { ...dashboard, name: displayName, updatedAt: "new" } : dashboard
|
|
676
|
+
);
|
|
677
|
+
const nextDashboardsWithTabs = nextDashboards.map((dashboard, dashboardIndex) => {
|
|
678
|
+
if (dashboardIndex !== index) return dashboard;
|
|
679
|
+
const normalized = normalizeDashboard(dashboard, index === 0 ? prev.canvas : undefined);
|
|
680
|
+
const renamedTabs = normalized.tabs.map((tab, tabIndex) =>
|
|
681
|
+
tabIndex === 0 ? { ...tab, name: displayName } : tab
|
|
682
|
+
);
|
|
683
|
+
return { ...normalized, tabs: renamedTabs };
|
|
684
|
+
});
|
|
685
|
+
const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
|
|
686
|
+
return {
|
|
687
|
+
...prev,
|
|
688
|
+
dashboards: nextDashboardsWithTabs,
|
|
689
|
+
canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], prev.canvas)
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
}, [activeDashboardId]);
|
|
693
|
+
|
|
694
|
+
const updateDashboardStatus = useCallback((dashboardId, status) => {
|
|
346
695
|
setConfig((prev) => ({
|
|
347
696
|
...prev,
|
|
348
|
-
dashboards: [
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
id: generateId("dashboard"),
|
|
352
|
-
name: "Untitled",
|
|
353
|
-
createdBy: "Workspace owner",
|
|
354
|
-
updatedAt: "new",
|
|
355
|
-
status: "draft"
|
|
356
|
-
}
|
|
357
|
-
]
|
|
697
|
+
dashboards: (prev.dashboards || []).map((dashboard) =>
|
|
698
|
+
dashboard.id === dashboardId ? { ...dashboard, status, updatedAt: "new" } : dashboard
|
|
699
|
+
)
|
|
358
700
|
}));
|
|
359
701
|
}, []);
|
|
360
702
|
|
|
703
|
+
const cloneDashboard = useCallback((index) => {
|
|
704
|
+
setConfig((prev) => {
|
|
705
|
+
const synced = syncActiveDashboard(prev, activeDashboardId);
|
|
706
|
+
const prevDashboards = synced.dashboards || [];
|
|
707
|
+
const sourceDashboard = prevDashboards[index];
|
|
708
|
+
if (!sourceDashboard) return prev;
|
|
709
|
+
const normalizedSource = normalizeDashboard(sourceDashboard, index === resolvedActiveDashboardIndex ? synced.canvas : undefined);
|
|
710
|
+
const name = `${sourceDashboard.name} Copy`;
|
|
711
|
+
const dashboard = {
|
|
712
|
+
...normalizedSource,
|
|
713
|
+
id: generateId("dashboard"),
|
|
714
|
+
name,
|
|
715
|
+
updatedAt: "new",
|
|
716
|
+
status: "draft",
|
|
717
|
+
tabs: normalizedSource.tabs.map((tab, tabIndex) =>
|
|
718
|
+
cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
|
|
719
|
+
)
|
|
720
|
+
};
|
|
721
|
+
dashboard.activeTabId = dashboard.tabs[0].id;
|
|
722
|
+
setSelectedWidgetId(null);
|
|
723
|
+
setSelectedPosition(findFreePosition(dashboard.tabs[0].widgets));
|
|
724
|
+
setDragPreview(null);
|
|
725
|
+
setEditingDashboardId(dashboard.id);
|
|
726
|
+
setActiveDashboardId(dashboard.id);
|
|
727
|
+
setConfigMessage(`Cloned ${sourceDashboard.name}`);
|
|
728
|
+
return {
|
|
729
|
+
...synced,
|
|
730
|
+
dashboards: [...prevDashboards, dashboard],
|
|
731
|
+
canvas: dashboardCanvasFrom(dashboard, synced.canvas)
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
}, [activeDashboardId, resolvedActiveDashboardIndex]);
|
|
735
|
+
|
|
736
|
+
const deleteDashboard = useCallback((index) => {
|
|
737
|
+
setConfig((prev) => {
|
|
738
|
+
const synced = syncActiveDashboard(prev, activeDashboardId);
|
|
739
|
+
const prevDashboards = synced.dashboards || [];
|
|
740
|
+
if (!prevDashboards[index]) return prev;
|
|
741
|
+
if (prevDashboards.length <= 1) {
|
|
742
|
+
const dashboard = createDashboardRecord("Untitled");
|
|
743
|
+
setSelectedWidgetId(null);
|
|
744
|
+
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
745
|
+
setDragPreview(null);
|
|
746
|
+
setEditingDashboardId(dashboard.id);
|
|
747
|
+
setActiveDashboardId(dashboard.id);
|
|
748
|
+
setConfigMessage("Reset dashboard");
|
|
749
|
+
return {
|
|
750
|
+
...synced,
|
|
751
|
+
dashboards: [dashboard],
|
|
752
|
+
canvas: dashboardCanvasFrom(dashboard, synced.canvas)
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const removed = prevDashboards[index];
|
|
756
|
+
const nextDashboards = prevDashboards.filter((_, dashboardIndex) => dashboardIndex !== index);
|
|
757
|
+
const nextActiveIndex = Math.min(index, nextDashboards.length - 1);
|
|
758
|
+
const nextActiveDashboard = normalizeDashboard(nextDashboards[nextActiveIndex], synced.canvas);
|
|
759
|
+
setSelectedWidgetId(null);
|
|
760
|
+
setSelectedPosition(findFreePosition(nextActiveDashboard.tabs[0]?.widgets || []));
|
|
761
|
+
setDragPreview(null);
|
|
762
|
+
setEditingDashboardId(nextActiveDashboard.id);
|
|
763
|
+
setActiveDashboardId(nextActiveDashboard.id);
|
|
764
|
+
setConfigMessage(`Deleted ${removed.name}`);
|
|
765
|
+
return {
|
|
766
|
+
...synced,
|
|
767
|
+
dashboards: nextDashboards.map((dashboard) => dashboard.id === nextActiveDashboard.id ? nextActiveDashboard : dashboard),
|
|
768
|
+
canvas: dashboardCanvasFrom(nextActiveDashboard, synced.canvas)
|
|
769
|
+
};
|
|
770
|
+
});
|
|
771
|
+
}, [activeDashboardId]);
|
|
772
|
+
|
|
773
|
+
const duplicateDashboard = useCallback(() => {
|
|
774
|
+
cloneDashboard(resolvedActiveDashboardIndex);
|
|
775
|
+
}, [cloneDashboard, resolvedActiveDashboardIndex]);
|
|
776
|
+
|
|
777
|
+
const duplicateTab = useCallback(() => {
|
|
778
|
+
setConfig((prev) => {
|
|
779
|
+
const prevTabs = getTabs(prev.canvas);
|
|
780
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
781
|
+
const source = prevTabs.find((tab) => tab.id === prevActiveId) || prevTabs[0];
|
|
782
|
+
const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
|
|
783
|
+
? { ...prevTabs[0], id: generateId("tab") }
|
|
784
|
+
: prevTabs[0];
|
|
785
|
+
const stableTabs = prevTabs.length === 1 ? [stableFirst] : prevTabs;
|
|
786
|
+
const cloned = {
|
|
787
|
+
id: generateId("tab"),
|
|
788
|
+
name: `${source.name} Copy`,
|
|
789
|
+
widgets: (source.widgets || []).map((widget) => ({
|
|
790
|
+
...cloneConfig(widget),
|
|
791
|
+
id: generateId("widget")
|
|
792
|
+
}))
|
|
793
|
+
};
|
|
794
|
+
const nextTabs = [...stableTabs, cloned];
|
|
795
|
+
setSelectedWidgetId(null);
|
|
796
|
+
setSelectedPosition(findFreePosition(cloned.widgets));
|
|
797
|
+
setDragPreview(null);
|
|
798
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, cloned.id));
|
|
799
|
+
});
|
|
800
|
+
}, [activeDashboardId]);
|
|
801
|
+
|
|
802
|
+
const deleteTab = useCallback((tabId) => {
|
|
803
|
+
setConfig((prev) => {
|
|
804
|
+
const prevTabs = getTabs(prev.canvas);
|
|
805
|
+
const tab = prevTabs.find((item) => item.id === tabId);
|
|
806
|
+
if (!tab) return prev;
|
|
807
|
+
if (prevTabs.length <= 1) {
|
|
808
|
+
const fallback = createEmptyTab(tab.name || "Tab 1");
|
|
809
|
+
setSelectedWidgetId(null);
|
|
810
|
+
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
811
|
+
setDragPreview(null);
|
|
812
|
+
setConfigMessage(`Cleared ${tab.name}`);
|
|
813
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, [fallback], fallback.id));
|
|
814
|
+
}
|
|
815
|
+
const nextTabs = prevTabs.filter((item) => item.id !== tabId);
|
|
816
|
+
const activeIndex = prevTabs.findIndex((item) => item.id === tabId);
|
|
817
|
+
const nextActiveTab = nextTabs[Math.min(activeIndex, nextTabs.length - 1)] || nextTabs[0];
|
|
818
|
+
setSelectedWidgetId(null);
|
|
819
|
+
setSelectedPosition(findFreePosition(nextActiveTab.widgets || []));
|
|
820
|
+
setDragPreview(null);
|
|
821
|
+
setConfigMessage(`Deleted ${tab.name}`);
|
|
822
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, nextActiveTab.id));
|
|
823
|
+
});
|
|
824
|
+
}, [activeDashboardId]);
|
|
825
|
+
|
|
826
|
+
const applyTemplateToCurrentTab = useCallback((templateId) => {
|
|
827
|
+
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
828
|
+
if (!template) return;
|
|
829
|
+
const clonedTab = cloneTemplateToTab(template, { tabName: template.name, idFactory: generateId });
|
|
830
|
+
setConfig((prev) => {
|
|
831
|
+
const prevTabs = getTabs(prev.canvas);
|
|
832
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
833
|
+
const dashboardIndex = activeDashboardIndex(prev.dashboards || [], activeDashboardId);
|
|
834
|
+
const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
|
|
835
|
+
? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
|
|
836
|
+
: prevTabs;
|
|
837
|
+
const nextTabs = stableTabs.map((tab) =>
|
|
838
|
+
tab.id === prevActiveId ? { ...tab, name: clonedTab.name, widgets: clonedTab.widgets } : tab
|
|
839
|
+
);
|
|
840
|
+
const nextCanvas = commitTabs(prev.canvas, nextTabs, prevActiveId);
|
|
841
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard, index) =>
|
|
842
|
+
index === dashboardIndex
|
|
843
|
+
? updateDashboardCanvas({ ...dashboard, name: template.name, updatedAt: "new", status: "draft" }, nextCanvas)
|
|
844
|
+
: dashboard
|
|
845
|
+
);
|
|
846
|
+
return {
|
|
847
|
+
...prev,
|
|
848
|
+
dashboards: nextDashboards,
|
|
849
|
+
canvas: nextCanvas
|
|
850
|
+
};
|
|
851
|
+
});
|
|
852
|
+
setSelectedWidgetId(null);
|
|
853
|
+
setSelectedPosition(findFreePosition(clonedTab.widgets));
|
|
854
|
+
setDragPreview(null);
|
|
855
|
+
setConfigMessage(`Applied ${template.name} to current tab`);
|
|
856
|
+
setTemplateGalleryOpen(false);
|
|
857
|
+
setPreviewTemplateId(null);
|
|
858
|
+
}, [activeDashboardId]);
|
|
859
|
+
|
|
860
|
+
const cloneTemplateAsDashboard = useCallback((templateId) => {
|
|
861
|
+
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
862
|
+
if (!template) return;
|
|
863
|
+
const cloned = cloneTemplateToDashboard(template, { idFactory: generateId });
|
|
864
|
+
setConfig((prev) => {
|
|
865
|
+
const synced = syncActiveDashboard(prev, activeDashboardId);
|
|
866
|
+
const dashboard = {
|
|
867
|
+
...cloned.dashboard,
|
|
868
|
+
tabs: [cloned.tab],
|
|
869
|
+
activeTabId: cloned.tab.id
|
|
870
|
+
};
|
|
871
|
+
setActiveDashboardId(dashboard.id);
|
|
872
|
+
return {
|
|
873
|
+
...synced,
|
|
874
|
+
dashboards: [...(synced.dashboards || []), dashboard],
|
|
875
|
+
canvas: dashboardCanvasFrom(dashboard, synced.canvas)
|
|
876
|
+
};
|
|
877
|
+
});
|
|
878
|
+
setSelectedWidgetId(null);
|
|
879
|
+
setSelectedPosition(findFreePosition(cloned.tab.widgets));
|
|
880
|
+
setDragPreview(null);
|
|
881
|
+
setConfigMessage(`Cloned ${template.name} as dashboard`);
|
|
882
|
+
setTemplateGalleryOpen(false);
|
|
883
|
+
setPreviewTemplateId(null);
|
|
884
|
+
}, [activeDashboardId]);
|
|
885
|
+
|
|
886
|
+
const exportConfig = useCallback(() => {
|
|
887
|
+
const syncedConfig = syncActiveDashboard(config, activeDashboardId);
|
|
888
|
+
const primaryDashboard = syncedConfig.dashboards?.[0] || {};
|
|
889
|
+
const wrapped = wrapWorkspaceTemplateExport(
|
|
890
|
+
{
|
|
891
|
+
dashboards: syncedConfig.dashboards,
|
|
892
|
+
widgetTypes: syncedConfig.widgetTypes,
|
|
893
|
+
canvas: syncedConfig.canvas
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
name: primaryDashboard.name || syncedConfig.name || "Workspace template",
|
|
897
|
+
description: syncedConfig.description || ""
|
|
898
|
+
}
|
|
899
|
+
);
|
|
900
|
+
const blob = new Blob([`${JSON.stringify(wrapped, null, 2)}\n`], { type: "application/json" });
|
|
901
|
+
const url = URL.createObjectURL(blob);
|
|
902
|
+
const anchor = document.createElement("a");
|
|
903
|
+
anchor.href = url;
|
|
904
|
+
anchor.download = "growthub-dashboard.template.json";
|
|
905
|
+
anchor.click();
|
|
906
|
+
URL.revokeObjectURL(url);
|
|
907
|
+
setConfigMessage("Exported workspace template");
|
|
908
|
+
}, [activeDashboardId, config]);
|
|
909
|
+
|
|
910
|
+
const importConfig = useCallback(async (event) => {
|
|
911
|
+
const file = event.target.files?.[0];
|
|
912
|
+
if (!file) return;
|
|
913
|
+
try {
|
|
914
|
+
const parsed = JSON.parse(await file.text());
|
|
915
|
+
const payload = unwrapWorkspaceTemplateImport(parsed);
|
|
916
|
+
validateWorkspaceConfig(payload);
|
|
917
|
+
const importedDashboards = (payload.dashboards || []).map((dashboard, index) =>
|
|
918
|
+
normalizeDashboard(dashboard, index === 0 ? payload.canvas : undefined)
|
|
919
|
+
);
|
|
920
|
+
const importedActiveDashboard = importedDashboards[0];
|
|
921
|
+
setConfig((prev) => ({
|
|
922
|
+
...prev,
|
|
923
|
+
dashboards: importedDashboards,
|
|
924
|
+
widgetTypes: payload.widgetTypes,
|
|
925
|
+
canvas: importedActiveDashboard ? dashboardCanvasFrom(importedActiveDashboard, payload.canvas) : payload.canvas
|
|
926
|
+
}));
|
|
927
|
+
setActiveDashboardId(importedActiveDashboard?.id || null);
|
|
928
|
+
const importedTabs = getTabs(importedActiveDashboard ? dashboardCanvasFrom(importedActiveDashboard, payload.canvas) : payload.canvas);
|
|
929
|
+
setSelectedWidgetId(null);
|
|
930
|
+
setSelectedPosition(findFreePosition(importedTabs[0]?.widgets || []));
|
|
931
|
+
setDragPreview(null);
|
|
932
|
+
setConfigMessage(`Imported ${file.name}`);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
setConfigMessage(error.message || "Import failed");
|
|
935
|
+
} finally {
|
|
936
|
+
event.target.value = "";
|
|
937
|
+
}
|
|
938
|
+
}, []);
|
|
939
|
+
|
|
361
940
|
const save = useCallback(async () => {
|
|
362
941
|
if (saving) return;
|
|
363
942
|
setSaving(true);
|
|
364
943
|
try {
|
|
365
944
|
const stamp = todayIsoDate();
|
|
366
|
-
const
|
|
367
|
-
|
|
945
|
+
const syncedConfig = syncActiveDashboard(config, activeDashboardId);
|
|
946
|
+
const updatedDashboards = (syncedConfig.dashboards || []).map((dashboard) =>
|
|
947
|
+
dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], activeDashboardId)
|
|
948
|
+
? { ...dashboard, updatedAt: stamp }
|
|
949
|
+
: dashboard
|
|
368
950
|
);
|
|
369
951
|
const response = await fetch("/api/workspace", {
|
|
370
952
|
method: "PATCH",
|
|
371
953
|
headers: { "content-type": "application/json" },
|
|
372
954
|
body: JSON.stringify({
|
|
373
955
|
dashboards: updatedDashboards,
|
|
374
|
-
widgetTypes:
|
|
375
|
-
canvas:
|
|
956
|
+
widgetTypes: syncedConfig.widgetTypes,
|
|
957
|
+
canvas: syncedConfig.canvas
|
|
376
958
|
})
|
|
377
959
|
});
|
|
378
960
|
const payload = await response.json();
|
|
379
961
|
if (response.ok && payload.workspaceConfig) {
|
|
380
|
-
|
|
962
|
+
const savedDashboards = (payload.workspaceConfig.dashboards || []).map((dashboard, index) =>
|
|
963
|
+
normalizeDashboard(dashboard, index === 0 ? payload.workspaceConfig.canvas : undefined)
|
|
964
|
+
);
|
|
965
|
+
const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === activeDashboardId) || savedDashboards[0];
|
|
966
|
+
setConfig({
|
|
967
|
+
...payload.workspaceConfig,
|
|
968
|
+
dashboards: savedDashboards,
|
|
969
|
+
canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
|
|
970
|
+
});
|
|
971
|
+
setConfigMessage("Saved dashboard config");
|
|
972
|
+
} else {
|
|
973
|
+
setConfigMessage(payload.error || "Save failed");
|
|
381
974
|
}
|
|
975
|
+
} catch (error) {
|
|
976
|
+
setConfigMessage(error.message || "Save failed");
|
|
382
977
|
} finally {
|
|
383
978
|
setSaving(false);
|
|
384
979
|
}
|
|
385
|
-
}, [saving, config]);
|
|
980
|
+
}, [activeDashboardId, saving, config]);
|
|
386
981
|
|
|
387
982
|
const reopenPanel = useCallback(() => setPanelOpen(true), []);
|
|
388
983
|
const closePanel = useCallback(() => setPanelOpen(false), []);
|
|
@@ -449,9 +1044,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
449
1044
|
)
|
|
450
1045
|
};
|
|
451
1046
|
});
|
|
452
|
-
return
|
|
1047
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
453
1048
|
});
|
|
454
|
-
}, [activeWidgets]);
|
|
1049
|
+
}, [activeDashboardId, activeWidgets]);
|
|
455
1050
|
const finishResizeDrag = useCallback(() => {
|
|
456
1051
|
if (!resizeDragRef.current) return;
|
|
457
1052
|
resizeDragRef.current = null;
|
|
@@ -475,9 +1070,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
475
1070
|
)
|
|
476
1071
|
};
|
|
477
1072
|
});
|
|
478
|
-
return
|
|
1073
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
479
1074
|
});
|
|
480
|
-
}, [selectedWidgetId]);
|
|
1075
|
+
}, [activeDashboardId, selectedWidgetId]);
|
|
481
1076
|
const updateSelectedWidgetConfig = useCallback((updates) => {
|
|
482
1077
|
if (!selectedWidget) return;
|
|
483
1078
|
updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
|
|
@@ -493,10 +1088,24 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
493
1088
|
const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
|
|
494
1089
|
setSelectedWidgetId(null);
|
|
495
1090
|
setSelectedPosition(findFreePosition(nextActiveWidgets));
|
|
496
|
-
return
|
|
1091
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
497
1092
|
});
|
|
1093
|
+
}, [activeDashboardId]);
|
|
1094
|
+
|
|
1095
|
+
const closeTemplateGallery = useCallback(() => {
|
|
1096
|
+
setTemplateGalleryOpen(false);
|
|
1097
|
+
setPreviewTemplateId(null);
|
|
498
1098
|
}, []);
|
|
499
1099
|
|
|
1100
|
+
useEffect(() => {
|
|
1101
|
+
if (!templateGalleryOpen) return undefined;
|
|
1102
|
+
const handler = (event) => {
|
|
1103
|
+
if (event.key === "Escape") closeTemplateGallery();
|
|
1104
|
+
};
|
|
1105
|
+
window.addEventListener("keydown", handler);
|
|
1106
|
+
return () => window.removeEventListener("keydown", handler);
|
|
1107
|
+
}, [templateGalleryOpen, closeTemplateGallery]);
|
|
1108
|
+
|
|
500
1109
|
const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
|
|
501
1110
|
|
|
502
1111
|
return <main className="workspace-builder" style={builderStyle}>
|
|
@@ -525,10 +1134,22 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
525
1134
|
<h1>{config.name}</h1>
|
|
526
1135
|
</div>
|
|
527
1136
|
<div className="workspace-toolbar-actions">
|
|
1137
|
+
<button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
|
|
528
1138
|
<button type="button" onClick={addDashboard}>New Dashboard</button>
|
|
1139
|
+
<button type="button" onClick={duplicateDashboard}>Duplicate Dashboard</button>
|
|
1140
|
+
<button type="button" onClick={() => importInputRef.current?.click()}>Import</button>
|
|
1141
|
+
<button type="button" onClick={exportConfig}>Export</button>
|
|
529
1142
|
<button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
|
|
530
1143
|
</div>
|
|
1144
|
+
<input
|
|
1145
|
+
ref={importInputRef}
|
|
1146
|
+
type="file"
|
|
1147
|
+
accept="application/json,.json"
|
|
1148
|
+
className="workspace-hidden-input"
|
|
1149
|
+
onChange={importConfig}
|
|
1150
|
+
/>
|
|
531
1151
|
</header>
|
|
1152
|
+
{configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
|
|
532
1153
|
|
|
533
1154
|
<section className="workspace-table" id="dashboards" aria-label="Dashboards">
|
|
534
1155
|
<div className="workspace-table-heading">
|
|
@@ -540,12 +1161,45 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
540
1161
|
<span>Created by</span>
|
|
541
1162
|
<span>Last update</span>
|
|
542
1163
|
<span>Status</span>
|
|
1164
|
+
<span>Actions</span>
|
|
543
1165
|
</div>
|
|
544
|
-
{dashboards.map((dashboard) => <div className="workspace-table-row" key={dashboard.id}>
|
|
545
|
-
<span
|
|
1166
|
+
{dashboards.map((dashboard, index) => <div className="workspace-table-row" key={dashboard.id}>
|
|
1167
|
+
<span className="workspace-dashboard-title">
|
|
1168
|
+
{editingDashboardId === dashboard.id ? <input
|
|
1169
|
+
aria-label={`Rename ${dashboard.name}`}
|
|
1170
|
+
onBlur={() => setEditingDashboardId(null)}
|
|
1171
|
+
onChange={(event) => renameDashboard(dashboard.id, event.target.value)}
|
|
1172
|
+
onKeyDown={(event) => {
|
|
1173
|
+
if (event.key === "Enter" || event.key === "Escape") {
|
|
1174
|
+
event.currentTarget.blur();
|
|
1175
|
+
}
|
|
1176
|
+
}}
|
|
1177
|
+
value={dashboard.name}
|
|
1178
|
+
/> : <button
|
|
1179
|
+
className={index === resolvedActiveDashboardIndex ? "active" : ""}
|
|
1180
|
+
onClick={() => selectDashboard(index)}
|
|
1181
|
+
type="button"
|
|
1182
|
+
>{dashboard.name}</button>}
|
|
1183
|
+
</span>
|
|
546
1184
|
<span>{dashboard.createdBy}</span>
|
|
547
1185
|
<span>{dashboard.updatedAt}</span>
|
|
548
|
-
<
|
|
1186
|
+
<span>
|
|
1187
|
+
<select
|
|
1188
|
+
aria-label={`Status for ${dashboard.name}`}
|
|
1189
|
+
onChange={(event) => updateDashboardStatus(dashboard.id, event.target.value)}
|
|
1190
|
+
value={dashboard.status}
|
|
1191
|
+
>
|
|
1192
|
+
<option value="draft">draft</option>
|
|
1193
|
+
<option value="active">active</option>
|
|
1194
|
+
<option value="archived">archived</option>
|
|
1195
|
+
</select>
|
|
1196
|
+
</span>
|
|
1197
|
+
<span className="workspace-dashboard-actions">
|
|
1198
|
+
<button type="button" onClick={() => selectDashboard(index)}>Edit</button>
|
|
1199
|
+
<button type="button" onClick={() => setEditingDashboardId(dashboard.id)}>Rename</button>
|
|
1200
|
+
<button type="button" onClick={() => cloneDashboard(index)}>Clone</button>
|
|
1201
|
+
<button type="button" onClick={() => deleteDashboard(index)}>Delete</button>
|
|
1202
|
+
</span>
|
|
549
1203
|
</div>)}
|
|
550
1204
|
</section>
|
|
551
1205
|
|
|
@@ -556,8 +1210,28 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
556
1210
|
className={tab.id === activeTabId ? "active" : ""}
|
|
557
1211
|
type="button"
|
|
558
1212
|
onClick={() => switchTab(tab.id)}
|
|
559
|
-
>
|
|
1213
|
+
>
|
|
1214
|
+
<span>{tab.name}</span>
|
|
1215
|
+
<span
|
|
1216
|
+
aria-label={`Delete tab ${tab.name}`}
|
|
1217
|
+
className="workspace-tab-delete"
|
|
1218
|
+
onClick={(event) => {
|
|
1219
|
+
event.stopPropagation();
|
|
1220
|
+
deleteTab(tab.id);
|
|
1221
|
+
}}
|
|
1222
|
+
onKeyDown={(event) => {
|
|
1223
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1224
|
+
event.preventDefault();
|
|
1225
|
+
event.stopPropagation();
|
|
1226
|
+
deleteTab(tab.id);
|
|
1227
|
+
}
|
|
1228
|
+
}}
|
|
1229
|
+
role="button"
|
|
1230
|
+
tabIndex={0}
|
|
1231
|
+
>x</span>
|
|
1232
|
+
</button>)}
|
|
560
1233
|
<button type="button" onClick={addTab}>New Tab</button>
|
|
1234
|
+
<button type="button" onClick={duplicateTab}>Duplicate Tab</button>
|
|
561
1235
|
</div>
|
|
562
1236
|
<div
|
|
563
1237
|
className="workspace-grid"
|
|
@@ -610,6 +1284,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
610
1284
|
</section>
|
|
611
1285
|
</section>
|
|
612
1286
|
|
|
1287
|
+
{templateGalleryOpen ? <TemplateGallery
|
|
1288
|
+
templates={NORMALIZED_TEMPLATES}
|
|
1289
|
+
previewTemplateId={previewTemplateId}
|
|
1290
|
+
onPreview={setPreviewTemplateId}
|
|
1291
|
+
onClose={closeTemplateGallery}
|
|
1292
|
+
onApplyToCurrentTab={applyTemplateToCurrentTab}
|
|
1293
|
+
onCloneAsDashboard={cloneTemplateAsDashboard}
|
|
1294
|
+
/> : null}
|
|
1295
|
+
|
|
613
1296
|
{panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
614
1297
|
<div className="workspace-panel-title">
|
|
615
1298
|
<button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
|
|
@@ -622,6 +1305,27 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
622
1305
|
<span>Title</span>
|
|
623
1306
|
<input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
|
|
624
1307
|
</label>
|
|
1308
|
+
{selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
|
|
1309
|
+
<label>
|
|
1310
|
+
<span>Sample Values</span>
|
|
1311
|
+
<input
|
|
1312
|
+
value={serializeChartValues(selectedWidget.config?.values || [])}
|
|
1313
|
+
onChange={(event) => updateSelectedWidgetConfig({ values: normalizeChartValues(event.target.value) })}
|
|
1314
|
+
/>
|
|
1315
|
+
</label>
|
|
1316
|
+
<label>
|
|
1317
|
+
<span>Static Binding</span>
|
|
1318
|
+
<select
|
|
1319
|
+
value={selectedWidget.config?.binding?.mode || "json"}
|
|
1320
|
+
onChange={(event) => updateSelectedWidgetConfig({
|
|
1321
|
+
binding: event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.reportingJson
|
|
1322
|
+
})}
|
|
1323
|
+
>
|
|
1324
|
+
<option value="json">Sample JSON</option>
|
|
1325
|
+
<option value="csv">Sample CSV</option>
|
|
1326
|
+
</select>
|
|
1327
|
+
</label>
|
|
1328
|
+
</section> : null}
|
|
625
1329
|
{selectedWidget.kind === "iframe" ? <label>
|
|
626
1330
|
<span>URL to Embed</span>
|
|
627
1331
|
<input
|
|
@@ -638,14 +1342,68 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
638
1342
|
onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
|
|
639
1343
|
/>
|
|
640
1344
|
</label> : null}
|
|
641
|
-
{selectedWidget.kind === "view" ? <
|
|
1345
|
+
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
1346
|
+
<label>
|
|
1347
|
+
<span>Source</span>
|
|
1348
|
+
<input
|
|
1349
|
+
value={selectedWidget.config?.source || ""}
|
|
1350
|
+
onChange={(event) => updateSelectedWidgetConfig({ source: event.target.value })}
|
|
1351
|
+
/>
|
|
1352
|
+
</label>
|
|
1353
|
+
<label>
|
|
1354
|
+
<span>Columns</span>
|
|
1355
|
+
<input
|
|
1356
|
+
value={serializeLineList(selectedWidget.config?.columns || [])}
|
|
1357
|
+
onChange={(event) => updateSelectedWidgetConfig({ columns: parseLineList(event.target.value) })}
|
|
1358
|
+
/>
|
|
1359
|
+
</label>
|
|
1360
|
+
<label>
|
|
1361
|
+
<span>Manual Rows</span>
|
|
1362
|
+
<textarea
|
|
1363
|
+
value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
|
|
1364
|
+
onChange={(event) => {
|
|
1365
|
+
const columns = selectedWidget.config?.columns?.length ? selectedWidget.config.columns : ["Name", "Domain Name"];
|
|
1366
|
+
updateSelectedWidgetConfig({
|
|
1367
|
+
rows: parseManualRows(event.target.value, columns),
|
|
1368
|
+
binding: { mode: "manual", source: "Manual rows", rows: parseManualRows(event.target.value, columns) }
|
|
1369
|
+
});
|
|
1370
|
+
}}
|
|
1371
|
+
/>
|
|
1372
|
+
</label>
|
|
1373
|
+
<label>
|
|
1374
|
+
<span>Static Binding</span>
|
|
1375
|
+
<select
|
|
1376
|
+
value={selectedWidget.config?.binding?.mode || "manual"}
|
|
1377
|
+
onChange={(event) => {
|
|
1378
|
+
const binding = event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.companiesManual;
|
|
1379
|
+
updateSelectedWidgetConfig({ binding });
|
|
1380
|
+
}}
|
|
1381
|
+
>
|
|
1382
|
+
<option value="manual">Manual Rows</option>
|
|
1383
|
+
<option value="csv">Sample CSV</option>
|
|
1384
|
+
</select>
|
|
1385
|
+
</label>
|
|
1386
|
+
<div className="workspace-settings-list">
|
|
642
1387
|
<p className="workspace-panel-label">Settings</p>
|
|
643
1388
|
<div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
|
|
644
1389
|
<div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
|
|
645
|
-
<div><span>Fields</span><code>
|
|
1390
|
+
<div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
|
|
646
1391
|
<div><span>Filter</span><code>›</code></div>
|
|
647
1392
|
<div><span>Sort</span><code>›</code></div>
|
|
648
|
-
|
|
1393
|
+
</div>
|
|
1394
|
+
</section> : null}
|
|
1395
|
+
{selectedWidget.kind === "rich-text" ? <label>
|
|
1396
|
+
<span>Static Binding</span>
|
|
1397
|
+
<select
|
|
1398
|
+
value={selectedWidget.config?.binding?.mode || "manual"}
|
|
1399
|
+
onChange={(event) => updateSelectedWidgetConfig({
|
|
1400
|
+
binding: { mode: event.target.value, source: event.target.value === "manual" ? "Manual text" : "Sample JSON", rows: [] }
|
|
1401
|
+
})}
|
|
1402
|
+
>
|
|
1403
|
+
<option value="manual">Manual Text</option>
|
|
1404
|
+
<option value="json">Sample JSON</option>
|
|
1405
|
+
</select>
|
|
1406
|
+
</label> : null}
|
|
649
1407
|
<div className="workspace-settings-list">
|
|
650
1408
|
<p className="workspace-panel-label">Placement</p>
|
|
651
1409
|
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|