@growthub/cli 0.9.8 โ 0.9.10
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 +1264 -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 +1691 -138
- 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 +220 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +10 -64
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +3 -0
- package/package.json +1 -1
|
@@ -2,8 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
BarChart3,
|
|
7
|
+
Bolt,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
Code2,
|
|
10
|
+
Columns3,
|
|
11
|
+
Copy,
|
|
12
|
+
Database,
|
|
13
|
+
Download,
|
|
14
|
+
ExternalLink,
|
|
15
|
+
FileText,
|
|
16
|
+
Filter,
|
|
17
|
+
Gauge,
|
|
18
|
+
Grid2X2,
|
|
19
|
+
Home,
|
|
20
|
+
Import,
|
|
21
|
+
Italic,
|
|
22
|
+
LayoutDashboard,
|
|
23
|
+
Link as LinkIcon,
|
|
24
|
+
List,
|
|
25
|
+
Maximize2,
|
|
26
|
+
Pencil,
|
|
27
|
+
PieChart,
|
|
28
|
+
Plus,
|
|
29
|
+
Quote,
|
|
30
|
+
Rows3,
|
|
31
|
+
Save,
|
|
32
|
+
Search,
|
|
33
|
+
Settings,
|
|
34
|
+
Sigma,
|
|
35
|
+
SlidersHorizontal,
|
|
36
|
+
Sparkles,
|
|
37
|
+
Table2,
|
|
38
|
+
Trash2,
|
|
39
|
+
Type,
|
|
40
|
+
X
|
|
41
|
+
} from "lucide-react";
|
|
5
42
|
import {
|
|
6
43
|
DASHBOARD_TEMPLATES,
|
|
44
|
+
KNOWN_AGGREGATIONS,
|
|
45
|
+
KNOWN_CHART_TYPES,
|
|
46
|
+
KNOWN_FILTER_CONJUNCTIONS,
|
|
47
|
+
KNOWN_FILTER_OPERATORS,
|
|
48
|
+
KNOWN_SORT_DIRECTIONS,
|
|
7
49
|
SAMPLE_DATA_BINDINGS,
|
|
8
50
|
SAMPLE_VIEW_ROWS,
|
|
9
51
|
cloneTemplateToDashboard,
|
|
@@ -14,6 +56,60 @@ import {
|
|
|
14
56
|
validateWorkspaceConfig,
|
|
15
57
|
wrapWorkspaceTemplateExport
|
|
16
58
|
} from "@/lib/workspace-schema";
|
|
59
|
+
import { governedWorkspaceIntegrationCatalog } from "@/lib/domain/integrations";
|
|
60
|
+
|
|
61
|
+
const DEFAULT_CHART_TYPE = "bar-vertical";
|
|
62
|
+
const DEFAULT_FILTER_OP = "and";
|
|
63
|
+
const DEFAULT_FILTER_OPERATOR = "contains";
|
|
64
|
+
const DEFAULT_SORT_DIRECTION = "asc";
|
|
65
|
+
const SUB_PANEL_ROOT = "root";
|
|
66
|
+
|
|
67
|
+
const CHART_TYPE_LABELS = {
|
|
68
|
+
"bar-vertical": "Vertical Bar",
|
|
69
|
+
"bar-horizontal": "Horizontal Bar",
|
|
70
|
+
"pie": "Pie",
|
|
71
|
+
"sum": "Sum",
|
|
72
|
+
"gauge": "Gauge"
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const CHART_TYPE_ICONS = {
|
|
76
|
+
"bar-vertical": BarChart3,
|
|
77
|
+
"bar-horizontal": Rows3,
|
|
78
|
+
"pie": PieChart,
|
|
79
|
+
"sum": Sigma,
|
|
80
|
+
"gauge": Gauge
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
|
|
84
|
+
|
|
85
|
+
const WIDGET_KIND_ICONS = {
|
|
86
|
+
chart: BarChart3,
|
|
87
|
+
view: Table2,
|
|
88
|
+
iframe: Code2,
|
|
89
|
+
"rich-text": FileText
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const FILTER_OPERATOR_LABELS = {
|
|
93
|
+
eq: "equals",
|
|
94
|
+
ne: "does not equal",
|
|
95
|
+
contains: "contains",
|
|
96
|
+
gt: "is greater than",
|
|
97
|
+
lt: "is less than",
|
|
98
|
+
isEmpty: "is empty",
|
|
99
|
+
isNotEmpty: "is not empty"
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const COLUMN_ICON_FOR = (name) => {
|
|
103
|
+
const lower = String(name || "").toLowerCase();
|
|
104
|
+
if (lower.includes("name") || lower.includes("title")) return "๐";
|
|
105
|
+
if (lower.includes("domain") || lower.includes("url") || lower.includes("link")) return "๐";
|
|
106
|
+
if (lower.includes("address") || lower.includes("location")) return "๐บ";
|
|
107
|
+
if (lower.includes("employee") || lower.includes("people") || lower.includes("user")) return "๐ฅ";
|
|
108
|
+
if (lower.includes("linkedin")) return "in";
|
|
109
|
+
if (lower.includes("twitter") || lower === "x") return "๐";
|
|
110
|
+
if (lower.includes("date") || lower.includes("created") || lower.includes("updated")) return "๐
";
|
|
111
|
+
return "โฆ";
|
|
112
|
+
};
|
|
17
113
|
|
|
18
114
|
const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
|
|
19
115
|
const GRID_COLUMNS = 12;
|
|
@@ -76,7 +172,7 @@ function commitTabs(canvas, tabs, activeTabId) {
|
|
|
76
172
|
}
|
|
77
173
|
|
|
78
174
|
function createDashboardRecord(name = "Untitled") {
|
|
79
|
-
const tab = createEmptyTab(
|
|
175
|
+
const tab = createEmptyTab("Tab 1");
|
|
80
176
|
return {
|
|
81
177
|
id: generateId("dashboard"),
|
|
82
178
|
name,
|
|
@@ -140,7 +236,7 @@ function updateDashboardCanvas(dashboard, canvas) {
|
|
|
140
236
|
}
|
|
141
237
|
|
|
142
238
|
function createDashboardFromTab(name, tab, source = {}) {
|
|
143
|
-
const clonedTab = cloneTabForDashboard(tab, name);
|
|
239
|
+
const clonedTab = cloneTabForDashboard(tab, tab?.name || "Tab 1");
|
|
144
240
|
return {
|
|
145
241
|
...source,
|
|
146
242
|
id: generateId("dashboard"),
|
|
@@ -189,6 +285,24 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
|
|
|
189
285
|
};
|
|
190
286
|
}
|
|
191
287
|
|
|
288
|
+
function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
|
|
289
|
+
const nextName = name.trim() || "Untitled";
|
|
290
|
+
const prevDashboards = config.dashboards || [];
|
|
291
|
+
const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
|
|
292
|
+
if (index < 0) return config;
|
|
293
|
+
const nextDashboardsWithTabs = prevDashboards.map((dashboard, dashboardIndex) => {
|
|
294
|
+
if (dashboard.id !== dashboardId) return dashboard;
|
|
295
|
+
const normalized = normalizeDashboard(dashboard, dashboardIndex === 0 ? config.canvas : undefined);
|
|
296
|
+
return { ...normalized, name: nextName, updatedAt: "new" };
|
|
297
|
+
});
|
|
298
|
+
const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
|
|
299
|
+
return {
|
|
300
|
+
...config,
|
|
301
|
+
dashboards: nextDashboardsWithTabs,
|
|
302
|
+
canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], config.canvas)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
192
306
|
function findFreePosition(widgets) {
|
|
193
307
|
const occupied = new Set();
|
|
194
308
|
for (const widget of widgets) {
|
|
@@ -245,6 +359,18 @@ function clampPositionToFreeSpace(position, widgets) {
|
|
|
245
359
|
return collides ? findFreePosition(widgets) : bounded;
|
|
246
360
|
}
|
|
247
361
|
|
|
362
|
+
function clampWidgetMovePosition(position, widget, widgets) {
|
|
363
|
+
const bounded = {
|
|
364
|
+
...widget.position,
|
|
365
|
+
x: Math.max(0, Math.min(position.x, GRID_COLUMNS - widget.position.w)),
|
|
366
|
+
y: Math.max(0, Math.min(position.y, GRID_ROWS - widget.position.h))
|
|
367
|
+
};
|
|
368
|
+
const otherWidgets = widgets.filter((item) => item.id !== widget.id);
|
|
369
|
+
return otherWidgets.some((item) => positionsOverlap(bounded, item.position))
|
|
370
|
+
? widget.position
|
|
371
|
+
: bounded;
|
|
372
|
+
}
|
|
373
|
+
|
|
248
374
|
function cellIndexFromGridPointer(event, gridElement) {
|
|
249
375
|
if (!gridElement) return null;
|
|
250
376
|
const rect = gridElement.getBoundingClientRect();
|
|
@@ -264,6 +390,13 @@ function cellIndexFromGridPointer(event, gridElement) {
|
|
|
264
390
|
return row * GRID_COLUMNS + column;
|
|
265
391
|
}
|
|
266
392
|
|
|
393
|
+
function cellPointFromIndex(index) {
|
|
394
|
+
return {
|
|
395
|
+
x: index % GRID_COLUMNS,
|
|
396
|
+
y: Math.floor(index / GRID_COLUMNS)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
267
400
|
function resizePositionFromCell(position, corner, index) {
|
|
268
401
|
const cellX = index % GRID_COLUMNS;
|
|
269
402
|
const cellY = Math.floor(index / GRID_COLUMNS);
|
|
@@ -313,10 +446,48 @@ function widgetKindLabel(kind) {
|
|
|
313
446
|
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
314
447
|
}
|
|
315
448
|
|
|
449
|
+
function IconGlyph({ icon: Icon, size = 16 }) {
|
|
450
|
+
if (!Icon) return null;
|
|
451
|
+
return <Icon aria-hidden="true" size={size} strokeWidth={1.9} />;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isLikelyHttpUrl(value) {
|
|
455
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
456
|
+
try {
|
|
457
|
+
const url = new URL(value.trim());
|
|
458
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
316
464
|
function cloneConfig(value) {
|
|
317
465
|
return JSON.parse(JSON.stringify(value));
|
|
318
466
|
}
|
|
319
467
|
|
|
468
|
+
function escapeHtml(value) {
|
|
469
|
+
return String(value || "")
|
|
470
|
+
.replaceAll("&", "&")
|
|
471
|
+
.replaceAll("<", "<")
|
|
472
|
+
.replaceAll(">", ">")
|
|
473
|
+
.replaceAll('"', """)
|
|
474
|
+
.replaceAll("'", "'");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function richTextToHtml(value) {
|
|
478
|
+
const escaped = escapeHtml(value || "Start writing...");
|
|
479
|
+
return escaped
|
|
480
|
+
.replace(/^### (.*)$/gm, "<h3>$1</h3>")
|
|
481
|
+
.replace(/^## (.*)$/gm, "<h2>$1</h2>")
|
|
482
|
+
.replace(/^# (.*)$/gm, "<h1>$1</h1>")
|
|
483
|
+
.replace(/^> (.*)$/gm, "<blockquote>$1</blockquote>")
|
|
484
|
+
.replace(/^- (.*)$/gm, "<li>$1</li>")
|
|
485
|
+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
|
486
|
+
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
|
487
|
+
.replace(/\[(.*?)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
|
|
488
|
+
.replace(/\n/g, "<br />");
|
|
489
|
+
}
|
|
490
|
+
|
|
320
491
|
function normalizeChartValues(value) {
|
|
321
492
|
return String(value)
|
|
322
493
|
.split(",")
|
|
@@ -361,6 +532,129 @@ function serializeManualRows(rows, columns) {
|
|
|
361
532
|
.join("\n");
|
|
362
533
|
}
|
|
363
534
|
|
|
535
|
+
function getColumnList(widget) {
|
|
536
|
+
return Array.isArray(widget?.config?.columns) ? widget.config.columns : [];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function getOrderedColumns(widget) {
|
|
540
|
+
const columns = getColumnList(widget);
|
|
541
|
+
const order = Array.isArray(widget?.config?.fieldSettings?.order) ? widget.config.fieldSettings.order : [];
|
|
542
|
+
if (!order.length) return columns;
|
|
543
|
+
const known = new Set(columns);
|
|
544
|
+
const ordered = order.filter((name) => known.has(name));
|
|
545
|
+
const remaining = columns.filter((name) => !ordered.includes(name));
|
|
546
|
+
return [...ordered, ...remaining];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function getHiddenColumnSet(widget) {
|
|
550
|
+
const hidden = Array.isArray(widget?.config?.fieldSettings?.hidden) ? widget.config.fieldSettings.hidden : [];
|
|
551
|
+
return new Set(hidden);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function getVisibleColumns(widget) {
|
|
555
|
+
const ordered = getOrderedColumns(widget);
|
|
556
|
+
const hidden = getHiddenColumnSet(widget);
|
|
557
|
+
return ordered.filter((name) => !hidden.has(name));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function withFieldSettings(config, patch) {
|
|
561
|
+
const current = isPlainConfigObject(config?.fieldSettings) ? config.fieldSettings : { hidden: [], order: [] };
|
|
562
|
+
return {
|
|
563
|
+
...config,
|
|
564
|
+
fieldSettings: {
|
|
565
|
+
hidden: Array.isArray(current.hidden) ? [...current.hidden] : [],
|
|
566
|
+
order: Array.isArray(current.order) ? [...current.order] : [],
|
|
567
|
+
...patch
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function isPlainConfigObject(value) {
|
|
573
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function reorderColumn(widget, fieldId, direction) {
|
|
577
|
+
const ordered = getOrderedColumns(widget);
|
|
578
|
+
const index = ordered.indexOf(fieldId);
|
|
579
|
+
if (index < 0) return widget.config?.fieldSettings;
|
|
580
|
+
const target = direction === "up" ? index - 1 : index + 1;
|
|
581
|
+
if (target < 0 || target >= ordered.length) return widget.config?.fieldSettings;
|
|
582
|
+
const next = [...ordered];
|
|
583
|
+
const [moved] = next.splice(index, 1);
|
|
584
|
+
next.splice(target, 0, moved);
|
|
585
|
+
return { ...(widget.config?.fieldSettings || {}), order: next };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function toggleColumnHidden(widget, fieldId) {
|
|
589
|
+
const hidden = getHiddenColumnSet(widget);
|
|
590
|
+
if (hidden.has(fieldId)) hidden.delete(fieldId);
|
|
591
|
+
else hidden.add(fieldId);
|
|
592
|
+
return { ...(widget.config?.fieldSettings || {}), hidden: Array.from(hidden) };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function getSortClauses(widget) {
|
|
596
|
+
return Array.isArray(widget?.config?.sort) ? widget.config.sort : [];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function getFilterConfig(widget) {
|
|
600
|
+
const filter = widget?.config?.filter;
|
|
601
|
+
if (!isPlainConfigObject(filter)) return { op: DEFAULT_FILTER_OP, clauses: [] };
|
|
602
|
+
return {
|
|
603
|
+
op: KNOWN_FILTER_CONJUNCTIONS.includes(filter.op) ? filter.op : DEFAULT_FILTER_OP,
|
|
604
|
+
clauses: Array.isArray(filter.clauses) ? filter.clauses : []
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function getChartType(widget) {
|
|
609
|
+
const chartType = widget?.config?.chartType;
|
|
610
|
+
return KNOWN_CHART_TYPES.includes(chartType) ? chartType : DEFAULT_CHART_TYPE;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function getChartAxis(widget, axisKey) {
|
|
614
|
+
const axis = widget?.config?.[axisKey];
|
|
615
|
+
return isPlainConfigObject(axis) ? axis : {};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function getChartStyle(widget) {
|
|
619
|
+
const style = widget?.config?.style;
|
|
620
|
+
return isPlainConfigObject(style) ? style : {};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function summarizeSource(widget) {
|
|
624
|
+
const binding = widget?.config?.binding;
|
|
625
|
+
if (binding?.mode === "integration" && binding.source) return binding.source;
|
|
626
|
+
if (widget?.config?.source) return widget.config.source;
|
|
627
|
+
return "Static";
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function summarizeFields(widget) {
|
|
631
|
+
const total = getColumnList(widget).length;
|
|
632
|
+
const hidden = getHiddenColumnSet(widget).size;
|
|
633
|
+
if (!total) return "0 shown";
|
|
634
|
+
return hidden ? `${total - hidden} of ${total} shown` : `${total} shown`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function summarizeSort(widget) {
|
|
638
|
+
const sort = getSortClauses(widget);
|
|
639
|
+
if (!sort.length) return "โบ";
|
|
640
|
+
if (sort.length === 1) {
|
|
641
|
+
const [first] = sort;
|
|
642
|
+
return `${first.fieldId} ${first.direction || DEFAULT_SORT_DIRECTION}`;
|
|
643
|
+
}
|
|
644
|
+
return `${sort.length} sorts`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function summarizeFilter(widget) {
|
|
648
|
+
const filter = getFilterConfig(widget);
|
|
649
|
+
const count = filter.clauses.length;
|
|
650
|
+
if (!count) return "โบ";
|
|
651
|
+
return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function describeIntegrationLane(integration) {
|
|
655
|
+
return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
|
|
656
|
+
}
|
|
657
|
+
|
|
364
658
|
const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
|
|
365
659
|
...normalizeWorkspaceTemplate(template),
|
|
366
660
|
widgets: template.widgets
|
|
@@ -404,9 +698,36 @@ function TemplateGallery({
|
|
|
404
698
|
onPreview,
|
|
405
699
|
onClose,
|
|
406
700
|
onApplyToCurrentTab,
|
|
407
|
-
onCloneAsDashboard
|
|
701
|
+
onCloneAsDashboard,
|
|
702
|
+
filter,
|
|
703
|
+
onFilterChange
|
|
408
704
|
}) {
|
|
705
|
+
const categories = useMemo(() => {
|
|
706
|
+
const set = new Set();
|
|
707
|
+
templates.forEach((template) => {
|
|
708
|
+
if (template.category) set.add(template.category);
|
|
709
|
+
});
|
|
710
|
+
return ["all", ...Array.from(set)];
|
|
711
|
+
}, [templates]);
|
|
712
|
+
const tags = useMemo(() => {
|
|
713
|
+
const set = new Set();
|
|
714
|
+
templates.forEach((template) => {
|
|
715
|
+
(template.tags || []).forEach((tag) => set.add(tag));
|
|
716
|
+
});
|
|
717
|
+
return ["all", ...Array.from(set)];
|
|
718
|
+
}, [templates]);
|
|
719
|
+
const filtered = useMemo(() => {
|
|
720
|
+
const query = (filter?.query || "").trim().toLowerCase();
|
|
721
|
+
return templates.filter((template) => {
|
|
722
|
+
if (filter?.category && filter.category !== "all" && template.category !== filter.category) return false;
|
|
723
|
+
if (filter?.tag && filter.tag !== "all" && !(template.tags || []).includes(filter.tag)) return false;
|
|
724
|
+
if (!query) return true;
|
|
725
|
+
const haystack = `${template.name} ${template.description} ${(template.tags || []).join(" ")} ${(template.bestFor || []).join(" ")}`.toLowerCase();
|
|
726
|
+
return haystack.includes(query);
|
|
727
|
+
});
|
|
728
|
+
}, [templates, filter]);
|
|
409
729
|
const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
|
|
730
|
+
const setFilter = (patch) => onFilterChange({ ...(filter || {}), ...patch });
|
|
410
731
|
return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
|
|
411
732
|
<div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
|
|
412
733
|
<section className="template-gallery-panel">
|
|
@@ -417,8 +738,31 @@ function TemplateGallery({
|
|
|
417
738
|
</div>
|
|
418
739
|
<button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
|
|
419
740
|
</header>
|
|
741
|
+
<div className="template-gallery-filters">
|
|
742
|
+
<input
|
|
743
|
+
aria-label="Search templates"
|
|
744
|
+
placeholder="Search templatesโฆ"
|
|
745
|
+
value={filter?.query || ""}
|
|
746
|
+
onChange={(event) => setFilter({ query: event.target.value })}
|
|
747
|
+
/>
|
|
748
|
+
<select
|
|
749
|
+
aria-label="Filter by category"
|
|
750
|
+
value={filter?.category || "all"}
|
|
751
|
+
onChange={(event) => setFilter({ category: event.target.value })}
|
|
752
|
+
>
|
|
753
|
+
{categories.map((category) => <option key={category} value={category}>{category === "all" ? "All categories" : category}</option>)}
|
|
754
|
+
</select>
|
|
755
|
+
<select
|
|
756
|
+
aria-label="Filter by tag"
|
|
757
|
+
value={filter?.tag || "all"}
|
|
758
|
+
onChange={(event) => setFilter({ tag: event.target.value })}
|
|
759
|
+
>
|
|
760
|
+
{tags.map((tag) => <option key={tag} value={tag}>{tag === "all" ? "All tags" : `#${tag}`}</option>)}
|
|
761
|
+
</select>
|
|
762
|
+
</div>
|
|
420
763
|
<div className="template-gallery-grid">
|
|
421
|
-
{
|
|
764
|
+
{filtered.length === 0 ? <p className="workspace-panel-hint">No templates match those filters.</p> : null}
|
|
765
|
+
{filtered.map((template) => {
|
|
422
766
|
const isPreviewing = previewTemplate?.id === template.id;
|
|
423
767
|
return <article
|
|
424
768
|
className={`template-card${isPreviewing ? " previewing" : ""}`}
|
|
@@ -441,8 +785,8 @@ function TemplateGallery({
|
|
|
441
785
|
</div> : null}
|
|
442
786
|
<div className="template-card-actions">
|
|
443
787
|
<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)}>
|
|
788
|
+
<button type="button" className="primary" onClick={() => onApplyToCurrentTab(template.id)}>Use Here</button>
|
|
789
|
+
<button type="button" onClick={() => onCloneAsDashboard(template.id)}>New Dashboard</button>
|
|
446
790
|
</div>
|
|
447
791
|
</article>;
|
|
448
792
|
})}
|
|
@@ -455,10 +799,631 @@ function TemplateGallery({
|
|
|
455
799
|
</div>;
|
|
456
800
|
}
|
|
457
801
|
|
|
458
|
-
function
|
|
459
|
-
|
|
802
|
+
function SubPanelHeader({ title, breadcrumb, onBack }) {
|
|
803
|
+
return <div className="workspace-widget-subpanel-header">
|
|
804
|
+
<button type="button" className="workspace-widget-subpanel-back" aria-label={`Back from ${title}`} onClick={onBack}>โน</button>
|
|
805
|
+
<div>
|
|
806
|
+
{breadcrumb ? <p>{breadcrumb}</p> : null}
|
|
807
|
+
<strong>{title}</strong>
|
|
808
|
+
</div>
|
|
809
|
+
</div>;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
813
|
+
const binding = widget.config?.binding || {};
|
|
814
|
+
const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
|
|
815
|
+
const [query, setQuery] = useState("");
|
|
816
|
+
const [laneFilter, setLaneFilter] = useState("all");
|
|
817
|
+
const groups = useMemo(() => {
|
|
818
|
+
const list = Array.isArray(integrations) ? integrations : [];
|
|
819
|
+
const filtered = list.filter((item) => {
|
|
820
|
+
if (laneFilter !== "all" && item.lane !== laneFilter) return false;
|
|
821
|
+
const text = `${item.label} ${item.provider} ${item.description}`.toLowerCase();
|
|
822
|
+
return !query.trim() || text.includes(query.trim().toLowerCase());
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
"data-source": filtered.filter((item) => item.lane === "data-source"),
|
|
826
|
+
"workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
|
|
827
|
+
};
|
|
828
|
+
}, [integrations, laneFilter, query]);
|
|
829
|
+
const selectStatic = useCallback(() => {
|
|
830
|
+
if (widget.kind === "chart") {
|
|
831
|
+
onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
|
|
832
|
+
} else {
|
|
833
|
+
onChange({
|
|
834
|
+
...widget.config,
|
|
835
|
+
source: widget.config?.source || "Companies",
|
|
836
|
+
binding: SAMPLE_DATA_BINDINGS.companiesManual
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}, [onChange, widget.config, widget.kind]);
|
|
840
|
+
const selectIntegration = useCallback((integration) => {
|
|
841
|
+
onChange({
|
|
842
|
+
...widget.config,
|
|
843
|
+
source: integration.label,
|
|
844
|
+
binding: {
|
|
845
|
+
mode: "integration",
|
|
846
|
+
source: integration.label,
|
|
847
|
+
integrationId: integration.id,
|
|
848
|
+
lane: integration.lane
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}, [onChange, widget.config]);
|
|
852
|
+
return <section className="workspace-widget-subpanel">
|
|
853
|
+
<SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
|
|
854
|
+
<div className="workspace-source-controls">
|
|
855
|
+
<label>
|
|
856
|
+
<Search size={14} aria-hidden="true" />
|
|
857
|
+
<input
|
|
858
|
+
aria-label="Search sources"
|
|
859
|
+
placeholder="Search sources"
|
|
860
|
+
value={query}
|
|
861
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
862
|
+
/>
|
|
863
|
+
</label>
|
|
864
|
+
<label>
|
|
865
|
+
<Database size={14} aria-hidden="true" />
|
|
866
|
+
<select
|
|
867
|
+
aria-label="Filter source type"
|
|
868
|
+
value={laneFilter}
|
|
869
|
+
onChange={(event) => setLaneFilter(event.target.value)}
|
|
870
|
+
>
|
|
871
|
+
<option value="all">All source types</option>
|
|
872
|
+
<option value="data-source">Data sources</option>
|
|
873
|
+
<option value="workspace-integration">Workspace tools</option>
|
|
874
|
+
</select>
|
|
875
|
+
<ChevronDown size={14} aria-hidden="true" />
|
|
876
|
+
</label>
|
|
877
|
+
</div>
|
|
878
|
+
<p className="workspace-panel-label">Static data</p>
|
|
879
|
+
<div className="workspace-source-list">
|
|
880
|
+
<button
|
|
881
|
+
type="button"
|
|
882
|
+
className={`workspace-source-row${currentMode !== "integration" ? " active" : ""}`}
|
|
883
|
+
onClick={selectStatic}
|
|
884
|
+
>
|
|
885
|
+
<span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
|
|
886
|
+
<span className="workspace-source-meta">
|
|
887
|
+
<strong>Static rows</strong>
|
|
888
|
+
<em>Inline data โ no external authority required.</em>
|
|
889
|
+
</span>
|
|
890
|
+
{currentMode !== "integration" ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
|
|
891
|
+
</button>
|
|
892
|
+
</div>
|
|
893
|
+
{Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
|
|
894
|
+
<p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
|
|
895
|
+
<div className="workspace-source-list">
|
|
896
|
+
{items.map((integration) => {
|
|
897
|
+
const isActive = currentMode === "integration" && binding.integrationId === integration.id;
|
|
898
|
+
const connected = integration.isConnected || integration.status === "connected";
|
|
899
|
+
return <button
|
|
900
|
+
key={integration.id}
|
|
901
|
+
type="button"
|
|
902
|
+
className={`workspace-source-row${isActive ? " active" : ""}`}
|
|
903
|
+
onClick={() => selectIntegration(integration)}
|
|
904
|
+
>
|
|
905
|
+
<span className="workspace-source-icon" aria-hidden="true">{integration.icon || integration.label?.[0] || "โข"}</span>
|
|
906
|
+
<span className="workspace-source-meta">
|
|
907
|
+
<strong>{integration.label}</strong>
|
|
908
|
+
<em>{describeIntegrationLane(integration)} ยท {connected ? "connected" : "needs connection"}</em>
|
|
909
|
+
</span>
|
|
910
|
+
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
|
|
911
|
+
</button>;
|
|
912
|
+
})}
|
|
913
|
+
</div>
|
|
914
|
+
</div> : null)}
|
|
915
|
+
<p className="workspace-panel-hint">
|
|
916
|
+
Selecting a source writes a binding reference only. The browser does not query integrations or store tokens.
|
|
917
|
+
</p>
|
|
918
|
+
</section>;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
922
|
+
const ordered = getOrderedColumns(widget);
|
|
923
|
+
const hidden = getHiddenColumnSet(widget);
|
|
924
|
+
const visible = ordered.filter((name) => !hidden.has(name));
|
|
925
|
+
const hiddenList = ordered.filter((name) => hidden.has(name));
|
|
926
|
+
const [hiddenOpen, setHiddenOpen] = useState(true);
|
|
927
|
+
const [draftField, setDraftField] = useState("");
|
|
928
|
+
const move = (fieldId, direction) => {
|
|
929
|
+
const next = reorderColumn(widget, fieldId, direction);
|
|
930
|
+
onChange({ ...widget.config, fieldSettings: next });
|
|
931
|
+
};
|
|
932
|
+
const toggle = (fieldId) => {
|
|
933
|
+
const next = toggleColumnHidden(widget, fieldId);
|
|
934
|
+
onChange({ ...widget.config, fieldSettings: next });
|
|
935
|
+
};
|
|
936
|
+
const removeColumn = (fieldId) => {
|
|
937
|
+
const nextColumns = ordered.filter((name) => name !== fieldId);
|
|
938
|
+
const fs = widget.config?.fieldSettings || {};
|
|
939
|
+
onChange({
|
|
940
|
+
...widget.config,
|
|
941
|
+
columns: nextColumns,
|
|
942
|
+
fieldSettings: {
|
|
943
|
+
hidden: (fs.hidden || []).filter((name) => name !== fieldId),
|
|
944
|
+
order: (fs.order || []).filter((name) => name !== fieldId)
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
const addColumn = () => {
|
|
949
|
+
const trimmed = draftField.trim();
|
|
950
|
+
if (!trimmed || ordered.includes(trimmed)) return;
|
|
951
|
+
onChange({ ...widget.config, columns: [...ordered, trimmed] });
|
|
952
|
+
setDraftField("");
|
|
953
|
+
};
|
|
954
|
+
return <section className="workspace-widget-subpanel">
|
|
955
|
+
<SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
|
|
956
|
+
<p className="workspace-panel-label">Visible fields</p>
|
|
957
|
+
<div className="workspace-field-rows">
|
|
958
|
+
{visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
|
|
959
|
+
{visible.map((name, index) => <div key={name} className="workspace-field-row">
|
|
960
|
+
<span className="workspace-field-row-handle" aria-hidden="true">::</span>
|
|
961
|
+
<span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
|
|
962
|
+
<span className="workspace-field-row-name">{name}</span>
|
|
963
|
+
<span className="workspace-field-row-actions">
|
|
964
|
+
<button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>โ</button>
|
|
965
|
+
<button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>โ</button>
|
|
966
|
+
<button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>๐</button>
|
|
967
|
+
<button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>โ</button>
|
|
968
|
+
</span>
|
|
969
|
+
</div>)}
|
|
970
|
+
</div>
|
|
971
|
+
<button
|
|
972
|
+
type="button"
|
|
973
|
+
className="workspace-hidden-fields-toggle"
|
|
974
|
+
onClick={() => setHiddenOpen((value) => !value)}
|
|
975
|
+
aria-expanded={hiddenOpen}
|
|
976
|
+
>
|
|
977
|
+
<span>๐โ๐จ Hidden Fields</span>
|
|
978
|
+
<span aria-hidden="true">{hiddenOpen ? "โ" : "+"}</span>
|
|
979
|
+
</button>
|
|
980
|
+
{hiddenOpen ? <div className="workspace-field-rows workspace-hidden-fields">
|
|
981
|
+
{hiddenList.length === 0 ? <p className="workspace-panel-hint">No hidden fields.</p> : null}
|
|
982
|
+
{hiddenList.map((name) => <div key={name} className="workspace-field-row workspace-field-row-hidden">
|
|
983
|
+
<span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
|
|
984
|
+
<span className="workspace-field-row-name">{name}</span>
|
|
985
|
+
<span className="workspace-field-row-actions">
|
|
986
|
+
<button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>๐</button>
|
|
987
|
+
<button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>โ</button>
|
|
988
|
+
</span>
|
|
989
|
+
</div>)}
|
|
990
|
+
</div> : null}
|
|
991
|
+
<div className="workspace-field-add">
|
|
992
|
+
<input
|
|
993
|
+
aria-label="New field name"
|
|
994
|
+
value={draftField}
|
|
995
|
+
placeholder="Add fieldโฆ"
|
|
996
|
+
onChange={(event) => setDraftField(event.target.value)}
|
|
997
|
+
onKeyDown={(event) => {
|
|
998
|
+
if (event.key === "Enter") {
|
|
999
|
+
event.preventDefault();
|
|
1000
|
+
addColumn();
|
|
1001
|
+
}
|
|
1002
|
+
}}
|
|
1003
|
+
/>
|
|
1004
|
+
<button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
|
|
1005
|
+
</div>
|
|
1006
|
+
</section>;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function SortSubPanel({ widget, onChange, onBack }) {
|
|
1010
|
+
const sort = getSortClauses(widget);
|
|
1011
|
+
const columns = getColumnList(widget);
|
|
1012
|
+
const updateSort = (next) => onChange({ ...widget.config, sort: next });
|
|
1013
|
+
const addClause = () => {
|
|
1014
|
+
const fieldId = columns[0] || "";
|
|
1015
|
+
if (!fieldId) return;
|
|
1016
|
+
updateSort([...sort, { fieldId, direction: DEFAULT_SORT_DIRECTION }]);
|
|
1017
|
+
};
|
|
1018
|
+
const updateClause = (index, patch) => {
|
|
1019
|
+
updateSort(sort.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause));
|
|
1020
|
+
};
|
|
1021
|
+
const removeClause = (index) => updateSort(sort.filter((_, idx) => idx !== index));
|
|
1022
|
+
return <section className="workspace-widget-subpanel">
|
|
1023
|
+
<SubPanelHeader title="Sorts" breadcrumb={widget.title} onBack={onBack} />
|
|
1024
|
+
<p className="workspace-panel-label">Sorts</p>
|
|
1025
|
+
<div className="workspace-sort-list">
|
|
1026
|
+
{sort.length === 0 ? <p className="workspace-panel-hint">No sorts applied.</p> : null}
|
|
1027
|
+
{sort.map((clause, index) => <div key={index} className="workspace-sort-row">
|
|
1028
|
+
<select
|
|
1029
|
+
aria-label={`Sort ${index + 1} field`}
|
|
1030
|
+
value={clause.fieldId}
|
|
1031
|
+
onChange={(event) => updateClause(index, { fieldId: event.target.value })}
|
|
1032
|
+
>
|
|
1033
|
+
{!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
|
|
1034
|
+
{columns.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
1035
|
+
</select>
|
|
1036
|
+
<select
|
|
1037
|
+
aria-label={`Sort ${index + 1} direction`}
|
|
1038
|
+
value={clause.direction || DEFAULT_SORT_DIRECTION}
|
|
1039
|
+
onChange={(event) => updateClause(index, { direction: event.target.value })}
|
|
1040
|
+
>
|
|
1041
|
+
{KNOWN_SORT_DIRECTIONS.map((dir) => <option key={dir} value={dir}>{dir === "asc" ? "Ascending" : "Descending"}</option>)}
|
|
1042
|
+
</select>
|
|
1043
|
+
<button type="button" aria-label={`Remove sort ${index + 1}`} onClick={() => removeClause(index)}>โ</button>
|
|
1044
|
+
</div>)}
|
|
1045
|
+
</div>
|
|
1046
|
+
<button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
|
|
1047
|
+
+ Add sort
|
|
1048
|
+
</button>
|
|
1049
|
+
<p className="workspace-panel-hint">
|
|
1050
|
+
Sort metadata persists with the widget. Live integrations are not queried from the browser.
|
|
1051
|
+
</p>
|
|
1052
|
+
</section>;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function FilterSubPanel({ widget, onChange, onBack }) {
|
|
1056
|
+
const filter = getFilterConfig(widget);
|
|
1057
|
+
const columns = getColumnList(widget);
|
|
1058
|
+
const setFilter = (next) => onChange({ ...widget.config, filter: next });
|
|
1059
|
+
const setOp = (op) => setFilter({ ...filter, op });
|
|
1060
|
+
const addClause = () => {
|
|
1061
|
+
const fieldId = columns[0] || "";
|
|
1062
|
+
if (!fieldId) return;
|
|
1063
|
+
setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: DEFAULT_FILTER_OPERATOR, value: "" }] });
|
|
1064
|
+
};
|
|
1065
|
+
const updateClause = (index, patch) => {
|
|
1066
|
+
setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
|
|
1067
|
+
};
|
|
1068
|
+
const removeClause = (index) => {
|
|
1069
|
+
setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
|
|
1070
|
+
};
|
|
1071
|
+
return <section className="workspace-widget-subpanel">
|
|
1072
|
+
<SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
|
|
1073
|
+
<div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
|
|
1074
|
+
{KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
|
|
1075
|
+
key={op}
|
|
1076
|
+
type="button"
|
|
1077
|
+
role="radio"
|
|
1078
|
+
aria-checked={filter.op === op}
|
|
1079
|
+
className={filter.op === op ? "active" : ""}
|
|
1080
|
+
onClick={() => setOp(op)}
|
|
1081
|
+
>{op.toUpperCase()}</button>)}
|
|
1082
|
+
</div>
|
|
1083
|
+
<div className="workspace-filter-list">
|
|
1084
|
+
{filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
|
|
1085
|
+
{filter.clauses.map((clause, index) => {
|
|
1086
|
+
const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
|
|
1087
|
+
return <div key={index} className="workspace-filter-clause">
|
|
1088
|
+
<select
|
|
1089
|
+
aria-label={`Filter ${index + 1} field`}
|
|
1090
|
+
value={clause.fieldId}
|
|
1091
|
+
onChange={(event) => updateClause(index, { fieldId: event.target.value })}
|
|
1092
|
+
>
|
|
1093
|
+
{!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
|
|
1094
|
+
{columns.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
1095
|
+
</select>
|
|
1096
|
+
<select
|
|
1097
|
+
aria-label={`Filter ${index + 1} operator`}
|
|
1098
|
+
value={clause.operator || DEFAULT_FILTER_OPERATOR}
|
|
1099
|
+
onChange={(event) => updateClause(index, { operator: event.target.value })}
|
|
1100
|
+
>
|
|
1101
|
+
{KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
|
|
1102
|
+
</select>
|
|
1103
|
+
{!valueless ? <input
|
|
1104
|
+
aria-label={`Filter ${index + 1} value`}
|
|
1105
|
+
value={clause.value ?? ""}
|
|
1106
|
+
placeholder="value"
|
|
1107
|
+
onChange={(event) => updateClause(index, { value: event.target.value })}
|
|
1108
|
+
/> : <span className="workspace-filter-clause-empty">โ</span>}
|
|
1109
|
+
<button type="button" aria-label={`Remove filter ${index + 1}`} onClick={() => removeClause(index)}>โ</button>
|
|
1110
|
+
</div>;
|
|
1111
|
+
})}
|
|
1112
|
+
</div>
|
|
1113
|
+
<button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
|
|
1114
|
+
+ Add filter
|
|
1115
|
+
</button>
|
|
1116
|
+
<p className="workspace-panel-hint">
|
|
1117
|
+
Filter metadata persists with the widget. Live integration queries stay in the CLI / hosted layers.
|
|
1118
|
+
</p>
|
|
1119
|
+
</section>;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function ChartConfigPanel({ widget, onChange, onSubPage }) {
|
|
1123
|
+
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
1124
|
+
const xAxis = getChartAxis(widget, "xAxis");
|
|
1125
|
+
const yAxis = getChartAxis(widget, "yAxis");
|
|
1126
|
+
const style = getChartStyle(widget);
|
|
1127
|
+
const setChartType = (type) => onChange({ ...widget.config, chartType: type });
|
|
1128
|
+
const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
|
|
1129
|
+
const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
|
|
1130
|
+
const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
|
|
1131
|
+
return <section className="workspace-chart-config">
|
|
1132
|
+
<p className="workspace-panel-label">Chart type</p>
|
|
1133
|
+
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
1134
|
+
{VISIBLE_CHART_TYPES.map((type) => {
|
|
1135
|
+
const TypeIcon = CHART_TYPE_ICONS[type];
|
|
1136
|
+
return <button
|
|
1137
|
+
key={type}
|
|
1138
|
+
type="button"
|
|
1139
|
+
role="tab"
|
|
1140
|
+
aria-selected={chartType === type}
|
|
1141
|
+
className={chartType === type ? "active" : ""}
|
|
1142
|
+
onClick={() => setChartType(type)}
|
|
1143
|
+
title={CHART_TYPE_LABELS[type]}
|
|
1144
|
+
>
|
|
1145
|
+
<IconGlyph icon={TypeIcon} size={17} />
|
|
1146
|
+
<em>{CHART_TYPE_LABELS[type]}</em>
|
|
1147
|
+
</button>;
|
|
1148
|
+
})}
|
|
1149
|
+
</div>
|
|
1150
|
+
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
|
|
1151
|
+
<span>Source</span><code>{summarizeSource(widget)}</code>
|
|
1152
|
+
</button>
|
|
1153
|
+
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
|
|
1154
|
+
<span>Filter</span><code>{summarizeFilter(widget)}</code>
|
|
1155
|
+
</button>
|
|
1156
|
+
<p className="workspace-panel-label">X axis</p>
|
|
1157
|
+
<label>
|
|
1158
|
+
<span>Data on display</span>
|
|
1159
|
+
<input
|
|
1160
|
+
value={xAxis.field || ""}
|
|
1161
|
+
placeholder="Stage"
|
|
1162
|
+
onChange={(event) => setXAxis({ field: event.target.value })}
|
|
1163
|
+
/>
|
|
1164
|
+
</label>
|
|
1165
|
+
<label>
|
|
1166
|
+
<span>Sort by</span>
|
|
1167
|
+
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
1168
|
+
<option value="position">Stage position ascending</option>
|
|
1169
|
+
<option value="asc">Value ascending</option>
|
|
1170
|
+
<option value="desc">Value descending</option>
|
|
1171
|
+
</select>
|
|
1172
|
+
</label>
|
|
1173
|
+
<label className="workspace-toggle-row">
|
|
1174
|
+
<span>Omit zero values</span>
|
|
1175
|
+
<input
|
|
1176
|
+
type="checkbox"
|
|
1177
|
+
checked={Boolean(xAxis.omitZero)}
|
|
1178
|
+
onChange={(event) => setXAxis({ omitZero: event.target.checked })}
|
|
1179
|
+
/>
|
|
1180
|
+
</label>
|
|
1181
|
+
<p className="workspace-panel-label">Y axis</p>
|
|
1182
|
+
<label>
|
|
1183
|
+
<span>Aggregation</span>
|
|
1184
|
+
<select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
|
|
1185
|
+
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
1186
|
+
</select>
|
|
1187
|
+
</label>
|
|
1188
|
+
<label>
|
|
1189
|
+
<span>Data on display</span>
|
|
1190
|
+
<input
|
|
1191
|
+
value={yAxis.field || ""}
|
|
1192
|
+
placeholder="Amount"
|
|
1193
|
+
onChange={(event) => setYAxis({ field: event.target.value })}
|
|
1194
|
+
/>
|
|
1195
|
+
</label>
|
|
1196
|
+
<label>
|
|
1197
|
+
<span>Group by</span>
|
|
1198
|
+
<input
|
|
1199
|
+
value={yAxis.groupBy || ""}
|
|
1200
|
+
placeholder="โ"
|
|
1201
|
+
onChange={(event) => setYAxis({ groupBy: event.target.value })}
|
|
1202
|
+
/>
|
|
1203
|
+
</label>
|
|
1204
|
+
<div className="workspace-axis-range">
|
|
1205
|
+
<label>
|
|
1206
|
+
<span>Min range</span>
|
|
1207
|
+
<input
|
|
1208
|
+
value={yAxis.min ?? ""}
|
|
1209
|
+
placeholder="Min"
|
|
1210
|
+
onChange={(event) => setYAxis({ min: event.target.value })}
|
|
1211
|
+
/>
|
|
1212
|
+
</label>
|
|
1213
|
+
<label>
|
|
1214
|
+
<span>Max range</span>
|
|
1215
|
+
<input
|
|
1216
|
+
value={yAxis.max ?? ""}
|
|
1217
|
+
placeholder="Max"
|
|
1218
|
+
onChange={(event) => setYAxis({ max: event.target.value })}
|
|
1219
|
+
/>
|
|
1220
|
+
</label>
|
|
1221
|
+
</div>
|
|
1222
|
+
<p className="workspace-panel-label">Style</p>
|
|
1223
|
+
<label>
|
|
1224
|
+
<span>Colors</span>
|
|
1225
|
+
<select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
|
|
1226
|
+
<option value="auto">Auto</option>
|
|
1227
|
+
<option value="accent">Accent</option>
|
|
1228
|
+
<option value="manual">Manual</option>
|
|
1229
|
+
</select>
|
|
1230
|
+
</label>
|
|
1231
|
+
{style.colors === "manual" ? <div className="workspace-color-picker-row">
|
|
1232
|
+
<label>
|
|
1233
|
+
<span>Manual color</span>
|
|
1234
|
+
<input
|
|
1235
|
+
type="color"
|
|
1236
|
+
value={style.manualColor || "#38bdf8"}
|
|
1237
|
+
onChange={(event) => setStyle({ manualColor: event.target.value })}
|
|
1238
|
+
/>
|
|
1239
|
+
</label>
|
|
1240
|
+
<input
|
|
1241
|
+
aria-label="Manual color hex"
|
|
1242
|
+
value={style.manualColor || "#38bdf8"}
|
|
1243
|
+
onChange={(event) => setStyle({ manualColor: event.target.value })}
|
|
1244
|
+
/>
|
|
1245
|
+
</div> : null}
|
|
1246
|
+
<label>
|
|
1247
|
+
<span>Axis name</span>
|
|
1248
|
+
<input
|
|
1249
|
+
value={style.axisName || ""}
|
|
1250
|
+
placeholder="None"
|
|
1251
|
+
onChange={(event) => setStyle({ axisName: event.target.value })}
|
|
1252
|
+
/>
|
|
1253
|
+
</label>
|
|
1254
|
+
<label className="workspace-toggle-row">
|
|
1255
|
+
<span>Data labels</span>
|
|
1256
|
+
<input
|
|
1257
|
+
type="checkbox"
|
|
1258
|
+
checked={Boolean(style.dataLabels)}
|
|
1259
|
+
onChange={(event) => setStyle({ dataLabels: event.target.checked })}
|
|
1260
|
+
/>
|
|
1261
|
+
</label>
|
|
1262
|
+
</section>;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function CommandPalette({ commands, onClose }) {
|
|
1266
|
+
const [query, setQuery] = useState("");
|
|
1267
|
+
const inputRef = useRef(null);
|
|
1268
|
+
const [highlight, setHighlight] = useState(0);
|
|
1269
|
+
useEffect(() => {
|
|
1270
|
+
inputRef.current?.focus();
|
|
1271
|
+
}, []);
|
|
1272
|
+
const filtered = useMemo(() => {
|
|
1273
|
+
const trimmed = query.trim().toLowerCase();
|
|
1274
|
+
if (!trimmed) return commands;
|
|
1275
|
+
return commands.filter((command) => {
|
|
1276
|
+
const haystack = `${command.label} ${command.group || ""} ${(command.aliases || []).join(" ")}`.toLowerCase();
|
|
1277
|
+
return haystack.includes(trimmed);
|
|
1278
|
+
});
|
|
1279
|
+
}, [commands, query]);
|
|
1280
|
+
useEffect(() => {
|
|
1281
|
+
setHighlight((value) => Math.min(value, Math.max(0, filtered.length - 1)));
|
|
1282
|
+
}, [filtered.length]);
|
|
1283
|
+
const handleKeyDown = (event) => {
|
|
1284
|
+
if (event.key === "ArrowDown") {
|
|
1285
|
+
event.preventDefault();
|
|
1286
|
+
setHighlight((value) => Math.min(filtered.length - 1, value + 1));
|
|
1287
|
+
} else if (event.key === "ArrowUp") {
|
|
1288
|
+
event.preventDefault();
|
|
1289
|
+
setHighlight((value) => Math.max(0, value - 1));
|
|
1290
|
+
} else if (event.key === "Enter") {
|
|
1291
|
+
event.preventDefault();
|
|
1292
|
+
const command = filtered[highlight];
|
|
1293
|
+
if (command && !command.disabled) {
|
|
1294
|
+
command.run();
|
|
1295
|
+
onClose();
|
|
1296
|
+
}
|
|
1297
|
+
} else if (event.key === "Escape") {
|
|
1298
|
+
event.preventDefault();
|
|
1299
|
+
onClose();
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
const groups = useMemo(() => {
|
|
1303
|
+
const map = new Map();
|
|
1304
|
+
filtered.forEach((command) => {
|
|
1305
|
+
const key = command.group || "General";
|
|
1306
|
+
if (!map.has(key)) map.set(key, []);
|
|
1307
|
+
map.get(key).push(command);
|
|
1308
|
+
});
|
|
1309
|
+
return Array.from(map.entries());
|
|
1310
|
+
}, [filtered]);
|
|
1311
|
+
return <div className="workspace-command-palette" role="dialog" aria-modal="true" aria-label="Command palette">
|
|
1312
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
1313
|
+
<section className="workspace-command-palette-panel" onKeyDown={handleKeyDown}>
|
|
1314
|
+
<header className="workspace-command-palette-input">
|
|
1315
|
+
<span aria-hidden="true">โ</span>
|
|
1316
|
+
<input
|
|
1317
|
+
ref={inputRef}
|
|
1318
|
+
value={query}
|
|
1319
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
1320
|
+
placeholder="Type a commandโฆ"
|
|
1321
|
+
aria-label="Command palette search"
|
|
1322
|
+
/>
|
|
1323
|
+
<kbd>esc</kbd>
|
|
1324
|
+
</header>
|
|
1325
|
+
<div className="workspace-command-palette-list" role="listbox">
|
|
1326
|
+
{filtered.length === 0 ? <p className="workspace-panel-hint">No matching commands.</p> : null}
|
|
1327
|
+
{groups.map(([group, items]) => <div key={group} className="workspace-command-palette-group">
|
|
1328
|
+
<p className="workspace-panel-label">{group}</p>
|
|
1329
|
+
{items.map((command) => {
|
|
1330
|
+
const globalIndex = filtered.indexOf(command);
|
|
1331
|
+
const isHighlighted = globalIndex === highlight;
|
|
1332
|
+
return <button
|
|
1333
|
+
key={command.id}
|
|
1334
|
+
type="button"
|
|
1335
|
+
role="option"
|
|
1336
|
+
aria-selected={isHighlighted}
|
|
1337
|
+
className={`workspace-command-palette-item${isHighlighted ? " active" : ""}${command.disabled ? " disabled" : ""}`}
|
|
1338
|
+
disabled={command.disabled}
|
|
1339
|
+
onMouseEnter={() => setHighlight(globalIndex)}
|
|
1340
|
+
onClick={() => {
|
|
1341
|
+
if (command.disabled) return;
|
|
1342
|
+
command.run();
|
|
1343
|
+
onClose();
|
|
1344
|
+
}}
|
|
1345
|
+
>
|
|
1346
|
+
<span aria-hidden="true">{typeof command.icon === "string" ? command.icon : <IconGlyph icon={command.icon} size={15} />}</span>
|
|
1347
|
+
<span className="workspace-command-palette-label">{command.label}</span>
|
|
1348
|
+
{command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
|
|
1349
|
+
</button>;
|
|
1350
|
+
})}
|
|
1351
|
+
</div>)}
|
|
1352
|
+
</div>
|
|
1353
|
+
<footer className="workspace-command-palette-footer">
|
|
1354
|
+
<span>โ โ navigate</span>
|
|
1355
|
+
<span>โต run</span>
|
|
1356
|
+
<span>esc close</span>
|
|
1357
|
+
</footer>
|
|
1358
|
+
</section>
|
|
1359
|
+
</div>;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function RichTextEditor({ value, onChange }) {
|
|
1363
|
+
const textareaRef = useRef(null);
|
|
1364
|
+
const insert = useCallback((prefix, suffix = "", placeholder = "text") => {
|
|
1365
|
+
const textarea = textareaRef.current;
|
|
1366
|
+
const current = value || "";
|
|
1367
|
+
const start = textarea?.selectionStart ?? current.length;
|
|
1368
|
+
const end = textarea?.selectionEnd ?? current.length;
|
|
1369
|
+
const selected = current.slice(start, end) || placeholder;
|
|
1370
|
+
const next = `${current.slice(0, start)}${prefix}${selected}${suffix}${current.slice(end)}`;
|
|
1371
|
+
onChange(next);
|
|
1372
|
+
requestAnimationFrame(() => {
|
|
1373
|
+
textarea?.focus();
|
|
1374
|
+
const cursor = start + prefix.length + selected.length + suffix.length;
|
|
1375
|
+
textarea?.setSelectionRange(cursor, cursor);
|
|
1376
|
+
});
|
|
1377
|
+
}, [onChange, value]);
|
|
1378
|
+
return <div className="workspace-rich-text-editor">
|
|
1379
|
+
<div className="workspace-rich-text-toolbar" role="toolbar" aria-label="Rich text controls">
|
|
1380
|
+
<button type="button" aria-label="Heading" onClick={() => insert("## ", "", "Heading")}><Type size={14} /></button>
|
|
1381
|
+
<button type="button" aria-label="Bold" onClick={() => insert("**", "**")}><strong>B</strong></button>
|
|
1382
|
+
<button type="button" aria-label="Italic" onClick={() => insert("*", "*")}><Italic size={14} /></button>
|
|
1383
|
+
<button type="button" aria-label="Quote" onClick={() => insert("> ", "", "Quote")}><Quote size={14} /></button>
|
|
1384
|
+
<button type="button" aria-label="Bullet list" onClick={() => insert("- ", "", "List item")}><List size={14} /></button>
|
|
1385
|
+
<button type="button" aria-label="Link" onClick={() => insert("[", "](https://)", "Link")}><LinkIcon size={14} /></button>
|
|
1386
|
+
</div>
|
|
1387
|
+
<textarea
|
|
1388
|
+
ref={textareaRef}
|
|
1389
|
+
placeholder="Write text..."
|
|
1390
|
+
value={value || ""}
|
|
1391
|
+
onChange={(event) => onChange(event.target.value)}
|
|
1392
|
+
/>
|
|
1393
|
+
</div>;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function IframePreviewModal({ widget, onClose }) {
|
|
1397
|
+
const url = widget?.config?.url || "";
|
|
1398
|
+
const valid = isLikelyHttpUrl(url);
|
|
1399
|
+
return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label={`${widget?.title || "iFrame"} expanded preview`}>
|
|
1400
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
1401
|
+
<section className="workspace-iframe-modal">
|
|
1402
|
+
<header>
|
|
1403
|
+
<div>
|
|
1404
|
+
<p>iFrame preview</p>
|
|
1405
|
+
<h2>{widget?.title || "Untitled iFrame"}</h2>
|
|
1406
|
+
</div>
|
|
1407
|
+
<div>
|
|
1408
|
+
{valid ? <a href={url} target="_blank" rel="noreferrer"><ExternalLink size={15} /> Open</a> : null}
|
|
1409
|
+
<button type="button" aria-label="Close iFrame preview" onClick={onClose}><X size={16} /></button>
|
|
1410
|
+
</div>
|
|
1411
|
+
</header>
|
|
1412
|
+
{valid ? <iframe title={widget?.title || "iFrame preview"} src={url} /> : <div className="workspace-iframe-invalid">Enter a valid http(s) URL to preview this iFrame.</div>}
|
|
1413
|
+
</section>
|
|
1414
|
+
</div>;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
|
|
1418
|
+
const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
|
|
1419
|
+
const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
|
|
1420
|
+
const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
|
|
460
1421
|
const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
|
|
461
1422
|
const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
|
|
1423
|
+
const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
|
|
1424
|
+
const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
|
|
1425
|
+
const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
|
|
1426
|
+
const chartColor = chartStyle.colors === "manual" && chartStyle.manualColor ? chartStyle.manualColor : undefined;
|
|
462
1427
|
return <article
|
|
463
1428
|
className={`workspace-widget-preview${selected ? " selected" : ""}`}
|
|
464
1429
|
onClick={onSelect}
|
|
@@ -468,7 +1433,11 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
468
1433
|
}}
|
|
469
1434
|
>
|
|
470
1435
|
<div className="workspace-widget-preview-title">
|
|
471
|
-
<span
|
|
1436
|
+
<span
|
|
1437
|
+
aria-hidden="true"
|
|
1438
|
+
className="workspace-widget-drag-handle"
|
|
1439
|
+
onPointerDown={(event) => onMoveStart(event)}
|
|
1440
|
+
>::</span>
|
|
472
1441
|
<strong>{widget.title}</strong>
|
|
473
1442
|
<button
|
|
474
1443
|
aria-label={`Remove ${widget.title}`}
|
|
@@ -477,7 +1446,7 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
477
1446
|
onRemove();
|
|
478
1447
|
}}
|
|
479
1448
|
type="button"
|
|
480
|
-
|
|
1449
|
+
><X size={13} /></button>
|
|
481
1450
|
</div>
|
|
482
1451
|
{widget.kind === "view" ? <div
|
|
483
1452
|
className="workspace-view-table"
|
|
@@ -491,11 +1460,23 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
491
1460
|
<footer>Calculate</footer>
|
|
492
1461
|
</div> : null}
|
|
493
1462
|
{widget.kind === "iframe" ? <div className="workspace-iframe-preview">
|
|
494
|
-
{widget.config?.url ? <
|
|
1463
|
+
{isLikelyHttpUrl(widget.config?.url) ? <iframe title={`${widget.title} preview`} src={widget.config.url} /> : <span>Enter a valid http(s) URL</span>}
|
|
1464
|
+
<button type="button" onClick={(event) => {
|
|
1465
|
+
event.stopPropagation();
|
|
1466
|
+
onExpandIframe(widget);
|
|
1467
|
+
}}><Maximize2 size={14} /> Expand</button>
|
|
495
1468
|
</div> : null}
|
|
496
|
-
{widget.kind === "rich-text" ? <
|
|
497
|
-
{widget.kind === "chart" ? <div className=
|
|
498
|
-
{chartValues.
|
|
1469
|
+
{widget.kind === "rich-text" ? <div className="workspace-rich-text-preview" dangerouslySetInnerHTML={{ __html: richTextToHtml(widget.config?.text) }} /> : null}
|
|
1470
|
+
{widget.kind === "chart" ? <div className={`workspace-chart-preview kind-${chartType}`} data-data-labels={dataLabels ? "true" : "false"} style={chartColor ? { "--chart-accent": chartColor } : undefined}>
|
|
1471
|
+
{chartType === "sum" ? <strong className="workspace-chart-sum">{chartValues.reduce((acc, v) => acc + v, 0)}</strong> : null}
|
|
1472
|
+
{chartType === "gauge" ? <span className="workspace-chart-gauge" style={{ "--gauge-fill": `${Math.min(100, chartValues[chartValues.length - 1] || 0)}%` }} /> : null}
|
|
1473
|
+
{chartType === "pie" ? <span className="workspace-chart-pie" aria-hidden="true" /> : null}
|
|
1474
|
+
{chartType === "bar-horizontal" ? chartValues.map((height, index) => <span key={index} className="workspace-chart-bar-h" style={{ width: `${Math.max(5, Math.min(100, height))}%` }}>
|
|
1475
|
+
{dataLabels ? <em>{height}</em> : null}
|
|
1476
|
+
</span>) : null}
|
|
1477
|
+
{chartType === "bar-vertical" || !chartType ? chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }}>
|
|
1478
|
+
{dataLabels ? <em>{height}</em> : null}
|
|
1479
|
+
</span>) : null}
|
|
499
1480
|
</div> : null}
|
|
500
1481
|
{selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
|
|
501
1482
|
aria-label={`Resize ${widget.title} from ${corner} corner`}
|
|
@@ -507,7 +1488,170 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
|
|
|
507
1488
|
</article>;
|
|
508
1489
|
}
|
|
509
1490
|
|
|
510
|
-
|
|
1491
|
+
const DEFAULT_PERSISTENCE = {
|
|
1492
|
+
mode: "filesystem",
|
|
1493
|
+
adapter: "filesystem",
|
|
1494
|
+
canSave: true,
|
|
1495
|
+
saveLabel: "Save writes growthub.config.json on disk.",
|
|
1496
|
+
reason: "Local development",
|
|
1497
|
+
nextAction: null,
|
|
1498
|
+
guidance: null
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
function countCanvasWidgets(canvas) {
|
|
1502
|
+
if (!canvas) return 0;
|
|
1503
|
+
if (Array.isArray(canvas.tabs) && canvas.tabs.length) {
|
|
1504
|
+
return canvas.tabs.reduce((acc, tab) => acc + (Array.isArray(tab.widgets) ? tab.widgets.length : 0), 0);
|
|
1505
|
+
}
|
|
1506
|
+
return Array.isArray(canvas.widgets) ? canvas.widgets.length : 0;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function countCanvasTabs(canvas) {
|
|
1510
|
+
if (!canvas) return 0;
|
|
1511
|
+
if (Array.isArray(canvas.tabs) && canvas.tabs.length) return canvas.tabs.length;
|
|
1512
|
+
return 1;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integrationAdapter, onClose }) {
|
|
1516
|
+
const branding = (config && config.branding) || {};
|
|
1517
|
+
const dashboards = Array.isArray(config?.dashboards) ? config.dashboards : [];
|
|
1518
|
+
const tabCount = countCanvasTabs(config?.canvas);
|
|
1519
|
+
const widgetCount = countCanvasWidgets(config?.canvas);
|
|
1520
|
+
const persist = persistence || DEFAULT_PERSISTENCE;
|
|
1521
|
+
return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace settings">
|
|
1522
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
1523
|
+
<section className="workspace-overlay-panel">
|
|
1524
|
+
<header className="workspace-overlay-header">
|
|
1525
|
+
<div>
|
|
1526
|
+
<p>Workspace</p>
|
|
1527
|
+
<h2>Workspace Settings</h2>
|
|
1528
|
+
</div>
|
|
1529
|
+
<button type="button" aria-label="Close workspace settings" onClick={onClose} autoFocus>x</button>
|
|
1530
|
+
</header>
|
|
1531
|
+
<p className="workspace-overlay-note">
|
|
1532
|
+
Inspect-only. Sourced from <code>growthub.config.json</code> + <code>GET /api/workspace</code>.
|
|
1533
|
+
Edit branding by updating <code>growthub.config.json</code> inside your governed fork.
|
|
1534
|
+
The builder itself never holds tokens, never executes hosted workflows, and never bypasses the PATCH allowlist
|
|
1535
|
+
(<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>).
|
|
1536
|
+
</p>
|
|
1537
|
+
<div className="workspace-readiness">
|
|
1538
|
+
<article className="workspace-readiness-section">
|
|
1539
|
+
<h3>Identity</h3>
|
|
1540
|
+
<div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
|
|
1541
|
+
<div className="workspace-readiness-row"><span>Workspace ID</span><code>{config?.id || "Unknown"}</code></div>
|
|
1542
|
+
<div className="workspace-readiness-row"><span>Brand name</span><strong>{branding.name || "Unknown"}</strong></div>
|
|
1543
|
+
<div className="workspace-readiness-row"><span>Logo URL</span><code>{branding.logoUrl || "โ"}</code></div>
|
|
1544
|
+
<div className="workspace-readiness-row"><span>Accent</span>
|
|
1545
|
+
<span className="workspace-readiness-badge" style={{ background: branding.accent || "#3f68ff" }}>{branding.accent || "โ"}</span>
|
|
1546
|
+
</div>
|
|
1547
|
+
</article>
|
|
1548
|
+
<article className="workspace-readiness-section">
|
|
1549
|
+
<h3>Persistence</h3>
|
|
1550
|
+
<div className="workspace-readiness-row"><span>Mode</span>
|
|
1551
|
+
<span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
|
|
1552
|
+
</div>
|
|
1553
|
+
<div className="workspace-readiness-row"><span>Adapter</span><code>{persist.adapter}</code></div>
|
|
1554
|
+
<div className="workspace-readiness-row"><span>Can save</span>
|
|
1555
|
+
<span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
|
|
1556
|
+
</div>
|
|
1557
|
+
<div className="workspace-readiness-row"><span>Save behavior</span><strong>{persist.saveLabel}</strong></div>
|
|
1558
|
+
<div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
|
|
1559
|
+
{persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
|
|
1560
|
+
{persist.nextAction ? <div className="workspace-readiness-row"><span>Next action</span><em>{persist.nextAction}</em></div> : null}
|
|
1561
|
+
</article>
|
|
1562
|
+
<article className="workspace-readiness-section">
|
|
1563
|
+
<h3>Integrations</h3>
|
|
1564
|
+
<div className="workspace-readiness-row"><span>Integration adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
|
|
1565
|
+
<div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
|
|
1566
|
+
<div className="workspace-readiness-row"><span>Bridge</span>
|
|
1567
|
+
<span className={`workspace-readiness-badge ${adapterConfig.growthubBridge?.hasAccessToken ? "good" : ""}`}>
|
|
1568
|
+
{adapterConfig.growthubBridge?.hasAccessToken ? "token configured" : "no token"}
|
|
1569
|
+
</span>
|
|
1570
|
+
</div>
|
|
1571
|
+
<div className="workspace-readiness-row"><span>Bridge base URL</span><code>{adapterConfig.growthubBridge?.baseUrl || "โ"}</code></div>
|
|
1572
|
+
<div className="workspace-readiness-row"><span>Authority</span><strong>{integrationAdapter.authority}</strong></div>
|
|
1573
|
+
</article>
|
|
1574
|
+
<article className="workspace-readiness-section">
|
|
1575
|
+
<h3>Counts</h3>
|
|
1576
|
+
<div className="workspace-readiness-row"><span>Dashboards</span><strong>{dashboards.length}</strong></div>
|
|
1577
|
+
<div className="workspace-readiness-row"><span>Tabs (active canvas)</span><strong>{tabCount}</strong></div>
|
|
1578
|
+
<div className="workspace-readiness-row"><span>Widgets (active canvas)</span><strong>{widgetCount}</strong></div>
|
|
1579
|
+
<div className="workspace-readiness-row"><span>Template format</span><code>growthub-workspace-template</code></div>
|
|
1580
|
+
</article>
|
|
1581
|
+
</div>
|
|
1582
|
+
</section>
|
|
1583
|
+
</div>;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose }) {
|
|
1587
|
+
const persist = persistence || DEFAULT_PERSISTENCE;
|
|
1588
|
+
const pipelines = Array.isArray(config?.pipelines) ? config.pipelines : [];
|
|
1589
|
+
const integrations = Array.isArray(config?.integrations) ? config.integrations : [];
|
|
1590
|
+
const capabilities = Array.isArray(config?.capabilities) ? config.capabilities : [];
|
|
1591
|
+
return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace management">
|
|
1592
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
1593
|
+
<section className="workspace-overlay-panel">
|
|
1594
|
+
<header className="workspace-overlay-header">
|
|
1595
|
+
<div>
|
|
1596
|
+
<p>Workspace</p>
|
|
1597
|
+
<h2>Management</h2>
|
|
1598
|
+
</div>
|
|
1599
|
+
<button type="button" aria-label="Close management panel" onClick={onClose} autoFocus>x</button>
|
|
1600
|
+
</header>
|
|
1601
|
+
<p className="workspace-overlay-note">
|
|
1602
|
+
Inspect-only. Workflow execution stays in <code>growthub workflow</code> / <code>growthub bridge</code>; this panel does not
|
|
1603
|
+
execute, does not call hosted endpoints, and does not expose tokens.
|
|
1604
|
+
</p>
|
|
1605
|
+
<div className="workspace-readiness">
|
|
1606
|
+
<article className="workspace-readiness-section">
|
|
1607
|
+
<h3>Workspace</h3>
|
|
1608
|
+
<div className="workspace-readiness-row"><span>ID</span><code>{config?.id || "Unknown"}</code></div>
|
|
1609
|
+
<div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
|
|
1610
|
+
<div className="workspace-readiness-row"><span>Capabilities</span>
|
|
1611
|
+
<span>{capabilities.length ? capabilities.join(", ") : "none"}</span>
|
|
1612
|
+
</div>
|
|
1613
|
+
</article>
|
|
1614
|
+
<article className="workspace-readiness-section">
|
|
1615
|
+
<h3>API</h3>
|
|
1616
|
+
<div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas</code></div>
|
|
1617
|
+
<div className="workspace-readiness-row"><span>Unknown field</span><code>400</code></div>
|
|
1618
|
+
<div className="workspace-readiness-row"><span>Read-only runtime</span><code>409 + guidance</code></div>
|
|
1619
|
+
<div className="workspace-readiness-row"><span>Can save now</span>
|
|
1620
|
+
<span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
|
|
1621
|
+
</div>
|
|
1622
|
+
</article>
|
|
1623
|
+
<article className="workspace-readiness-section">
|
|
1624
|
+
<h3>Workflows</h3>
|
|
1625
|
+
{pipelines.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
|
|
1626
|
+
<em>No workflows declared in growthub.config.json. Connect via <code>growthub workflow</code> after Bridge auth.</em>
|
|
1627
|
+
</div> : pipelines.map((pipeline, index) => <div className="workspace-readiness-row" key={pipeline.id || index}>
|
|
1628
|
+
<span>{pipeline.id || `pipeline-${index}`}</span><strong>{pipeline.name || "Untitled"}</strong>
|
|
1629
|
+
</div>)}
|
|
1630
|
+
</article>
|
|
1631
|
+
<article className="workspace-readiness-section">
|
|
1632
|
+
<h3>Integrations</h3>
|
|
1633
|
+
<div className="workspace-readiness-row"><span>Adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
|
|
1634
|
+
<div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
|
|
1635
|
+
{integrations.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
|
|
1636
|
+
<em>No static integrations declared. Use <code>growthub bridge agents bind</code> for hosted bindings.</em>
|
|
1637
|
+
</div> : integrations.map((integration, index) => <div className="workspace-readiness-row" key={integration.id || index}>
|
|
1638
|
+
<span>{integration.id || `integration-${index}`}</span><strong>{integration.name || "Untitled"}</strong>
|
|
1639
|
+
</div>)}
|
|
1640
|
+
</article>
|
|
1641
|
+
<article className="workspace-readiness-section">
|
|
1642
|
+
<h3>Persistence</h3>
|
|
1643
|
+
<div className="workspace-readiness-row"><span>Mode</span>
|
|
1644
|
+
<span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
|
|
1645
|
+
</div>
|
|
1646
|
+
<div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
|
|
1647
|
+
{persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
|
|
1648
|
+
</article>
|
|
1649
|
+
</div>
|
|
1650
|
+
</section>
|
|
1651
|
+
</div>;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
|
|
511
1655
|
const [config, setConfig] = useState(() => {
|
|
512
1656
|
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
513
1657
|
? initialConfig.dashboards.map((dashboard, index) =>
|
|
@@ -523,8 +1667,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
523
1667
|
const [saving, setSaving] = useState(false);
|
|
524
1668
|
const [panelOpen, setPanelOpen] = useState(true);
|
|
525
1669
|
const [templateGalleryOpen, setTemplateGalleryOpen] = useState(false);
|
|
1670
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
1671
|
+
const [managementOpen, setManagementOpen] = useState(false);
|
|
526
1672
|
const [previewTemplateId, setPreviewTemplateId] = useState(null);
|
|
527
1673
|
const [editingDashboardId, setEditingDashboardId] = useState(null);
|
|
1674
|
+
const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
|
|
1675
|
+
const [workspaceView, setWorkspaceView] = useState("dashboards");
|
|
528
1676
|
const [activeDashboardId, setActiveDashboardId] = useState(() =>
|
|
529
1677
|
getActiveDashboardId(
|
|
530
1678
|
Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
|
|
@@ -541,13 +1689,20 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
541
1689
|
const activeTabId = getActiveTabId(canvas);
|
|
542
1690
|
const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
|
|
543
1691
|
const activeWidgets = activeTab.widgets || [];
|
|
1692
|
+
const activeDashboard = dashboards[resolvedActiveDashboardIndex] || dashboards[0] || null;
|
|
544
1693
|
const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
|
|
545
1694
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null);
|
|
546
1695
|
const [dragStartCell, setDragStartCell] = useState(null);
|
|
547
1696
|
const [dragPreview, setDragPreview] = useState(null);
|
|
548
1697
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
1698
|
+
const [moveDrag, setMoveDrag] = useState(null);
|
|
549
1699
|
const [configMessage, setConfigMessage] = useState("");
|
|
1700
|
+
const [inspectorPath, setInspectorPath] = useState(SUB_PANEL_ROOT);
|
|
1701
|
+
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
1702
|
+
const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
|
|
1703
|
+
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
550
1704
|
const resizeDragRef = useRef(null);
|
|
1705
|
+
const moveDragRef = useRef(null);
|
|
551
1706
|
const importInputRef = useRef(null);
|
|
552
1707
|
const addSlot = dragPreview || selectedPosition;
|
|
553
1708
|
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
|
|
@@ -633,7 +1788,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
633
1788
|
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
634
1789
|
setDragPreview(null);
|
|
635
1790
|
setEditingDashboardId(dashboard.id);
|
|
1791
|
+
setEditingDashboardDraft(dashboard.name);
|
|
636
1792
|
setActiveDashboardId(dashboard.id);
|
|
1793
|
+
setWorkspaceView("builder");
|
|
637
1794
|
setConfigMessage(`Created ${name}`);
|
|
638
1795
|
return {
|
|
639
1796
|
...synced,
|
|
@@ -653,8 +1810,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
653
1810
|
setSelectedWidgetId(null);
|
|
654
1811
|
setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
|
|
655
1812
|
setDragPreview(null);
|
|
656
|
-
setEditingDashboardId(
|
|
1813
|
+
setEditingDashboardId(null);
|
|
1814
|
+
setEditingDashboardDraft("");
|
|
657
1815
|
setActiveDashboardId(dashboard.id);
|
|
1816
|
+
setWorkspaceView("builder");
|
|
658
1817
|
setConfigMessage(`Editing ${dashboard.name}`);
|
|
659
1818
|
return {
|
|
660
1819
|
...synced,
|
|
@@ -664,32 +1823,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
664
1823
|
});
|
|
665
1824
|
}, [activeDashboardId]);
|
|
666
1825
|
|
|
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]);
|
|
1826
|
+
const enterDashboardTitleEdit = useCallback((dashboard) => {
|
|
1827
|
+
if (!dashboard) return;
|
|
1828
|
+
setEditingDashboardId(dashboard.id);
|
|
1829
|
+
setEditingDashboardDraft(dashboard.name);
|
|
1830
|
+
setWorkspaceView("dashboards");
|
|
1831
|
+
}, []);
|
|
693
1832
|
|
|
694
1833
|
const updateDashboardStatus = useCallback((dashboardId, status) => {
|
|
695
1834
|
setConfig((prev) => ({
|
|
@@ -714,9 +1853,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
714
1853
|
name,
|
|
715
1854
|
updatedAt: "new",
|
|
716
1855
|
status: "draft",
|
|
717
|
-
tabs: normalizedSource.tabs.map((tab,
|
|
718
|
-
cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
|
|
719
|
-
)
|
|
1856
|
+
tabs: normalizedSource.tabs.map((tab) => cloneTabForDashboard(tab, tab.name || "Tab 1"))
|
|
720
1857
|
};
|
|
721
1858
|
dashboard.activeTabId = dashboard.tabs[0].id;
|
|
722
1859
|
setSelectedWidgetId(null);
|
|
@@ -724,6 +1861,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
724
1861
|
setDragPreview(null);
|
|
725
1862
|
setEditingDashboardId(dashboard.id);
|
|
726
1863
|
setActiveDashboardId(dashboard.id);
|
|
1864
|
+
setWorkspaceView("builder");
|
|
727
1865
|
setConfigMessage(`Cloned ${sourceDashboard.name}`);
|
|
728
1866
|
return {
|
|
729
1867
|
...synced,
|
|
@@ -840,7 +1978,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
840
1978
|
const nextCanvas = commitTabs(prev.canvas, nextTabs, prevActiveId);
|
|
841
1979
|
const nextDashboards = (prev.dashboards || []).map((dashboard, index) =>
|
|
842
1980
|
index === dashboardIndex
|
|
843
|
-
? updateDashboardCanvas({ ...dashboard,
|
|
1981
|
+
? updateDashboardCanvas({ ...dashboard, updatedAt: "new", status: "draft" }, nextCanvas)
|
|
844
1982
|
: dashboard
|
|
845
1983
|
);
|
|
846
1984
|
return {
|
|
@@ -937,14 +2075,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
937
2075
|
}
|
|
938
2076
|
}, []);
|
|
939
2077
|
|
|
940
|
-
const
|
|
2078
|
+
const persistWorkspaceConfig = useCallback(async (nextConfig, nextActiveDashboardId = activeDashboardId) => {
|
|
941
2079
|
if (saving) return;
|
|
942
2080
|
setSaving(true);
|
|
943
2081
|
try {
|
|
944
2082
|
const stamp = todayIsoDate();
|
|
945
|
-
const syncedConfig = syncActiveDashboard(
|
|
2083
|
+
const syncedConfig = syncActiveDashboard(nextConfig, nextActiveDashboardId);
|
|
946
2084
|
const updatedDashboards = (syncedConfig.dashboards || []).map((dashboard) =>
|
|
947
|
-
dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [],
|
|
2085
|
+
dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], nextActiveDashboardId)
|
|
948
2086
|
? { ...dashboard, updatedAt: stamp }
|
|
949
2087
|
: dashboard
|
|
950
2088
|
);
|
|
@@ -962,13 +2100,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
962
2100
|
const savedDashboards = (payload.workspaceConfig.dashboards || []).map((dashboard, index) =>
|
|
963
2101
|
normalizeDashboard(dashboard, index === 0 ? payload.workspaceConfig.canvas : undefined)
|
|
964
2102
|
);
|
|
965
|
-
const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id ===
|
|
2103
|
+
const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === nextActiveDashboardId) || savedDashboards[0];
|
|
966
2104
|
setConfig({
|
|
967
2105
|
...payload.workspaceConfig,
|
|
968
2106
|
dashboards: savedDashboards,
|
|
969
2107
|
canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
|
|
970
2108
|
});
|
|
971
|
-
setConfigMessage("Saved dashboard config");
|
|
972
2109
|
} else {
|
|
973
2110
|
setConfigMessage(payload.error || "Save failed");
|
|
974
2111
|
}
|
|
@@ -979,7 +2116,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
979
2116
|
}
|
|
980
2117
|
}, [activeDashboardId, saving, config]);
|
|
981
2118
|
|
|
982
|
-
const
|
|
2119
|
+
const save = useCallback(async () => {
|
|
2120
|
+
await persistWorkspaceConfig(config, activeDashboardId);
|
|
2121
|
+
}, [activeDashboardId, config, persistWorkspaceConfig]);
|
|
2122
|
+
|
|
2123
|
+
const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
|
|
2124
|
+
const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
|
|
2125
|
+
setEditingDashboardId(null);
|
|
2126
|
+
setEditingDashboardDraft("");
|
|
2127
|
+
setConfig(nextConfig);
|
|
2128
|
+
await persistWorkspaceConfig(nextConfig, activeDashboardId);
|
|
2129
|
+
}, [activeDashboardId, config, editingDashboardDraft, persistWorkspaceConfig]);
|
|
2130
|
+
|
|
2131
|
+
const cancelDashboardTitleEdit = useCallback((dashboard) => {
|
|
2132
|
+
if (!dashboard) return;
|
|
2133
|
+
if (editingDashboardDraft.trim() !== dashboard.name) {
|
|
2134
|
+
const discard = window.confirm("Discard dashboard title changes?");
|
|
2135
|
+
if (!discard) {
|
|
2136
|
+
requestAnimationFrame(() => {
|
|
2137
|
+
document.querySelector(`[data-dashboard-title-input="${dashboard.id}"]`)?.focus();
|
|
2138
|
+
});
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
setEditingDashboardId(null);
|
|
2143
|
+
setEditingDashboardDraft("");
|
|
2144
|
+
}, [editingDashboardDraft]);
|
|
2145
|
+
|
|
983
2146
|
const closePanel = useCallback(() => setPanelOpen(false), []);
|
|
984
2147
|
const beginCellDrag = useCallback((index, event) => {
|
|
985
2148
|
const x = index % GRID_COLUMNS;
|
|
@@ -1052,10 +2215,77 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1052
2215
|
resizeDragRef.current = null;
|
|
1053
2216
|
setResizeDrag(null);
|
|
1054
2217
|
}, []);
|
|
2218
|
+
const beginMoveDrag = useCallback((widget, event) => {
|
|
2219
|
+
event.preventDefault();
|
|
2220
|
+
event.stopPropagation();
|
|
2221
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
2222
|
+
const pointerIndex = cellIndexFromGridPointer(event, gridRef.current);
|
|
2223
|
+
const pointerCell = pointerIndex === null ? { x: widget.position.x, y: widget.position.y } : cellPointFromIndex(pointerIndex);
|
|
2224
|
+
const nextMoveDrag = {
|
|
2225
|
+
widgetId: widget.id,
|
|
2226
|
+
offsetX: Math.max(0, Math.min(widget.position.w - 1, pointerCell.x - widget.position.x)),
|
|
2227
|
+
offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
|
|
2228
|
+
};
|
|
2229
|
+
setSelectedWidgetId(widget.id);
|
|
2230
|
+
setPanelOpen(true);
|
|
2231
|
+
moveDragRef.current = nextMoveDrag;
|
|
2232
|
+
setMoveDrag(nextMoveDrag);
|
|
2233
|
+
}, []);
|
|
2234
|
+
const updateMoveDrag = useCallback((event) => {
|
|
2235
|
+
const activeMoveDrag = moveDragRef.current;
|
|
2236
|
+
if (!activeMoveDrag) return;
|
|
2237
|
+
event.preventDefault();
|
|
2238
|
+
const index = cellIndexFromGridPointer(event, gridRef.current);
|
|
2239
|
+
if (index === null) return;
|
|
2240
|
+
const point = cellPointFromIndex(index);
|
|
2241
|
+
const movingWidget = activeWidgets.find((widget) => widget.id === activeMoveDrag.widgetId);
|
|
2242
|
+
if (!movingWidget) return;
|
|
2243
|
+
const nextPosition = clampWidgetMovePosition({
|
|
2244
|
+
x: point.x - activeMoveDrag.offsetX,
|
|
2245
|
+
y: point.y - activeMoveDrag.offsetY
|
|
2246
|
+
}, movingWidget, activeWidgets);
|
|
2247
|
+
setConfig((prev) => {
|
|
2248
|
+
const prevTabs = getTabs(prev.canvas);
|
|
2249
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
2250
|
+
const nextTabs = prevTabs.map((tab) => {
|
|
2251
|
+
if (tab.id !== prevActiveId) return tab;
|
|
2252
|
+
return {
|
|
2253
|
+
...tab,
|
|
2254
|
+
widgets: (tab.widgets || []).map((widget) =>
|
|
2255
|
+
widget.id === activeMoveDrag.widgetId ? { ...widget, position: nextPosition } : widget
|
|
2256
|
+
)
|
|
2257
|
+
};
|
|
2258
|
+
});
|
|
2259
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
2260
|
+
});
|
|
2261
|
+
}, [activeDashboardId, activeWidgets]);
|
|
2262
|
+
const finishMoveDrag = useCallback(() => {
|
|
2263
|
+
if (!moveDragRef.current) return;
|
|
2264
|
+
moveDragRef.current = null;
|
|
2265
|
+
setMoveDrag(null);
|
|
2266
|
+
}, []);
|
|
1055
2267
|
const selectWidget = useCallback((widgetId) => {
|
|
1056
2268
|
setSelectedWidgetId(widgetId);
|
|
2269
|
+
setInspectorPath(SUB_PANEL_ROOT);
|
|
1057
2270
|
setPanelOpen(true);
|
|
1058
2271
|
}, []);
|
|
2272
|
+
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
2273
|
+
if (!selectedWidgetId) return;
|
|
2274
|
+
setConfig((prev) => {
|
|
2275
|
+
const prevTabs = getTabs(prev.canvas);
|
|
2276
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
2277
|
+
const nextTabs = prevTabs.map((tab) => {
|
|
2278
|
+
if (tab.id !== prevActiveId) return tab;
|
|
2279
|
+
return {
|
|
2280
|
+
...tab,
|
|
2281
|
+
widgets: (tab.widgets || []).map((widget) =>
|
|
2282
|
+
widget.id === selectedWidgetId ? { ...widget, config: nextConfig } : widget
|
|
2283
|
+
)
|
|
2284
|
+
};
|
|
2285
|
+
});
|
|
2286
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
2287
|
+
});
|
|
2288
|
+
}, [activeDashboardId, selectedWidgetId]);
|
|
1059
2289
|
const updateSelectedWidget = useCallback((updates) => {
|
|
1060
2290
|
if (!selectedWidgetId) return;
|
|
1061
2291
|
setConfig((prev) => {
|
|
@@ -1092,10 +2322,56 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1092
2322
|
});
|
|
1093
2323
|
}, [activeDashboardId]);
|
|
1094
2324
|
|
|
2325
|
+
const duplicateSelectedWidget = useCallback(() => {
|
|
2326
|
+
if (!selectedWidget) return;
|
|
2327
|
+
setConfig((prev) => {
|
|
2328
|
+
const prevTabs = getTabs(prev.canvas);
|
|
2329
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
2330
|
+
const tabWidgets = prevTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
|
|
2331
|
+
const position = clampPositionToFreeSpace(
|
|
2332
|
+
{ ...selectedWidget.position, x: selectedWidget.position.x, y: selectedWidget.position.y },
|
|
2333
|
+
tabWidgets
|
|
2334
|
+
);
|
|
2335
|
+
const cloned = {
|
|
2336
|
+
...cloneConfig(selectedWidget),
|
|
2337
|
+
id: generateId("widget"),
|
|
2338
|
+
title: `${selectedWidget.title} Copy`,
|
|
2339
|
+
position
|
|
2340
|
+
};
|
|
2341
|
+
const nextTabs = prevTabs.map((tab) => {
|
|
2342
|
+
if (tab.id !== prevActiveId) return tab;
|
|
2343
|
+
return { ...tab, widgets: [...(tab.widgets || []), cloned] };
|
|
2344
|
+
});
|
|
2345
|
+
setSelectedWidgetId(cloned.id);
|
|
2346
|
+
setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
|
|
2347
|
+
setConfigMessage(`Duplicated ${selectedWidget.title}`);
|
|
2348
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
2349
|
+
});
|
|
2350
|
+
}, [activeDashboardId, selectedWidget]);
|
|
2351
|
+
|
|
1095
2352
|
const closeTemplateGallery = useCallback(() => {
|
|
1096
2353
|
setTemplateGalleryOpen(false);
|
|
1097
2354
|
setPreviewTemplateId(null);
|
|
1098
2355
|
}, []);
|
|
2356
|
+
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
|
2357
|
+
const closeManagement = useCallback(() => setManagementOpen(false), []);
|
|
2358
|
+
const resetWidgetSelection = useCallback(() => {
|
|
2359
|
+
setSelectedWidgetId(null);
|
|
2360
|
+
setPanelOpen(true);
|
|
2361
|
+
}, []);
|
|
2362
|
+
const showDashboardHome = useCallback(() => {
|
|
2363
|
+
setEditingDashboardId(null);
|
|
2364
|
+
setEditingDashboardDraft("");
|
|
2365
|
+
setWorkspaceView("dashboards");
|
|
2366
|
+
}, []);
|
|
2367
|
+
const resetWidgetSelectionOnOutsidePointer = useCallback((event) => {
|
|
2368
|
+
if (!selectedWidgetId) return;
|
|
2369
|
+
if (resizeDragRef.current || moveDragRef.current) return;
|
|
2370
|
+
const target = event.target;
|
|
2371
|
+
if (!(target instanceof Element)) return;
|
|
2372
|
+
if (target.closest(".workspace-widget-preview, .workspace-widget-panel, .workspace-overlay, .template-gallery")) return;
|
|
2373
|
+
resetWidgetSelection();
|
|
2374
|
+
}, [resetWidgetSelection, selectedWidgetId]);
|
|
1099
2375
|
|
|
1100
2376
|
useEffect(() => {
|
|
1101
2377
|
if (!templateGalleryOpen) return undefined;
|
|
@@ -1106,20 +2382,212 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1106
2382
|
return () => window.removeEventListener("keydown", handler);
|
|
1107
2383
|
}, [templateGalleryOpen, closeTemplateGallery]);
|
|
1108
2384
|
|
|
1109
|
-
|
|
2385
|
+
useEffect(() => {
|
|
2386
|
+
if (!settingsOpen) return undefined;
|
|
2387
|
+
const handler = (event) => {
|
|
2388
|
+
if (event.key === "Escape") closeSettings();
|
|
2389
|
+
};
|
|
2390
|
+
window.addEventListener("keydown", handler);
|
|
2391
|
+
return () => window.removeEventListener("keydown", handler);
|
|
2392
|
+
}, [settingsOpen, closeSettings]);
|
|
2393
|
+
|
|
2394
|
+
useEffect(() => {
|
|
2395
|
+
if (!managementOpen) return undefined;
|
|
2396
|
+
const handler = (event) => {
|
|
2397
|
+
if (event.key === "Escape") closeManagement();
|
|
2398
|
+
};
|
|
2399
|
+
window.addEventListener("keydown", handler);
|
|
2400
|
+
return () => window.removeEventListener("keydown", handler);
|
|
2401
|
+
}, [managementOpen, closeManagement]);
|
|
2402
|
+
|
|
2403
|
+
useEffect(() => {
|
|
2404
|
+
const handler = (event) => {
|
|
2405
|
+
if (commandPaletteOpen) return;
|
|
2406
|
+
const target = event.target;
|
|
2407
|
+
const isEditable = target instanceof HTMLElement && (
|
|
2408
|
+
target.tagName === "INPUT" ||
|
|
2409
|
+
target.tagName === "TEXTAREA" ||
|
|
2410
|
+
target.isContentEditable
|
|
2411
|
+
);
|
|
2412
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
2413
|
+
if (meta && (event.key === "k" || event.key === "K")) {
|
|
2414
|
+
event.preventDefault();
|
|
2415
|
+
setCommandPaletteOpen(true);
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
if (event.key === "/" && !isEditable && !templateGalleryOpen && !settingsOpen && !managementOpen) {
|
|
2419
|
+
event.preventDefault();
|
|
2420
|
+
setCommandPaletteOpen(true);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (!isEditable && workspaceView === "builder" && panelOpen && !commandPaletteOpen && !templateGalleryOpen && !settingsOpen && !managementOpen) {
|
|
2424
|
+
const quickMap = { c: "chart", v: "view", i: "iframe", t: "rich-text" };
|
|
2425
|
+
const kind = quickMap[event.key.toLowerCase()];
|
|
2426
|
+
if (kind) {
|
|
2427
|
+
event.preventDefault();
|
|
2428
|
+
addWidget(kind);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
window.addEventListener("keydown", handler);
|
|
2433
|
+
return () => window.removeEventListener("keydown", handler);
|
|
2434
|
+
}, [addWidget, commandPaletteOpen, managementOpen, panelOpen, settingsOpen, templateGalleryOpen, workspaceView]);
|
|
2435
|
+
|
|
2436
|
+
const builderStyle = workspaceView === "dashboards" || !panelOpen
|
|
2437
|
+
? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
|
|
2438
|
+
: undefined;
|
|
2439
|
+
|
|
2440
|
+
const closeCommandPalette = useCallback(() => setCommandPaletteOpen(false), []);
|
|
1110
2441
|
|
|
1111
|
-
|
|
2442
|
+
const paletteCommands = useMemo(() => {
|
|
2443
|
+
const list = [];
|
|
2444
|
+
list.push({
|
|
2445
|
+
id: "dashboard.new", group: "Dashboard", icon: Plus, label: "Create dashboard", shortcut: "N",
|
|
2446
|
+
run: () => addDashboard()
|
|
2447
|
+
});
|
|
2448
|
+
list.push({
|
|
2449
|
+
id: "dashboard.duplicate", group: "Dashboard", icon: Copy, label: "Duplicate dashboard",
|
|
2450
|
+
run: () => duplicateDashboard(),
|
|
2451
|
+
disabled: !activeDashboard
|
|
2452
|
+
});
|
|
2453
|
+
list.push({
|
|
2454
|
+
id: "dashboard.delete", group: "Dashboard", icon: Trash2, label: "Delete dashboard",
|
|
2455
|
+
disabled: !activeDashboard,
|
|
2456
|
+
run: () => {
|
|
2457
|
+
if (resolvedActiveDashboardIndex >= 0) deleteDashboard(resolvedActiveDashboardIndex);
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
list.push({
|
|
2461
|
+
id: "dashboard.export", group: "Dashboard", icon: Download, label: "Export dashboard",
|
|
2462
|
+
run: () => exportConfig()
|
|
2463
|
+
});
|
|
2464
|
+
list.push({
|
|
2465
|
+
id: "dashboard.import", group: "Dashboard", icon: Import, label: "Import dashboards",
|
|
2466
|
+
run: () => importInputRef.current?.click()
|
|
2467
|
+
});
|
|
2468
|
+
list.push({
|
|
2469
|
+
id: "dashboard.templates", group: "Dashboard", icon: Grid2X2, label: "Open template gallery",
|
|
2470
|
+
run: () => setTemplateGalleryOpen(true)
|
|
2471
|
+
});
|
|
2472
|
+
list.push({
|
|
2473
|
+
id: "tab.new", group: "Tab", icon: Plus, label: "New tab",
|
|
2474
|
+
run: () => addTab()
|
|
2475
|
+
});
|
|
2476
|
+
list.push({
|
|
2477
|
+
id: "tab.duplicate", group: "Tab", icon: Copy, label: "Duplicate tab",
|
|
2478
|
+
run: () => duplicateTab()
|
|
2479
|
+
});
|
|
2480
|
+
[
|
|
2481
|
+
["chart", "Add chart widget", "C"],
|
|
2482
|
+
["view", "Add view widget", "V"],
|
|
2483
|
+
["iframe", "Add iFrame widget", "I"],
|
|
2484
|
+
["rich-text", "Add rich text widget", "T"]
|
|
2485
|
+
].forEach(([kind, label, shortcut]) => {
|
|
2486
|
+
list.push({
|
|
2487
|
+
id: `widget.add.${kind}`,
|
|
2488
|
+
group: "Widget Add",
|
|
2489
|
+
icon: WIDGET_KIND_ICONS[kind],
|
|
2490
|
+
label,
|
|
2491
|
+
shortcut,
|
|
2492
|
+
disabled: workspaceView !== "builder",
|
|
2493
|
+
run: () => addWidget(kind)
|
|
2494
|
+
});
|
|
2495
|
+
});
|
|
2496
|
+
list.push({
|
|
2497
|
+
id: "widget.duplicate", group: "Widget", icon: Copy, label: "Duplicate selected widget",
|
|
2498
|
+
disabled: !selectedWidget,
|
|
2499
|
+
run: () => duplicateSelectedWidget()
|
|
2500
|
+
});
|
|
2501
|
+
list.push({
|
|
2502
|
+
id: "widget.remove", group: "Widget", icon: Trash2, label: "Remove selected widget",
|
|
2503
|
+
disabled: !selectedWidget,
|
|
2504
|
+
run: () => selectedWidget && removeSelectedWidget(selectedWidget.id)
|
|
2505
|
+
});
|
|
2506
|
+
list.push({
|
|
2507
|
+
id: "widget.source", group: "Widget", icon: Database, label: "Open widget source",
|
|
2508
|
+
disabled: !selectedWidget,
|
|
2509
|
+
run: () => {
|
|
2510
|
+
setPanelOpen(true);
|
|
2511
|
+
setInspectorPath("source");
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
list.push({
|
|
2515
|
+
id: "widget.fields", group: "Widget", icon: Columns3, label: "Open widget fields",
|
|
2516
|
+
disabled: !(selectedWidget && selectedWidget.kind === "view"),
|
|
2517
|
+
run: () => {
|
|
2518
|
+
setPanelOpen(true);
|
|
2519
|
+
setInspectorPath("fields");
|
|
2520
|
+
}
|
|
2521
|
+
});
|
|
2522
|
+
list.push({
|
|
2523
|
+
id: "widget.sort", group: "Widget", icon: SlidersHorizontal, label: "Open widget sorts",
|
|
2524
|
+
disabled: !(selectedWidget && selectedWidget.kind === "view"),
|
|
2525
|
+
run: () => {
|
|
2526
|
+
setPanelOpen(true);
|
|
2527
|
+
setInspectorPath("sort");
|
|
2528
|
+
}
|
|
2529
|
+
});
|
|
2530
|
+
list.push({
|
|
2531
|
+
id: "widget.filter", group: "Widget", icon: Filter, label: "Open widget filter",
|
|
2532
|
+
disabled: !selectedWidget,
|
|
2533
|
+
run: () => {
|
|
2534
|
+
setPanelOpen(true);
|
|
2535
|
+
setInspectorPath("filter");
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
list.push({
|
|
2539
|
+
id: "workspace.save", group: "Workspace", icon: Save, label: saving ? "Saving..." : "Save workspace",
|
|
2540
|
+
disabled: saving,
|
|
2541
|
+
shortcut: "S",
|
|
2542
|
+
run: () => save()
|
|
2543
|
+
});
|
|
2544
|
+
list.push({
|
|
2545
|
+
id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
|
|
2546
|
+
run: () => setSettingsOpen(true)
|
|
2547
|
+
});
|
|
2548
|
+
list.push({
|
|
2549
|
+
id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
|
|
2550
|
+
run: () => setManagementOpen(true)
|
|
2551
|
+
});
|
|
2552
|
+
list.push({
|
|
2553
|
+
id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
|
|
2554
|
+
run: () => showDashboardHome()
|
|
2555
|
+
});
|
|
2556
|
+
list.push({
|
|
2557
|
+
id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
|
|
2558
|
+
run: () => { window.location.href = "/settings/integrations"; }
|
|
2559
|
+
});
|
|
2560
|
+
return list;
|
|
2561
|
+
}, [
|
|
2562
|
+
activeDashboard,
|
|
2563
|
+
addWidget,
|
|
2564
|
+
addDashboard,
|
|
2565
|
+
addTab,
|
|
2566
|
+
deleteDashboard,
|
|
2567
|
+
duplicateDashboard,
|
|
2568
|
+
duplicateSelectedWidget,
|
|
2569
|
+
duplicateTab,
|
|
2570
|
+
exportConfig,
|
|
2571
|
+
removeSelectedWidget,
|
|
2572
|
+
resolvedActiveDashboardIndex,
|
|
2573
|
+
save,
|
|
2574
|
+
saving,
|
|
2575
|
+
selectedWidget,
|
|
2576
|
+
showDashboardHome,
|
|
2577
|
+
workspaceView
|
|
2578
|
+
]);
|
|
2579
|
+
|
|
2580
|
+
return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
|
|
1112
2581
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
1113
2582
|
<div className="workspace-brand">
|
|
1114
2583
|
<span className="workspace-mark">G</span>
|
|
1115
2584
|
<span>Growthub Workspace</span>
|
|
1116
2585
|
</div>
|
|
1117
2586
|
<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>
|
|
2587
|
+
<button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
|
|
1122
2588
|
<Link href="/settings/integrations">Integrations</Link>
|
|
2589
|
+
<button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
|
|
2590
|
+
<button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
|
|
1123
2591
|
</nav>
|
|
1124
2592
|
<div className="workspace-rail-status">
|
|
1125
2593
|
<span className="status-dot" />
|
|
@@ -1130,16 +2598,21 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1130
2598
|
<section className="workspace-surface">
|
|
1131
2599
|
<header className="workspace-toolbar">
|
|
1132
2600
|
<div>
|
|
1133
|
-
|
|
1134
|
-
|
|
2601
|
+
{workspaceView === "builder" ? <>
|
|
2602
|
+
<p>{activeTab?.name || "Tab 1"}</p>
|
|
2603
|
+
<h1>{activeDashboard?.name || "Untitled"}</h1>
|
|
2604
|
+
</> : <>
|
|
2605
|
+
<p>Workspace home</p>
|
|
2606
|
+
<h1>Dashboards</h1>
|
|
2607
|
+
</>}
|
|
1135
2608
|
</div>
|
|
1136
2609
|
<div className="workspace-toolbar-actions">
|
|
1137
|
-
<button type="button" onClick={() => setTemplateGalleryOpen(true)}
|
|
1138
|
-
<button type="button" onClick={addDashboard}
|
|
1139
|
-
<button type="button" onClick={duplicateDashboard}
|
|
1140
|
-
<button type="button" onClick={() => importInputRef.current?.click()}
|
|
1141
|
-
<button type="button" onClick={exportConfig}
|
|
1142
|
-
<button type="button" onClick={save} disabled={saving}
|
|
2610
|
+
<button type="button" onClick={() => setTemplateGalleryOpen(true)}><Grid2X2 size={15} />Templates</button>
|
|
2611
|
+
<button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
|
|
2612
|
+
<button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
|
|
2613
|
+
<button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
|
|
2614
|
+
<button type="button" onClick={exportConfig}><Download size={15} />Export</button>
|
|
2615
|
+
<button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
|
|
1143
2616
|
</div>
|
|
1144
2617
|
<input
|
|
1145
2618
|
ref={importInputRef}
|
|
@@ -1149,12 +2622,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1149
2622
|
onChange={importConfig}
|
|
1150
2623
|
/>
|
|
1151
2624
|
</header>
|
|
1152
|
-
{configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
|
|
1153
2625
|
|
|
1154
|
-
<section className="workspace-table" id="dashboards" aria-label="Dashboards">
|
|
2626
|
+
{workspaceView === "dashboards" ? <section className="workspace-table" id="dashboards" aria-label="Dashboards">
|
|
1155
2627
|
<div className="workspace-table-heading">
|
|
1156
2628
|
<strong>Dashboards</strong>
|
|
1157
|
-
<span>{dashboards.length}
|
|
2629
|
+
<span>{dashboards.length} dashboard{dashboards.length === 1 ? "" : "s"}</span>
|
|
1158
2630
|
</div>
|
|
1159
2631
|
<div className="workspace-table-row workspace-table-head">
|
|
1160
2632
|
<span>Title</span>
|
|
@@ -1165,19 +2637,35 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1165
2637
|
</div>
|
|
1166
2638
|
{dashboards.map((dashboard, index) => <div className="workspace-table-row" key={dashboard.id}>
|
|
1167
2639
|
<span className="workspace-dashboard-title">
|
|
1168
|
-
{editingDashboardId === dashboard.id ? <
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
2640
|
+
{editingDashboardId === dashboard.id ? <span className="workspace-dashboard-title-editor">
|
|
2641
|
+
<input
|
|
2642
|
+
aria-label={`Rename ${dashboard.name}`}
|
|
2643
|
+
autoFocus
|
|
2644
|
+
data-dashboard-title-input={dashboard.id}
|
|
2645
|
+
onBlur={() => cancelDashboardTitleEdit(dashboard)}
|
|
2646
|
+
onChange={(event) => setEditingDashboardDraft(event.target.value)}
|
|
2647
|
+
onKeyDown={(event) => {
|
|
2648
|
+
if (event.key === "Enter") {
|
|
2649
|
+
event.preventDefault();
|
|
2650
|
+
confirmDashboardTitleEdit(dashboard.id);
|
|
2651
|
+
}
|
|
2652
|
+
if (event.key === "Escape") {
|
|
2653
|
+
event.preventDefault();
|
|
2654
|
+
cancelDashboardTitleEdit(dashboard);
|
|
2655
|
+
}
|
|
2656
|
+
}}
|
|
2657
|
+
value={editingDashboardDraft}
|
|
2658
|
+
/>
|
|
2659
|
+
<button
|
|
2660
|
+
aria-label={`Confirm ${dashboard.name} title`}
|
|
2661
|
+
className="workspace-dashboard-title-confirm"
|
|
2662
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
2663
|
+
onClick={() => confirmDashboardTitleEdit(dashboard.id)}
|
|
2664
|
+
type="button"
|
|
2665
|
+
>โ</button>
|
|
2666
|
+
</span> : <button
|
|
1179
2667
|
className={index === resolvedActiveDashboardIndex ? "active" : ""}
|
|
1180
|
-
onClick={() =>
|
|
2668
|
+
onClick={() => enterDashboardTitleEdit(dashboard)}
|
|
1181
2669
|
type="button"
|
|
1182
2670
|
>{dashboard.name}</button>}
|
|
1183
2671
|
</span>
|
|
@@ -1196,14 +2684,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1196
2684
|
</span>
|
|
1197
2685
|
<span className="workspace-dashboard-actions">
|
|
1198
2686
|
<button type="button" onClick={() => selectDashboard(index)}>Edit</button>
|
|
1199
|
-
<button type="button" onClick={() =>
|
|
2687
|
+
<button type="button" onClick={() => enterDashboardTitleEdit(dashboard)}>Rename</button>
|
|
1200
2688
|
<button type="button" onClick={() => cloneDashboard(index)}>Clone</button>
|
|
1201
2689
|
<button type="button" onClick={() => deleteDashboard(index)}>Delete</button>
|
|
1202
2690
|
</span>
|
|
1203
2691
|
</div>)}
|
|
1204
|
-
</section>
|
|
2692
|
+
</section> : null}
|
|
1205
2693
|
|
|
1206
|
-
<section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
|
|
2694
|
+
{workspaceView === "builder" ? <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
|
|
1207
2695
|
<div className="workspace-tabs">
|
|
1208
2696
|
{tabs.map((tab) => <button
|
|
1209
2697
|
key={tab.id}
|
|
@@ -1230,19 +2718,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1230
2718
|
tabIndex={0}
|
|
1231
2719
|
>x</span>
|
|
1232
2720
|
</button>)}
|
|
1233
|
-
<button type="button" onClick={addTab}
|
|
1234
|
-
<button type="button" onClick={duplicateTab}
|
|
2721
|
+
<button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
|
|
2722
|
+
<button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
|
|
1235
2723
|
</div>
|
|
1236
2724
|
<div
|
|
1237
|
-
className=
|
|
2725
|
+
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
1238
2726
|
ref={gridRef}
|
|
1239
2727
|
onPointerMove={updatePointerDrag}
|
|
1240
2728
|
onPointerUp={(event) => {
|
|
1241
2729
|
finishPointerDrag(event);
|
|
1242
2730
|
finishResizeDrag();
|
|
2731
|
+
finishMoveDrag();
|
|
2732
|
+
}}
|
|
2733
|
+
onPointerLeave={() => {
|
|
2734
|
+
finishResizeDrag();
|
|
2735
|
+
finishMoveDrag();
|
|
2736
|
+
}}
|
|
2737
|
+
onPointerMoveCapture={(event) => {
|
|
2738
|
+
updateResizeDrag(event);
|
|
2739
|
+
updateMoveDrag(event);
|
|
1243
2740
|
}}
|
|
1244
|
-
onPointerLeave={finishResizeDrag}
|
|
1245
|
-
onPointerMoveCapture={updateResizeDrag}
|
|
1246
2741
|
style={{ "--workspace-columns": canvas.layout.columns, "--workspace-rows": GRID_ROWS }}
|
|
1247
2742
|
>
|
|
1248
2743
|
{Array.from({ length: GRID_CELL_COUNT }).map((_, index) => {
|
|
@@ -1274,14 +2769,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1274
2769
|
</button>
|
|
1275
2770
|
{activeWidgets.map((widget) => <WidgetPreview
|
|
1276
2771
|
key={widget.id}
|
|
2772
|
+
onMoveStart={(event) => beginMoveDrag(widget, event)}
|
|
1277
2773
|
onRemove={() => removeSelectedWidget(widget.id)}
|
|
1278
2774
|
onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
|
|
1279
2775
|
onSelect={() => selectWidget(widget.id)}
|
|
2776
|
+
onExpandIframe={setExpandedIframeWidget}
|
|
1280
2777
|
selected={widget.id === selectedWidgetId}
|
|
1281
2778
|
widget={widget}
|
|
1282
2779
|
/>)}
|
|
1283
2780
|
</div>
|
|
1284
|
-
</section>
|
|
2781
|
+
</section> : null}
|
|
1285
2782
|
</section>
|
|
1286
2783
|
|
|
1287
2784
|
{templateGalleryOpen ? <TemplateGallery
|
|
@@ -1291,20 +2788,67 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1291
2788
|
onClose={closeTemplateGallery}
|
|
1292
2789
|
onApplyToCurrentTab={applyTemplateToCurrentTab}
|
|
1293
2790
|
onCloneAsDashboard={cloneTemplateAsDashboard}
|
|
2791
|
+
filter={templateFilter}
|
|
2792
|
+
onFilterChange={setTemplateFilter}
|
|
2793
|
+
/> : null}
|
|
2794
|
+
|
|
2795
|
+
{settingsOpen ? <WorkspaceSettingsPanel
|
|
2796
|
+
config={config}
|
|
2797
|
+
persistence={persistence}
|
|
2798
|
+
adapterConfig={adapterConfig}
|
|
2799
|
+
integrationAdapter={integrationAdapter}
|
|
2800
|
+
onClose={closeSettings}
|
|
2801
|
+
/> : null}
|
|
2802
|
+
|
|
2803
|
+
{managementOpen ? <WorkspaceManagementPanel
|
|
2804
|
+
config={config}
|
|
2805
|
+
persistence={persistence}
|
|
2806
|
+
adapterConfig={adapterConfig}
|
|
2807
|
+
onClose={closeManagement}
|
|
1294
2808
|
/> : null}
|
|
1295
2809
|
|
|
1296
|
-
{panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
2810
|
+
{workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
1297
2811
|
<div className="workspace-panel-title">
|
|
1298
2812
|
<button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
|
|
1299
2813
|
<span aria-hidden="true">+</span>
|
|
1300
2814
|
<strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
|
|
1301
2815
|
{selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
|
|
1302
2816
|
</div>
|
|
1303
|
-
{selectedWidget ? <
|
|
2817
|
+
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
2818
|
+
<button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
|
|
2819
|
+
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
|
|
2820
|
+
</div> : null}
|
|
2821
|
+
{selectedWidget && inspectorPath === "source" ? <SourceSubPanel
|
|
2822
|
+
widget={selectedWidget}
|
|
2823
|
+
integrations={governedWorkspaceIntegrationCatalog}
|
|
2824
|
+
onChange={replaceSelectedWidgetConfig}
|
|
2825
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
2826
|
+
/> : null}
|
|
2827
|
+
{selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
|
|
2828
|
+
widget={selectedWidget}
|
|
2829
|
+
onChange={replaceSelectedWidgetConfig}
|
|
2830
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
2831
|
+
/> : null}
|
|
2832
|
+
{selectedWidget && inspectorPath === "sort" ? <SortSubPanel
|
|
2833
|
+
widget={selectedWidget}
|
|
2834
|
+
onChange={replaceSelectedWidgetConfig}
|
|
2835
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
2836
|
+
/> : null}
|
|
2837
|
+
{selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
|
|
2838
|
+
widget={selectedWidget}
|
|
2839
|
+
onChange={replaceSelectedWidgetConfig}
|
|
2840
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
2841
|
+
/> : null}
|
|
2842
|
+
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
|
|
1304
2843
|
<label>
|
|
1305
2844
|
<span>Title</span>
|
|
1306
2845
|
<input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
|
|
1307
2846
|
</label>
|
|
2847
|
+
{selectedWidget.kind === "chart" ? <ChartConfigPanel
|
|
2848
|
+
widget={selectedWidget}
|
|
2849
|
+
onChange={replaceSelectedWidgetConfig}
|
|
2850
|
+
onSubPage={(name) => setInspectorPath(name)}
|
|
2851
|
+
/> : null}
|
|
1308
2852
|
{selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
|
|
1309
2853
|
<label>
|
|
1310
2854
|
<span>Sample Values</span>
|
|
@@ -1323,40 +2867,36 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1323
2867
|
>
|
|
1324
2868
|
<option value="json">Sample JSON</option>
|
|
1325
2869
|
<option value="csv">Sample CSV</option>
|
|
2870
|
+
{selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
|
|
1326
2871
|
</select>
|
|
1327
2872
|
</label>
|
|
1328
2873
|
</section> : null}
|
|
1329
|
-
{selectedWidget.kind === "iframe" ? <label>
|
|
2874
|
+
{selectedWidget.kind === "iframe" ? <label className="workspace-field-with-hint">
|
|
1330
2875
|
<span>URL to Embed</span>
|
|
1331
2876
|
<input
|
|
1332
2877
|
placeholder="https://example.com/embed"
|
|
1333
2878
|
value={selectedWidget.config?.url || ""}
|
|
1334
2879
|
onChange={(event) => updateSelectedWidgetConfig({ url: event.target.value })}
|
|
1335
2880
|
/>
|
|
2881
|
+
<small className={isLikelyHttpUrl(selectedWidget.config?.url) ? "workspace-field-hint good" : "workspace-field-hint warn"}>
|
|
2882
|
+
{isLikelyHttpUrl(selectedWidget.config?.url)
|
|
2883
|
+
? "Looks like a valid http(s) URL"
|
|
2884
|
+
: selectedWidget.config?.url
|
|
2885
|
+
? "URL must start with http:// or https://"
|
|
2886
|
+
: "Add an http(s) URL to embed"}
|
|
2887
|
+
</small>
|
|
1336
2888
|
</label> : null}
|
|
1337
|
-
{selectedWidget.kind === "rich-text" ? <label>
|
|
2889
|
+
{selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
|
|
1338
2890
|
<span>Content</span>
|
|
1339
|
-
<
|
|
1340
|
-
placeholder="Write text..."
|
|
2891
|
+
<RichTextEditor
|
|
1341
2892
|
value={selectedWidget.config?.text || ""}
|
|
1342
|
-
onChange={(
|
|
2893
|
+
onChange={(text) => updateSelectedWidgetConfig({ text })}
|
|
1343
2894
|
/>
|
|
2895
|
+
<small className="workspace-field-hint">
|
|
2896
|
+
{(selectedWidget.config?.text || "").length} characters ยท markdown controls
|
|
2897
|
+
</small>
|
|
1344
2898
|
</label> : null}
|
|
1345
2899
|
{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
2900
|
<label>
|
|
1361
2901
|
<span>Manual Rows</span>
|
|
1362
2902
|
<textarea
|
|
@@ -1370,26 +2910,23 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1370
2910
|
}}
|
|
1371
2911
|
/>
|
|
1372
2912
|
</label>
|
|
1373
|
-
<label>
|
|
1374
|
-
<
|
|
1375
|
-
<
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
>
|
|
1382
|
-
<
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
<div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
|
|
1391
|
-
<div><span>Filter</span><code>โบ</code></div>
|
|
1392
|
-
<div><span>Sort</span><code>โบ</code></div>
|
|
2913
|
+
<div className="workspace-settings-list" role="group" aria-label="View widget settings">
|
|
2914
|
+
<p className="workspace-panel-label">Settings</p>
|
|
2915
|
+
<button type="button" className="workspace-settings-row" disabled>
|
|
2916
|
+
<span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code>
|
|
2917
|
+
</button>
|
|
2918
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("source")}>
|
|
2919
|
+
<span>Source</span><code>{summarizeSource(selectedWidget)}</code>
|
|
2920
|
+
</button>
|
|
2921
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
|
|
2922
|
+
<span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
|
|
2923
|
+
</button>
|
|
2924
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
|
|
2925
|
+
<span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
|
|
2926
|
+
</button>
|
|
2927
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
|
|
2928
|
+
<span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
|
|
2929
|
+
</button>
|
|
1393
2930
|
</div>
|
|
1394
2931
|
</section> : null}
|
|
1395
2932
|
{selectedWidget.kind === "rich-text" ? <label>
|
|
@@ -1409,16 +2946,30 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1409
2946
|
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|
|
1410
2947
|
<div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
|
|
1411
2948
|
</div>
|
|
1412
|
-
</section> :
|
|
2949
|
+
</section> : null}
|
|
2950
|
+
{!selectedWidget ? <section>
|
|
2951
|
+
<div className="workspace-widget-empty">
|
|
2952
|
+
<strong>Pick a widget kind</strong>
|
|
2953
|
+
<p>
|
|
2954
|
+
Widgets snap to the 12-column ร 16-row grid. {addSlot.w} ร {addSlot.h} cells
|
|
2955
|
+
selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
|
|
2956
|
+
canvas to reshape the placement. Press <kbd>โK</kbd> for the command palette.
|
|
2957
|
+
</p>
|
|
2958
|
+
</div>
|
|
1413
2959
|
<p className="workspace-panel-label">Widget type</p>
|
|
1414
2960
|
<div className="workspace-widget-types">
|
|
1415
|
-
{widgetTypes.map((widget) =>
|
|
1416
|
-
|
|
2961
|
+
{widgetTypes.map((widget) => {
|
|
2962
|
+
const KindIcon = WIDGET_KIND_ICONS[widget.kind];
|
|
2963
|
+
const shortcut = widget.kind === "chart" ? "C" : widget.kind === "view" ? "V" : widget.kind === "iframe" ? "I" : "T";
|
|
2964
|
+
return <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
|
|
2965
|
+
<span><IconGlyph icon={KindIcon} size={15} /></span>
|
|
1417
2966
|
{widget.label}
|
|
1418
|
-
|
|
2967
|
+
<kbd>{shortcut}</kbd>
|
|
2968
|
+
</button>;
|
|
2969
|
+
})}
|
|
1419
2970
|
</div>
|
|
1420
|
-
</section>}
|
|
1421
|
-
<section className="workspace-bindings" id="bindings">
|
|
2971
|
+
</section> : null}
|
|
2972
|
+
{inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-bindings" id="bindings">
|
|
1422
2973
|
<p className="workspace-panel-label">Config bindings</p>
|
|
1423
2974
|
{Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
|
|
1424
2975
|
<span>{key}</span>
|
|
@@ -1428,8 +2979,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
|
|
|
1428
2979
|
<span>integrationAdapter</span>
|
|
1429
2980
|
<code>{adapterConfig.integrationAdapter}</code>
|
|
1430
2981
|
</div>
|
|
1431
|
-
</section>
|
|
2982
|
+
</section> : null}
|
|
1432
2983
|
</aside> : null}
|
|
2984
|
+
{expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
|
|
2985
|
+
{commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
|
|
1433
2986
|
</main>;
|
|
1434
2987
|
}
|
|
1435
2988
|
|