@growthub/cli 0.9.9 → 0.9.11
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 +1 -1
- package/assets/worker-kits/creative-strategist-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-agency-portal-starter-v1/kit.json +4 -1
- package/assets/worker-kits/growthub-ai-website-cloner-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-creative-video-pipeline-v1/kit.json +4 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +50 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +980 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +5 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +4 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1686 -68
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js +12 -16
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +61 -11
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +31 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +236 -9
- 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 +5 -2
- package/assets/worker-kits/growthub-email-marketing-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-geo-seo-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-hyperframes-studio-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-marketing-skills-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-open-higgsfield-studio-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-open-montage-studio-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-postiz-social-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-twenty-crm-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-video-use-studio-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-zernio-social-v1/kit.json +5 -2
- package/dist/index.js +1750 -433
- 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
|
+
Check,
|
|
9
|
+
ChevronDown,
|
|
10
|
+
Code2,
|
|
11
|
+
Columns3,
|
|
12
|
+
Copy,
|
|
13
|
+
Database,
|
|
14
|
+
Download,
|
|
15
|
+
ExternalLink,
|
|
16
|
+
FileText,
|
|
17
|
+
Filter,
|
|
18
|
+
Gauge,
|
|
19
|
+
Grid2X2,
|
|
20
|
+
Home,
|
|
21
|
+
Import,
|
|
22
|
+
Italic,
|
|
23
|
+
LayoutDashboard,
|
|
24
|
+
Link as LinkIcon,
|
|
25
|
+
List,
|
|
26
|
+
Maximize2,
|
|
27
|
+
Pencil,
|
|
28
|
+
PieChart,
|
|
29
|
+
Plus,
|
|
30
|
+
Quote,
|
|
31
|
+
Rows3,
|
|
32
|
+
Save,
|
|
33
|
+
Search,
|
|
34
|
+
Settings,
|
|
35
|
+
Sigma,
|
|
36
|
+
SlidersHorizontal,
|
|
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,79 @@ 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
|
+
const MANAGED_INTEGRATION_SOURCE_TYPE = "managed-integrations";
|
|
67
|
+
const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
|
|
68
|
+
|
|
69
|
+
const SOURCE_TYPE_OBJECTS = [
|
|
70
|
+
{
|
|
71
|
+
id: MANAGED_INTEGRATION_SOURCE_TYPE,
|
|
72
|
+
label: "Managed Integrations",
|
|
73
|
+
authority: "Growthub Bridge",
|
|
74
|
+
description: "Bridge or BYO adapters resolve metadata server-side."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: CUSTOM_API_SOURCE_TYPE,
|
|
78
|
+
label: "Custom APIs/Webhooks",
|
|
79
|
+
authority: "Custom endpoint",
|
|
80
|
+
description: "Reference a governed endpoint object without storing credentials in widget config."
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const ENTITY_REFERENCE_FIELD_IDS = ["id", "entityId"];
|
|
85
|
+
|
|
86
|
+
const CHART_TYPE_LABELS = {
|
|
87
|
+
"bar-vertical": "Vertical Bar",
|
|
88
|
+
"bar-horizontal": "Horizontal Bar",
|
|
89
|
+
"pie": "Pie",
|
|
90
|
+
"sum": "Sum",
|
|
91
|
+
"gauge": "Gauge"
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const CHART_TYPE_ICONS = {
|
|
95
|
+
"bar-vertical": BarChart3,
|
|
96
|
+
"bar-horizontal": Rows3,
|
|
97
|
+
"pie": PieChart,
|
|
98
|
+
"sum": Sigma,
|
|
99
|
+
"gauge": Gauge
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
|
|
103
|
+
|
|
104
|
+
const WIDGET_KIND_ICONS = {
|
|
105
|
+
chart: BarChart3,
|
|
106
|
+
view: Table2,
|
|
107
|
+
iframe: Code2,
|
|
108
|
+
"rich-text": FileText
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const FILTER_OPERATOR_LABELS = {
|
|
112
|
+
eq: "equals",
|
|
113
|
+
ne: "does not equal",
|
|
114
|
+
contains: "contains",
|
|
115
|
+
gt: "is greater than",
|
|
116
|
+
lt: "is less than",
|
|
117
|
+
isEmpty: "is empty",
|
|
118
|
+
isNotEmpty: "is not empty"
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const COLUMN_ICON_FOR = (name) => {
|
|
122
|
+
const lower = String(name || "").toLowerCase();
|
|
123
|
+
if (lower.includes("name") || lower.includes("title")) return "🏛";
|
|
124
|
+
if (lower.includes("domain") || lower.includes("url") || lower.includes("link")) return "🔗";
|
|
125
|
+
if (lower.includes("address") || lower.includes("location")) return "🗺";
|
|
126
|
+
if (lower.includes("employee") || lower.includes("people") || lower.includes("user")) return "👥";
|
|
127
|
+
if (lower.includes("linkedin")) return "in";
|
|
128
|
+
if (lower.includes("twitter") || lower === "x") return "𝕏";
|
|
129
|
+
if (lower.includes("date") || lower.includes("created") || lower.includes("updated")) return "📅";
|
|
130
|
+
return "▦";
|
|
131
|
+
};
|
|
17
132
|
|
|
18
133
|
const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
|
|
19
134
|
const GRID_COLUMNS = 12;
|
|
@@ -350,6 +465,11 @@ function widgetKindLabel(kind) {
|
|
|
350
465
|
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
351
466
|
}
|
|
352
467
|
|
|
468
|
+
function IconGlyph({ icon: Icon, size = 16 }) {
|
|
469
|
+
if (!Icon) return null;
|
|
470
|
+
return <Icon aria-hidden="true" size={size} strokeWidth={1.9} />;
|
|
471
|
+
}
|
|
472
|
+
|
|
353
473
|
function isLikelyHttpUrl(value) {
|
|
354
474
|
if (typeof value !== "string" || !value.trim()) return false;
|
|
355
475
|
try {
|
|
@@ -364,6 +484,29 @@ function cloneConfig(value) {
|
|
|
364
484
|
return JSON.parse(JSON.stringify(value));
|
|
365
485
|
}
|
|
366
486
|
|
|
487
|
+
function escapeHtml(value) {
|
|
488
|
+
return String(value || "")
|
|
489
|
+
.replaceAll("&", "&")
|
|
490
|
+
.replaceAll("<", "<")
|
|
491
|
+
.replaceAll(">", ">")
|
|
492
|
+
.replaceAll('"', """)
|
|
493
|
+
.replaceAll("'", "'");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function richTextToHtml(value) {
|
|
497
|
+
const escaped = escapeHtml(value || "Start writing...");
|
|
498
|
+
return escaped
|
|
499
|
+
.replace(/^### (.*)$/gm, "<h3>$1</h3>")
|
|
500
|
+
.replace(/^## (.*)$/gm, "<h2>$1</h2>")
|
|
501
|
+
.replace(/^# (.*)$/gm, "<h1>$1</h1>")
|
|
502
|
+
.replace(/^> (.*)$/gm, "<blockquote>$1</blockquote>")
|
|
503
|
+
.replace(/^- (.*)$/gm, "<li>$1</li>")
|
|
504
|
+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
|
505
|
+
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
|
506
|
+
.replace(/\[(.*?)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
|
|
507
|
+
.replace(/\n/g, "<br />");
|
|
508
|
+
}
|
|
509
|
+
|
|
367
510
|
function normalizeChartValues(value) {
|
|
368
511
|
return String(value)
|
|
369
512
|
.split(",")
|
|
@@ -408,6 +551,282 @@ function serializeManualRows(rows, columns) {
|
|
|
408
551
|
.join("\n");
|
|
409
552
|
}
|
|
410
553
|
|
|
554
|
+
function getColumnList(widget) {
|
|
555
|
+
return Array.isArray(widget?.config?.columns) ? widget.config.columns : [];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function getOrderedColumns(widget) {
|
|
559
|
+
const columns = getColumnList(widget);
|
|
560
|
+
const order = Array.isArray(widget?.config?.fieldSettings?.order) ? widget.config.fieldSettings.order : [];
|
|
561
|
+
if (!order.length) return columns;
|
|
562
|
+
const known = new Set(columns);
|
|
563
|
+
const ordered = order.filter((name) => known.has(name));
|
|
564
|
+
const remaining = columns.filter((name) => !ordered.includes(name));
|
|
565
|
+
return [...ordered, ...remaining];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function getHiddenColumnSet(widget) {
|
|
569
|
+
const hidden = Array.isArray(widget?.config?.fieldSettings?.hidden) ? widget.config.fieldSettings.hidden : [];
|
|
570
|
+
return new Set(hidden);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function getVisibleColumns(widget) {
|
|
574
|
+
const ordered = getOrderedColumns(widget);
|
|
575
|
+
const hidden = getHiddenColumnSet(widget);
|
|
576
|
+
return ordered.filter((name) => !hidden.has(name));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function withFieldSettings(config, patch) {
|
|
580
|
+
const current = isPlainConfigObject(config?.fieldSettings) ? config.fieldSettings : { hidden: [], order: [] };
|
|
581
|
+
return {
|
|
582
|
+
...config,
|
|
583
|
+
fieldSettings: {
|
|
584
|
+
hidden: Array.isArray(current.hidden) ? [...current.hidden] : [],
|
|
585
|
+
order: Array.isArray(current.order) ? [...current.order] : [],
|
|
586
|
+
...patch
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function isPlainConfigObject(value) {
|
|
592
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function reorderColumn(widget, fieldId, direction) {
|
|
596
|
+
const ordered = getOrderedColumns(widget);
|
|
597
|
+
const index = ordered.indexOf(fieldId);
|
|
598
|
+
if (index < 0) return widget.config?.fieldSettings;
|
|
599
|
+
const target = direction === "up" ? index - 1 : index + 1;
|
|
600
|
+
if (target < 0 || target >= ordered.length) return widget.config?.fieldSettings;
|
|
601
|
+
const next = [...ordered];
|
|
602
|
+
const [moved] = next.splice(index, 1);
|
|
603
|
+
next.splice(target, 0, moved);
|
|
604
|
+
return { ...(widget.config?.fieldSettings || {}), order: next };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function toggleColumnHidden(widget, fieldId) {
|
|
608
|
+
const hidden = getHiddenColumnSet(widget);
|
|
609
|
+
if (hidden.has(fieldId)) hidden.delete(fieldId);
|
|
610
|
+
else hidden.add(fieldId);
|
|
611
|
+
return { ...(widget.config?.fieldSettings || {}), hidden: Array.from(hidden) };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function getSortClauses(widget) {
|
|
615
|
+
return Array.isArray(widget?.config?.sort) ? widget.config.sort : [];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function getFilterConfig(widget) {
|
|
619
|
+
const filter = widget?.config?.filter;
|
|
620
|
+
if (!isPlainConfigObject(filter)) return { op: DEFAULT_FILTER_OP, clauses: [] };
|
|
621
|
+
return {
|
|
622
|
+
op: KNOWN_FILTER_CONJUNCTIONS.includes(filter.op) ? filter.op : DEFAULT_FILTER_OP,
|
|
623
|
+
clauses: Array.isArray(filter.clauses) ? filter.clauses : []
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function getChartType(widget) {
|
|
628
|
+
const chartType = widget?.config?.chartType;
|
|
629
|
+
return KNOWN_CHART_TYPES.includes(chartType) ? chartType : DEFAULT_CHART_TYPE;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function getChartAxis(widget, axisKey) {
|
|
633
|
+
const axis = widget?.config?.[axisKey];
|
|
634
|
+
return isPlainConfigObject(axis) ? axis : {};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function getChartStyle(widget) {
|
|
638
|
+
const style = widget?.config?.style;
|
|
639
|
+
return isPlainConfigObject(style) ? style : {};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function summarizeSource(widget) {
|
|
643
|
+
const binding = widget?.config?.binding;
|
|
644
|
+
if (binding?.mode === "integration") {
|
|
645
|
+
const source = binding.source || "Integration";
|
|
646
|
+
if (binding.entityLabel) return `${source} · ${binding.entityLabel}`;
|
|
647
|
+
if (binding.entityId) return `${source} · ${binding.entityId}`;
|
|
648
|
+
return source;
|
|
649
|
+
}
|
|
650
|
+
if (widget?.config?.source) return widget.config.source;
|
|
651
|
+
return "Static";
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function summarizeSourceType(binding) {
|
|
655
|
+
if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
|
|
656
|
+
if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE) return "Managed Integrations";
|
|
657
|
+
return "Static data";
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function resolveBindingSourceType(binding) {
|
|
661
|
+
if (binding?.sourceType) return binding.sourceType;
|
|
662
|
+
if (binding?.mode === "integration") return MANAGED_INTEGRATION_SOURCE_TYPE;
|
|
663
|
+
return "static";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function summarizeFields(widget) {
|
|
667
|
+
const total = getColumnList(widget).length;
|
|
668
|
+
const hidden = getHiddenColumnSet(widget).size;
|
|
669
|
+
if (!total) return "0 shown";
|
|
670
|
+
return hidden ? `${total - hidden} of ${total} shown` : `${total} shown`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function summarizeSort(widget) {
|
|
674
|
+
const sort = getSortClauses(widget);
|
|
675
|
+
if (!sort.length) return "›";
|
|
676
|
+
if (sort.length === 1) {
|
|
677
|
+
const [first] = sort;
|
|
678
|
+
return `${first.fieldId} ${first.direction || DEFAULT_SORT_DIRECTION}`;
|
|
679
|
+
}
|
|
680
|
+
return `${sort.length} sorts`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function summarizeFilter(widget) {
|
|
684
|
+
const filter = getFilterConfig(widget);
|
|
685
|
+
const count = filter.clauses.length;
|
|
686
|
+
if (!count) return "›";
|
|
687
|
+
return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function describeIntegrationLane(integration) {
|
|
691
|
+
return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function flattenIntegrationSettings(integrationSettings) {
|
|
695
|
+
const grouped = integrationSettings?.integrations || integrationSettings || {};
|
|
696
|
+
const runtime = [
|
|
697
|
+
...(Array.isArray(grouped.dataSources) ? grouped.dataSources : []),
|
|
698
|
+
...(Array.isArray(grouped.workspaceIntegrations) ? grouped.workspaceIntegrations : [])
|
|
699
|
+
];
|
|
700
|
+
const byId = new Map();
|
|
701
|
+
for (const item of [...governedWorkspaceIntegrationCatalog, ...runtime]) {
|
|
702
|
+
if (item?.id) byId.set(item.id, { ...byId.get(item.id), ...item });
|
|
703
|
+
}
|
|
704
|
+
return Array.from(byId.values());
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function getFilterFieldOptions(widget, entities = []) {
|
|
708
|
+
const fields = new Set(getColumnList(widget));
|
|
709
|
+
const binding = widget?.config?.binding || {};
|
|
710
|
+
if (binding.mode === "integration") {
|
|
711
|
+
["id", "label", "secondaryLabel", "entityType", "provider", "lane", "status"].forEach((field) => fields.add(field));
|
|
712
|
+
fields.add("provider");
|
|
713
|
+
fields.add("lane");
|
|
714
|
+
for (const entity of entities) {
|
|
715
|
+
if (entity?.metadata && typeof entity.metadata === "object") {
|
|
716
|
+
Object.keys(entity.metadata).forEach((field) => fields.add(field));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (binding.sourceType === CUSTOM_API_SOURCE_TYPE) {
|
|
721
|
+
const customFields = Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"];
|
|
722
|
+
customFields.forEach((field) => fields.add(field));
|
|
723
|
+
}
|
|
724
|
+
return Array.from(fields).filter(Boolean);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function getEntityFieldValue(entity, fieldId) {
|
|
728
|
+
if (!entity || !fieldId) return "";
|
|
729
|
+
if (fieldId === "id" || fieldId === "entityId") return entity.id || "";
|
|
730
|
+
if (fieldId === "label" || fieldId === "name") return entity.label || "";
|
|
731
|
+
if (fieldId === "secondaryLabel") return entity.secondaryLabel || "";
|
|
732
|
+
if (fieldId === "entityType") return entity.entityType || "";
|
|
733
|
+
if (fieldId === "provider") return entity.provider || "";
|
|
734
|
+
if (fieldId === "lane") return entity.lane || "";
|
|
735
|
+
if (fieldId === "status") return entity.status || "";
|
|
736
|
+
if (entity.metadata && typeof entity.metadata === "object" && entity.metadata[fieldId] !== undefined) {
|
|
737
|
+
return String(entity.metadata[fieldId]);
|
|
738
|
+
}
|
|
739
|
+
return "";
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function getEntityFieldChoices(entities) {
|
|
743
|
+
const fields = new Map();
|
|
744
|
+
const add = (id, label) => {
|
|
745
|
+
if (id && !fields.has(id)) fields.set(id, { id, label });
|
|
746
|
+
};
|
|
747
|
+
add("id", "Stable ID");
|
|
748
|
+
add("label", "Primary label");
|
|
749
|
+
add("secondaryLabel", "Secondary label");
|
|
750
|
+
add("entityType", "Entity type");
|
|
751
|
+
add("provider", "Provider");
|
|
752
|
+
add("lane", "Lane");
|
|
753
|
+
add("status", "Status");
|
|
754
|
+
for (const entity of entities) {
|
|
755
|
+
if (entity?.metadata && typeof entity.metadata === "object") {
|
|
756
|
+
Object.keys(entity.metadata).forEach((field) => add(field, field));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return Array.from(fields.values());
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function getFilterFieldChoices(widget, entities = []) {
|
|
763
|
+
const binding = widget?.config?.binding || {};
|
|
764
|
+
if (binding.mode === "integration" && entities.length) return getEntityFieldChoices(entities);
|
|
765
|
+
return getFilterFieldOptions(widget, entities).map((id) => ({ id, label: id }));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getEntityValueChoices(entities, fieldId) {
|
|
769
|
+
const seen = new Map();
|
|
770
|
+
for (const entity of entities) {
|
|
771
|
+
const value = getEntityFieldValue(entity, fieldId);
|
|
772
|
+
if (!value || seen.has(value)) continue;
|
|
773
|
+
const label = fieldId === "id" || fieldId === "entityId"
|
|
774
|
+
? `${entity.label || value} · ${value}`
|
|
775
|
+
: value;
|
|
776
|
+
seen.set(value, { value, label, entity });
|
|
777
|
+
}
|
|
778
|
+
return Array.from(seen.values());
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function findEntityByFieldValue(entities, fieldId, value) {
|
|
782
|
+
if (!value) return null;
|
|
783
|
+
return entities.find((entity) => getEntityFieldValue(entity, fieldId) === value) || null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function updateWidgetEntityBinding(widget, entity) {
|
|
787
|
+
const binding = widget.config?.binding || {};
|
|
788
|
+
const existingFilter = widget.config?.filter;
|
|
789
|
+
const existingClauses = Array.isArray(existingFilter?.clauses) ? existingFilter.clauses : [];
|
|
790
|
+
|
|
791
|
+
if (!entity) {
|
|
792
|
+
const { entityId, entityType, entityLabel, ...restBinding } = binding;
|
|
793
|
+
const cleanedClauses = existingClauses.filter(
|
|
794
|
+
(clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
|
|
795
|
+
);
|
|
796
|
+
return {
|
|
797
|
+
...widget.config,
|
|
798
|
+
binding: restBinding,
|
|
799
|
+
filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: cleanedClauses }
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const entityClause = { fieldId: "id", operator: "eq", value: entity.id };
|
|
804
|
+
const otherClauses = existingClauses.filter(
|
|
805
|
+
(clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
|
|
806
|
+
);
|
|
807
|
+
const nextBinding = {
|
|
808
|
+
...binding,
|
|
809
|
+
entityId: entity.id,
|
|
810
|
+
entityLabel: entity.label
|
|
811
|
+
};
|
|
812
|
+
if (entity.entityType) nextBinding.entityType = entity.entityType;
|
|
813
|
+
else delete nextBinding.entityType;
|
|
814
|
+
return {
|
|
815
|
+
...widget.config,
|
|
816
|
+
source: binding.source || entity.label,
|
|
817
|
+
binding: nextBinding,
|
|
818
|
+
filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: [entityClause, ...otherClauses] }
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function resolveChartColor(style, branding) {
|
|
823
|
+
if (style?.colors === "manual" && style.manualColor) return style.manualColor;
|
|
824
|
+
if (style?.colors === "brand-local") return branding?.accent || "#3f68ff";
|
|
825
|
+
if (style?.colors === "brand-bridge") return branding?.bridgeAccent || branding?.accent || "#3f68ff";
|
|
826
|
+
if (style?.colors === "accent") return "#38bdf8";
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
411
830
|
const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
|
|
412
831
|
...normalizeWorkspaceTemplate(template),
|
|
413
832
|
widgets: template.widgets
|
|
@@ -451,9 +870,36 @@ function TemplateGallery({
|
|
|
451
870
|
onPreview,
|
|
452
871
|
onClose,
|
|
453
872
|
onApplyToCurrentTab,
|
|
454
|
-
onCloneAsDashboard
|
|
873
|
+
onCloneAsDashboard,
|
|
874
|
+
filter,
|
|
875
|
+
onFilterChange
|
|
455
876
|
}) {
|
|
877
|
+
const categories = useMemo(() => {
|
|
878
|
+
const set = new Set();
|
|
879
|
+
templates.forEach((template) => {
|
|
880
|
+
if (template.category) set.add(template.category);
|
|
881
|
+
});
|
|
882
|
+
return ["all", ...Array.from(set)];
|
|
883
|
+
}, [templates]);
|
|
884
|
+
const tags = useMemo(() => {
|
|
885
|
+
const set = new Set();
|
|
886
|
+
templates.forEach((template) => {
|
|
887
|
+
(template.tags || []).forEach((tag) => set.add(tag));
|
|
888
|
+
});
|
|
889
|
+
return ["all", ...Array.from(set)];
|
|
890
|
+
}, [templates]);
|
|
891
|
+
const filtered = useMemo(() => {
|
|
892
|
+
const query = (filter?.query || "").trim().toLowerCase();
|
|
893
|
+
return templates.filter((template) => {
|
|
894
|
+
if (filter?.category && filter.category !== "all" && template.category !== filter.category) return false;
|
|
895
|
+
if (filter?.tag && filter.tag !== "all" && !(template.tags || []).includes(filter.tag)) return false;
|
|
896
|
+
if (!query) return true;
|
|
897
|
+
const haystack = `${template.name} ${template.description} ${(template.tags || []).join(" ")} ${(template.bestFor || []).join(" ")}`.toLowerCase();
|
|
898
|
+
return haystack.includes(query);
|
|
899
|
+
});
|
|
900
|
+
}, [templates, filter]);
|
|
456
901
|
const previewTemplate = templates.find((template) => template.id === previewTemplateId) || null;
|
|
902
|
+
const setFilter = (patch) => onFilterChange({ ...(filter || {}), ...patch });
|
|
457
903
|
return <div className="template-gallery" role="dialog" aria-modal="true" aria-label="Workspace templates">
|
|
458
904
|
<div className="template-gallery-backdrop" onClick={onClose} aria-hidden="true" />
|
|
459
905
|
<section className="template-gallery-panel">
|
|
@@ -464,8 +910,31 @@ function TemplateGallery({
|
|
|
464
910
|
</div>
|
|
465
911
|
<button type="button" aria-label="Close template gallery" onClick={onClose}>x</button>
|
|
466
912
|
</header>
|
|
913
|
+
<div className="template-gallery-filters">
|
|
914
|
+
<input
|
|
915
|
+
aria-label="Search templates"
|
|
916
|
+
placeholder="Search templates…"
|
|
917
|
+
value={filter?.query || ""}
|
|
918
|
+
onChange={(event) => setFilter({ query: event.target.value })}
|
|
919
|
+
/>
|
|
920
|
+
<select
|
|
921
|
+
aria-label="Filter by category"
|
|
922
|
+
value={filter?.category || "all"}
|
|
923
|
+
onChange={(event) => setFilter({ category: event.target.value })}
|
|
924
|
+
>
|
|
925
|
+
{categories.map((category) => <option key={category} value={category}>{category === "all" ? "All categories" : category}</option>)}
|
|
926
|
+
</select>
|
|
927
|
+
<select
|
|
928
|
+
aria-label="Filter by tag"
|
|
929
|
+
value={filter?.tag || "all"}
|
|
930
|
+
onChange={(event) => setFilter({ tag: event.target.value })}
|
|
931
|
+
>
|
|
932
|
+
{tags.map((tag) => <option key={tag} value={tag}>{tag === "all" ? "All tags" : `#${tag}`}</option>)}
|
|
933
|
+
</select>
|
|
934
|
+
</div>
|
|
467
935
|
<div className="template-gallery-grid">
|
|
468
|
-
{
|
|
936
|
+
{filtered.length === 0 ? <p className="workspace-panel-hint">No templates match those filters.</p> : null}
|
|
937
|
+
{filtered.map((template) => {
|
|
469
938
|
const isPreviewing = previewTemplate?.id === template.id;
|
|
470
939
|
return <article
|
|
471
940
|
className={`template-card${isPreviewing ? " previewing" : ""}`}
|
|
@@ -502,10 +971,923 @@ function TemplateGallery({
|
|
|
502
971
|
</div>;
|
|
503
972
|
}
|
|
504
973
|
|
|
505
|
-
function
|
|
506
|
-
|
|
974
|
+
function SubPanelHeader({ title, breadcrumb, onBack }) {
|
|
975
|
+
return <div className="workspace-widget-subpanel-header">
|
|
976
|
+
<button type="button" className="workspace-widget-subpanel-back" aria-label={`Back from ${title}`} onClick={onBack}>‹</button>
|
|
977
|
+
<div>
|
|
978
|
+
{breadcrumb ? <p>{breadcrumb}</p> : null}
|
|
979
|
+
<strong>{title}</strong>
|
|
980
|
+
</div>
|
|
981
|
+
</div>;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* EntityBadge — chip showing the selected entity on the source panel and root inspector.
|
|
986
|
+
* Displays primary label + muted secondary label (stable ID). onClear is optional.
|
|
987
|
+
*/
|
|
988
|
+
function EntityBadge({ entity, onClear }) {
|
|
989
|
+
const initials = entity.entityType
|
|
990
|
+
? entity.entityType[0].toUpperCase()
|
|
991
|
+
: (entity.label?.[0] || "•").toUpperCase();
|
|
992
|
+
return <div className="workspace-entity-badge">
|
|
993
|
+
<span className="workspace-entity-badge-icon" aria-hidden="true">{initials}</span>
|
|
994
|
+
<span className="workspace-entity-badge-meta">
|
|
995
|
+
<strong title={entity.label}>{entity.label}</strong>
|
|
996
|
+
{entity.secondaryLabel ? <em title={entity.secondaryLabel}>{entity.secondaryLabel}</em> : null}
|
|
997
|
+
</span>
|
|
998
|
+
{onClear ? <button
|
|
999
|
+
type="button"
|
|
1000
|
+
className="workspace-entity-badge-clear"
|
|
1001
|
+
aria-label={`Clear selected entity ${entity.label}`}
|
|
1002
|
+
onClick={onClear}
|
|
1003
|
+
>
|
|
1004
|
+
<X size={11} />
|
|
1005
|
+
</button> : null}
|
|
1006
|
+
</div>;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function UniversalSourceInfoCard() {
|
|
1010
|
+
return <p className="workspace-source-info-card">
|
|
1011
|
+
Universal source objects support managed integrations and custom APIs/webhooks through normalized metadata and stable saved references.
|
|
1012
|
+
</p>;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* EntitySelector — compact dropdown for picking a normalized source object after
|
|
1017
|
+
* an integration is selected from the SourceSubPanel.
|
|
1018
|
+
*
|
|
1019
|
+
* Governed invariant: only the object `id` is persisted. The `label` is
|
|
1020
|
+
* display-only and may be refreshed from adapter metadata at any time.
|
|
1021
|
+
* The browser never holds source credentials or executes source queries.
|
|
1022
|
+
*/
|
|
1023
|
+
function EntitySelector({ integration, entities, selectedEntityId, selectedEntityLabel, selectedEntityType, onSelect, loading }) {
|
|
1024
|
+
const selected = entities.find((e) => e.id === selectedEntityId)
|
|
1025
|
+
|| (selectedEntityId ? {
|
|
1026
|
+
id: selectedEntityId,
|
|
1027
|
+
label: selectedEntityLabel || selectedEntityId,
|
|
1028
|
+
secondaryLabel: selectedEntityId,
|
|
1029
|
+
entityType: selectedEntityType
|
|
1030
|
+
} : null);
|
|
1031
|
+
|
|
1032
|
+
const clearSelected = () => {
|
|
1033
|
+
if (!selectedEntityId || window.confirm("Remove the selected source object from this widget?")) {
|
|
1034
|
+
onSelect(null);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
return <div className="workspace-entity-selector">
|
|
1039
|
+
<p className="workspace-panel-label">Source object</p>
|
|
1040
|
+
{selected ? <EntityBadge entity={selected} onClear={clearSelected} /> : null}
|
|
1041
|
+
{loading ? <p className="workspace-entity-empty">Loading source objects…</p> : null}
|
|
1042
|
+
{!loading && !entities.length ? <p className="workspace-entity-empty">
|
|
1043
|
+
No source objects returned. Configure a server-side API/webhook object resolver for this integration.
|
|
1044
|
+
</p> : null}
|
|
1045
|
+
{!loading && entities.length ? <label className="workspace-entity-dropdown">
|
|
1046
|
+
<span>Select source object</span>
|
|
1047
|
+
<select
|
|
1048
|
+
aria-label="Select source object"
|
|
1049
|
+
value={selectedEntityId || ""}
|
|
1050
|
+
onChange={(event) => {
|
|
1051
|
+
const entity = entities.find((item) => item.id === event.target.value);
|
|
1052
|
+
onSelect(entity || null);
|
|
1053
|
+
}}
|
|
1054
|
+
>
|
|
1055
|
+
<option value="">Choose an object</option>
|
|
1056
|
+
{entities.map((entity) => <option key={entity.id} value={entity.id}>
|
|
1057
|
+
{entity.label}{entity.secondaryLabel ? ` · ${entity.secondaryLabel}` : ""}
|
|
1058
|
+
</option>)}
|
|
1059
|
+
</select>
|
|
1060
|
+
</label> : null}
|
|
1061
|
+
</div>;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
1065
|
+
const binding = widget.config?.binding || {};
|
|
1066
|
+
const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
|
|
1067
|
+
const activeSourceType = resolveBindingSourceType(binding);
|
|
1068
|
+
const [query, setQuery] = useState("");
|
|
1069
|
+
const [laneFilter, setLaneFilter] = useState("all");
|
|
1070
|
+
const hasConnectedSource = Boolean(
|
|
1071
|
+
binding.integrationId ||
|
|
1072
|
+
binding.endpointRef ||
|
|
1073
|
+
binding.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE ||
|
|
1074
|
+
binding.sourceType === CUSTOM_API_SOURCE_TYPE
|
|
1075
|
+
);
|
|
1076
|
+
const confirmSourceChange = useCallback((nextLabel) => {
|
|
1077
|
+
if (!hasConnectedSource) return true;
|
|
1078
|
+
const currentLabel = summarizeSource(widget);
|
|
1079
|
+
return window.confirm(`Change source from ${currentLabel} to ${nextLabel}? This updates the widget binding and can clear source-object filters.`);
|
|
1080
|
+
}, [hasConnectedSource, widget]);
|
|
1081
|
+
|
|
1082
|
+
const activeIntegration = useMemo(() => {
|
|
1083
|
+
if (currentMode !== "integration" || !binding.integrationId) return null;
|
|
1084
|
+
const list = Array.isArray(integrations) ? integrations : [];
|
|
1085
|
+
return list.find((item) => item.id === binding.integrationId) || null;
|
|
1086
|
+
}, [currentMode, binding.integrationId, integrations]);
|
|
1087
|
+
|
|
1088
|
+
const groups = useMemo(() => {
|
|
1089
|
+
const list = Array.isArray(integrations) ? integrations : [];
|
|
1090
|
+
const filtered = list.filter((item) => {
|
|
1091
|
+
if (laneFilter !== "all" && item.lane !== laneFilter) return false;
|
|
1092
|
+
const text = `${item.label} ${item.provider} ${item.description}`.toLowerCase();
|
|
1093
|
+
return !query.trim() || text.includes(query.trim().toLowerCase());
|
|
1094
|
+
});
|
|
1095
|
+
return {
|
|
1096
|
+
"data-source": filtered.filter((item) => item.lane === "data-source"),
|
|
1097
|
+
"workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
|
|
1098
|
+
};
|
|
1099
|
+
}, [integrations, laneFilter, query]);
|
|
1100
|
+
|
|
1101
|
+
const selectStatic = useCallback(() => {
|
|
1102
|
+
if (!confirmSourceChange("Static rows")) return;
|
|
1103
|
+
if (widget.kind === "chart") {
|
|
1104
|
+
onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
|
|
1105
|
+
} else {
|
|
1106
|
+
onChange({
|
|
1107
|
+
...widget.config,
|
|
1108
|
+
source: widget.config?.source || "Companies",
|
|
1109
|
+
binding: SAMPLE_DATA_BINDINGS.companiesManual
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
}, [confirmSourceChange, onChange, widget.config, widget.kind]);
|
|
1113
|
+
|
|
1114
|
+
const selectCustomApi = useCallback(() => {
|
|
1115
|
+
if (!confirmSourceChange("Custom APIs/Webhooks")) return;
|
|
1116
|
+
onChange({
|
|
1117
|
+
...widget.config,
|
|
1118
|
+
source: "Custom APIs/Webhooks",
|
|
1119
|
+
binding: {
|
|
1120
|
+
...binding,
|
|
1121
|
+
mode: "json",
|
|
1122
|
+
source: "Custom APIs/Webhooks",
|
|
1123
|
+
sourceType: CUSTOM_API_SOURCE_TYPE,
|
|
1124
|
+
sourceAuthority: "custom-api",
|
|
1125
|
+
endpointRef: binding.endpointRef || "",
|
|
1126
|
+
fields: Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"]
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
}, [binding, confirmSourceChange, onChange, widget.config]);
|
|
1130
|
+
|
|
1131
|
+
const updateCustomFields = useCallback((value) => {
|
|
1132
|
+
const fields = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1133
|
+
onChange({
|
|
1134
|
+
...widget.config,
|
|
1135
|
+
binding: {
|
|
1136
|
+
...binding,
|
|
1137
|
+
mode: "json",
|
|
1138
|
+
source: "Custom APIs/Webhooks",
|
|
1139
|
+
sourceType: CUSTOM_API_SOURCE_TYPE,
|
|
1140
|
+
sourceAuthority: "custom-api",
|
|
1141
|
+
fields
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
}, [binding, onChange, widget.config]);
|
|
1145
|
+
|
|
1146
|
+
const updateEndpointRef = useCallback((value) => {
|
|
1147
|
+
onChange({
|
|
1148
|
+
...widget.config,
|
|
1149
|
+
binding: {
|
|
1150
|
+
...binding,
|
|
1151
|
+
mode: "json",
|
|
1152
|
+
source: "Custom APIs/Webhooks",
|
|
1153
|
+
sourceType: CUSTOM_API_SOURCE_TYPE,
|
|
1154
|
+
sourceAuthority: "custom-api",
|
|
1155
|
+
endpointRef: value
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
}, [binding, onChange, widget.config]);
|
|
1159
|
+
|
|
1160
|
+
const selectIntegration = useCallback((integration) => {
|
|
1161
|
+
if (binding.integrationId && binding.integrationId !== integration.id && !confirmSourceChange(integration.label)) return;
|
|
1162
|
+
onChange({
|
|
1163
|
+
...widget.config,
|
|
1164
|
+
source: integration.label,
|
|
1165
|
+
binding: {
|
|
1166
|
+
mode: "integration",
|
|
1167
|
+
source: integration.label,
|
|
1168
|
+
sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
|
|
1169
|
+
sourceAuthority: "growthub-bridge",
|
|
1170
|
+
integrationId: integration.id,
|
|
1171
|
+
lane: integration.lane,
|
|
1172
|
+
provider: integration.provider
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
}, [binding.integrationId, confirmSourceChange, onChange, widget.config]);
|
|
1176
|
+
|
|
1177
|
+
return <section className="workspace-widget-subpanel">
|
|
1178
|
+
<SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
|
|
1179
|
+
<UniversalSourceInfoCard />
|
|
1180
|
+
<p className="workspace-panel-label">Source type</p>
|
|
1181
|
+
<div className="workspace-source-object-list">
|
|
1182
|
+
{SOURCE_TYPE_OBJECTS.map((sourceType) => {
|
|
1183
|
+
const isActive = activeSourceType === sourceType.id;
|
|
1184
|
+
return <button
|
|
1185
|
+
key={sourceType.id}
|
|
1186
|
+
type="button"
|
|
1187
|
+
className={`workspace-source-object-row${isActive ? " active" : ""}`}
|
|
1188
|
+
onClick={sourceType.id === CUSTOM_API_SOURCE_TYPE ? selectCustomApi : undefined}
|
|
1189
|
+
disabled={sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE}
|
|
1190
|
+
>
|
|
1191
|
+
<span className="workspace-source-object-icon" aria-hidden="true">
|
|
1192
|
+
{sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE ? <Database size={15} /> : <LinkIcon size={15} />}
|
|
1193
|
+
</span>
|
|
1194
|
+
<span className="workspace-source-meta">
|
|
1195
|
+
<strong>{sourceType.label}</strong>
|
|
1196
|
+
<em>{sourceType.authority} · {sourceType.description}</em>
|
|
1197
|
+
</span>
|
|
1198
|
+
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1199
|
+
</button>;
|
|
1200
|
+
})}
|
|
1201
|
+
</div>
|
|
1202
|
+
<div className="workspace-source-controls">
|
|
1203
|
+
<label>
|
|
1204
|
+
<Search size={14} aria-hidden="true" />
|
|
1205
|
+
<input
|
|
1206
|
+
aria-label="Search sources"
|
|
1207
|
+
placeholder="Search connectors"
|
|
1208
|
+
value={query}
|
|
1209
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
1210
|
+
/>
|
|
1211
|
+
</label>
|
|
1212
|
+
<label>
|
|
1213
|
+
<Database size={14} aria-hidden="true" />
|
|
1214
|
+
<select
|
|
1215
|
+
aria-label="Filter source type"
|
|
1216
|
+
value={laneFilter}
|
|
1217
|
+
onChange={(event) => setLaneFilter(event.target.value)}
|
|
1218
|
+
>
|
|
1219
|
+
<option value="all">All connector lanes</option>
|
|
1220
|
+
<option value="data-source">Data sources</option>
|
|
1221
|
+
<option value="workspace-integration">Workspace tools</option>
|
|
1222
|
+
</select>
|
|
1223
|
+
<ChevronDown size={14} aria-hidden="true" />
|
|
1224
|
+
</label>
|
|
1225
|
+
</div>
|
|
1226
|
+
<p className="workspace-panel-label">Static data</p>
|
|
1227
|
+
<div className="workspace-source-list">
|
|
1228
|
+
<button
|
|
1229
|
+
type="button"
|
|
1230
|
+
className={`workspace-source-row${activeSourceType === "static" ? " active" : ""}`}
|
|
1231
|
+
onClick={selectStatic}
|
|
1232
|
+
>
|
|
1233
|
+
<span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
|
|
1234
|
+
<span className="workspace-source-meta">
|
|
1235
|
+
<strong>Static rows</strong>
|
|
1236
|
+
<em>Inline JSON, CSV, or manual rows remain supported.</em>
|
|
1237
|
+
</span>
|
|
1238
|
+
{activeSourceType === "static" ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1239
|
+
</button>
|
|
1240
|
+
</div>
|
|
1241
|
+
{Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
|
|
1242
|
+
<p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
|
|
1243
|
+
<div className="workspace-source-list">
|
|
1244
|
+
{items.map((integration) => {
|
|
1245
|
+
const isActive = currentMode === "integration" && binding.integrationId === integration.id;
|
|
1246
|
+
const connected = integration.isConnected || integration.status === "connected";
|
|
1247
|
+
return <button
|
|
1248
|
+
key={integration.id}
|
|
1249
|
+
type="button"
|
|
1250
|
+
className={`workspace-source-row${isActive ? " active" : ""}`}
|
|
1251
|
+
onClick={() => selectIntegration(integration)}
|
|
1252
|
+
>
|
|
1253
|
+
<span className="workspace-source-icon" aria-hidden="true">{integration.icon || integration.label?.[0] || "•"}</span>
|
|
1254
|
+
<span className="workspace-source-meta">
|
|
1255
|
+
<strong>{integration.label}</strong>
|
|
1256
|
+
<em>{describeIntegrationLane(integration)} · {connected ? "connected" : "needs connection"}</em>
|
|
1257
|
+
</span>
|
|
1258
|
+
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1259
|
+
</button>;
|
|
1260
|
+
})}
|
|
1261
|
+
</div>
|
|
1262
|
+
</div> : null)}
|
|
1263
|
+
{activeSourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-custom-source-config">
|
|
1264
|
+
<label>
|
|
1265
|
+
<span>Endpoint reference</span>
|
|
1266
|
+
<input
|
|
1267
|
+
value={binding.endpointRef || ""}
|
|
1268
|
+
placeholder="api.clients.primary"
|
|
1269
|
+
onChange={(event) => updateEndpointRef(event.target.value)}
|
|
1270
|
+
/>
|
|
1271
|
+
</label>
|
|
1272
|
+
<label>
|
|
1273
|
+
<span>Available fields</span>
|
|
1274
|
+
<input
|
|
1275
|
+
value={(Array.isArray(binding.fields) ? binding.fields : []).join(", ")}
|
|
1276
|
+
placeholder="entityId, status, createdAt"
|
|
1277
|
+
onChange={(event) => updateCustomFields(event.target.value)}
|
|
1278
|
+
/>
|
|
1279
|
+
</label>
|
|
1280
|
+
</div> : null}
|
|
1281
|
+
{currentMode === "integration" && binding.integrationId ? <div className="workspace-active-source-state">
|
|
1282
|
+
<span>Active source</span>
|
|
1283
|
+
<strong>{activeIntegration?.label || binding.source || binding.integrationId}</strong>
|
|
1284
|
+
<code>{binding.integrationId}</code>
|
|
1285
|
+
</div> : null}
|
|
1286
|
+
<p className="workspace-panel-hint">
|
|
1287
|
+
Selecting a source writes a binding reference only. The browser only calls local workspace routes and never stores source credentials.
|
|
1288
|
+
</p>
|
|
1289
|
+
</section>;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
1293
|
+
const ordered = getOrderedColumns(widget);
|
|
1294
|
+
const hidden = getHiddenColumnSet(widget);
|
|
1295
|
+
const visible = ordered.filter((name) => !hidden.has(name));
|
|
1296
|
+
const hiddenList = ordered.filter((name) => hidden.has(name));
|
|
1297
|
+
const [hiddenOpen, setHiddenOpen] = useState(true);
|
|
1298
|
+
const [draftField, setDraftField] = useState("");
|
|
1299
|
+
const move = (fieldId, direction) => {
|
|
1300
|
+
const next = reorderColumn(widget, fieldId, direction);
|
|
1301
|
+
onChange({ ...widget.config, fieldSettings: next });
|
|
1302
|
+
};
|
|
1303
|
+
const toggle = (fieldId) => {
|
|
1304
|
+
const next = toggleColumnHidden(widget, fieldId);
|
|
1305
|
+
onChange({ ...widget.config, fieldSettings: next });
|
|
1306
|
+
};
|
|
1307
|
+
const removeColumn = (fieldId) => {
|
|
1308
|
+
const nextColumns = ordered.filter((name) => name !== fieldId);
|
|
1309
|
+
const fs = widget.config?.fieldSettings || {};
|
|
1310
|
+
onChange({
|
|
1311
|
+
...widget.config,
|
|
1312
|
+
columns: nextColumns,
|
|
1313
|
+
fieldSettings: {
|
|
1314
|
+
hidden: (fs.hidden || []).filter((name) => name !== fieldId),
|
|
1315
|
+
order: (fs.order || []).filter((name) => name !== fieldId)
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
const addColumn = () => {
|
|
1320
|
+
const trimmed = draftField.trim();
|
|
1321
|
+
if (!trimmed || ordered.includes(trimmed)) return;
|
|
1322
|
+
onChange({ ...widget.config, columns: [...ordered, trimmed] });
|
|
1323
|
+
setDraftField("");
|
|
1324
|
+
};
|
|
1325
|
+
return <section className="workspace-widget-subpanel">
|
|
1326
|
+
<SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
|
|
1327
|
+
<p className="workspace-panel-label">Visible fields</p>
|
|
1328
|
+
<div className="workspace-field-rows">
|
|
1329
|
+
{visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
|
|
1330
|
+
{visible.map((name, index) => <div key={name} className="workspace-field-row">
|
|
1331
|
+
<span className="workspace-field-row-handle" aria-hidden="true">::</span>
|
|
1332
|
+
<span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
|
|
1333
|
+
<span className="workspace-field-row-name">{name}</span>
|
|
1334
|
+
<span className="workspace-field-row-actions">
|
|
1335
|
+
<button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>↑</button>
|
|
1336
|
+
<button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>↓</button>
|
|
1337
|
+
<button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>👁</button>
|
|
1338
|
+
<button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
|
|
1339
|
+
</span>
|
|
1340
|
+
</div>)}
|
|
1341
|
+
</div>
|
|
1342
|
+
<button
|
|
1343
|
+
type="button"
|
|
1344
|
+
className="workspace-hidden-fields-toggle"
|
|
1345
|
+
onClick={() => setHiddenOpen((value) => !value)}
|
|
1346
|
+
aria-expanded={hiddenOpen}
|
|
1347
|
+
>
|
|
1348
|
+
<span>👁🗨 Hidden Fields</span>
|
|
1349
|
+
<span aria-hidden="true">{hiddenOpen ? "−" : "+"}</span>
|
|
1350
|
+
</button>
|
|
1351
|
+
{hiddenOpen ? <div className="workspace-field-rows workspace-hidden-fields">
|
|
1352
|
+
{hiddenList.length === 0 ? <p className="workspace-panel-hint">No hidden fields.</p> : null}
|
|
1353
|
+
{hiddenList.map((name) => <div key={name} className="workspace-field-row workspace-field-row-hidden">
|
|
1354
|
+
<span className="workspace-field-row-icon" aria-hidden="true">{COLUMN_ICON_FOR(name)}</span>
|
|
1355
|
+
<span className="workspace-field-row-name">{name}</span>
|
|
1356
|
+
<span className="workspace-field-row-actions">
|
|
1357
|
+
<button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>👁</button>
|
|
1358
|
+
<button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
|
|
1359
|
+
</span>
|
|
1360
|
+
</div>)}
|
|
1361
|
+
</div> : null}
|
|
1362
|
+
<div className="workspace-field-add">
|
|
1363
|
+
<input
|
|
1364
|
+
aria-label="New field name"
|
|
1365
|
+
value={draftField}
|
|
1366
|
+
placeholder="Add field…"
|
|
1367
|
+
onChange={(event) => setDraftField(event.target.value)}
|
|
1368
|
+
onKeyDown={(event) => {
|
|
1369
|
+
if (event.key === "Enter") {
|
|
1370
|
+
event.preventDefault();
|
|
1371
|
+
addColumn();
|
|
1372
|
+
}
|
|
1373
|
+
}}
|
|
1374
|
+
/>
|
|
1375
|
+
<button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
|
|
1376
|
+
</div>
|
|
1377
|
+
</section>;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function SortSubPanel({ widget, onChange, onBack }) {
|
|
1381
|
+
const sort = getSortClauses(widget);
|
|
1382
|
+
const columns = getColumnList(widget);
|
|
1383
|
+
const updateSort = (next) => onChange({ ...widget.config, sort: next });
|
|
1384
|
+
const addClause = () => {
|
|
1385
|
+
const fieldId = columns[0] || "";
|
|
1386
|
+
if (!fieldId) return;
|
|
1387
|
+
updateSort([...sort, { fieldId, direction: DEFAULT_SORT_DIRECTION }]);
|
|
1388
|
+
};
|
|
1389
|
+
const updateClause = (index, patch) => {
|
|
1390
|
+
updateSort(sort.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause));
|
|
1391
|
+
};
|
|
1392
|
+
const removeClause = (index) => updateSort(sort.filter((_, idx) => idx !== index));
|
|
1393
|
+
return <section className="workspace-widget-subpanel">
|
|
1394
|
+
<SubPanelHeader title="Sorts" breadcrumb={widget.title} onBack={onBack} />
|
|
1395
|
+
<p className="workspace-panel-label">Sorts</p>
|
|
1396
|
+
<div className="workspace-sort-list">
|
|
1397
|
+
{sort.length === 0 ? <p className="workspace-panel-hint">No sorts applied.</p> : null}
|
|
1398
|
+
{sort.map((clause, index) => <div key={index} className="workspace-sort-row">
|
|
1399
|
+
<select
|
|
1400
|
+
aria-label={`Sort ${index + 1} field`}
|
|
1401
|
+
value={clause.fieldId}
|
|
1402
|
+
onChange={(event) => updateClause(index, { fieldId: event.target.value })}
|
|
1403
|
+
>
|
|
1404
|
+
{!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
|
|
1405
|
+
{columns.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
1406
|
+
</select>
|
|
1407
|
+
<select
|
|
1408
|
+
aria-label={`Sort ${index + 1} direction`}
|
|
1409
|
+
value={clause.direction || DEFAULT_SORT_DIRECTION}
|
|
1410
|
+
onChange={(event) => updateClause(index, { direction: event.target.value })}
|
|
1411
|
+
>
|
|
1412
|
+
{KNOWN_SORT_DIRECTIONS.map((dir) => <option key={dir} value={dir}>{dir === "asc" ? "Ascending" : "Descending"}</option>)}
|
|
1413
|
+
</select>
|
|
1414
|
+
<button type="button" aria-label={`Remove sort ${index + 1}`} onClick={() => removeClause(index)}>✕</button>
|
|
1415
|
+
</div>)}
|
|
1416
|
+
</div>
|
|
1417
|
+
<button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
|
|
1418
|
+
+ Add sort
|
|
1419
|
+
</button>
|
|
1420
|
+
<p className="workspace-panel-hint">
|
|
1421
|
+
Sort metadata persists with the widget. Live integrations are not queried from the browser.
|
|
1422
|
+
</p>
|
|
1423
|
+
</section>;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function FilterSubPanel({ widget, integrations, onChange, onBack }) {
|
|
1427
|
+
const binding = widget.config?.binding || {};
|
|
1428
|
+
const filter = getFilterConfig(widget);
|
|
1429
|
+
const [entities, setEntities] = useState([]);
|
|
1430
|
+
const [entitiesLoading, setEntitiesLoading] = useState(false);
|
|
1431
|
+
const fieldChoices = getFilterFieldChoices(widget, entities);
|
|
1432
|
+
const columns = fieldChoices.map((field) => field.id);
|
|
1433
|
+
const setFilter = (next) => onChange({ ...widget.config, filter: next });
|
|
1434
|
+
const setOp = (op) => setFilter({ ...filter, op });
|
|
1435
|
+
const activeIntegration = useMemo(() => {
|
|
1436
|
+
if (binding.mode !== "integration" || !binding.integrationId) return null;
|
|
1437
|
+
const list = Array.isArray(integrations) ? integrations : [];
|
|
1438
|
+
return list.find((item) => item.id === binding.integrationId) || null;
|
|
1439
|
+
}, [binding.integrationId, binding.mode, integrations]);
|
|
1440
|
+
|
|
1441
|
+
useEffect(() => {
|
|
1442
|
+
if (!binding.integrationId || binding.mode !== "integration") {
|
|
1443
|
+
setEntities([]);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
let cancelled = false;
|
|
1447
|
+
setEntitiesLoading(true);
|
|
1448
|
+
fetch(`/api/workspace/integration-entities?integrationId=${encodeURIComponent(binding.integrationId)}`, { cache: "no-store" })
|
|
1449
|
+
.then((res) => res.ok ? res.json() : { entities: [] })
|
|
1450
|
+
.then((data) => {
|
|
1451
|
+
if (!cancelled) {
|
|
1452
|
+
setEntities(Array.isArray(data.entities) ? data.entities : []);
|
|
1453
|
+
setEntitiesLoading(false);
|
|
1454
|
+
}
|
|
1455
|
+
})
|
|
1456
|
+
.catch(() => {
|
|
1457
|
+
if (!cancelled) {
|
|
1458
|
+
setEntities([]);
|
|
1459
|
+
setEntitiesLoading(false);
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
return () => { cancelled = true; };
|
|
1463
|
+
}, [binding.integrationId, binding.mode]);
|
|
1464
|
+
|
|
1465
|
+
const addClause = () => {
|
|
1466
|
+
const fieldId = binding.mode === "integration" && entities.length ? "id" : (columns[0] || "");
|
|
1467
|
+
if (!fieldId) return;
|
|
1468
|
+
setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: "eq", value: "" }] });
|
|
1469
|
+
};
|
|
1470
|
+
const updateField = (index, fieldId) => {
|
|
1471
|
+
setFilter({
|
|
1472
|
+
...filter,
|
|
1473
|
+
clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, fieldId, value: "" } : clause)
|
|
1474
|
+
});
|
|
1475
|
+
};
|
|
1476
|
+
const selectEntity = useCallback((entity) => {
|
|
1477
|
+
onChange(updateWidgetEntityBinding(widget, entity));
|
|
1478
|
+
}, [onChange, widget]);
|
|
1479
|
+
const updateClause = (index, patch) => {
|
|
1480
|
+
setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
|
|
1481
|
+
};
|
|
1482
|
+
const removeClause = (index) => {
|
|
1483
|
+
setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
|
|
1484
|
+
};
|
|
1485
|
+
return <section className="workspace-widget-subpanel">
|
|
1486
|
+
<SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
|
|
1487
|
+
<UniversalSourceInfoCard />
|
|
1488
|
+
<div className="workspace-filter-source-state">
|
|
1489
|
+
<span>{summarizeSourceType(binding)}</span>
|
|
1490
|
+
<strong>{summarizeSource(widget)}</strong>
|
|
1491
|
+
</div>
|
|
1492
|
+
{binding.mode === "integration" && binding.integrationId ? <EntitySelector
|
|
1493
|
+
integration={activeIntegration}
|
|
1494
|
+
entities={entities}
|
|
1495
|
+
selectedEntityId={binding.entityId || null}
|
|
1496
|
+
selectedEntityLabel={binding.entityLabel || null}
|
|
1497
|
+
selectedEntityType={binding.entityType || null}
|
|
1498
|
+
onSelect={selectEntity}
|
|
1499
|
+
loading={entitiesLoading}
|
|
1500
|
+
/> : null}
|
|
1501
|
+
{binding.sourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-filter-source-state">
|
|
1502
|
+
<span>Custom endpoint</span>
|
|
1503
|
+
<code>{binding.endpointRef || "No endpoint reference set"}</code>
|
|
1504
|
+
</div> : null}
|
|
1505
|
+
<div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
|
|
1506
|
+
{KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
|
|
1507
|
+
key={op}
|
|
1508
|
+
type="button"
|
|
1509
|
+
role="radio"
|
|
1510
|
+
aria-checked={filter.op === op}
|
|
1511
|
+
className={filter.op === op ? "active" : ""}
|
|
1512
|
+
onClick={() => setOp(op)}
|
|
1513
|
+
>{op.toUpperCase()}</button>)}
|
|
1514
|
+
</div>
|
|
1515
|
+
<div className="workspace-filter-list">
|
|
1516
|
+
{filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
|
|
1517
|
+
{filter.clauses.map((clause, index) => {
|
|
1518
|
+
const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
|
|
1519
|
+
const valueChoices = binding.mode === "integration" ? getEntityValueChoices(entities, clause.fieldId) : [];
|
|
1520
|
+
return <div key={index} className="workspace-filter-clause">
|
|
1521
|
+
<select
|
|
1522
|
+
aria-label={`Filter ${index + 1} field`}
|
|
1523
|
+
value={clause.fieldId}
|
|
1524
|
+
onChange={(event) => updateField(index, event.target.value)}
|
|
1525
|
+
>
|
|
1526
|
+
{!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
|
|
1527
|
+
{fieldChoices.map((field) => <option key={field.id} value={field.id}>{field.label}</option>)}
|
|
1528
|
+
</select>
|
|
1529
|
+
<select
|
|
1530
|
+
aria-label={`Filter ${index + 1} operator`}
|
|
1531
|
+
value={clause.operator || DEFAULT_FILTER_OPERATOR}
|
|
1532
|
+
onChange={(event) => updateClause(index, { operator: event.target.value })}
|
|
1533
|
+
>
|
|
1534
|
+
{KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
|
|
1535
|
+
</select>
|
|
1536
|
+
{!valueless && binding.mode === "integration" && valueChoices.length ? <select
|
|
1537
|
+
aria-label={`Filter ${index + 1} value`}
|
|
1538
|
+
value={clause.value ?? ""}
|
|
1539
|
+
onChange={(event) => {
|
|
1540
|
+
const entity = findEntityByFieldValue(entities, clause.fieldId, event.target.value);
|
|
1541
|
+
updateClause(index, { value: event.target.value });
|
|
1542
|
+
if (entity && (clause.fieldId === "id" || clause.fieldId === "entityId")) {
|
|
1543
|
+
onChange(updateWidgetEntityBinding(widget, entity));
|
|
1544
|
+
}
|
|
1545
|
+
}}
|
|
1546
|
+
>
|
|
1547
|
+
<option value="">Select value</option>
|
|
1548
|
+
{valueChoices.map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
|
|
1549
|
+
</select> : !valueless ? <input
|
|
1550
|
+
aria-label={`Filter ${index + 1} value`}
|
|
1551
|
+
value={clause.value ?? ""}
|
|
1552
|
+
placeholder="value"
|
|
1553
|
+
onChange={(event) => updateClause(index, { value: event.target.value })}
|
|
1554
|
+
/> : <span className="workspace-filter-clause-empty">—</span>}
|
|
1555
|
+
<button type="button" aria-label={`Remove filter ${index + 1}`} onClick={() => removeClause(index)}>✕</button>
|
|
1556
|
+
</div>;
|
|
1557
|
+
})}
|
|
1558
|
+
</div>
|
|
1559
|
+
<button type="button" className="workspace-add-clause" onClick={addClause} disabled={!columns.length}>
|
|
1560
|
+
+ Add filter
|
|
1561
|
+
</button>
|
|
1562
|
+
<p className="workspace-panel-hint">
|
|
1563
|
+
Filter metadata persists with the widget. Live integration queries stay in the CLI / hosted layers.
|
|
1564
|
+
</p>
|
|
1565
|
+
</section>;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
1569
|
+
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
1570
|
+
const xAxis = getChartAxis(widget, "xAxis");
|
|
1571
|
+
const yAxis = getChartAxis(widget, "yAxis");
|
|
1572
|
+
const style = getChartStyle(widget);
|
|
1573
|
+
const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
|
|
1574
|
+
const setChartType = (type) => onChange({ ...widget.config, chartType: type });
|
|
1575
|
+
const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
|
|
1576
|
+
const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
|
|
1577
|
+
const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
|
|
1578
|
+
return <section className="workspace-chart-config">
|
|
1579
|
+
<p className="workspace-panel-label">Chart type</p>
|
|
1580
|
+
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
1581
|
+
{VISIBLE_CHART_TYPES.map((type) => {
|
|
1582
|
+
const TypeIcon = CHART_TYPE_ICONS[type];
|
|
1583
|
+
return <button
|
|
1584
|
+
key={type}
|
|
1585
|
+
type="button"
|
|
1586
|
+
role="tab"
|
|
1587
|
+
aria-selected={chartType === type}
|
|
1588
|
+
className={chartType === type ? "active" : ""}
|
|
1589
|
+
onClick={() => setChartType(type)}
|
|
1590
|
+
title={CHART_TYPE_LABELS[type]}
|
|
1591
|
+
>
|
|
1592
|
+
<IconGlyph icon={TypeIcon} size={17} />
|
|
1593
|
+
<em>{CHART_TYPE_LABELS[type]}</em>
|
|
1594
|
+
</button>;
|
|
1595
|
+
})}
|
|
1596
|
+
</div>
|
|
1597
|
+
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
|
|
1598
|
+
<span>Source</span><code>{summarizeSourceType(widget.config?.binding)} · {summarizeSource(widget)}</code>
|
|
1599
|
+
</button>
|
|
1600
|
+
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
|
|
1601
|
+
<span>Filter</span><code>{summarizeFilter(widget)}</code>
|
|
1602
|
+
</button>
|
|
1603
|
+
{widget.config?.binding?.entityId ? <EntityBadge entity={{
|
|
1604
|
+
id: widget.config.binding.entityId,
|
|
1605
|
+
label: widget.config.binding.entityLabel || widget.config.binding.entityId,
|
|
1606
|
+
secondaryLabel: widget.config.binding.entityId,
|
|
1607
|
+
entityType: widget.config.binding.entityType
|
|
1608
|
+
}} /> : null}
|
|
1609
|
+
<p className="workspace-panel-label">X axis</p>
|
|
1610
|
+
<label>
|
|
1611
|
+
<span>Data on display</span>
|
|
1612
|
+
<input
|
|
1613
|
+
value={xAxis.field || ""}
|
|
1614
|
+
placeholder="Stage"
|
|
1615
|
+
onChange={(event) => setXAxis({ field: event.target.value })}
|
|
1616
|
+
/>
|
|
1617
|
+
</label>
|
|
1618
|
+
<label>
|
|
1619
|
+
<span>Sort by</span>
|
|
1620
|
+
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
1621
|
+
<option value="position">Stage position ascending</option>
|
|
1622
|
+
<option value="asc">Value ascending</option>
|
|
1623
|
+
<option value="desc">Value descending</option>
|
|
1624
|
+
</select>
|
|
1625
|
+
</label>
|
|
1626
|
+
<label className="workspace-toggle-row">
|
|
1627
|
+
<span>Omit zero values</span>
|
|
1628
|
+
<input
|
|
1629
|
+
type="checkbox"
|
|
1630
|
+
checked={Boolean(xAxis.omitZero)}
|
|
1631
|
+
onChange={(event) => setXAxis({ omitZero: event.target.checked })}
|
|
1632
|
+
/>
|
|
1633
|
+
</label>
|
|
1634
|
+
<p className="workspace-panel-label">Y axis</p>
|
|
1635
|
+
<label>
|
|
1636
|
+
<span>Aggregation</span>
|
|
1637
|
+
<select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
|
|
1638
|
+
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
1639
|
+
</select>
|
|
1640
|
+
</label>
|
|
1641
|
+
<label>
|
|
1642
|
+
<span>Data on display</span>
|
|
1643
|
+
<input
|
|
1644
|
+
value={yAxis.field || ""}
|
|
1645
|
+
placeholder="Amount"
|
|
1646
|
+
onChange={(event) => setYAxis({ field: event.target.value })}
|
|
1647
|
+
/>
|
|
1648
|
+
</label>
|
|
1649
|
+
<label>
|
|
1650
|
+
<span>Group by</span>
|
|
1651
|
+
<input
|
|
1652
|
+
value={yAxis.groupBy || ""}
|
|
1653
|
+
placeholder="—"
|
|
1654
|
+
onChange={(event) => setYAxis({ groupBy: event.target.value })}
|
|
1655
|
+
/>
|
|
1656
|
+
</label>
|
|
1657
|
+
<div className="workspace-axis-range">
|
|
1658
|
+
<label>
|
|
1659
|
+
<span>Min range</span>
|
|
1660
|
+
<input
|
|
1661
|
+
value={yAxis.min ?? ""}
|
|
1662
|
+
placeholder="Min"
|
|
1663
|
+
onChange={(event) => setYAxis({ min: event.target.value })}
|
|
1664
|
+
/>
|
|
1665
|
+
</label>
|
|
1666
|
+
<label>
|
|
1667
|
+
<span>Max range</span>
|
|
1668
|
+
<input
|
|
1669
|
+
value={yAxis.max ?? ""}
|
|
1670
|
+
placeholder="Max"
|
|
1671
|
+
onChange={(event) => setYAxis({ max: event.target.value })}
|
|
1672
|
+
/>
|
|
1673
|
+
</label>
|
|
1674
|
+
</div>
|
|
1675
|
+
<p className="workspace-panel-label">Style</p>
|
|
1676
|
+
<label>
|
|
1677
|
+
<span>Colors</span>
|
|
1678
|
+
<select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
|
|
1679
|
+
<option value="auto">Auto</option>
|
|
1680
|
+
<option value="accent">Accent</option>
|
|
1681
|
+
<option value="brand-local">Local brand kit</option>
|
|
1682
|
+
<option value="brand-bridge">Bridge brand kit</option>
|
|
1683
|
+
<option value="manual">Manual</option>
|
|
1684
|
+
</select>
|
|
1685
|
+
</label>
|
|
1686
|
+
<div className="workspace-color-preview-row">
|
|
1687
|
+
<span>Active color</span>
|
|
1688
|
+
<em style={{ background: activeColor }} />
|
|
1689
|
+
<code>{activeColor}</code>
|
|
1690
|
+
</div>
|
|
1691
|
+
{style.colors === "manual" ? <div className="workspace-color-picker-row">
|
|
1692
|
+
<label>
|
|
1693
|
+
<span>Manual color</span>
|
|
1694
|
+
<input
|
|
1695
|
+
type="color"
|
|
1696
|
+
value={style.manualColor || "#38bdf8"}
|
|
1697
|
+
onChange={(event) => setStyle({ manualColor: event.target.value })}
|
|
1698
|
+
/>
|
|
1699
|
+
</label>
|
|
1700
|
+
<input
|
|
1701
|
+
aria-label="Manual color hex"
|
|
1702
|
+
value={style.manualColor || "#38bdf8"}
|
|
1703
|
+
onChange={(event) => setStyle({ manualColor: event.target.value })}
|
|
1704
|
+
/>
|
|
1705
|
+
</div> : null}
|
|
1706
|
+
<label>
|
|
1707
|
+
<span>Axis name</span>
|
|
1708
|
+
<input
|
|
1709
|
+
value={style.axisName || ""}
|
|
1710
|
+
placeholder="None"
|
|
1711
|
+
onChange={(event) => setStyle({ axisName: event.target.value })}
|
|
1712
|
+
/>
|
|
1713
|
+
</label>
|
|
1714
|
+
<label className="workspace-toggle-row">
|
|
1715
|
+
<span>Data labels</span>
|
|
1716
|
+
<input
|
|
1717
|
+
type="checkbox"
|
|
1718
|
+
checked={Boolean(style.dataLabels)}
|
|
1719
|
+
onChange={(event) => setStyle({ dataLabels: event.target.checked })}
|
|
1720
|
+
/>
|
|
1721
|
+
</label>
|
|
1722
|
+
</section>;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function CommandPalette({ commands, onClose }) {
|
|
1726
|
+
const [query, setQuery] = useState("");
|
|
1727
|
+
const inputRef = useRef(null);
|
|
1728
|
+
const [highlight, setHighlight] = useState(0);
|
|
1729
|
+
useEffect(() => {
|
|
1730
|
+
inputRef.current?.focus();
|
|
1731
|
+
}, []);
|
|
1732
|
+
const filtered = useMemo(() => {
|
|
1733
|
+
const trimmed = query.trim().toLowerCase();
|
|
1734
|
+
if (!trimmed) return commands;
|
|
1735
|
+
return commands.filter((command) => {
|
|
1736
|
+
const haystack = `${command.label} ${command.group || ""} ${(command.aliases || []).join(" ")}`.toLowerCase();
|
|
1737
|
+
return haystack.includes(trimmed);
|
|
1738
|
+
});
|
|
1739
|
+
}, [commands, query]);
|
|
1740
|
+
useEffect(() => {
|
|
1741
|
+
setHighlight((value) => Math.min(value, Math.max(0, filtered.length - 1)));
|
|
1742
|
+
}, [filtered.length]);
|
|
1743
|
+
const handleKeyDown = (event) => {
|
|
1744
|
+
if (event.key === "ArrowDown") {
|
|
1745
|
+
event.preventDefault();
|
|
1746
|
+
setHighlight((value) => Math.min(filtered.length - 1, value + 1));
|
|
1747
|
+
} else if (event.key === "ArrowUp") {
|
|
1748
|
+
event.preventDefault();
|
|
1749
|
+
setHighlight((value) => Math.max(0, value - 1));
|
|
1750
|
+
} else if (event.key === "Enter") {
|
|
1751
|
+
event.preventDefault();
|
|
1752
|
+
const command = filtered[highlight];
|
|
1753
|
+
if (command && !command.disabled) {
|
|
1754
|
+
command.run();
|
|
1755
|
+
onClose();
|
|
1756
|
+
}
|
|
1757
|
+
} else if (event.key === "Escape") {
|
|
1758
|
+
event.preventDefault();
|
|
1759
|
+
onClose();
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
const groups = useMemo(() => {
|
|
1763
|
+
const map = new Map();
|
|
1764
|
+
filtered.forEach((command) => {
|
|
1765
|
+
const key = command.group || "General";
|
|
1766
|
+
if (!map.has(key)) map.set(key, []);
|
|
1767
|
+
map.get(key).push(command);
|
|
1768
|
+
});
|
|
1769
|
+
return Array.from(map.entries());
|
|
1770
|
+
}, [filtered]);
|
|
1771
|
+
return <div className="workspace-command-palette" role="dialog" aria-modal="true" aria-label="Command palette">
|
|
1772
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
1773
|
+
<section className="workspace-command-palette-panel" onKeyDown={handleKeyDown}>
|
|
1774
|
+
<header className="workspace-command-palette-input">
|
|
1775
|
+
<span aria-hidden="true">⌘</span>
|
|
1776
|
+
<input
|
|
1777
|
+
ref={inputRef}
|
|
1778
|
+
value={query}
|
|
1779
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
1780
|
+
placeholder="Type a command…"
|
|
1781
|
+
aria-label="Command palette search"
|
|
1782
|
+
/>
|
|
1783
|
+
<kbd>esc</kbd>
|
|
1784
|
+
</header>
|
|
1785
|
+
<div className="workspace-command-palette-list" role="listbox">
|
|
1786
|
+
{filtered.length === 0 ? <p className="workspace-panel-hint">No matching commands.</p> : null}
|
|
1787
|
+
{groups.map(([group, items]) => <div key={group} className="workspace-command-palette-group">
|
|
1788
|
+
<p className="workspace-panel-label">{group}</p>
|
|
1789
|
+
{items.map((command) => {
|
|
1790
|
+
const globalIndex = filtered.indexOf(command);
|
|
1791
|
+
const isHighlighted = globalIndex === highlight;
|
|
1792
|
+
return <button
|
|
1793
|
+
key={command.id}
|
|
1794
|
+
type="button"
|
|
1795
|
+
role="option"
|
|
1796
|
+
aria-selected={isHighlighted}
|
|
1797
|
+
className={`workspace-command-palette-item${isHighlighted ? " active" : ""}${command.disabled ? " disabled" : ""}`}
|
|
1798
|
+
disabled={command.disabled}
|
|
1799
|
+
onMouseEnter={() => setHighlight(globalIndex)}
|
|
1800
|
+
onClick={() => {
|
|
1801
|
+
if (command.disabled) return;
|
|
1802
|
+
command.run();
|
|
1803
|
+
onClose();
|
|
1804
|
+
}}
|
|
1805
|
+
>
|
|
1806
|
+
<span aria-hidden="true">{typeof command.icon === "string" ? command.icon : <IconGlyph icon={command.icon} size={15} />}</span>
|
|
1807
|
+
<span className="workspace-command-palette-label">{command.label}</span>
|
|
1808
|
+
{command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
|
|
1809
|
+
</button>;
|
|
1810
|
+
})}
|
|
1811
|
+
</div>)}
|
|
1812
|
+
</div>
|
|
1813
|
+
<footer className="workspace-command-palette-footer">
|
|
1814
|
+
<span>↑ ↓ navigate</span>
|
|
1815
|
+
<span>↵ run</span>
|
|
1816
|
+
<span>esc close</span>
|
|
1817
|
+
</footer>
|
|
1818
|
+
</section>
|
|
1819
|
+
</div>;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function RichTextEditor({ value, onChange }) {
|
|
1823
|
+
const textareaRef = useRef(null);
|
|
1824
|
+
const insert = useCallback((prefix, suffix = "", placeholder = "text") => {
|
|
1825
|
+
const textarea = textareaRef.current;
|
|
1826
|
+
const current = value || "";
|
|
1827
|
+
const start = textarea?.selectionStart ?? current.length;
|
|
1828
|
+
const end = textarea?.selectionEnd ?? current.length;
|
|
1829
|
+
const selected = current.slice(start, end) || placeholder;
|
|
1830
|
+
const next = `${current.slice(0, start)}${prefix}${selected}${suffix}${current.slice(end)}`;
|
|
1831
|
+
onChange(next);
|
|
1832
|
+
requestAnimationFrame(() => {
|
|
1833
|
+
textarea?.focus();
|
|
1834
|
+
const cursor = start + prefix.length + selected.length + suffix.length;
|
|
1835
|
+
textarea?.setSelectionRange(cursor, cursor);
|
|
1836
|
+
});
|
|
1837
|
+
}, [onChange, value]);
|
|
1838
|
+
return <div className="workspace-rich-text-editor">
|
|
1839
|
+
<div className="workspace-rich-text-toolbar" role="toolbar" aria-label="Rich text controls">
|
|
1840
|
+
<button type="button" aria-label="Heading" onClick={() => insert("## ", "", "Heading")}><Type size={14} /></button>
|
|
1841
|
+
<button type="button" aria-label="Bold" onClick={() => insert("**", "**")}><strong>B</strong></button>
|
|
1842
|
+
<button type="button" aria-label="Italic" onClick={() => insert("*", "*")}><Italic size={14} /></button>
|
|
1843
|
+
<button type="button" aria-label="Quote" onClick={() => insert("> ", "", "Quote")}><Quote size={14} /></button>
|
|
1844
|
+
<button type="button" aria-label="Bullet list" onClick={() => insert("- ", "", "List item")}><List size={14} /></button>
|
|
1845
|
+
<button type="button" aria-label="Link" onClick={() => insert("[", "](https://)", "Link")}><LinkIcon size={14} /></button>
|
|
1846
|
+
</div>
|
|
1847
|
+
<textarea
|
|
1848
|
+
ref={textareaRef}
|
|
1849
|
+
placeholder="Write text..."
|
|
1850
|
+
value={value || ""}
|
|
1851
|
+
onChange={(event) => onChange(event.target.value)}
|
|
1852
|
+
/>
|
|
1853
|
+
</div>;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function IframePreviewModal({ widget, onClose }) {
|
|
1857
|
+
const url = widget?.config?.url || "";
|
|
1858
|
+
const valid = isLikelyHttpUrl(url);
|
|
1859
|
+
return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label={`${widget?.title || "iFrame"} expanded preview`}>
|
|
1860
|
+
<div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
|
|
1861
|
+
<section className="workspace-iframe-modal">
|
|
1862
|
+
<header>
|
|
1863
|
+
<div>
|
|
1864
|
+
<p>iFrame preview</p>
|
|
1865
|
+
<h2>{widget?.title || "Untitled iFrame"}</h2>
|
|
1866
|
+
</div>
|
|
1867
|
+
<div>
|
|
1868
|
+
{valid ? <a href={url} target="_blank" rel="noreferrer"><ExternalLink size={15} /> Open</a> : null}
|
|
1869
|
+
<button type="button" aria-label="Close iFrame preview" onClick={onClose}><X size={16} /></button>
|
|
1870
|
+
</div>
|
|
1871
|
+
</header>
|
|
1872
|
+
{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>}
|
|
1873
|
+
</section>
|
|
1874
|
+
</div>;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
|
|
1878
|
+
const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
|
|
1879
|
+
const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
|
|
1880
|
+
const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
|
|
507
1881
|
const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
|
|
508
1882
|
const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
|
|
1883
|
+
const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
|
|
1884
|
+
const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
|
|
1885
|
+
const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
|
|
1886
|
+
const chartColor = resolveChartColor(chartStyle, branding);
|
|
1887
|
+
const selectedSourceObject = widget.config?.binding?.entityId ? {
|
|
1888
|
+
id: widget.config.binding.entityId,
|
|
1889
|
+
label: widget.config.binding.entityLabel || widget.config.binding.entityId
|
|
1890
|
+
} : null;
|
|
509
1891
|
return <article
|
|
510
1892
|
className={`workspace-widget-preview${selected ? " selected" : ""}`}
|
|
511
1893
|
onClick={onSelect}
|
|
@@ -521,6 +1903,10 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
|
|
|
521
1903
|
onPointerDown={(event) => onMoveStart(event)}
|
|
522
1904
|
>::</span>
|
|
523
1905
|
<strong>{widget.title}</strong>
|
|
1906
|
+
{selectedSourceObject ? <span
|
|
1907
|
+
className="workspace-widget-source-chip"
|
|
1908
|
+
title={`${selectedSourceObject.label} · ${selectedSourceObject.id}`}
|
|
1909
|
+
>{selectedSourceObject.label}</span> : null}
|
|
524
1910
|
<button
|
|
525
1911
|
aria-label={`Remove ${widget.title}`}
|
|
526
1912
|
onClick={(event) => {
|
|
@@ -528,7 +1914,7 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
|
|
|
528
1914
|
onRemove();
|
|
529
1915
|
}}
|
|
530
1916
|
type="button"
|
|
531
|
-
|
|
1917
|
+
><X size={13} /></button>
|
|
532
1918
|
</div>
|
|
533
1919
|
{widget.kind === "view" ? <div
|
|
534
1920
|
className="workspace-view-table"
|
|
@@ -542,11 +1928,23 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
|
|
|
542
1928
|
<footer>Calculate</footer>
|
|
543
1929
|
</div> : null}
|
|
544
1930
|
{widget.kind === "iframe" ? <div className="workspace-iframe-preview">
|
|
545
|
-
{widget.config?.url ? <
|
|
1931
|
+
{isLikelyHttpUrl(widget.config?.url) ? <iframe title={`${widget.title} preview`} src={widget.config.url} /> : <span>Enter a valid http(s) URL</span>}
|
|
1932
|
+
<button type="button" onClick={(event) => {
|
|
1933
|
+
event.stopPropagation();
|
|
1934
|
+
onExpandIframe(widget);
|
|
1935
|
+
}}><Maximize2 size={14} /> Expand</button>
|
|
546
1936
|
</div> : null}
|
|
547
|
-
{widget.kind === "rich-text" ? <
|
|
548
|
-
{widget.kind === "chart" ? <div className=
|
|
549
|
-
{chartValues.
|
|
1937
|
+
{widget.kind === "rich-text" ? <div className="workspace-rich-text-preview" dangerouslySetInnerHTML={{ __html: richTextToHtml(widget.config?.text) }} /> : null}
|
|
1938
|
+
{widget.kind === "chart" ? <div className={`workspace-chart-preview kind-${chartType}`} data-data-labels={dataLabels ? "true" : "false"} style={chartColor ? { "--chart-accent": chartColor } : undefined}>
|
|
1939
|
+
{chartType === "sum" ? <strong className="workspace-chart-sum">{chartValues.reduce((acc, v) => acc + v, 0)}</strong> : null}
|
|
1940
|
+
{chartType === "gauge" ? <span className="workspace-chart-gauge" style={{ "--gauge-fill": `${Math.min(100, chartValues[chartValues.length - 1] || 0)}%` }} /> : null}
|
|
1941
|
+
{chartType === "pie" ? <span className="workspace-chart-pie" aria-hidden="true" /> : null}
|
|
1942
|
+
{chartType === "bar-horizontal" ? chartValues.map((height, index) => <span key={index} className="workspace-chart-bar-h" style={{ width: `${Math.max(5, Math.min(100, height))}%` }}>
|
|
1943
|
+
{dataLabels ? <em>{height}</em> : null}
|
|
1944
|
+
</span>) : null}
|
|
1945
|
+
{chartType === "bar-vertical" || !chartType ? chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }}>
|
|
1946
|
+
{dataLabels ? <em>{height}</em> : null}
|
|
1947
|
+
</span>) : null}
|
|
550
1948
|
</div> : null}
|
|
551
1949
|
{selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
|
|
552
1950
|
aria-label={`Resize ${widget.title} from ${corner} corner`}
|
|
@@ -721,7 +2119,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
721
2119
|
</div>;
|
|
722
2120
|
}
|
|
723
2121
|
|
|
724
|
-
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
|
|
2122
|
+
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
725
2123
|
const [config, setConfig] = useState(() => {
|
|
726
2124
|
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
727
2125
|
? initialConfig.dashboards.map((dashboard, index) =>
|
|
@@ -767,11 +2165,17 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
767
2165
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
768
2166
|
const [moveDrag, setMoveDrag] = useState(null);
|
|
769
2167
|
const [configMessage, setConfigMessage] = useState("");
|
|
2168
|
+
const [inspectorPath, setInspectorPath] = useState(SUB_PANEL_ROOT);
|
|
2169
|
+
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
2170
|
+
const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
|
|
2171
|
+
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
770
2172
|
const resizeDragRef = useRef(null);
|
|
771
2173
|
const moveDragRef = useRef(null);
|
|
772
2174
|
const importInputRef = useRef(null);
|
|
773
2175
|
const addSlot = dragPreview || selectedPosition;
|
|
774
2176
|
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
|
|
2177
|
+
const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
|
|
2178
|
+
const branding = config.branding || {};
|
|
775
2179
|
const occupiedCells = useMemo(() => {
|
|
776
2180
|
const cells = new Set();
|
|
777
2181
|
for (const widget of activeWidgets) {
|
|
@@ -1332,8 +2736,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1332
2736
|
}, []);
|
|
1333
2737
|
const selectWidget = useCallback((widgetId) => {
|
|
1334
2738
|
setSelectedWidgetId(widgetId);
|
|
2739
|
+
setInspectorPath(SUB_PANEL_ROOT);
|
|
1335
2740
|
setPanelOpen(true);
|
|
1336
2741
|
}, []);
|
|
2742
|
+
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
2743
|
+
if (!selectedWidgetId) return;
|
|
2744
|
+
setConfig((prev) => {
|
|
2745
|
+
const prevTabs = getTabs(prev.canvas);
|
|
2746
|
+
const prevActiveId = getActiveTabId(prev.canvas);
|
|
2747
|
+
const nextTabs = prevTabs.map((tab) => {
|
|
2748
|
+
if (tab.id !== prevActiveId) return tab;
|
|
2749
|
+
return {
|
|
2750
|
+
...tab,
|
|
2751
|
+
widgets: (tab.widgets || []).map((widget) =>
|
|
2752
|
+
widget.id === selectedWidgetId ? { ...widget, config: nextConfig } : widget
|
|
2753
|
+
)
|
|
2754
|
+
};
|
|
2755
|
+
});
|
|
2756
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
2757
|
+
});
|
|
2758
|
+
}, [activeDashboardId, selectedWidgetId]);
|
|
1337
2759
|
const updateSelectedWidget = useCallback((updates) => {
|
|
1338
2760
|
if (!selectedWidgetId) return;
|
|
1339
2761
|
setConfig((prev) => {
|
|
@@ -1448,10 +2870,183 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1448
2870
|
return () => window.removeEventListener("keydown", handler);
|
|
1449
2871
|
}, [managementOpen, closeManagement]);
|
|
1450
2872
|
|
|
2873
|
+
useEffect(() => {
|
|
2874
|
+
const handler = (event) => {
|
|
2875
|
+
if (commandPaletteOpen) return;
|
|
2876
|
+
const target = event.target;
|
|
2877
|
+
const isEditable = target instanceof HTMLElement && (
|
|
2878
|
+
target.tagName === "INPUT" ||
|
|
2879
|
+
target.tagName === "TEXTAREA" ||
|
|
2880
|
+
target.isContentEditable
|
|
2881
|
+
);
|
|
2882
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
2883
|
+
if (meta && (event.key === "k" || event.key === "K")) {
|
|
2884
|
+
event.preventDefault();
|
|
2885
|
+
setCommandPaletteOpen(true);
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
if (event.key === "/" && !isEditable && !templateGalleryOpen && !settingsOpen && !managementOpen) {
|
|
2889
|
+
event.preventDefault();
|
|
2890
|
+
setCommandPaletteOpen(true);
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
if (!isEditable && workspaceView === "builder" && panelOpen && !commandPaletteOpen && !templateGalleryOpen && !settingsOpen && !managementOpen) {
|
|
2894
|
+
const quickMap = { c: "chart", v: "view", i: "iframe", t: "rich-text" };
|
|
2895
|
+
const kind = quickMap[event.key.toLowerCase()];
|
|
2896
|
+
if (kind) {
|
|
2897
|
+
event.preventDefault();
|
|
2898
|
+
addWidget(kind);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
};
|
|
2902
|
+
window.addEventListener("keydown", handler);
|
|
2903
|
+
return () => window.removeEventListener("keydown", handler);
|
|
2904
|
+
}, [addWidget, commandPaletteOpen, managementOpen, panelOpen, settingsOpen, templateGalleryOpen, workspaceView]);
|
|
2905
|
+
|
|
1451
2906
|
const builderStyle = workspaceView === "dashboards" || !panelOpen
|
|
1452
2907
|
? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
|
|
1453
2908
|
: undefined;
|
|
1454
2909
|
|
|
2910
|
+
const closeCommandPalette = useCallback(() => setCommandPaletteOpen(false), []);
|
|
2911
|
+
|
|
2912
|
+
const paletteCommands = useMemo(() => {
|
|
2913
|
+
const list = [];
|
|
2914
|
+
list.push({
|
|
2915
|
+
id: "dashboard.new", group: "Dashboard", icon: Plus, label: "Create dashboard", shortcut: "N",
|
|
2916
|
+
run: () => addDashboard()
|
|
2917
|
+
});
|
|
2918
|
+
list.push({
|
|
2919
|
+
id: "dashboard.duplicate", group: "Dashboard", icon: Copy, label: "Duplicate dashboard",
|
|
2920
|
+
run: () => duplicateDashboard(),
|
|
2921
|
+
disabled: !activeDashboard
|
|
2922
|
+
});
|
|
2923
|
+
list.push({
|
|
2924
|
+
id: "dashboard.delete", group: "Dashboard", icon: Trash2, label: "Delete dashboard",
|
|
2925
|
+
disabled: !activeDashboard,
|
|
2926
|
+
run: () => {
|
|
2927
|
+
if (resolvedActiveDashboardIndex >= 0) deleteDashboard(resolvedActiveDashboardIndex);
|
|
2928
|
+
}
|
|
2929
|
+
});
|
|
2930
|
+
list.push({
|
|
2931
|
+
id: "dashboard.export", group: "Dashboard", icon: Download, label: "Export dashboard",
|
|
2932
|
+
run: () => exportConfig()
|
|
2933
|
+
});
|
|
2934
|
+
list.push({
|
|
2935
|
+
id: "dashboard.import", group: "Dashboard", icon: Import, label: "Import dashboards",
|
|
2936
|
+
run: () => importInputRef.current?.click()
|
|
2937
|
+
});
|
|
2938
|
+
list.push({
|
|
2939
|
+
id: "dashboard.templates", group: "Dashboard", icon: Grid2X2, label: "Open template gallery",
|
|
2940
|
+
run: () => setTemplateGalleryOpen(true)
|
|
2941
|
+
});
|
|
2942
|
+
list.push({
|
|
2943
|
+
id: "tab.new", group: "Tab", icon: Plus, label: "New tab",
|
|
2944
|
+
run: () => addTab()
|
|
2945
|
+
});
|
|
2946
|
+
list.push({
|
|
2947
|
+
id: "tab.duplicate", group: "Tab", icon: Copy, label: "Duplicate tab",
|
|
2948
|
+
run: () => duplicateTab()
|
|
2949
|
+
});
|
|
2950
|
+
[
|
|
2951
|
+
["chart", "Add chart widget", "C"],
|
|
2952
|
+
["view", "Add view widget", "V"],
|
|
2953
|
+
["iframe", "Add iFrame widget", "I"],
|
|
2954
|
+
["rich-text", "Add rich text widget", "T"]
|
|
2955
|
+
].forEach(([kind, label, shortcut]) => {
|
|
2956
|
+
list.push({
|
|
2957
|
+
id: `widget.add.${kind}`,
|
|
2958
|
+
group: "Widget Add",
|
|
2959
|
+
icon: WIDGET_KIND_ICONS[kind],
|
|
2960
|
+
label,
|
|
2961
|
+
shortcut,
|
|
2962
|
+
disabled: workspaceView !== "builder",
|
|
2963
|
+
run: () => addWidget(kind)
|
|
2964
|
+
});
|
|
2965
|
+
});
|
|
2966
|
+
list.push({
|
|
2967
|
+
id: "widget.duplicate", group: "Widget", icon: Copy, label: "Duplicate selected widget",
|
|
2968
|
+
disabled: !selectedWidget,
|
|
2969
|
+
run: () => duplicateSelectedWidget()
|
|
2970
|
+
});
|
|
2971
|
+
list.push({
|
|
2972
|
+
id: "widget.remove", group: "Widget", icon: Trash2, label: "Remove selected widget",
|
|
2973
|
+
disabled: !selectedWidget,
|
|
2974
|
+
run: () => selectedWidget && removeSelectedWidget(selectedWidget.id)
|
|
2975
|
+
});
|
|
2976
|
+
list.push({
|
|
2977
|
+
id: "widget.source", group: "Widget", icon: Database, label: "Open widget source",
|
|
2978
|
+
disabled: !selectedWidget,
|
|
2979
|
+
run: () => {
|
|
2980
|
+
setPanelOpen(true);
|
|
2981
|
+
setInspectorPath("source");
|
|
2982
|
+
}
|
|
2983
|
+
});
|
|
2984
|
+
list.push({
|
|
2985
|
+
id: "widget.fields", group: "Widget", icon: Columns3, label: "Open widget fields",
|
|
2986
|
+
disabled: !(selectedWidget && selectedWidget.kind === "view"),
|
|
2987
|
+
run: () => {
|
|
2988
|
+
setPanelOpen(true);
|
|
2989
|
+
setInspectorPath("fields");
|
|
2990
|
+
}
|
|
2991
|
+
});
|
|
2992
|
+
list.push({
|
|
2993
|
+
id: "widget.sort", group: "Widget", icon: SlidersHorizontal, label: "Open widget sorts",
|
|
2994
|
+
disabled: !(selectedWidget && selectedWidget.kind === "view"),
|
|
2995
|
+
run: () => {
|
|
2996
|
+
setPanelOpen(true);
|
|
2997
|
+
setInspectorPath("sort");
|
|
2998
|
+
}
|
|
2999
|
+
});
|
|
3000
|
+
list.push({
|
|
3001
|
+
id: "widget.filter", group: "Widget", icon: Filter, label: "Open widget filter",
|
|
3002
|
+
disabled: !selectedWidget,
|
|
3003
|
+
run: () => {
|
|
3004
|
+
setPanelOpen(true);
|
|
3005
|
+
setInspectorPath("filter");
|
|
3006
|
+
}
|
|
3007
|
+
});
|
|
3008
|
+
list.push({
|
|
3009
|
+
id: "workspace.save", group: "Workspace", icon: Save, label: saving ? "Saving..." : "Save workspace",
|
|
3010
|
+
disabled: saving,
|
|
3011
|
+
shortcut: "S",
|
|
3012
|
+
run: () => save()
|
|
3013
|
+
});
|
|
3014
|
+
list.push({
|
|
3015
|
+
id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
|
|
3016
|
+
run: () => setSettingsOpen(true)
|
|
3017
|
+
});
|
|
3018
|
+
list.push({
|
|
3019
|
+
id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
|
|
3020
|
+
run: () => setManagementOpen(true)
|
|
3021
|
+
});
|
|
3022
|
+
list.push({
|
|
3023
|
+
id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
|
|
3024
|
+
run: () => showDashboardHome()
|
|
3025
|
+
});
|
|
3026
|
+
list.push({
|
|
3027
|
+
id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
|
|
3028
|
+
run: () => { window.location.href = "/settings/integrations"; }
|
|
3029
|
+
});
|
|
3030
|
+
return list;
|
|
3031
|
+
}, [
|
|
3032
|
+
activeDashboard,
|
|
3033
|
+
addWidget,
|
|
3034
|
+
addDashboard,
|
|
3035
|
+
addTab,
|
|
3036
|
+
deleteDashboard,
|
|
3037
|
+
duplicateDashboard,
|
|
3038
|
+
duplicateSelectedWidget,
|
|
3039
|
+
duplicateTab,
|
|
3040
|
+
exportConfig,
|
|
3041
|
+
removeSelectedWidget,
|
|
3042
|
+
resolvedActiveDashboardIndex,
|
|
3043
|
+
save,
|
|
3044
|
+
saving,
|
|
3045
|
+
selectedWidget,
|
|
3046
|
+
showDashboardHome,
|
|
3047
|
+
workspaceView
|
|
3048
|
+
]);
|
|
3049
|
+
|
|
1455
3050
|
return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
|
|
1456
3051
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
1457
3052
|
<div className="workspace-brand">
|
|
@@ -1482,12 +3077,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1482
3077
|
</>}
|
|
1483
3078
|
</div>
|
|
1484
3079
|
<div className="workspace-toolbar-actions">
|
|
1485
|
-
<button type="button" onClick={() => setTemplateGalleryOpen(true)}
|
|
1486
|
-
<button type="button" onClick={addDashboard}
|
|
1487
|
-
<button type="button" onClick={duplicateDashboard}
|
|
1488
|
-
<button type="button" onClick={() => importInputRef.current?.click()}
|
|
1489
|
-
<button type="button" onClick={exportConfig}
|
|
1490
|
-
<button type="button" onClick={save} disabled={saving}
|
|
3080
|
+
<button type="button" onClick={() => setTemplateGalleryOpen(true)}><Grid2X2 size={15} />Templates</button>
|
|
3081
|
+
<button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
|
|
3082
|
+
<button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
|
|
3083
|
+
<button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
|
|
3084
|
+
<button type="button" onClick={exportConfig}><Download size={15} />Export</button>
|
|
3085
|
+
<button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
|
|
1491
3086
|
</div>
|
|
1492
3087
|
<input
|
|
1493
3088
|
ref={importInputRef}
|
|
@@ -1593,8 +3188,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1593
3188
|
tabIndex={0}
|
|
1594
3189
|
>x</span>
|
|
1595
3190
|
</button>)}
|
|
1596
|
-
<button type="button" onClick={addTab}
|
|
1597
|
-
<button type="button" onClick={duplicateTab}
|
|
3191
|
+
<button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
|
|
3192
|
+
<button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
|
|
1598
3193
|
</div>
|
|
1599
3194
|
<div
|
|
1600
3195
|
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
@@ -1644,10 +3239,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1644
3239
|
</button>
|
|
1645
3240
|
{activeWidgets.map((widget) => <WidgetPreview
|
|
1646
3241
|
key={widget.id}
|
|
3242
|
+
branding={branding}
|
|
1647
3243
|
onMoveStart={(event) => beginMoveDrag(widget, event)}
|
|
1648
3244
|
onRemove={() => removeSelectedWidget(widget.id)}
|
|
1649
3245
|
onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
|
|
1650
3246
|
onSelect={() => selectWidget(widget.id)}
|
|
3247
|
+
onExpandIframe={setExpandedIframeWidget}
|
|
1651
3248
|
selected={widget.id === selectedWidgetId}
|
|
1652
3249
|
widget={widget}
|
|
1653
3250
|
/>)}
|
|
@@ -1662,6 +3259,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1662
3259
|
onClose={closeTemplateGallery}
|
|
1663
3260
|
onApplyToCurrentTab={applyTemplateToCurrentTab}
|
|
1664
3261
|
onCloneAsDashboard={cloneTemplateAsDashboard}
|
|
3262
|
+
filter={templateFilter}
|
|
3263
|
+
onFilterChange={setTemplateFilter}
|
|
1665
3264
|
/> : null}
|
|
1666
3265
|
|
|
1667
3266
|
{settingsOpen ? <WorkspaceSettingsPanel
|
|
@@ -1686,15 +3285,43 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1686
3285
|
<strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
|
|
1687
3286
|
{selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
|
|
1688
3287
|
</div>
|
|
1689
|
-
{selectedWidget ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
1690
|
-
<button type="button" onClick={duplicateSelectedWidget}
|
|
1691
|
-
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}
|
|
3288
|
+
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
3289
|
+
<button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
|
|
3290
|
+
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
|
|
1692
3291
|
</div> : null}
|
|
1693
|
-
{selectedWidget
|
|
3292
|
+
{selectedWidget && inspectorPath === "source" ? <SourceSubPanel
|
|
3293
|
+
widget={selectedWidget}
|
|
3294
|
+
integrations={availableIntegrations}
|
|
3295
|
+
onChange={replaceSelectedWidgetConfig}
|
|
3296
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3297
|
+
/> : null}
|
|
3298
|
+
{selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
|
|
3299
|
+
widget={selectedWidget}
|
|
3300
|
+
onChange={replaceSelectedWidgetConfig}
|
|
3301
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3302
|
+
/> : null}
|
|
3303
|
+
{selectedWidget && inspectorPath === "sort" ? <SortSubPanel
|
|
3304
|
+
widget={selectedWidget}
|
|
3305
|
+
onChange={replaceSelectedWidgetConfig}
|
|
3306
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3307
|
+
/> : null}
|
|
3308
|
+
{selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
|
|
3309
|
+
widget={selectedWidget}
|
|
3310
|
+
integrations={availableIntegrations}
|
|
3311
|
+
onChange={replaceSelectedWidgetConfig}
|
|
3312
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3313
|
+
/> : null}
|
|
3314
|
+
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
|
|
1694
3315
|
<label>
|
|
1695
3316
|
<span>Title</span>
|
|
1696
3317
|
<input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
|
|
1697
3318
|
</label>
|
|
3319
|
+
{selectedWidget.kind === "chart" ? <ChartConfigPanel
|
|
3320
|
+
widget={selectedWidget}
|
|
3321
|
+
branding={branding}
|
|
3322
|
+
onChange={replaceSelectedWidgetConfig}
|
|
3323
|
+
onSubPage={(name) => setInspectorPath(name)}
|
|
3324
|
+
/> : null}
|
|
1698
3325
|
{selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
|
|
1699
3326
|
<label>
|
|
1700
3327
|
<span>Sample Values</span>
|
|
@@ -1713,6 +3340,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1713
3340
|
>
|
|
1714
3341
|
<option value="json">Sample JSON</option>
|
|
1715
3342
|
<option value="csv">Sample CSV</option>
|
|
3343
|
+
{selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
|
|
1716
3344
|
</select>
|
|
1717
3345
|
</label>
|
|
1718
3346
|
</section> : null}
|
|
@@ -1733,30 +3361,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1733
3361
|
</label> : null}
|
|
1734
3362
|
{selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
|
|
1735
3363
|
<span>Content</span>
|
|
1736
|
-
<
|
|
1737
|
-
placeholder="Write text..."
|
|
3364
|
+
<RichTextEditor
|
|
1738
3365
|
value={selectedWidget.config?.text || ""}
|
|
1739
|
-
onChange={(
|
|
3366
|
+
onChange={(text) => updateSelectedWidgetConfig({ text })}
|
|
1740
3367
|
/>
|
|
1741
3368
|
<small className="workspace-field-hint">
|
|
1742
|
-
{(selectedWidget.config?.text || "").length} characters ·
|
|
3369
|
+
{(selectedWidget.config?.text || "").length} characters · markdown controls
|
|
1743
3370
|
</small>
|
|
1744
3371
|
</label> : null}
|
|
1745
3372
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
1746
|
-
<label>
|
|
1747
|
-
<span>Source</span>
|
|
1748
|
-
<input
|
|
1749
|
-
value={selectedWidget.config?.source || ""}
|
|
1750
|
-
onChange={(event) => updateSelectedWidgetConfig({ source: event.target.value })}
|
|
1751
|
-
/>
|
|
1752
|
-
</label>
|
|
1753
|
-
<label>
|
|
1754
|
-
<span>Columns</span>
|
|
1755
|
-
<input
|
|
1756
|
-
value={serializeLineList(selectedWidget.config?.columns || [])}
|
|
1757
|
-
onChange={(event) => updateSelectedWidgetConfig({ columns: parseLineList(event.target.value) })}
|
|
1758
|
-
/>
|
|
1759
|
-
</label>
|
|
1760
3373
|
<label>
|
|
1761
3374
|
<span>Manual Rows</span>
|
|
1762
3375
|
<textarea
|
|
@@ -1770,26 +3383,23 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1770
3383
|
}}
|
|
1771
3384
|
/>
|
|
1772
3385
|
</label>
|
|
1773
|
-
<label>
|
|
1774
|
-
<
|
|
1775
|
-
<
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
>
|
|
1782
|
-
<
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
<div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
|
|
1791
|
-
<div><span>Filter</span><code>›</code></div>
|
|
1792
|
-
<div><span>Sort</span><code>›</code></div>
|
|
3386
|
+
<div className="workspace-settings-list" role="group" aria-label="View widget settings">
|
|
3387
|
+
<p className="workspace-panel-label">Settings</p>
|
|
3388
|
+
<button type="button" className="workspace-settings-row" disabled>
|
|
3389
|
+
<span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code>
|
|
3390
|
+
</button>
|
|
3391
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("source")}>
|
|
3392
|
+
<span>Source</span><code>{summarizeSource(selectedWidget)}</code>
|
|
3393
|
+
</button>
|
|
3394
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
|
|
3395
|
+
<span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
|
|
3396
|
+
</button>
|
|
3397
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
|
|
3398
|
+
<span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
|
|
3399
|
+
</button>
|
|
3400
|
+
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
|
|
3401
|
+
<span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
|
|
3402
|
+
</button>
|
|
1793
3403
|
</div>
|
|
1794
3404
|
</section> : null}
|
|
1795
3405
|
{selectedWidget.kind === "rich-text" ? <label>
|
|
@@ -1809,24 +3419,30 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1809
3419
|
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|
|
1810
3420
|
<div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
|
|
1811
3421
|
</div>
|
|
1812
|
-
</section> :
|
|
3422
|
+
</section> : null}
|
|
3423
|
+
{!selectedWidget ? <section>
|
|
1813
3424
|
<div className="workspace-widget-empty">
|
|
1814
3425
|
<strong>Pick a widget kind</strong>
|
|
1815
3426
|
<p>
|
|
1816
3427
|
Widgets snap to the 12-column × 16-row grid. {addSlot.w} × {addSlot.h} cells
|
|
1817
3428
|
selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
|
|
1818
|
-
canvas to reshape the placement.
|
|
3429
|
+
canvas to reshape the placement. Press <kbd>⌘K</kbd> for the command palette.
|
|
1819
3430
|
</p>
|
|
1820
3431
|
</div>
|
|
1821
3432
|
<p className="workspace-panel-label">Widget type</p>
|
|
1822
3433
|
<div className="workspace-widget-types">
|
|
1823
|
-
{widgetTypes.map((widget) =>
|
|
1824
|
-
|
|
3434
|
+
{widgetTypes.map((widget) => {
|
|
3435
|
+
const KindIcon = WIDGET_KIND_ICONS[widget.kind];
|
|
3436
|
+
const shortcut = widget.kind === "chart" ? "C" : widget.kind === "view" ? "V" : widget.kind === "iframe" ? "I" : "T";
|
|
3437
|
+
return <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
|
|
3438
|
+
<span><IconGlyph icon={KindIcon} size={15} /></span>
|
|
1825
3439
|
{widget.label}
|
|
1826
|
-
|
|
3440
|
+
<kbd>{shortcut}</kbd>
|
|
3441
|
+
</button>;
|
|
3442
|
+
})}
|
|
1827
3443
|
</div>
|
|
1828
|
-
</section>}
|
|
1829
|
-
<section className="workspace-bindings" id="bindings">
|
|
3444
|
+
</section> : null}
|
|
3445
|
+
{inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-bindings" id="bindings">
|
|
1830
3446
|
<p className="workspace-panel-label">Config bindings</p>
|
|
1831
3447
|
{Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
|
|
1832
3448
|
<span>{key}</span>
|
|
@@ -1836,8 +3452,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
|
|
|
1836
3452
|
<span>integrationAdapter</span>
|
|
1837
3453
|
<code>{adapterConfig.integrationAdapter}</code>
|
|
1838
3454
|
</div>
|
|
1839
|
-
</section>
|
|
3455
|
+
</section> : null}
|
|
1840
3456
|
</aside> : null}
|
|
3457
|
+
{expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
|
|
3458
|
+
{commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
|
|
1841
3459
|
</main>;
|
|
1842
3460
|
}
|
|
1843
3461
|
|