@growthub/cli 0.9.12 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/apis-webhooks/route.js +59 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +700 -214
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +2468 -793
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +22 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1558 -437
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +264 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
- 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 +7 -0
- package/dist/index.js +1764 -40675
- package/package.json +1 -1
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import {
|
|
6
|
+
Activity,
|
|
7
|
+
BarChart2,
|
|
6
8
|
BarChart3,
|
|
7
9
|
Bolt,
|
|
10
|
+
Box,
|
|
11
|
+
Building2,
|
|
12
|
+
Calendar,
|
|
8
13
|
Check,
|
|
14
|
+
CheckSquare,
|
|
9
15
|
ChevronDown,
|
|
10
16
|
Code2,
|
|
11
17
|
Columns3,
|
|
@@ -16,28 +22,41 @@ import {
|
|
|
16
22
|
FileText,
|
|
17
23
|
Filter,
|
|
18
24
|
Gauge,
|
|
25
|
+
Globe,
|
|
26
|
+
GripVertical,
|
|
19
27
|
Grid2X2,
|
|
28
|
+
Hash,
|
|
20
29
|
Home,
|
|
21
30
|
Import,
|
|
22
31
|
Italic,
|
|
32
|
+
Layers,
|
|
23
33
|
LayoutDashboard,
|
|
24
34
|
Link as LinkIcon,
|
|
35
|
+
Link2,
|
|
25
36
|
List,
|
|
37
|
+
Mail,
|
|
26
38
|
Maximize2,
|
|
27
39
|
Pencil,
|
|
28
40
|
PieChart,
|
|
29
41
|
Plus,
|
|
30
42
|
Quote,
|
|
43
|
+
RefreshCw,
|
|
31
44
|
Rows3,
|
|
32
45
|
Save,
|
|
33
46
|
Search,
|
|
34
47
|
Settings,
|
|
48
|
+
ShoppingCart,
|
|
35
49
|
Sigma,
|
|
36
50
|
SlidersHorizontal,
|
|
51
|
+
Star,
|
|
37
52
|
Table2,
|
|
53
|
+
Tag,
|
|
54
|
+
ToggleLeft,
|
|
38
55
|
Trash2,
|
|
39
56
|
Type,
|
|
40
|
-
|
|
57
|
+
Users,
|
|
58
|
+
X,
|
|
59
|
+
Zap,
|
|
41
60
|
} from "lucide-react";
|
|
42
61
|
import {
|
|
43
62
|
DASHBOARD_TEMPLATES,
|
|
@@ -47,7 +66,6 @@ import {
|
|
|
47
66
|
KNOWN_FILTER_OPERATORS,
|
|
48
67
|
KNOWN_SORT_DIRECTIONS,
|
|
49
68
|
SAMPLE_DATA_BINDINGS,
|
|
50
|
-
SAMPLE_VIEW_ROWS,
|
|
51
69
|
cloneTemplateToDashboard,
|
|
52
70
|
cloneTemplateToTab,
|
|
53
71
|
defaultConfigFor,
|
|
@@ -57,7 +75,7 @@ import {
|
|
|
57
75
|
wrapWorkspaceTemplateExport
|
|
58
76
|
} from "@/lib/workspace-schema";
|
|
59
77
|
import { governedWorkspaceIntegrationCatalog } from "@/lib/domain/integrations";
|
|
60
|
-
import { listWorkspaceDataModelTables } from "@/lib/workspace-data-model";
|
|
78
|
+
import { OBJECT_TYPE_PRESETS, listWorkspaceDataModelTables } from "@/lib/workspace-data-model";
|
|
61
79
|
|
|
62
80
|
const DEFAULT_CHART_TYPE = "bar-vertical";
|
|
63
81
|
const DEFAULT_FILTER_OP = "and";
|
|
@@ -67,6 +85,8 @@ const SUB_PANEL_ROOT = "root";
|
|
|
67
85
|
const MANAGED_INTEGRATION_SOURCE_TYPE = "managed-integrations";
|
|
68
86
|
const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
|
|
69
87
|
const DATA_MODEL_SOURCE_TYPE = "workspace-data-model";
|
|
88
|
+
const LIVE_SOURCE_TYPE = "workspace-source-records";
|
|
89
|
+
const TESTED_SOURCE_STATUSES = new Set(["connected", "approved", "ok", "success"]);
|
|
70
90
|
|
|
71
91
|
const SOURCE_TYPE_OBJECTS = [
|
|
72
92
|
{
|
|
@@ -85,6 +105,32 @@ const SOURCE_TYPE_OBJECTS = [
|
|
|
85
105
|
|
|
86
106
|
const ENTITY_REFERENCE_FIELD_IDS = ["id", "entityId"];
|
|
87
107
|
|
|
108
|
+
function hasSavedResponseShape(row) {
|
|
109
|
+
const raw = row?.lastResponse || row?.LastResponse;
|
|
110
|
+
if (!raw || typeof raw !== "string") return false;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
return parsed !== null && (Array.isArray(parsed) || typeof parsed === "object");
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasTestedSavedRow(table) {
|
|
120
|
+
return (table.rows || []).some((row) => {
|
|
121
|
+
const status = String(row?.status || row?.Status || "").toLowerCase();
|
|
122
|
+
return TESTED_SOURCE_STATUSES.has(status) && hasSavedResponseShape(row);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isSelectableDataModelSource(table) {
|
|
127
|
+
if (table?.storage !== "manual-object") return false;
|
|
128
|
+
if (table.objectType === "api-registry") return false;
|
|
129
|
+
if (table.objectType === "data-source") return hasTestedSavedRow(table);
|
|
130
|
+
const hasStatusField = (table.columns || []).some((column) => String(column).toLowerCase() === "status");
|
|
131
|
+
return hasStatusField ? hasTestedSavedRow(table) : true;
|
|
132
|
+
}
|
|
133
|
+
|
|
88
134
|
const CHART_TYPE_LABELS = {
|
|
89
135
|
"bar-vertical": "Vertical Bar",
|
|
90
136
|
"bar-horizontal": "Horizontal Bar",
|
|
@@ -132,6 +178,88 @@ const COLUMN_ICON_FOR = (name) => {
|
|
|
132
178
|
return "▦";
|
|
133
179
|
};
|
|
134
180
|
|
|
181
|
+
// Icon map for workspace data model object types
|
|
182
|
+
const OBJ_ICON_MAP = {
|
|
183
|
+
Activity, BarChart2, Box, Building2, Calendar, CheckSquare, Code2,
|
|
184
|
+
Database, FileText, Globe, Hash, Layers, Link2, List, Mail, Plus,
|
|
185
|
+
ShoppingCart, Star, Tag, Type, Users, Zap,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
function ObjectIcon({ name, size = 14, className }) {
|
|
189
|
+
const Icon = OBJ_ICON_MAP[name] || Database;
|
|
190
|
+
return <Icon size={size} className={className} aria-hidden="true" />;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Infer a lightweight type from a field name for the dropdown icon
|
|
194
|
+
function inferFieldType(name) {
|
|
195
|
+
const n = String(name || "").toLowerCase();
|
|
196
|
+
if (n.includes("date") || n.includes("_at") || n.includes("created") || n.includes("updated")) return "date";
|
|
197
|
+
if (n === "status" || n === "stage" || n === "type" || n === "priority" || n === "authtype") return "select";
|
|
198
|
+
if (n.includes("count") || n.includes("num") || n.includes("amount") || n.includes("arr") || n.includes("price")) return "number";
|
|
199
|
+
if (n.startsWith("is_") || n.includes("active") || n.includes("enabled")) return "boolean";
|
|
200
|
+
return "text";
|
|
201
|
+
}
|
|
202
|
+
const FIELD_TYPE_ICON_MAP = { date: Calendar, select: List, number: Hash, boolean: ToggleLeft };
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* FieldDropdown — searchable field picker driven by a source object's column list.
|
|
206
|
+
* Used by ChartConfigPanel for X/Y axis "Data on display" selection.
|
|
207
|
+
*/
|
|
208
|
+
function FieldDropdown({ fields, value, onChange, placeholder = "Select field…", disabled }) {
|
|
209
|
+
const [open, setOpen] = useState(false);
|
|
210
|
+
const [query, setQuery] = useState("");
|
|
211
|
+
const ref = useRef(null);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!open) return;
|
|
215
|
+
function handle(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
|
216
|
+
document.addEventListener("mousedown", handle);
|
|
217
|
+
return () => document.removeEventListener("mousedown", handle);
|
|
218
|
+
}, [open]);
|
|
219
|
+
|
|
220
|
+
const filtered = useMemo(() => {
|
|
221
|
+
const q = query.trim().toLowerCase();
|
|
222
|
+
return q ? (fields || []).filter((f) => f.toLowerCase().includes(q)) : (fields || []);
|
|
223
|
+
}, [fields, query]);
|
|
224
|
+
|
|
225
|
+
function pick(field) { onChange(field); setOpen(false); setQuery(""); }
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className="field-dropdown-wrap" ref={ref}>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
className={`field-dropdown-trigger${open ? " open" : ""}${disabled ? " disabled" : ""}`}
|
|
232
|
+
onClick={() => !disabled && setOpen((v) => !v)}
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
>
|
|
235
|
+
<span className="field-dropdown-label">{value || placeholder}</span>
|
|
236
|
+
<ChevronDown size={12} aria-hidden="true" />
|
|
237
|
+
</button>
|
|
238
|
+
{open && (
|
|
239
|
+
<div className="field-dropdown-popover" role="listbox">
|
|
240
|
+
<div className="field-dropdown-search">
|
|
241
|
+
<Search size={11} aria-hidden="true" />
|
|
242
|
+
<input autoFocus placeholder="Search fields" value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
243
|
+
</div>
|
|
244
|
+
{filtered.length > 0 ? filtered.map((field) => {
|
|
245
|
+
const FIcon = FIELD_TYPE_ICON_MAP[inferFieldType(field)] || Type;
|
|
246
|
+
const sel = value === field;
|
|
247
|
+
return (
|
|
248
|
+
<button key={field} type="button" role="option" aria-selected={sel}
|
|
249
|
+
className={`field-dropdown-item${sel ? " selected" : ""}`}
|
|
250
|
+
onClick={() => pick(field)}>
|
|
251
|
+
<FIcon size={13} aria-hidden="true" />
|
|
252
|
+
<span>{field}</span>
|
|
253
|
+
{sel && <Check size={12} strokeWidth={2.5} aria-hidden="true" />}
|
|
254
|
+
</button>
|
|
255
|
+
);
|
|
256
|
+
}) : <p className="field-dropdown-empty">{fields?.length ? "No match" : "No source selected"}</p>}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
135
263
|
const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
|
|
136
264
|
const GRID_COLUMNS = 12;
|
|
137
265
|
const GRID_ROWS = 16;
|
|
@@ -146,6 +274,16 @@ function generateId(prefix) {
|
|
|
146
274
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
|
147
275
|
}
|
|
148
276
|
|
|
277
|
+
function textColorForAccent(accent) {
|
|
278
|
+
const hex = String(accent || "").replace("#", "");
|
|
279
|
+
if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
|
|
280
|
+
const red = parseInt(hex.slice(0, 2), 16);
|
|
281
|
+
const green = parseInt(hex.slice(2, 4), 16);
|
|
282
|
+
const blue = parseInt(hex.slice(4, 6), 16);
|
|
283
|
+
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
|
|
284
|
+
return luminance > 0.62 ? "#252525" : "#ffffff";
|
|
285
|
+
}
|
|
286
|
+
|
|
149
287
|
function defaultTitleFor(kind) {
|
|
150
288
|
switch (kind) {
|
|
151
289
|
case "chart": return "Untitled chart";
|
|
@@ -532,7 +670,7 @@ function serializeLineList(values) {
|
|
|
532
670
|
}
|
|
533
671
|
|
|
534
672
|
function parseManualRows(value, columns) {
|
|
535
|
-
const activeColumns = columns.length ? columns : [
|
|
673
|
+
const activeColumns = columns.length ? columns : [];
|
|
536
674
|
return String(value)
|
|
537
675
|
.split("\n")
|
|
538
676
|
.map((row) => row.trim())
|
|
@@ -547,7 +685,7 @@ function parseManualRows(value, columns) {
|
|
|
547
685
|
}
|
|
548
686
|
|
|
549
687
|
function serializeManualRows(rows, columns) {
|
|
550
|
-
const activeColumns = columns.length ? columns : [
|
|
688
|
+
const activeColumns = columns.length ? columns : [];
|
|
551
689
|
return (Array.isArray(rows) ? rows : [])
|
|
552
690
|
.map((row) => activeColumns.map((column) => row?.[column] || "").join(" | "))
|
|
553
691
|
.join("\n");
|
|
@@ -657,11 +795,14 @@ function summarizeSource(widget) {
|
|
|
657
795
|
function summarizeSourceType(binding) {
|
|
658
796
|
if (binding?.sourceType === DATA_MODEL_SOURCE_TYPE) return "Data Model";
|
|
659
797
|
if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
|
|
660
|
-
if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE) return "Managed Integrations";
|
|
798
|
+
if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE || binding?.sourceStorage === LIVE_SOURCE_TYPE) return "Managed Integrations";
|
|
661
799
|
return "Static data";
|
|
662
800
|
}
|
|
663
801
|
|
|
664
802
|
function resolveBindingSourceType(binding) {
|
|
803
|
+
// LIVE_SOURCE_TYPE is internal infrastructure — resolve to its user-facing source category
|
|
804
|
+
if (binding?.sourceStorage === LIVE_SOURCE_TYPE) return MANAGED_INTEGRATION_SOURCE_TYPE;
|
|
805
|
+
if (binding?.sourceType === LIVE_SOURCE_TYPE) return MANAGED_INTEGRATION_SOURCE_TYPE;
|
|
665
806
|
if (binding?.sourceType) return binding.sourceType;
|
|
666
807
|
if (binding?.mode === "integration") return MANAGED_INTEGRATION_SOURCE_TYPE;
|
|
667
808
|
return "static";
|
|
@@ -1086,378 +1227,1034 @@ function EntitySelector({ integration, entities, selectedEntityId, selectedEntit
|
|
|
1086
1227
|
</div>;
|
|
1087
1228
|
}
|
|
1088
1229
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1230
|
+
/**
|
|
1231
|
+
* LiveSourcePanel — step-by-step no-code wizard for configuring a live source
|
|
1232
|
+
* binding backed by the source-resolver-registry.
|
|
1233
|
+
*
|
|
1234
|
+
* Steps:
|
|
1235
|
+
* 1 — Auth mode (Bridge / BYO Token)
|
|
1236
|
+
* 2 — Integration (pick from available or enter custom id)
|
|
1237
|
+
* 3 — Entity config (entity type, entity id — optional)
|
|
1238
|
+
* 4 — Source ID (stable key for growthub.source-records.json)
|
|
1239
|
+
* 5 — Test + Preview (POST /api/workspace/test-source)
|
|
1240
|
+
*
|
|
1241
|
+
* Apply button is only enabled after a successful test (testState.ok === true).
|
|
1242
|
+
* When the user clicks Apply the binding is committed to the widget config.
|
|
1243
|
+
*/
|
|
1244
|
+
function LiveSourcePanel({ widget, integrations, adapterConfig, onApply, onCancel }) {
|
|
1245
|
+
const existing = widget.config?.binding || {};
|
|
1246
|
+
const [step, setStep] = useState(1);
|
|
1247
|
+
const [authMode, setAuthMode] = useState(existing.sourceAuthority === "byo-token" ? "byo-token" : "bridge");
|
|
1248
|
+
const [integrationId, setIntegrationId] = useState(existing.integrationId || "");
|
|
1249
|
+
const [entityType, setEntityType] = useState(existing.entityType || "");
|
|
1250
|
+
const [entityId, setEntityId] = useState(existing.entityId || "");
|
|
1251
|
+
const [sourceId, setSourceId] = useState(existing.sourceId || existing.integrationId || "");
|
|
1252
|
+
const [testState, setTestState] = useState(null);
|
|
1253
|
+
const [testing, setTesting] = useState(false);
|
|
1254
|
+
|
|
1255
|
+
const isBridge = adapterConfig?.integrationAdapter === "growthub-bridge";
|
|
1256
|
+
const hasBridgeToken = adapterConfig?.growthubBridge?.hasAccessToken;
|
|
1257
|
+
const availableIntegrations = Array.isArray(integrations) ? integrations : [];
|
|
1258
|
+
|
|
1259
|
+
const canProceedStep1 = authMode === "bridge" || authMode === "byo-token";
|
|
1260
|
+
const canProceedStep2 = typeof integrationId === "string" && integrationId.trim().length > 0;
|
|
1261
|
+
const canProceedStep3 = true;
|
|
1262
|
+
const canProceedStep4 = typeof sourceId === "string" && sourceId.trim().length > 0;
|
|
1263
|
+
const canApply = testState?.ok === true;
|
|
1264
|
+
|
|
1265
|
+
const autoSourceId = integrationId.trim().replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1266
|
+
|
|
1267
|
+
function handleIntegrationSelect(id) {
|
|
1268
|
+
setIntegrationId(id);
|
|
1269
|
+
if (!sourceId || sourceId === autoSourceId) {
|
|
1270
|
+
setSourceId(id.replace(/[^a-z0-9-]/gi, "-").toLowerCase());
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1107
1273
|
|
|
1108
|
-
|
|
1109
|
-
if (
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1274
|
+
async function runTest() {
|
|
1275
|
+
if (!canProceedStep2) return;
|
|
1276
|
+
setTesting(true);
|
|
1277
|
+
setTestState(null);
|
|
1278
|
+
try {
|
|
1279
|
+
const res = await fetch("/api/workspace/test-source", {
|
|
1280
|
+
method: "POST",
|
|
1281
|
+
headers: { "content-type": "application/json" },
|
|
1282
|
+
body: JSON.stringify({
|
|
1283
|
+
integrationId: integrationId.trim(),
|
|
1284
|
+
binding: {
|
|
1285
|
+
integrationId: integrationId.trim(),
|
|
1286
|
+
entityType: entityType.trim() || undefined,
|
|
1287
|
+
entityId: entityId.trim() || undefined,
|
|
1288
|
+
sourceId: sourceId.trim() || integrationId.trim(),
|
|
1289
|
+
authMode
|
|
1290
|
+
}
|
|
1291
|
+
})
|
|
1292
|
+
});
|
|
1293
|
+
const data = await res.json();
|
|
1294
|
+
setTestState(data);
|
|
1295
|
+
if (data.ok) setStep(5);
|
|
1296
|
+
} catch {
|
|
1297
|
+
setTestState({ ok: false, reason: "network-error", error: "Network error — check console" });
|
|
1298
|
+
} finally {
|
|
1299
|
+
setTesting(false);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1113
1302
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1303
|
+
function applyBinding() {
|
|
1304
|
+
if (!canApply) return;
|
|
1305
|
+
onApply({
|
|
1306
|
+
...widget.config,
|
|
1307
|
+
source: integrationId.trim(),
|
|
1308
|
+
binding: {
|
|
1309
|
+
mode: "integration",
|
|
1310
|
+
source: integrationId.trim(),
|
|
1311
|
+
sourceStorage: LIVE_SOURCE_TYPE,
|
|
1312
|
+
sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
|
|
1313
|
+
sourceId: sourceId.trim() || integrationId.trim(),
|
|
1314
|
+
integrationId: integrationId.trim(),
|
|
1315
|
+
entityType: entityType.trim() || undefined,
|
|
1316
|
+
entityId: entityId.trim() || undefined,
|
|
1317
|
+
sourceAuthority: authMode === "bridge" ? "growthub-bridge" : "byo-token"
|
|
1318
|
+
}
|
|
1120
1319
|
});
|
|
1121
|
-
|
|
1122
|
-
"data-source": filtered.filter((item) => item.lane === "data-source"),
|
|
1123
|
-
"workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
|
|
1124
|
-
};
|
|
1125
|
-
}, [integrations, laneFilter, query]);
|
|
1320
|
+
}
|
|
1126
1321
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1322
|
+
return <div className="live-source-wizard">
|
|
1323
|
+
{/* Step breadcrumb */}
|
|
1324
|
+
<div className="live-source-steps" role="list">
|
|
1325
|
+
{["Auth", "Integration", "Entity", "Source ID", "Test"].map((label, idx) => {
|
|
1326
|
+
const s = idx + 1;
|
|
1327
|
+
const done = step > s;
|
|
1328
|
+
const active = step === s;
|
|
1329
|
+
return <span
|
|
1330
|
+
key={s}
|
|
1331
|
+
className={`live-source-step${active ? " active" : ""}${done ? " done" : ""}`}
|
|
1332
|
+
role="listitem"
|
|
1333
|
+
aria-current={active ? "step" : undefined}
|
|
1334
|
+
>
|
|
1335
|
+
<span className="live-source-step-dot">{done ? "✓" : s}</span>
|
|
1336
|
+
<span className="live-source-step-label">{label}</span>
|
|
1337
|
+
</span>;
|
|
1338
|
+
})}
|
|
1339
|
+
</div>
|
|
1340
|
+
|
|
1341
|
+
{/* Step 1: Auth mode */}
|
|
1342
|
+
{step === 1 && <div className="live-source-step-body">
|
|
1343
|
+
<p className="live-source-step-title">How does this integration authenticate?</p>
|
|
1344
|
+
<p className="live-source-step-hint">Your token stays server-side. The browser only sees normalized records.</p>
|
|
1345
|
+
<div className="live-source-auth-toggle" role="radiogroup" aria-label="Auth mode">
|
|
1346
|
+
<button
|
|
1347
|
+
type="button"
|
|
1348
|
+
role="radio"
|
|
1349
|
+
aria-checked={authMode === "bridge"}
|
|
1350
|
+
className={authMode === "bridge" ? "active" : ""}
|
|
1351
|
+
onClick={() => setAuthMode("bridge")}
|
|
1352
|
+
>
|
|
1353
|
+
<strong>Growthub Bridge</strong>
|
|
1354
|
+
<em>{isBridge && hasBridgeToken ? "Connected — token in env" : "Set GROWTHUB_BRIDGE_BASE_URL + GROWTHUB_BRIDGE_ACCESS_TOKEN"}</em>
|
|
1355
|
+
{isBridge && hasBridgeToken ? <span className="live-source-badge connected">connected</span> : <span className="live-source-badge warn">env required</span>}
|
|
1356
|
+
</button>
|
|
1357
|
+
<button
|
|
1358
|
+
type="button"
|
|
1359
|
+
role="radio"
|
|
1360
|
+
aria-checked={authMode === "byo-token"}
|
|
1361
|
+
className={authMode === "byo-token" ? "active" : ""}
|
|
1362
|
+
onClick={() => setAuthMode("byo-token")}
|
|
1363
|
+
>
|
|
1364
|
+
<strong>BYO Token / Custom env</strong>
|
|
1365
|
+
<em>Your resolver reads the env var you specify. Set it in .env or Vercel env.</em>
|
|
1366
|
+
<span className="live-source-badge neutral">custom</span>
|
|
1367
|
+
</button>
|
|
1368
|
+
</div>
|
|
1369
|
+
<button type="button" className="live-source-next" disabled={!canProceedStep1} onClick={() => setStep(2)}>
|
|
1370
|
+
Next → Choose integration
|
|
1371
|
+
</button>
|
|
1372
|
+
</div>}
|
|
1373
|
+
|
|
1374
|
+
{/* Step 2: Integration */}
|
|
1375
|
+
{step === 2 && <div className="live-source-step-body">
|
|
1376
|
+
<p className="live-source-step-title">Which integration?</p>
|
|
1377
|
+
<p className="live-source-step-hint">Pick a connected integration or enter a custom resolver id that matches what your resolver file registers.</p>
|
|
1378
|
+
<div className="live-source-integration-list">
|
|
1379
|
+
{availableIntegrations.filter((i) => i.isConnected || i.status === "connected").map((integration) => <button
|
|
1380
|
+
key={integration.id}
|
|
1381
|
+
type="button"
|
|
1382
|
+
className={`live-source-integration-row${integrationId === integration.id ? " active" : ""}`}
|
|
1383
|
+
onClick={() => handleIntegrationSelect(integration.id)}
|
|
1384
|
+
>
|
|
1385
|
+
<span className="live-source-integration-icon">{integration.icon || integration.label?.[0] || "•"}</span>
|
|
1386
|
+
<span className="live-source-integration-meta">
|
|
1387
|
+
<strong>{integration.label}</strong>
|
|
1388
|
+
<em>{integration.provider} · {integration.status}</em>
|
|
1389
|
+
</span>
|
|
1390
|
+
{integrationId === integration.id ? <Check size={15} /> : null}
|
|
1391
|
+
</button>)}
|
|
1392
|
+
{availableIntegrations.filter((i) => i.isConnected || i.status === "connected").length === 0
|
|
1393
|
+
? <p className="live-source-empty">No connected integrations — enter a custom resolver id below.</p>
|
|
1394
|
+
: null}
|
|
1395
|
+
</div>
|
|
1396
|
+
<label className="live-source-custom-id">
|
|
1397
|
+
<span>Custom resolver id</span>
|
|
1398
|
+
<input
|
|
1399
|
+
type="text"
|
|
1400
|
+
placeholder="my-crm, windsor-ai, custom-api…"
|
|
1401
|
+
value={integrationId}
|
|
1402
|
+
onChange={(e) => handleIntegrationSelect(e.target.value)}
|
|
1403
|
+
/>
|
|
1404
|
+
</label>
|
|
1405
|
+
<div className="live-source-nav">
|
|
1406
|
+
<button type="button" className="live-source-back" onClick={() => setStep(1)}>← Back</button>
|
|
1407
|
+
<button type="button" className="live-source-next" disabled={!canProceedStep2} onClick={() => setStep(3)}>
|
|
1408
|
+
Next → Entity config
|
|
1409
|
+
</button>
|
|
1410
|
+
</div>
|
|
1411
|
+
</div>}
|
|
1412
|
+
|
|
1413
|
+
{/* Step 3: Entity config */}
|
|
1414
|
+
{step === 3 && <div className="live-source-step-body">
|
|
1415
|
+
<p className="live-source-step-title">Entity configuration <em>(optional)</em></p>
|
|
1416
|
+
<p className="live-source-step-hint">Tell the resolver which object to fetch. Leave blank if your resolver fetches everything by default.</p>
|
|
1417
|
+
<label className="live-source-field">
|
|
1418
|
+
<span>Entity type</span>
|
|
1419
|
+
<input
|
|
1420
|
+
type="text"
|
|
1421
|
+
placeholder="project.tasks, records, contacts…"
|
|
1422
|
+
value={entityType}
|
|
1423
|
+
onChange={(e) => setEntityType(e.target.value)}
|
|
1424
|
+
/>
|
|
1425
|
+
</label>
|
|
1426
|
+
<label className="live-source-field">
|
|
1427
|
+
<span>Entity id / object id</span>
|
|
1428
|
+
<input
|
|
1429
|
+
type="text"
|
|
1430
|
+
placeholder="gid_12345, project_abc, board-id…"
|
|
1431
|
+
value={entityId}
|
|
1432
|
+
onChange={(e) => setEntityId(e.target.value)}
|
|
1433
|
+
/>
|
|
1434
|
+
</label>
|
|
1435
|
+
<div className="live-source-nav">
|
|
1436
|
+
<button type="button" className="live-source-back" onClick={() => setStep(2)}>← Back</button>
|
|
1437
|
+
<button type="button" className="live-source-next" disabled={!canProceedStep3} onClick={() => setStep(4)}>
|
|
1438
|
+
Next → Source ID
|
|
1439
|
+
</button>
|
|
1440
|
+
</div>
|
|
1441
|
+
</div>}
|
|
1442
|
+
|
|
1443
|
+
{/* Step 4: Source ID */}
|
|
1444
|
+
{step === 4 && <div className="live-source-step-body">
|
|
1445
|
+
<p className="live-source-step-title">Source ID</p>
|
|
1446
|
+
<p className="live-source-step-hint">A stable key used to store and retrieve live records. Defaults to the integration id.</p>
|
|
1447
|
+
<label className="live-source-field">
|
|
1448
|
+
<span>Source ID</span>
|
|
1449
|
+
<input
|
|
1450
|
+
type="text"
|
|
1451
|
+
placeholder={autoSourceId || "my-source"}
|
|
1452
|
+
value={sourceId}
|
|
1453
|
+
onChange={(e) => setSourceId(e.target.value)}
|
|
1454
|
+
/>
|
|
1455
|
+
</label>
|
|
1456
|
+
<p className="live-source-step-hint">Records will be stored under this key in <code>growthub.source-records.json</code> and available immediately after Refresh.</p>
|
|
1457
|
+
<div className="live-source-nav">
|
|
1458
|
+
<button type="button" className="live-source-back" onClick={() => setStep(3)}>← Back</button>
|
|
1459
|
+
<button type="button" className="live-source-next" disabled={!canProceedStep4} onClick={() => { setStep(5); }}>
|
|
1460
|
+
Next → Test connection
|
|
1461
|
+
</button>
|
|
1462
|
+
</div>
|
|
1463
|
+
</div>}
|
|
1464
|
+
|
|
1465
|
+
{/* Step 5: Test + preview */}
|
|
1466
|
+
{step === 5 && <div className="live-source-step-body">
|
|
1467
|
+
<p className="live-source-step-title">Test connection</p>
|
|
1468
|
+
<div className="live-source-summary">
|
|
1469
|
+
<span><em>Integration</em> <strong>{integrationId}</strong></span>
|
|
1470
|
+
{entityType ? <span><em>Entity type</em> <strong>{entityType}</strong></span> : null}
|
|
1471
|
+
{entityId ? <span><em>Entity id</em> <strong>{entityId}</strong></span> : null}
|
|
1472
|
+
<span><em>Auth</em> <strong>{authMode === "bridge" ? "Growthub Bridge" : "BYO Token"}</strong></span>
|
|
1473
|
+
</div>
|
|
1474
|
+
|
|
1475
|
+
{!testState && !testing && <button
|
|
1476
|
+
type="button"
|
|
1477
|
+
className="live-source-test-btn"
|
|
1478
|
+
onClick={runTest}
|
|
1479
|
+
disabled={testing || !canProceedStep2 || !canProceedStep4}
|
|
1480
|
+
>
|
|
1481
|
+
<RefreshCw size={15} />
|
|
1482
|
+
Run test fetch
|
|
1483
|
+
</button>}
|
|
1484
|
+
|
|
1485
|
+
{testing && <div className="live-source-testing">
|
|
1486
|
+
<RefreshCw size={15} className="spinning" />
|
|
1487
|
+
<span>Contacting resolver…</span>
|
|
1488
|
+
</div>}
|
|
1489
|
+
|
|
1490
|
+
{testState && !testState.ok && <div className="live-source-test-result error">
|
|
1491
|
+
<strong>{testState.reason === "no-resolver" ? "No resolver registered" : "Fetch failed"}</strong>
|
|
1492
|
+
{testState.reason === "no-resolver" && <p>
|
|
1493
|
+
No resolver is registered for <code>{integrationId}</code>.
|
|
1494
|
+
Registered resolvers: {testState.registeredResolvers?.length
|
|
1495
|
+
? testState.registeredResolvers.join(", ")
|
|
1496
|
+
: "none"}.
|
|
1497
|
+
<br />Upload a resolver file in the Management panel or add one to <code>lib/adapters/integrations/resolvers/</code>.
|
|
1498
|
+
</p>}
|
|
1499
|
+
{testState.reason !== "no-resolver" && <p>{testState.error}</p>}
|
|
1500
|
+
<button type="button" className="live-source-retry" onClick={runTest} disabled={testing}>Retry</button>
|
|
1501
|
+
</div>}
|
|
1502
|
+
|
|
1503
|
+
{testState?.ok && <div className="live-source-test-result success">
|
|
1504
|
+
<strong>✓ {testState.recordCount} record{testState.recordCount !== 1 ? "s" : ""} fetched</strong>
|
|
1505
|
+
<span>Columns: {testState.columns?.join(", ") || "—"}</span>
|
|
1506
|
+
{testState.preview?.length > 0 && <div className="live-source-preview">
|
|
1507
|
+
<table>
|
|
1508
|
+
<thead>
|
|
1509
|
+
<tr>{testState.columns?.slice(0, 6).map((col) => <th key={col}>{col}</th>)}</tr>
|
|
1510
|
+
</thead>
|
|
1511
|
+
<tbody>
|
|
1512
|
+
{testState.preview.map((row, idx) => <tr key={idx}>
|
|
1513
|
+
{testState.columns?.slice(0, 6).map((col) => <td key={col}>
|
|
1514
|
+
{row[col] === null || row[col] === undefined ? <em className="live-source-null">—</em> : String(row[col]).slice(0, 60)}
|
|
1515
|
+
</td>)}
|
|
1516
|
+
</tr>)}
|
|
1517
|
+
</tbody>
|
|
1518
|
+
</table>
|
|
1519
|
+
</div>}
|
|
1520
|
+
<button type="button" className="live-source-retry" onClick={runTest} disabled={testing}>Re-test</button>
|
|
1521
|
+
</div>}
|
|
1522
|
+
|
|
1523
|
+
<div className="live-source-nav">
|
|
1524
|
+
<button type="button" className="live-source-back" onClick={() => setStep(4)}>← Back</button>
|
|
1525
|
+
<button
|
|
1526
|
+
type="button"
|
|
1527
|
+
className="live-source-apply"
|
|
1528
|
+
disabled={!canApply}
|
|
1529
|
+
onClick={applyBinding}
|
|
1530
|
+
title={canApply ? "Apply live source binding to widget" : "Run a successful test first"}
|
|
1531
|
+
>
|
|
1532
|
+
{canApply ? "✓ Apply binding" : "Test required to apply"}
|
|
1533
|
+
</button>
|
|
1534
|
+
</div>
|
|
1535
|
+
</div>}
|
|
1536
|
+
|
|
1537
|
+
<button type="button" className="live-source-cancel" onClick={onCancel}>Cancel</button>
|
|
1538
|
+
</div>;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* SourceDropdown — compact inline source picker used in the chart config header.
|
|
1543
|
+
* Shows only workspace-config saved data model objects. No static rows, no integrations.
|
|
1544
|
+
*/
|
|
1545
|
+
function SourceDropdown({ widget, dataModelTables, onChange }) {
|
|
1546
|
+
const [open, setOpen] = useState(false);
|
|
1547
|
+
const [query, setQuery] = useState("");
|
|
1548
|
+
const ref = useRef(null);
|
|
1549
|
+
const binding = widget.config?.binding || {};
|
|
1550
|
+
|
|
1551
|
+
useEffect(() => {
|
|
1552
|
+
if (!open) return;
|
|
1553
|
+
function handle(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
|
1554
|
+
document.addEventListener("mousedown", handle);
|
|
1555
|
+
return () => document.removeEventListener("mousedown", handle);
|
|
1556
|
+
}, [open]);
|
|
1557
|
+
|
|
1558
|
+
const savedObjects = useMemo(() => {
|
|
1559
|
+
const list = (Array.isArray(dataModelTables) ? dataModelTables : []).filter(isSelectableDataModelSource);
|
|
1560
|
+
const q = query.trim().toLowerCase();
|
|
1561
|
+
return q ? list.filter((t) => `${t.label} ${t.source}`.toLowerCase().includes(q)) : list;
|
|
1135
1562
|
}, [dataModelTables, query]);
|
|
1136
1563
|
|
|
1137
|
-
|
|
1138
|
-
if (
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
} else {
|
|
1142
|
-
onChange({
|
|
1143
|
-
...widget.config,
|
|
1144
|
-
source: widget.config?.source || "Static rows",
|
|
1145
|
-
binding: { mode: "manual", source: "Static rows", rows: Array.isArray(widget.config?.rows) ? widget.config.rows : [] }
|
|
1146
|
-
});
|
|
1564
|
+
const currentLabel = (() => {
|
|
1565
|
+
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
1566
|
+
const found = (Array.isArray(dataModelTables) ? dataModelTables : []).find((t) => t.objectId === binding.objectId);
|
|
1567
|
+
return found?.label || binding.source || "Object";
|
|
1147
1568
|
}
|
|
1148
|
-
|
|
1569
|
+
return "Select source…";
|
|
1570
|
+
})();
|
|
1149
1571
|
|
|
1150
|
-
|
|
1151
|
-
if (!table || !confirmSourceChange(table.label)) return;
|
|
1572
|
+
function selectObject(table) {
|
|
1152
1573
|
onChange({
|
|
1153
1574
|
...widget.config,
|
|
1154
1575
|
source: table.source,
|
|
1155
1576
|
columns: table.columns,
|
|
1156
1577
|
rows: [],
|
|
1157
|
-
binding: {
|
|
1158
|
-
|
|
1159
|
-
source: table.source,
|
|
1160
|
-
sourceType: DATA_MODEL_SOURCE_TYPE,
|
|
1161
|
-
sourceAuthority: "workspace-config",
|
|
1162
|
-
objectId: table.objectId,
|
|
1163
|
-
rows: []
|
|
1164
|
-
},
|
|
1165
|
-
fieldSettings: {
|
|
1166
|
-
hidden: [],
|
|
1167
|
-
order: table.columns
|
|
1168
|
-
}
|
|
1578
|
+
binding: { mode: "manual", source: table.source, sourceType: DATA_MODEL_SOURCE_TYPE, sourceAuthority: "workspace-config", objectId: table.objectId },
|
|
1579
|
+
fieldSettings: { hidden: [], order: table.columns }
|
|
1169
1580
|
});
|
|
1170
|
-
|
|
1581
|
+
setOpen(false);
|
|
1582
|
+
setQuery("");
|
|
1583
|
+
}
|
|
1171
1584
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1585
|
+
return (
|
|
1586
|
+
<div className="source-dropdown-wrap" ref={ref}>
|
|
1587
|
+
<button type="button" className={`source-dropdown-trigger${open ? " open" : ""}`} onClick={() => setOpen((v) => !v)} aria-haspopup="listbox" aria-expanded={open}>
|
|
1588
|
+
<span className="source-dropdown-label">{currentLabel}</span>
|
|
1589
|
+
<ChevronDown size={13} aria-hidden="true" />
|
|
1590
|
+
</button>
|
|
1591
|
+
{open && (
|
|
1592
|
+
<div className="source-dropdown-popover" role="listbox">
|
|
1593
|
+
<div className="source-dropdown-search">
|
|
1594
|
+
<Search size={12} aria-hidden="true" />
|
|
1595
|
+
<input autoFocus placeholder="Search objects…" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search objects" />
|
|
1596
|
+
</div>
|
|
1597
|
+
{savedObjects.length > 0 ? savedObjects.map((table) => {
|
|
1598
|
+
const sel = binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId === table.objectId;
|
|
1599
|
+
return (
|
|
1600
|
+
<button key={table.id} type="button" role="option" aria-selected={sel} className={`source-dropdown-item${sel ? " selected" : ""}`} onClick={() => selectObject(table)}>
|
|
1601
|
+
<ObjectIcon name={table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database"} size={13} />
|
|
1602
|
+
<span>{table.label}</span>
|
|
1603
|
+
{sel && <Check size={12} strokeWidth={2.5} aria-hidden="true" />}
|
|
1604
|
+
</button>
|
|
1605
|
+
);
|
|
1606
|
+
}) : (
|
|
1607
|
+
<div className="source-dropdown-empty">
|
|
1608
|
+
<span>No objects yet.</span>
|
|
1609
|
+
<a href="/data-model" className="source-dropdown-hint">Set up sources on Data Model →</a>
|
|
1610
|
+
</div>
|
|
1611
|
+
)}
|
|
1612
|
+
</div>
|
|
1613
|
+
)}
|
|
1614
|
+
</div>
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* SourceRefreshConfigurator — inline test + apply panel shown inside
|
|
1620
|
+
* Managed Integration and Custom API/Webhook source config.
|
|
1621
|
+
*
|
|
1622
|
+
* Refresh is a *behavior* of a source, not a source type.
|
|
1623
|
+
* This component surfaces test-source and preview-records capabilities
|
|
1624
|
+
* without exposing resolver-registry internals to the user.
|
|
1625
|
+
*/
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* ResolverControlPanel — composable, provider-agnostic resolver management.
|
|
1629
|
+
*
|
|
1630
|
+
* Reads resolver metadata from /api/workspace/resolvers (entityTypes,
|
|
1631
|
+
* hasListEntities) and renders generic controls. No provider names,
|
|
1632
|
+
* no hardcoded field names — every control is driven by what the resolver
|
|
1633
|
+
* file itself declares.
|
|
1634
|
+
*
|
|
1635
|
+
* Props:
|
|
1636
|
+
* binding — current widget binding (read-only, source of truth)
|
|
1637
|
+
* adapterConfig — workspace adapter config (bridge vs byo-token auth mode)
|
|
1638
|
+
* onUpdateBinding — (nextBinding) => void — saves binding params to widget config
|
|
1639
|
+
* onRefreshAndSave — (binding, objectId) => Promise<void> — fetch+persist rows
|
|
1640
|
+
*/
|
|
1641
|
+
function ResolverControlPanel({ binding, adapterConfig, onUpdateBinding, onRefreshAndSave }) {
|
|
1642
|
+
const integrationId = binding?.integrationId;
|
|
1643
|
+
const objectId = binding?.objectId;
|
|
1644
|
+
|
|
1645
|
+
const [resolverMeta, setResolverMeta] = useState(null);
|
|
1646
|
+
const [metaLoading, setMetaLoading] = useState(true);
|
|
1647
|
+
const [entities, setEntities] = useState(null);
|
|
1648
|
+
const [entitiesLoading, setEntitiesLoading] = useState(false);
|
|
1649
|
+
const [entitiesError, setEntitiesError] = useState(null);
|
|
1650
|
+
|
|
1651
|
+
const [entityType, setEntityType] = useState(binding?.entityType || "");
|
|
1652
|
+
const [entityId, setEntityId] = useState(binding?.entityId || "");
|
|
1653
|
+
const [lookbackDays, setLookbackDays] = useState(binding?.days || 30);
|
|
1654
|
+
|
|
1655
|
+
const [testState, setTestState] = useState(null);
|
|
1656
|
+
const [testing, setTesting] = useState(false);
|
|
1657
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
1658
|
+
const [refreshResult, setRefreshResult] = useState(null);
|
|
1659
|
+
|
|
1660
|
+
// Load resolver metadata once on mount
|
|
1661
|
+
useEffect(() => {
|
|
1662
|
+
if (!integrationId) { setMetaLoading(false); return; }
|
|
1663
|
+
setMetaLoading(true);
|
|
1664
|
+
fetch("/api/workspace/resolvers")
|
|
1665
|
+
.then((r) => r.ok ? r.json() : null)
|
|
1666
|
+
.then((data) => {
|
|
1667
|
+
if (!data) return;
|
|
1668
|
+
const meta = Array.isArray(data.resolvers)
|
|
1669
|
+
? data.resolvers.find((r) => r.integrationId === integrationId) || null
|
|
1670
|
+
: null;
|
|
1671
|
+
setResolverMeta(meta);
|
|
1672
|
+
if (!entityType && meta?.entityTypes?.length) {
|
|
1673
|
+
setEntityType(meta.entityTypes[0]);
|
|
1674
|
+
}
|
|
1675
|
+
})
|
|
1676
|
+
.catch(() => setResolverMeta(null))
|
|
1677
|
+
.finally(() => setMetaLoading(false));
|
|
1678
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1679
|
+
}, [integrationId]);
|
|
1680
|
+
|
|
1681
|
+
// Load entity list when resolver declares listEntities
|
|
1682
|
+
useEffect(() => {
|
|
1683
|
+
if (!integrationId || !resolverMeta?.hasListEntities) return;
|
|
1684
|
+
setEntitiesLoading(true);
|
|
1685
|
+
setEntitiesError(null);
|
|
1686
|
+
fetch(`/api/workspace/list-entities?integrationId=${encodeURIComponent(integrationId)}`)
|
|
1687
|
+
.then((r) => r.ok ? r.json() : Promise.reject(r))
|
|
1688
|
+
.then((data) => setEntities(Array.isArray(data.entities) ? data.entities : []))
|
|
1689
|
+
.catch(() => { setEntitiesError("Could not load entities"); setEntities([]); })
|
|
1690
|
+
.finally(() => setEntitiesLoading(false));
|
|
1691
|
+
}, [integrationId, resolverMeta?.hasListEntities]);
|
|
1692
|
+
|
|
1693
|
+
const authMode = adapterConfig?.integrationAdapter === "growthub-bridge" ? "bridge" : "byo-token";
|
|
1694
|
+
|
|
1695
|
+
function buildTestBinding() {
|
|
1696
|
+
return {
|
|
1697
|
+
...binding,
|
|
1698
|
+
entityType: entityType || undefined,
|
|
1699
|
+
entityId: entityId || undefined,
|
|
1700
|
+
days: lookbackDays || undefined,
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function saveParams() {
|
|
1705
|
+
onUpdateBinding({
|
|
1706
|
+
...binding,
|
|
1707
|
+
entityType: entityType || undefined,
|
|
1708
|
+
entityId: entityId || undefined,
|
|
1709
|
+
days: lookbackDays || undefined,
|
|
1186
1710
|
});
|
|
1187
|
-
|
|
1711
|
+
}
|
|
1188
1712
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1713
|
+
async function runTest() {
|
|
1714
|
+
setTesting(true);
|
|
1715
|
+
setTestState(null);
|
|
1716
|
+
try {
|
|
1717
|
+
const res = await fetch("/api/workspace/test-source", {
|
|
1718
|
+
method: "POST",
|
|
1719
|
+
headers: { "content-type": "application/json" },
|
|
1720
|
+
body: JSON.stringify({ integrationId, binding: { ...buildTestBinding(), authMode } }),
|
|
1721
|
+
});
|
|
1722
|
+
setTestState(await res.json());
|
|
1723
|
+
} catch {
|
|
1724
|
+
setTestState({ ok: false, reason: "network-error", error: "Network error — check console" });
|
|
1725
|
+
} finally {
|
|
1726
|
+
setTesting(false);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function handleRefresh() {
|
|
1731
|
+
setRefreshing(true);
|
|
1732
|
+
setRefreshResult(null);
|
|
1733
|
+
try {
|
|
1734
|
+
await onRefreshAndSave(buildTestBinding(), objectId);
|
|
1735
|
+
setRefreshResult({ ok: true });
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
setRefreshResult({ ok: false, error: err.message || "Refresh failed" });
|
|
1738
|
+
} finally {
|
|
1739
|
+
setRefreshing(false);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (!integrationId) return null;
|
|
1744
|
+
|
|
1745
|
+
const isRegistered = !metaLoading && resolverMeta !== null;
|
|
1746
|
+
const isMissing = !metaLoading && resolverMeta === null;
|
|
1747
|
+
const entityTypes = resolverMeta?.entityTypes || [];
|
|
1748
|
+
|
|
1749
|
+
return (
|
|
1750
|
+
<section className="resolver-control-panel">
|
|
1751
|
+
{/* ── Status bar ─────────────────────────────────────────────── */}
|
|
1752
|
+
<div className="resolver-status-bar">
|
|
1753
|
+
<span className={`resolver-reg-badge${isRegistered ? " ok" : isMissing ? " missing" : ""}`}>
|
|
1754
|
+
{metaLoading ? "…" : isRegistered ? "✓" : "!"}
|
|
1755
|
+
</span>
|
|
1756
|
+
<code className="resolver-id-code">{integrationId}</code>
|
|
1757
|
+
<span className="resolver-status-label">
|
|
1758
|
+
{metaLoading
|
|
1759
|
+
? "checking…"
|
|
1760
|
+
: isRegistered
|
|
1761
|
+
? `resolver registered · ${entityTypes.length} entity type${entityTypes.length !== 1 ? "s" : ""}`
|
|
1762
|
+
: "resolver not found"}
|
|
1763
|
+
</span>
|
|
1764
|
+
</div>
|
|
1765
|
+
|
|
1766
|
+
{isMissing && (
|
|
1767
|
+
<p className="resolver-guidance warn">
|
|
1768
|
+
No resolver registered for <code>{integrationId}</code>.
|
|
1769
|
+
Add a file at <code>lib/adapters/integrations/resolvers/{integrationId}.js</code> that calls{" "}
|
|
1770
|
+
<code>{"registerSourceResolver({ integrationId: \"" + integrationId + "\", ... })"}</code>,
|
|
1771
|
+
then restart the dev server.
|
|
1772
|
+
</p>
|
|
1773
|
+
)}
|
|
1774
|
+
|
|
1775
|
+
{isRegistered && (
|
|
1776
|
+
<>
|
|
1777
|
+
{/* ── Entity type ─────────────────────────────────────────── */}
|
|
1778
|
+
{entityTypes.length > 0 && (
|
|
1779
|
+
<div className="resolver-param-row">
|
|
1780
|
+
<label className="resolver-param-label">Entity type</label>
|
|
1781
|
+
<select
|
|
1782
|
+
className="resolver-param-select"
|
|
1783
|
+
value={entityType}
|
|
1784
|
+
onChange={(e) => setEntityType(e.target.value)}
|
|
1785
|
+
>
|
|
1786
|
+
<option value="">— any —</option>
|
|
1787
|
+
{entityTypes.map((et) => (
|
|
1788
|
+
<option key={et} value={et}>{et}</option>
|
|
1789
|
+
))}
|
|
1790
|
+
</select>
|
|
1791
|
+
</div>
|
|
1792
|
+
)}
|
|
1793
|
+
|
|
1794
|
+
{/* ── Entity picker ────────────────────────────────────────── */}
|
|
1795
|
+
{resolverMeta?.hasListEntities && (
|
|
1796
|
+
<div className="resolver-param-row">
|
|
1797
|
+
<label className="resolver-param-label">Entity</label>
|
|
1798
|
+
{entitiesLoading ? (
|
|
1799
|
+
<span className="resolver-loading-label">Loading entities…</span>
|
|
1800
|
+
) : entitiesError ? (
|
|
1801
|
+
<span className="resolver-error-label">{entitiesError}</span>
|
|
1802
|
+
) : entities?.length ? (
|
|
1803
|
+
<select
|
|
1804
|
+
className="resolver-param-select"
|
|
1805
|
+
value={entityId}
|
|
1806
|
+
onChange={(e) => setEntityId(e.target.value)}
|
|
1807
|
+
>
|
|
1808
|
+
<option value="">— all —</option>
|
|
1809
|
+
{entities.map((ent) => (
|
|
1810
|
+
<option key={ent.id} value={ent.id}>{ent.label || ent.id}</option>
|
|
1811
|
+
))}
|
|
1812
|
+
</select>
|
|
1813
|
+
) : (
|
|
1814
|
+
<input
|
|
1815
|
+
className="resolver-param-input"
|
|
1816
|
+
value={entityId}
|
|
1817
|
+
placeholder="Entity id"
|
|
1818
|
+
onChange={(e) => setEntityId(e.target.value)}
|
|
1819
|
+
/>
|
|
1820
|
+
)}
|
|
1821
|
+
</div>
|
|
1822
|
+
)}
|
|
1823
|
+
|
|
1824
|
+
{/* ── Lookback ────────────────────────────────────────────── */}
|
|
1825
|
+
<div className="resolver-param-row">
|
|
1826
|
+
<label className="resolver-param-label">Lookback</label>
|
|
1827
|
+
<div className="resolver-lookback-row">
|
|
1828
|
+
{[7, 30, 90].map((d) => (
|
|
1829
|
+
<button
|
|
1830
|
+
key={d}
|
|
1831
|
+
type="button"
|
|
1832
|
+
className={`resolver-lookback-pill${lookbackDays === d ? " active" : ""}`}
|
|
1833
|
+
onClick={() => setLookbackDays(d)}
|
|
1834
|
+
>
|
|
1835
|
+
{d}d
|
|
1836
|
+
</button>
|
|
1837
|
+
))}
|
|
1838
|
+
<input
|
|
1839
|
+
type="number"
|
|
1840
|
+
className="resolver-lookback-custom"
|
|
1841
|
+
value={lookbackDays}
|
|
1842
|
+
min={1}
|
|
1843
|
+
max={365}
|
|
1844
|
+
aria-label="Custom lookback days"
|
|
1845
|
+
onChange={(e) => setLookbackDays(Number(e.target.value) || 30)}
|
|
1846
|
+
/>
|
|
1847
|
+
</div>
|
|
1848
|
+
</div>
|
|
1849
|
+
|
|
1850
|
+
{/* ── Save params ─────────────────────────────────────────── */}
|
|
1851
|
+
<button type="button" className="resolver-save-params-btn" onClick={saveParams}>
|
|
1852
|
+
Save parameters to binding
|
|
1853
|
+
</button>
|
|
1854
|
+
|
|
1855
|
+
{/* ── Test connection ─────────────────────────────────────── */}
|
|
1856
|
+
<div className="resolver-actions">
|
|
1857
|
+
<button
|
|
1858
|
+
type="button"
|
|
1859
|
+
className="resolver-test-btn"
|
|
1860
|
+
onClick={runTest}
|
|
1861
|
+
disabled={testing}
|
|
1862
|
+
>
|
|
1863
|
+
{testing ? "Testing…" : "Test connection"}
|
|
1864
|
+
</button>
|
|
1865
|
+
<button
|
|
1866
|
+
type="button"
|
|
1867
|
+
className="resolver-refresh-btn"
|
|
1868
|
+
onClick={handleRefresh}
|
|
1869
|
+
disabled={refreshing}
|
|
1870
|
+
title="Fetch all records and save to Data Model"
|
|
1871
|
+
>
|
|
1872
|
+
{refreshing ? "Fetching…" : "Fetch & save data"}
|
|
1873
|
+
</button>
|
|
1874
|
+
</div>
|
|
1875
|
+
|
|
1876
|
+
{/* ── Test result ─────────────────────────────────────────── */}
|
|
1877
|
+
{testState && !testState.ok && (
|
|
1878
|
+
<div className="resolver-result-block error">
|
|
1879
|
+
<p className="resolver-result-title">
|
|
1880
|
+
{testState.reason === "no-token" ? "Token required" : "Connection failed"}
|
|
1881
|
+
</p>
|
|
1882
|
+
<p className="resolver-result-detail">{testState.error || testState.reason}</p>
|
|
1883
|
+
{testState.reason === "no-token" && (
|
|
1884
|
+
<p className="resolver-guidance">
|
|
1885
|
+
Add the required env var to <code>.env.local</code> and restart the dev server.
|
|
1886
|
+
Check the resolver file for the exact variable name.
|
|
1887
|
+
</p>
|
|
1888
|
+
)}
|
|
1889
|
+
<button type="button" className="resolver-retry-btn" onClick={runTest} disabled={testing}>
|
|
1890
|
+
Retry
|
|
1891
|
+
</button>
|
|
1892
|
+
</div>
|
|
1893
|
+
)}
|
|
1894
|
+
|
|
1895
|
+
{testState?.ok && (
|
|
1896
|
+
<div className="resolver-result-block ok">
|
|
1897
|
+
<div className="resolver-result-header">
|
|
1898
|
+
<span className="resolver-result-ok-badge">✓ Connected</span>
|
|
1899
|
+
<span className="resolver-result-count">
|
|
1900
|
+
{testState.rowCount ?? testState.preview?.length ?? 0} records (preview)
|
|
1901
|
+
</span>
|
|
1902
|
+
</div>
|
|
1903
|
+
{testState.preview?.length > 0 && (
|
|
1904
|
+
<div className="resolver-preview-table-wrap">
|
|
1905
|
+
<table className="resolver-preview-table">
|
|
1906
|
+
<thead>
|
|
1907
|
+
<tr>
|
|
1908
|
+
{(testState.columns || []).slice(0, 5).map((col) => (
|
|
1909
|
+
<th key={col}>{col}</th>
|
|
1910
|
+
))}
|
|
1911
|
+
</tr>
|
|
1912
|
+
</thead>
|
|
1913
|
+
<tbody>
|
|
1914
|
+
{testState.preview.slice(0, 3).map((row, i) => (
|
|
1915
|
+
<tr key={i}>
|
|
1916
|
+
{(testState.columns || []).slice(0, 5).map((col) => (
|
|
1917
|
+
<td key={col}>
|
|
1918
|
+
{row[col] == null ? <em>—</em> : String(row[col]).slice(0, 30)}
|
|
1919
|
+
</td>
|
|
1920
|
+
))}
|
|
1921
|
+
</tr>
|
|
1922
|
+
))}
|
|
1923
|
+
</tbody>
|
|
1924
|
+
</table>
|
|
1925
|
+
</div>
|
|
1926
|
+
)}
|
|
1927
|
+
</div>
|
|
1928
|
+
)}
|
|
1929
|
+
|
|
1930
|
+
{/* ── Refresh result ─────────────────────────────────────── */}
|
|
1931
|
+
{refreshResult && (
|
|
1932
|
+
<div className={`resolver-result-block ${refreshResult.ok ? "ok" : "error"}`}>
|
|
1933
|
+
{refreshResult.ok
|
|
1934
|
+
? <span className="resolver-result-ok-badge">✓ Data saved to Data Model</span>
|
|
1935
|
+
: <p className="resolver-result-detail">{refreshResult.error}</p>}
|
|
1936
|
+
</div>
|
|
1937
|
+
)}
|
|
1938
|
+
</>
|
|
1939
|
+
)}
|
|
1940
|
+
</section>
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function SourceRefreshConfigurator({ widget, integrationId, adapterConfig, onApply }) {
|
|
1945
|
+
const existing = widget.config?.binding || {};
|
|
1946
|
+
const isRefreshable = Boolean(existing.sourceStorage === LIVE_SOURCE_TYPE);
|
|
1947
|
+
const [open, setOpen] = useState(isRefreshable);
|
|
1948
|
+
const [entityType, setEntityType] = useState(existing.entityType || "");
|
|
1949
|
+
const [entityId, setEntityId] = useState(existing.entityId || "");
|
|
1950
|
+
const [testState, setTestState] = useState(null);
|
|
1951
|
+
const [testing, setTesting] = useState(false);
|
|
1952
|
+
|
|
1953
|
+
const canApply = testState?.ok === true;
|
|
1954
|
+
const authMode = adapterConfig?.integrationAdapter === "growthub-bridge" ? "bridge" : "byo-token";
|
|
1955
|
+
|
|
1956
|
+
async function runTest() {
|
|
1957
|
+
setTesting(true);
|
|
1958
|
+
setTestState(null);
|
|
1959
|
+
try {
|
|
1960
|
+
const res = await fetch("/api/workspace/test-source", {
|
|
1961
|
+
method: "POST",
|
|
1962
|
+
headers: { "content-type": "application/json" },
|
|
1963
|
+
body: JSON.stringify({
|
|
1964
|
+
integrationId,
|
|
1965
|
+
binding: {
|
|
1966
|
+
integrationId,
|
|
1967
|
+
entityType: entityType.trim() || undefined,
|
|
1968
|
+
entityId: entityId.trim() || undefined,
|
|
1969
|
+
sourceId: integrationId,
|
|
1970
|
+
authMode,
|
|
1971
|
+
}
|
|
1972
|
+
})
|
|
1973
|
+
});
|
|
1974
|
+
const data = await res.json();
|
|
1975
|
+
setTestState(data);
|
|
1976
|
+
} catch {
|
|
1977
|
+
setTestState({ ok: false, error: "Network error — check console" });
|
|
1978
|
+
} finally {
|
|
1979
|
+
setTesting(false);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function applyRefreshable() {
|
|
1984
|
+
if (!canApply) return;
|
|
1985
|
+
onApply({
|
|
1192
1986
|
...widget.config,
|
|
1193
1987
|
binding: {
|
|
1194
|
-
...binding,
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
fields
|
|
1988
|
+
...widget.config?.binding,
|
|
1989
|
+
sourceStorage: LIVE_SOURCE_TYPE,
|
|
1990
|
+
sourceId: integrationId,
|
|
1991
|
+
entityType: entityType.trim() || undefined,
|
|
1992
|
+
entityId: entityId.trim() || undefined,
|
|
1200
1993
|
}
|
|
1201
1994
|
});
|
|
1202
|
-
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
if (!open) {
|
|
1998
|
+
return <div className="source-refresh-collapsed">
|
|
1999
|
+
<button type="button" className="source-refresh-toggle" onClick={() => setOpen(true)}>
|
|
2000
|
+
<RefreshCw size={13} aria-hidden="true" />
|
|
2001
|
+
{isRefreshable ? "Refresh enabled — reconfigure" : "Enable source refresh"}
|
|
2002
|
+
</button>
|
|
2003
|
+
{isRefreshable && <span className="source-refresh-active-badge">✓ refreshable</span>}
|
|
2004
|
+
</div>;
|
|
2005
|
+
}
|
|
1203
2006
|
|
|
1204
|
-
|
|
2007
|
+
return <div className="source-refresh-panel">
|
|
2008
|
+
<div className="source-refresh-header">
|
|
2009
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
2010
|
+
<strong>Source refresh</strong>
|
|
2011
|
+
<button type="button" className="source-refresh-close" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
|
2012
|
+
</div>
|
|
2013
|
+
<p className="source-refresh-hint">
|
|
2014
|
+
Test the connection to preview live records. Apply to enable the Refresh tab for this widget.
|
|
2015
|
+
</p>
|
|
2016
|
+
<label className="source-refresh-field">
|
|
2017
|
+
<span>Entity type <em>(optional)</em></span>
|
|
2018
|
+
<input value={entityType} placeholder="contacts, companies…" onChange={(e) => setEntityType(e.target.value)} />
|
|
2019
|
+
</label>
|
|
2020
|
+
<label className="source-refresh-field">
|
|
2021
|
+
<span>Entity id filter <em>(optional)</em></span>
|
|
2022
|
+
<input value={entityId} placeholder="specific record id" onChange={(e) => setEntityId(e.target.value)} />
|
|
2023
|
+
</label>
|
|
2024
|
+
<button type="button" className="source-refresh-test-btn" onClick={runTest} disabled={testing}>
|
|
2025
|
+
{testing ? "Testing…" : "Test connection"}
|
|
2026
|
+
</button>
|
|
2027
|
+
{testState && !testState.ok && (
|
|
2028
|
+
<div className="source-refresh-error">
|
|
2029
|
+
<strong>Connection failed</strong>
|
|
2030
|
+
<span>{testState.reason || testState.error || "Unknown error"}</span>
|
|
2031
|
+
<button type="button" onClick={runTest} disabled={testing}>Retry</button>
|
|
2032
|
+
</div>
|
|
2033
|
+
)}
|
|
2034
|
+
{testState?.ok && (
|
|
2035
|
+
<div className="source-refresh-success">
|
|
2036
|
+
<span>✓ Connection verified</span>
|
|
2037
|
+
{testState.preview?.length > 0 && (
|
|
2038
|
+
<div className="source-refresh-preview">
|
|
2039
|
+
<p className="source-refresh-preview-label">{testState.preview.length} record(s) preview</p>
|
|
2040
|
+
<table>
|
|
2041
|
+
<thead>
|
|
2042
|
+
<tr>{testState.columns?.slice(0, 5).map((col) => <th key={col}>{col}</th>)}</tr>
|
|
2043
|
+
</thead>
|
|
2044
|
+
<tbody>
|
|
2045
|
+
{testState.preview.slice(0, 3).map((row, idx) => (
|
|
2046
|
+
<tr key={idx}>
|
|
2047
|
+
{testState.columns?.slice(0, 5).map((col) => (
|
|
2048
|
+
<td key={col}>{row[col] == null ? <em>—</em> : String(row[col]).slice(0, 40)}</td>
|
|
2049
|
+
))}
|
|
2050
|
+
</tr>
|
|
2051
|
+
))}
|
|
2052
|
+
</tbody>
|
|
2053
|
+
</table>
|
|
2054
|
+
</div>
|
|
2055
|
+
)}
|
|
2056
|
+
<button type="button" className="source-refresh-apply" onClick={applyRefreshable}>
|
|
2057
|
+
✓ Apply refresh binding
|
|
2058
|
+
</button>
|
|
2059
|
+
</div>
|
|
2060
|
+
)}
|
|
2061
|
+
</div>;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
2065
|
+
const binding = widget.config?.binding || {};
|
|
2066
|
+
const [query, setQuery] = useState("");
|
|
2067
|
+
|
|
2068
|
+
const savedObjects = useMemo(() => {
|
|
2069
|
+
const list = (Array.isArray(dataModelTables) ? dataModelTables : []).filter(isSelectableDataModelSource);
|
|
2070
|
+
const q = query.trim().toLowerCase();
|
|
2071
|
+
return q ? list.filter((t) => `${t.label} ${t.source}`.toLowerCase().includes(q)) : list;
|
|
2072
|
+
}, [dataModelTables, query]);
|
|
2073
|
+
|
|
2074
|
+
const selectObject = useCallback((table) => {
|
|
2075
|
+
const alreadySelected = binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId === table.objectId;
|
|
2076
|
+
if (alreadySelected) return;
|
|
2077
|
+
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
2078
|
+
if (!window.confirm(`Change source to "${table.label}"?`)) return;
|
|
2079
|
+
}
|
|
1205
2080
|
onChange({
|
|
1206
2081
|
...widget.config,
|
|
2082
|
+
source: table.source,
|
|
2083
|
+
columns: table.columns,
|
|
2084
|
+
rows: [],
|
|
1207
2085
|
binding: {
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
}
|
|
2086
|
+
mode: "manual",
|
|
2087
|
+
source: table.source,
|
|
2088
|
+
sourceType: DATA_MODEL_SOURCE_TYPE,
|
|
2089
|
+
sourceAuthority: "workspace-config",
|
|
2090
|
+
objectId: table.objectId,
|
|
2091
|
+
},
|
|
2092
|
+
fieldSettings: { hidden: [], order: table.columns }
|
|
1215
2093
|
});
|
|
1216
2094
|
}, [binding, onChange, widget.config]);
|
|
1217
2095
|
|
|
1218
|
-
|
|
1219
|
-
if (binding.integrationId && binding.integrationId !== integration.id && !confirmSourceChange(integration.label)) return;
|
|
1220
|
-
onChange({
|
|
1221
|
-
...widget.config,
|
|
1222
|
-
source: integration.label,
|
|
1223
|
-
binding: {
|
|
1224
|
-
mode: "integration",
|
|
1225
|
-
source: integration.label,
|
|
1226
|
-
sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
|
|
1227
|
-
sourceAuthority: "growthub-bridge",
|
|
1228
|
-
integrationId: integration.id,
|
|
1229
|
-
lane: integration.lane,
|
|
1230
|
-
provider: integration.provider
|
|
1231
|
-
}
|
|
1232
|
-
});
|
|
1233
|
-
}, [binding.integrationId, confirmSourceChange, onChange, widget.config]);
|
|
2096
|
+
const activeObjectId = binding.sourceType === DATA_MODEL_SOURCE_TYPE ? binding.objectId : null;
|
|
1234
2097
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
{SOURCE_TYPE_OBJECTS.map((sourceType) => {
|
|
1241
|
-
const isActive = activeSourceType === sourceType.id;
|
|
1242
|
-
return <button
|
|
1243
|
-
key={sourceType.id}
|
|
1244
|
-
type="button"
|
|
1245
|
-
className={`workspace-source-object-row${isActive ? " active" : ""}`}
|
|
1246
|
-
onClick={sourceType.id === CUSTOM_API_SOURCE_TYPE ? selectCustomApi : undefined}
|
|
1247
|
-
disabled={sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE}
|
|
1248
|
-
>
|
|
1249
|
-
<span className="workspace-source-object-icon" aria-hidden="true">
|
|
1250
|
-
{sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE ? <Database size={15} /> : <LinkIcon size={15} />}
|
|
1251
|
-
</span>
|
|
1252
|
-
<span className="workspace-source-meta">
|
|
1253
|
-
<strong>{sourceType.label}</strong>
|
|
1254
|
-
<em>{sourceType.authority} · {sourceType.description}</em>
|
|
1255
|
-
</span>
|
|
1256
|
-
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1257
|
-
</button>;
|
|
1258
|
-
})}
|
|
1259
|
-
</div>
|
|
1260
|
-
<div className="workspace-source-controls">
|
|
1261
|
-
<label>
|
|
1262
|
-
<Search size={14} aria-hidden="true" />
|
|
2098
|
+
return (
|
|
2099
|
+
<section className="workspace-widget-subpanel">
|
|
2100
|
+
<SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
|
|
2101
|
+
<label className="workspace-source-search-wrap">
|
|
2102
|
+
<Search size={13} aria-hidden="true" />
|
|
1263
2103
|
<input
|
|
1264
|
-
|
|
1265
|
-
placeholder="Search connectors"
|
|
2104
|
+
placeholder="Search objects…"
|
|
1266
2105
|
value={query}
|
|
1267
|
-
onChange={(
|
|
2106
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2107
|
+
aria-label="Search data objects"
|
|
1268
2108
|
/>
|
|
1269
2109
|
</label>
|
|
1270
|
-
<
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
{widget.kind === "view" ? <>
|
|
1300
|
-
<p className="workspace-panel-label">Data Model objects</p>
|
|
1301
|
-
<div className="workspace-source-list">
|
|
1302
|
-
{availableDataObjects.length ? availableDataObjects.map((table) => {
|
|
1303
|
-
const isActive = binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId === table.objectId;
|
|
1304
|
-
return <button
|
|
1305
|
-
key={table.id}
|
|
1306
|
-
type="button"
|
|
1307
|
-
className={`workspace-source-row${isActive ? " active" : ""}`}
|
|
1308
|
-
onClick={() => selectDataModelObject(table)}
|
|
1309
|
-
>
|
|
1310
|
-
<span className="workspace-source-icon" aria-hidden="true"><Database size={15} /></span>
|
|
1311
|
-
<span className="workspace-source-meta">
|
|
1312
|
-
<strong>{table.label}</strong>
|
|
1313
|
-
<em>{table.columns.length} fields · {table.rows.length} records · workspace config</em>
|
|
1314
|
-
</span>
|
|
1315
|
-
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1316
|
-
</button>;
|
|
1317
|
-
}) : <p className="workspace-entity-empty">No manual Data Model objects yet.</p>}
|
|
2110
|
+
<div className="workspace-source-object-list">
|
|
2111
|
+
{savedObjects.length > 0 ? savedObjects.map((table) => {
|
|
2112
|
+
const isActive = activeObjectId === table.objectId;
|
|
2113
|
+
const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
|
|
2114
|
+
return (
|
|
2115
|
+
<button
|
|
2116
|
+
key={table.id}
|
|
2117
|
+
type="button"
|
|
2118
|
+
className={`workspace-source-object-row${isActive ? " active" : ""}`}
|
|
2119
|
+
onClick={() => selectObject(table)}
|
|
2120
|
+
>
|
|
2121
|
+
<span className="workspace-source-object-icon">
|
|
2122
|
+
<ObjectIcon name={iconName} size={15} />
|
|
2123
|
+
</span>
|
|
2124
|
+
<span className="workspace-source-object-meta">
|
|
2125
|
+
<strong>{table.label}</strong>
|
|
2126
|
+
<em>{table.columns.length} field{table.columns.length !== 1 ? "s" : ""} · {table.rows.length} record{table.rows.length !== 1 ? "s" : ""}</em>
|
|
2127
|
+
</span>
|
|
2128
|
+
{isActive && <Check size={14} strokeWidth={2.5} aria-hidden="true" />}
|
|
2129
|
+
</button>
|
|
2130
|
+
);
|
|
2131
|
+
}) : (
|
|
2132
|
+
<div className="workspace-source-empty">
|
|
2133
|
+
<Database size={22} aria-hidden="true" />
|
|
2134
|
+
<strong>No objects yet</strong>
|
|
2135
|
+
<p>Create Data Source, People, Tasks, or Custom objects on the Data Model page, then return here to bind them.</p>
|
|
2136
|
+
<a href="/data-model" className="workspace-source-empty-link">Go to Data Model →</a>
|
|
2137
|
+
</div>
|
|
2138
|
+
)}
|
|
1318
2139
|
</div>
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
2140
|
+
<p className="workspace-panel-hint">
|
|
2141
|
+
Selecting an object writes a config reference only. Resolver functions and auth credentials stay server-side.
|
|
2142
|
+
</p>
|
|
2143
|
+
</section>
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function FieldRow({ name, hidden, onToggle, onRemove, canRemove }) {
|
|
2148
|
+
const FIcon = FIELD_TYPE_ICON_MAP[inferFieldType(name)] || Type;
|
|
2149
|
+
return (
|
|
2150
|
+
<div className={`wfp-field-row${hidden ? " hidden" : ""}`}>
|
|
2151
|
+
<GripVertical size={13} className="wfp-grip" aria-hidden="true" />
|
|
2152
|
+
<span className="wfp-field-icon" aria-hidden="true"><FIcon size={13} /></span>
|
|
2153
|
+
<span className="wfp-field-name">{name}</span>
|
|
2154
|
+
<div className="wfp-field-actions">
|
|
2155
|
+
<button
|
|
2156
|
+
type="button"
|
|
2157
|
+
className={`wfp-eye-btn${hidden ? " off" : ""}`}
|
|
2158
|
+
onClick={() => onToggle(name)}
|
|
2159
|
+
aria-label={hidden ? `Show ${name}` : `Hide ${name}`}
|
|
2160
|
+
title={hidden ? "Show" : "Hide"}
|
|
2161
|
+
>
|
|
2162
|
+
{hidden
|
|
2163
|
+
? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" /><line x1="1" y1="1" x2="23" y2="23" /></svg>
|
|
2164
|
+
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></svg>
|
|
2165
|
+
}
|
|
2166
|
+
</button>
|
|
2167
|
+
{canRemove && (
|
|
2168
|
+
<button type="button" className="wfp-remove-btn" onClick={() => onRemove(name)} aria-label={`Remove ${name}`} title="Remove">
|
|
2169
|
+
<X size={12} />
|
|
2170
|
+
</button>
|
|
2171
|
+
)}
|
|
1340
2172
|
</div>
|
|
1341
|
-
</div>
|
|
1342
|
-
|
|
1343
|
-
<label>
|
|
1344
|
-
<span>Endpoint reference</span>
|
|
1345
|
-
<input
|
|
1346
|
-
value={binding.endpointRef || ""}
|
|
1347
|
-
placeholder="api.clients.primary"
|
|
1348
|
-
onChange={(event) => updateEndpointRef(event.target.value)}
|
|
1349
|
-
/>
|
|
1350
|
-
</label>
|
|
1351
|
-
<label>
|
|
1352
|
-
<span>Available fields</span>
|
|
1353
|
-
<input
|
|
1354
|
-
value={(Array.isArray(binding.fields) ? binding.fields : []).join(", ")}
|
|
1355
|
-
placeholder="entityId, status, createdAt"
|
|
1356
|
-
onChange={(event) => updateCustomFields(event.target.value)}
|
|
1357
|
-
/>
|
|
1358
|
-
</label>
|
|
1359
|
-
</div> : null}
|
|
1360
|
-
{currentMode === "integration" && binding.integrationId ? <div className="workspace-active-source-state">
|
|
1361
|
-
<span>Active source</span>
|
|
1362
|
-
<strong>{activeIntegration?.label || binding.source || binding.integrationId}</strong>
|
|
1363
|
-
<code>{binding.integrationId}</code>
|
|
1364
|
-
</div> : null}
|
|
1365
|
-
<p className="workspace-panel-hint">
|
|
1366
|
-
Selecting a source writes a binding reference only. The browser only calls local workspace routes and never stores source credentials.
|
|
1367
|
-
</p>
|
|
1368
|
-
</section>;
|
|
2173
|
+
</div>
|
|
2174
|
+
);
|
|
1369
2175
|
}
|
|
1370
2176
|
|
|
1371
2177
|
function FieldsSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
1372
2178
|
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1373
2179
|
const ordered = getOrderedColumns(viewWidget);
|
|
1374
|
-
const
|
|
1375
|
-
const visible = ordered.filter((
|
|
1376
|
-
const hiddenList = ordered.filter((
|
|
1377
|
-
const [hiddenOpen, setHiddenOpen] = useState(
|
|
2180
|
+
const hiddenSet = getHiddenColumnSet(viewWidget);
|
|
2181
|
+
const visible = ordered.filter((n) => !hiddenSet.has(n));
|
|
2182
|
+
const hiddenList = ordered.filter((n) => hiddenSet.has(n));
|
|
2183
|
+
const [hiddenOpen, setHiddenOpen] = useState(false);
|
|
1378
2184
|
const [draftField, setDraftField] = useState("");
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
onChange({ ...widget.config, fieldSettings: next });
|
|
1382
|
-
};
|
|
2185
|
+
const isBound = Boolean(dataModelTable);
|
|
2186
|
+
|
|
1383
2187
|
const toggle = (fieldId) => {
|
|
1384
|
-
|
|
1385
|
-
onChange({ ...widget.config, fieldSettings: next });
|
|
2188
|
+
onChange({ ...widget.config, fieldSettings: toggleColumnHidden(viewWidget, fieldId) });
|
|
1386
2189
|
};
|
|
1387
2190
|
const removeColumn = (fieldId) => {
|
|
1388
|
-
if (
|
|
1389
|
-
const nextColumns = ordered.filter((name) => name !== fieldId);
|
|
2191
|
+
if (isBound) return;
|
|
1390
2192
|
const fs = widget.config?.fieldSettings || {};
|
|
1391
2193
|
onChange({
|
|
1392
2194
|
...widget.config,
|
|
1393
|
-
columns:
|
|
2195
|
+
columns: ordered.filter((n) => n !== fieldId),
|
|
1394
2196
|
fieldSettings: {
|
|
1395
|
-
hidden: (fs.hidden || []).filter((
|
|
1396
|
-
order: (fs.order || []).filter((
|
|
2197
|
+
hidden: (fs.hidden || []).filter((n) => n !== fieldId),
|
|
2198
|
+
order: (fs.order || []).filter((n) => n !== fieldId)
|
|
1397
2199
|
}
|
|
1398
2200
|
});
|
|
1399
2201
|
};
|
|
1400
2202
|
const addColumn = () => {
|
|
1401
|
-
if (
|
|
1402
|
-
const
|
|
1403
|
-
if (!
|
|
1404
|
-
onChange({ ...widget.config, columns: [...ordered,
|
|
2203
|
+
if (isBound) return;
|
|
2204
|
+
const name = draftField.trim();
|
|
2205
|
+
if (!name || ordered.includes(name)) return;
|
|
2206
|
+
onChange({ ...widget.config, columns: [...ordered, name] });
|
|
1405
2207
|
setDraftField("");
|
|
1406
2208
|
};
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
<
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
}}
|
|
1457
|
-
/>
|
|
1458
|
-
<button type="button" onClick={addColumn} disabled={Boolean(dataModelTable) || !draftField.trim()}>Add</button>
|
|
1459
|
-
</div>
|
|
1460
|
-
</section>;
|
|
2209
|
+
|
|
2210
|
+
return (
|
|
2211
|
+
<section className="workspace-widget-subpanel">
|
|
2212
|
+
<SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
|
|
2213
|
+
{isBound && (
|
|
2214
|
+
<p className="workspace-panel-hint">
|
|
2215
|
+
Fields come from the bound object. Manage them on the <a href="/data-model" style={{ color: "#6366f1" }}>Data Model page</a>.
|
|
2216
|
+
</p>
|
|
2217
|
+
)}
|
|
2218
|
+
|
|
2219
|
+
<div className="wfp-field-list">
|
|
2220
|
+
{visible.length === 0 && <p className="workspace-panel-hint">No visible fields.</p>}
|
|
2221
|
+
{visible.map((name) => (
|
|
2222
|
+
<FieldRow key={name} name={name} hidden={false} onToggle={toggle} onRemove={removeColumn} canRemove={!isBound} />
|
|
2223
|
+
))}
|
|
2224
|
+
|
|
2225
|
+
{hiddenList.length > 0 && (
|
|
2226
|
+
<button
|
|
2227
|
+
type="button"
|
|
2228
|
+
className="wfp-hidden-toggle"
|
|
2229
|
+
onClick={() => setHiddenOpen((v) => !v)}
|
|
2230
|
+
aria-expanded={hiddenOpen}
|
|
2231
|
+
>
|
|
2232
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" /><line x1="1" y1="1" x2="23" y2="23" /></svg>
|
|
2233
|
+
<span>Hidden Fields</span>
|
|
2234
|
+
<span className="wfp-hidden-count">{hiddenList.length}</span>
|
|
2235
|
+
<ChevronDown size={13} className={hiddenOpen ? "wfp-chevron open" : "wfp-chevron"} />
|
|
2236
|
+
</button>
|
|
2237
|
+
)}
|
|
2238
|
+
|
|
2239
|
+
{hiddenOpen && hiddenList.map((name) => (
|
|
2240
|
+
<FieldRow key={name} name={name} hidden={true} onToggle={toggle} onRemove={removeColumn} canRemove={!isBound} />
|
|
2241
|
+
))}
|
|
2242
|
+
</div>
|
|
2243
|
+
|
|
2244
|
+
{!isBound && (
|
|
2245
|
+
<div className="wfp-add-field">
|
|
2246
|
+
<input
|
|
2247
|
+
placeholder="Add field…"
|
|
2248
|
+
value={draftField}
|
|
2249
|
+
aria-label="New field name"
|
|
2250
|
+
onChange={(e) => setDraftField(e.target.value)}
|
|
2251
|
+
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addColumn(); } }}
|
|
2252
|
+
/>
|
|
2253
|
+
<button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
|
|
2254
|
+
</div>
|
|
2255
|
+
)}
|
|
2256
|
+
</section>
|
|
2257
|
+
);
|
|
1461
2258
|
}
|
|
1462
2259
|
|
|
1463
2260
|
function SortSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
@@ -1507,12 +2304,20 @@ function SortSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
|
1507
2304
|
</section>;
|
|
1508
2305
|
}
|
|
1509
2306
|
|
|
1510
|
-
function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack }) {
|
|
2307
|
+
function FilterSubPanel({ widget, integrations, dataModelTable, adapterConfig, onRefreshAndSave, onChange, onBack }) {
|
|
1511
2308
|
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1512
2309
|
const binding = widget.config?.binding || {};
|
|
1513
2310
|
const filter = getFilterConfig(widget);
|
|
1514
2311
|
const [entities, setEntities] = useState([]);
|
|
1515
2312
|
const [entitiesLoading, setEntitiesLoading] = useState(false);
|
|
2313
|
+
// Resolver controls state (entity type, lookback, test, fetch)
|
|
2314
|
+
const [resolverMeta, setResolverMeta] = useState(null);
|
|
2315
|
+
const [entityType, setEntityType] = useState(binding.entityType || "");
|
|
2316
|
+
const [lookbackDays, setLookbackDays] = useState(binding.days || 30);
|
|
2317
|
+
const [testing, setTesting] = useState(false);
|
|
2318
|
+
const [testState, setTestState] = useState(null);
|
|
2319
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
2320
|
+
const [refreshResult, setRefreshResult] = useState(null);
|
|
1516
2321
|
const fieldChoices = getFilterFieldChoices(viewWidget, entities);
|
|
1517
2322
|
const columns = fieldChoices.map((field) => field.id);
|
|
1518
2323
|
const setFilter = (next) => onChange({ ...widget.config, filter: next });
|
|
@@ -1523,6 +2328,48 @@ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack
|
|
|
1523
2328
|
return list.find((item) => item.id === binding.integrationId) || null;
|
|
1524
2329
|
}, [binding.integrationId, binding.mode, integrations]);
|
|
1525
2330
|
|
|
2331
|
+
// Load resolver metadata when integration binding is present
|
|
2332
|
+
useEffect(() => {
|
|
2333
|
+
if (binding.mode !== "integration" || !binding.integrationId) { setResolverMeta(null); return; }
|
|
2334
|
+
fetch("/api/workspace/resolvers")
|
|
2335
|
+
.then((r) => r.ok ? r.json() : null)
|
|
2336
|
+
.then((data) => {
|
|
2337
|
+
if (!data) return;
|
|
2338
|
+
const meta = Array.isArray(data.resolvers)
|
|
2339
|
+
? data.resolvers.find((r) => r.integrationId === binding.integrationId) || null
|
|
2340
|
+
: null;
|
|
2341
|
+
setResolverMeta(meta);
|
|
2342
|
+
if (!entityType && meta?.entityTypes?.length) setEntityType(meta.entityTypes[0]);
|
|
2343
|
+
})
|
|
2344
|
+
.catch(() => setResolverMeta(null));
|
|
2345
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2346
|
+
}, [binding.integrationId, binding.mode]);
|
|
2347
|
+
|
|
2348
|
+
async function runTest() {
|
|
2349
|
+
if (!binding.integrationId) return;
|
|
2350
|
+
setTesting(true); setTestState(null);
|
|
2351
|
+
try {
|
|
2352
|
+
const res = await fetch("/api/workspace/test-source", {
|
|
2353
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
2354
|
+
body: JSON.stringify({ integrationId: binding.integrationId, binding: { ...binding, entityType: entityType || undefined, days: lookbackDays } })
|
|
2355
|
+
});
|
|
2356
|
+
setTestState(await res.json());
|
|
2357
|
+
} catch { setTestState({ ok: false, error: "Network error" }); }
|
|
2358
|
+
finally { setTesting(false); }
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
async function runFetch() {
|
|
2362
|
+
if (!binding.integrationId || !onRefreshAndSave) return;
|
|
2363
|
+
setRefreshing(true); setRefreshResult(null);
|
|
2364
|
+
try {
|
|
2365
|
+
await onRefreshAndSave({ ...binding, entityType: entityType || undefined, days: lookbackDays }, binding.objectId || binding.sourceId);
|
|
2366
|
+
setRefreshResult({ ok: true });
|
|
2367
|
+
} catch (err) { setRefreshResult({ ok: false, error: err.message || "Fetch failed" }); }
|
|
2368
|
+
finally { setRefreshing(false); }
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
const isResolverBacked = binding.mode === "integration" && resolverMeta !== null;
|
|
2372
|
+
|
|
1526
2373
|
useEffect(() => {
|
|
1527
2374
|
if (!binding.integrationId || binding.mode !== "integration") {
|
|
1528
2375
|
setEntities([]);
|
|
@@ -1583,6 +2430,58 @@ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack
|
|
|
1583
2430
|
onSelect={selectEntity}
|
|
1584
2431
|
loading={entitiesLoading}
|
|
1585
2432
|
/> : null}
|
|
2433
|
+
{isResolverBacked ? <div className="workspace-resolver-controls">
|
|
2434
|
+
{resolverMeta.entityTypes?.length > 0 ? <label className="workspace-panel-label">
|
|
2435
|
+
<span>Entity type</span>
|
|
2436
|
+
<select
|
|
2437
|
+
value={entityType}
|
|
2438
|
+
onChange={(e) => setEntityType(e.target.value)}
|
|
2439
|
+
>
|
|
2440
|
+
<option value="">— any —</option>
|
|
2441
|
+
{resolverMeta.entityTypes.map((et) => <option key={et} value={et}>{et}</option>)}
|
|
2442
|
+
</select>
|
|
2443
|
+
</label> : null}
|
|
2444
|
+
<label className="workspace-panel-label">
|
|
2445
|
+
<span>Lookback (days)</span>
|
|
2446
|
+
<div className="workspace-lookback-row">
|
|
2447
|
+
{[7, 30, 90].map((d) => <button
|
|
2448
|
+
key={d}
|
|
2449
|
+
type="button"
|
|
2450
|
+
className={`workspace-lookback-btn${lookbackDays === d ? " active" : ""}`}
|
|
2451
|
+
onClick={() => setLookbackDays(d)}
|
|
2452
|
+
>{d}d</button>)}
|
|
2453
|
+
<input
|
|
2454
|
+
type="number"
|
|
2455
|
+
min={1}
|
|
2456
|
+
max={365}
|
|
2457
|
+
value={lookbackDays}
|
|
2458
|
+
onChange={(e) => setLookbackDays(Number(e.target.value) || 30)}
|
|
2459
|
+
/>
|
|
2460
|
+
</div>
|
|
2461
|
+
</label>
|
|
2462
|
+
<div className="workspace-resolver-actions">
|
|
2463
|
+
<button
|
|
2464
|
+
type="button"
|
|
2465
|
+
className="workspace-settings-row-btn"
|
|
2466
|
+
disabled={testing}
|
|
2467
|
+
onClick={runTest}
|
|
2468
|
+
>{testing ? "Testing…" : "Test connection"}</button>
|
|
2469
|
+
{onRefreshAndSave ? <button
|
|
2470
|
+
type="button"
|
|
2471
|
+
className="workspace-settings-row-btn"
|
|
2472
|
+
disabled={refreshing}
|
|
2473
|
+
onClick={runFetch}
|
|
2474
|
+
>{refreshing ? "Fetching…" : "Fetch & save data"}</button> : null}
|
|
2475
|
+
</div>
|
|
2476
|
+
{testState ? <div className={`workspace-resolver-result${testState.ok ? " ok" : " error"}`}>
|
|
2477
|
+
{testState.ok
|
|
2478
|
+
? `Connected · ${testState.rowCount ?? testState.rows?.length ?? 0} rows`
|
|
2479
|
+
: `${testState.reason || "error"}: ${testState.error || "check resolver"}`}
|
|
2480
|
+
</div> : null}
|
|
2481
|
+
{refreshResult ? <div className={`workspace-resolver-result${refreshResult.ok ? " ok" : " error"}`}>
|
|
2482
|
+
{refreshResult.ok ? "Saved to data model" : refreshResult.error}
|
|
2483
|
+
</div> : null}
|
|
2484
|
+
</div> : null}
|
|
1586
2485
|
{binding.sourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-filter-source-state">
|
|
1587
2486
|
<span>Custom endpoint</span>
|
|
1588
2487
|
<code>{binding.endpointRef || "No endpoint reference set"}</code>
|
|
@@ -1650,7 +2549,7 @@ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack
|
|
|
1650
2549
|
</section>;
|
|
1651
2550
|
}
|
|
1652
2551
|
|
|
1653
|
-
function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
2552
|
+
function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPage }) {
|
|
1654
2553
|
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
1655
2554
|
const xAxis = getChartAxis(widget, "xAxis");
|
|
1656
2555
|
const yAxis = getChartAxis(widget, "yAxis");
|
|
@@ -1660,6 +2559,18 @@ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
|
1660
2559
|
const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
|
|
1661
2560
|
const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
|
|
1662
2561
|
const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
|
|
2562
|
+
|
|
2563
|
+
// Derive source fields from the bound data model object
|
|
2564
|
+
const sourceFields = useMemo(() => {
|
|
2565
|
+
const binding = widget.config?.binding;
|
|
2566
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return [];
|
|
2567
|
+
const table = (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
2568
|
+
.find((t) => t.objectId === binding.objectId || t.source === binding.source);
|
|
2569
|
+
return table?.columns || [];
|
|
2570
|
+
}, [widget.config?.binding, dataModelTables]);
|
|
2571
|
+
|
|
2572
|
+
const hasSource = sourceFields.length > 0;
|
|
2573
|
+
|
|
1663
2574
|
return <section className="workspace-chart-config">
|
|
1664
2575
|
<p className="workspace-panel-label">Chart type</p>
|
|
1665
2576
|
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
@@ -1679,82 +2590,74 @@ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
|
1679
2590
|
</button>;
|
|
1680
2591
|
})}
|
|
1681
2592
|
</div>
|
|
2593
|
+
|
|
2594
|
+
<p className="workspace-panel-label">Data</p>
|
|
1682
2595
|
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
|
|
1683
|
-
<span>Source</span><code>{
|
|
2596
|
+
<span>Source</span><code>{summarizeSource(widget) || "None"}</code>
|
|
1684
2597
|
</button>
|
|
1685
2598
|
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
|
|
1686
2599
|
<span>Filter</span><code>{summarizeFilter(widget)}</code>
|
|
1687
2600
|
</button>
|
|
1688
|
-
|
|
1689
|
-
id: widget.config.binding.entityId,
|
|
1690
|
-
label: widget.config.binding.entityLabel || widget.config.binding.entityId,
|
|
1691
|
-
secondaryLabel: widget.config.binding.entityId,
|
|
1692
|
-
entityType: widget.config.binding.entityType
|
|
1693
|
-
}} /> : null}
|
|
2601
|
+
|
|
1694
2602
|
<p className="workspace-panel-label">X axis</p>
|
|
1695
|
-
<
|
|
2603
|
+
<div className="workspace-settings-row-field">
|
|
1696
2604
|
<span>Data on display</span>
|
|
1697
|
-
<
|
|
2605
|
+
<FieldDropdown
|
|
2606
|
+
fields={sourceFields}
|
|
1698
2607
|
value={xAxis.field || ""}
|
|
1699
|
-
|
|
1700
|
-
|
|
2608
|
+
onChange={(field) => setXAxis({ field })}
|
|
2609
|
+
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2610
|
+
disabled={!hasSource}
|
|
1701
2611
|
/>
|
|
1702
|
-
</
|
|
1703
|
-
<
|
|
2612
|
+
</div>
|
|
2613
|
+
<div className="workspace-settings-row-field">
|
|
1704
2614
|
<span>Sort by</span>
|
|
1705
2615
|
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
1706
|
-
<option value="position">
|
|
1707
|
-
<option value="asc">Value
|
|
1708
|
-
<option value="desc">Value
|
|
2616
|
+
<option value="position">Position asc</option>
|
|
2617
|
+
<option value="asc">Value asc</option>
|
|
2618
|
+
<option value="desc">Value desc</option>
|
|
1709
2619
|
</select>
|
|
1710
|
-
</
|
|
2620
|
+
</div>
|
|
1711
2621
|
<label className="workspace-toggle-row">
|
|
1712
2622
|
<span>Omit zero values</span>
|
|
1713
|
-
<input
|
|
1714
|
-
type="checkbox"
|
|
1715
|
-
checked={Boolean(xAxis.omitZero)}
|
|
1716
|
-
onChange={(event) => setXAxis({ omitZero: event.target.checked })}
|
|
1717
|
-
/>
|
|
2623
|
+
<input type="checkbox" checked={Boolean(xAxis.omitZero)} onChange={(event) => setXAxis({ omitZero: event.target.checked })} />
|
|
1718
2624
|
</label>
|
|
2625
|
+
|
|
1719
2626
|
<p className="workspace-panel-label">Y axis</p>
|
|
1720
|
-
<
|
|
1721
|
-
<span>Aggregation</span>
|
|
1722
|
-
<select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
|
|
1723
|
-
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
1724
|
-
</select>
|
|
1725
|
-
</label>
|
|
1726
|
-
<label>
|
|
2627
|
+
<div className="workspace-settings-row-field">
|
|
1727
2628
|
<span>Data on display</span>
|
|
1728
|
-
<
|
|
2629
|
+
<FieldDropdown
|
|
2630
|
+
fields={sourceFields}
|
|
1729
2631
|
value={yAxis.field || ""}
|
|
1730
|
-
|
|
1731
|
-
|
|
2632
|
+
onChange={(field) => setYAxis({ field })}
|
|
2633
|
+
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2634
|
+
disabled={!hasSource}
|
|
1732
2635
|
/>
|
|
1733
|
-
</
|
|
1734
|
-
<
|
|
2636
|
+
</div>
|
|
2637
|
+
<div className="workspace-settings-row-field">
|
|
1735
2638
|
<span>Group by</span>
|
|
1736
|
-
<
|
|
2639
|
+
<FieldDropdown
|
|
2640
|
+
fields={sourceFields}
|
|
1737
2641
|
value={yAxis.groupBy || ""}
|
|
1738
|
-
|
|
1739
|
-
|
|
2642
|
+
onChange={(field) => setYAxis({ groupBy: field })}
|
|
2643
|
+
placeholder="None"
|
|
2644
|
+
disabled={!hasSource}
|
|
1740
2645
|
/>
|
|
1741
|
-
</
|
|
2646
|
+
</div>
|
|
2647
|
+
<div className="workspace-settings-row-field">
|
|
2648
|
+
<span>Aggregation</span>
|
|
2649
|
+
<select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
|
|
2650
|
+
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
2651
|
+
</select>
|
|
2652
|
+
</div>
|
|
1742
2653
|
<div className="workspace-axis-range">
|
|
1743
2654
|
<label>
|
|
1744
2655
|
<span>Min range</span>
|
|
1745
|
-
<input
|
|
1746
|
-
value={yAxis.min ?? ""}
|
|
1747
|
-
placeholder="Min"
|
|
1748
|
-
onChange={(event) => setYAxis({ min: event.target.value })}
|
|
1749
|
-
/>
|
|
2656
|
+
<input value={yAxis.min ?? ""} placeholder="Min" onChange={(event) => setYAxis({ min: event.target.value })} />
|
|
1750
2657
|
</label>
|
|
1751
2658
|
<label>
|
|
1752
2659
|
<span>Max range</span>
|
|
1753
|
-
<input
|
|
1754
|
-
value={yAxis.max ?? ""}
|
|
1755
|
-
placeholder="Max"
|
|
1756
|
-
onChange={(event) => setYAxis({ max: event.target.value })}
|
|
1757
|
-
/>
|
|
2660
|
+
<input value={yAxis.max ?? ""} placeholder="Max" onChange={(event) => setYAxis({ max: event.target.value })} />
|
|
1758
2661
|
</label>
|
|
1759
2662
|
</div>
|
|
1760
2663
|
<p className="workspace-panel-label">Style</p>
|
|
@@ -1960,10 +2863,10 @@ function IframePreviewModal({ widget, onClose }) {
|
|
|
1960
2863
|
}
|
|
1961
2864
|
|
|
1962
2865
|
function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
|
|
1963
|
-
const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : [
|
|
2866
|
+
const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : [];
|
|
1964
2867
|
const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
|
|
1965
2868
|
const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
|
|
1966
|
-
const viewRows = widget.config?.rows
|
|
2869
|
+
const viewRows = Array.isArray(widget.config?.rows) ? widget.config.rows : [];
|
|
1967
2870
|
const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
|
|
1968
2871
|
const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
|
|
1969
2872
|
const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
|
|
@@ -2010,6 +2913,7 @@ function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRe
|
|
|
2010
2913
|
{viewRows.slice(0, 6).map((row, rowIndex) => <div key={rowIndex}>
|
|
2011
2914
|
{viewColumns.map((column) => <span key={column}>{row?.[column] || ""}</span>)}
|
|
2012
2915
|
</div>)}
|
|
2916
|
+
{!viewColumns.length && !viewRows.length ? <div className="workspace-view-empty">Select a source</div> : null}
|
|
2013
2917
|
<footer>Calculate</footer>
|
|
2014
2918
|
</div> : null}
|
|
2015
2919
|
{widget.kind === "iframe" ? <div className="workspace-iframe-preview">
|
|
@@ -2136,6 +3040,200 @@ function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integratio
|
|
|
2136
3040
|
</div>;
|
|
2137
3041
|
}
|
|
2138
3042
|
|
|
3043
|
+
/**
|
|
3044
|
+
* ResolverRow — per-resolver status row inside the Management panel.
|
|
3045
|
+
* Shows metadata, linked data model objects, and a quick test button.
|
|
3046
|
+
* Generic — renders purely from resolver-declared metadata.
|
|
3047
|
+
*/
|
|
3048
|
+
function ResolverRow({ resolver, linkedObjects }) {
|
|
3049
|
+
const [testState, setTestState] = useState(null);
|
|
3050
|
+
const [testing, setTesting] = useState(false);
|
|
3051
|
+
|
|
3052
|
+
async function quickTest() {
|
|
3053
|
+
setTesting(true);
|
|
3054
|
+
setTestState(null);
|
|
3055
|
+
try {
|
|
3056
|
+
const res = await fetch("/api/workspace/test-source", {
|
|
3057
|
+
method: "POST",
|
|
3058
|
+
headers: { "content-type": "application/json" },
|
|
3059
|
+
body: JSON.stringify({
|
|
3060
|
+
integrationId: resolver.integrationId,
|
|
3061
|
+
binding: { integrationId: resolver.integrationId }
|
|
3062
|
+
}),
|
|
3063
|
+
});
|
|
3064
|
+
setTestState(await res.json());
|
|
3065
|
+
} catch {
|
|
3066
|
+
setTestState({ ok: false, reason: "network-error" });
|
|
3067
|
+
} finally {
|
|
3068
|
+
setTesting(false);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
return (
|
|
3073
|
+
<div className="mgmt-resolver-row">
|
|
3074
|
+
<div className="mgmt-resolver-header">
|
|
3075
|
+
<code className="mgmt-resolver-id">{resolver.integrationId}</code>
|
|
3076
|
+
<div className="mgmt-resolver-badges">
|
|
3077
|
+
{resolver.entityTypes.map((et) => (
|
|
3078
|
+
<span key={et} className="mgmt-resolver-type-badge">{et}</span>
|
|
3079
|
+
))}
|
|
3080
|
+
{resolver.hasListEntities && (
|
|
3081
|
+
<span className="mgmt-resolver-type-badge list">listEntities</span>
|
|
3082
|
+
)}
|
|
3083
|
+
</div>
|
|
3084
|
+
<button
|
|
3085
|
+
type="button"
|
|
3086
|
+
className="mgmt-resolver-test-btn"
|
|
3087
|
+
onClick={quickTest}
|
|
3088
|
+
disabled={testing}
|
|
3089
|
+
>
|
|
3090
|
+
{testing ? "…" : "Test"}
|
|
3091
|
+
</button>
|
|
3092
|
+
</div>
|
|
3093
|
+
{testState && (
|
|
3094
|
+
<div className={`mgmt-resolver-test-result ${testState.ok ? "ok" : "error"}`}>
|
|
3095
|
+
{testState.ok
|
|
3096
|
+
? `✓ connected · ${testState.rowCount ?? testState.preview?.length ?? 0} records`
|
|
3097
|
+
: `✗ ${testState.reason === "no-token" ? "token required" : testState.reason || testState.error}`}
|
|
3098
|
+
</div>
|
|
3099
|
+
)}
|
|
3100
|
+
{linkedObjects.length > 0 && (
|
|
3101
|
+
<div className="mgmt-resolver-linked">
|
|
3102
|
+
<span className="mgmt-resolver-linked-label">Data Model objects:</span>
|
|
3103
|
+
{linkedObjects.map((obj) => (
|
|
3104
|
+
<span key={obj.id} className="mgmt-resolver-linked-obj">{obj.label || obj.id}</span>
|
|
3105
|
+
))}
|
|
3106
|
+
</div>
|
|
3107
|
+
)}
|
|
3108
|
+
</div>
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function ResolverManagementSection({ canSave, config }) {
|
|
3113
|
+
const [resolverData, setResolverData] = useState(null);
|
|
3114
|
+
const [uploading, setUploading] = useState(false);
|
|
3115
|
+
const [uploadResult, setUploadResult] = useState(null);
|
|
3116
|
+
const fileInputRef = useRef(null);
|
|
3117
|
+
|
|
3118
|
+
useEffect(() => {
|
|
3119
|
+
fetch("/api/workspace/resolvers")
|
|
3120
|
+
.then((r) => r.ok ? r.json() : { files: [], registeredIds: [], resolvers: [], canUpload: false })
|
|
3121
|
+
.then(setResolverData)
|
|
3122
|
+
.catch(() => setResolverData({ files: [], registeredIds: [], resolvers: [], canUpload: false }));
|
|
3123
|
+
}, [uploadResult]);
|
|
3124
|
+
|
|
3125
|
+
const dataModelObjects = Array.isArray(config?.dataModel?.objects) ? config.dataModel.objects : [];
|
|
3126
|
+
|
|
3127
|
+
const linkedObjectsByResolver = useMemo(() => {
|
|
3128
|
+
const map = {};
|
|
3129
|
+
dataModelObjects.forEach((obj) => {
|
|
3130
|
+
const intId = obj.binding?.integrationId;
|
|
3131
|
+
if (!intId) return;
|
|
3132
|
+
if (!map[intId]) map[intId] = [];
|
|
3133
|
+
map[intId].push(obj);
|
|
3134
|
+
});
|
|
3135
|
+
return map;
|
|
3136
|
+
}, [dataModelObjects]);
|
|
3137
|
+
|
|
3138
|
+
async function handleFileChange(event) {
|
|
3139
|
+
const file = event.target.files?.[0];
|
|
3140
|
+
if (!file) return;
|
|
3141
|
+
setUploading(true);
|
|
3142
|
+
setUploadResult(null);
|
|
3143
|
+
const form = new FormData();
|
|
3144
|
+
form.append("file", file);
|
|
3145
|
+
try {
|
|
3146
|
+
const res = await fetch("/api/workspace/register-resolver", { method: "POST", body: form });
|
|
3147
|
+
const data = await res.json();
|
|
3148
|
+
setUploadResult(res.ok ? { ok: true, ...data } : { ok: false, ...data });
|
|
3149
|
+
} catch {
|
|
3150
|
+
setUploadResult({ ok: false, error: "Network error" });
|
|
3151
|
+
} finally {
|
|
3152
|
+
setUploading(false);
|
|
3153
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const resolvers = resolverData?.resolvers || [];
|
|
3158
|
+
|
|
3159
|
+
return <article className="workspace-readiness-section">
|
|
3160
|
+
<h3>Source Resolvers</h3>
|
|
3161
|
+
<div className="workspace-readiness-row">
|
|
3162
|
+
<span>Files</span>
|
|
3163
|
+
<code>{resolverData ? resolverData.files.length : "…"}</code>
|
|
3164
|
+
</div>
|
|
3165
|
+
<div className="workspace-readiness-row">
|
|
3166
|
+
<span>Registered</span>
|
|
3167
|
+
<code>{resolverData ? resolvers.length : "…"}</code>
|
|
3168
|
+
</div>
|
|
3169
|
+
<div className="workspace-readiness-row">
|
|
3170
|
+
<span>Data Model objects</span>
|
|
3171
|
+
<code>{dataModelObjects.length}</code>
|
|
3172
|
+
</div>
|
|
3173
|
+
|
|
3174
|
+
{/* Per-resolver rows */}
|
|
3175
|
+
{resolvers.length > 0 ? (
|
|
3176
|
+
<div className="mgmt-resolver-list">
|
|
3177
|
+
{resolvers.map((r) => (
|
|
3178
|
+
<ResolverRow
|
|
3179
|
+
key={r.integrationId}
|
|
3180
|
+
resolver={r}
|
|
3181
|
+
linkedObjects={linkedObjectsByResolver[r.integrationId] || []}
|
|
3182
|
+
/>
|
|
3183
|
+
))}
|
|
3184
|
+
</div>
|
|
3185
|
+
) : resolverData && (
|
|
3186
|
+
<p className="workspace-panel-hint">
|
|
3187
|
+
No resolvers registered. Add a <code>.js</code> file to{" "}
|
|
3188
|
+
<code>lib/adapters/integrations/resolvers/</code> that calls{" "}
|
|
3189
|
+
<code>registerSourceResolver({ integrationId, entityTypes, fetchRecords })</code>.
|
|
3190
|
+
</p>
|
|
3191
|
+
)}
|
|
3192
|
+
|
|
3193
|
+
{/* Data model objects without a resolver */}
|
|
3194
|
+
{dataModelObjects.filter((o) => o.binding?.integrationId && !linkedObjectsByResolver[o.binding.integrationId]?.length).length > 0 && (
|
|
3195
|
+
<div className="workspace-readiness-row warn">
|
|
3196
|
+
<span>Unresolved objects</span>
|
|
3197
|
+
<em>
|
|
3198
|
+
{dataModelObjects
|
|
3199
|
+
.filter((o) => o.binding?.integrationId && !resolvers.find((r) => r.integrationId === o.binding.integrationId))
|
|
3200
|
+
.map((o) => o.label || o.id)
|
|
3201
|
+
.join(", ")}
|
|
3202
|
+
{" — resolver file missing"}
|
|
3203
|
+
</em>
|
|
3204
|
+
</div>
|
|
3205
|
+
)}
|
|
3206
|
+
|
|
3207
|
+
{/* Upload */}
|
|
3208
|
+
{canSave ? <>
|
|
3209
|
+
<div className="workspace-readiness-row">
|
|
3210
|
+
<span>Upload resolver</span>
|
|
3211
|
+
<input ref={fileInputRef} type="file" accept=".js" style={{ display: "none" }} onChange={handleFileChange} />
|
|
3212
|
+
<button
|
|
3213
|
+
type="button"
|
|
3214
|
+
className="workspace-readiness-action"
|
|
3215
|
+
disabled={uploading}
|
|
3216
|
+
onClick={() => fileInputRef.current?.click()}
|
|
3217
|
+
>
|
|
3218
|
+
{uploading ? "Uploading…" : "Upload .js file"}
|
|
3219
|
+
</button>
|
|
3220
|
+
</div>
|
|
3221
|
+
{uploadResult && <div className={`workspace-readiness-row resolver-upload-result ${uploadResult.ok ? "good" : "error"}`}>
|
|
3222
|
+
<span>{uploadResult.ok ? "Saved" : "Error"}</span>
|
|
3223
|
+
<em>{uploadResult.ok ? uploadResult.path : uploadResult.error}</em>
|
|
3224
|
+
</div>}
|
|
3225
|
+
<p className="workspace-panel-hint">
|
|
3226
|
+
Upload a <code>.js</code> resolver file that calls <code>registerSourceResolver()</code>.
|
|
3227
|
+
The resolver file is the only place with provider-specific logic — the UI renders
|
|
3228
|
+
controls from the metadata it declares (<code>entityTypes</code>, <code>listEntities</code>).
|
|
3229
|
+
</p>
|
|
3230
|
+
</> : <div className="workspace-readiness-row">
|
|
3231
|
+
<span>Upload</span>
|
|
3232
|
+
<em>Requires <code>WORKSPACE_CONFIG_ALLOW_FS_WRITE=true</code> or add resolver files manually to <code>lib/adapters/integrations/resolvers/</code>.</em>
|
|
3233
|
+
</div>}
|
|
3234
|
+
</article>;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
2139
3237
|
function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose }) {
|
|
2140
3238
|
const persist = persistence || DEFAULT_PERSISTENCE;
|
|
2141
3239
|
const pipelines = Array.isArray(config?.pipelines) ? config.pipelines : [];
|
|
@@ -2199,6 +3297,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
2199
3297
|
<div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
|
|
2200
3298
|
{persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
|
|
2201
3299
|
</article>
|
|
3300
|
+
<ResolverManagementSection canSave={persist.canSave} config={config} />
|
|
2202
3301
|
</div>
|
|
2203
3302
|
</section>
|
|
2204
3303
|
</div>;
|
|
@@ -2254,6 +3353,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2254
3353
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
2255
3354
|
const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
|
|
2256
3355
|
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
3356
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
3357
|
+
const [refreshResult, setRefreshResult] = useState(null);
|
|
2257
3358
|
const resizeDragRef = useRef(null);
|
|
2258
3359
|
const moveDragRef = useRef(null);
|
|
2259
3360
|
const importInputRef = useRef(null);
|
|
@@ -2275,6 +3376,45 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2275
3376
|
return cells;
|
|
2276
3377
|
}, [activeWidgets]);
|
|
2277
3378
|
|
|
3379
|
+
/**
|
|
3380
|
+
* Collect all sourceIds from live-backed widgets on the active tab.
|
|
3381
|
+
* A widget is live-backed when its binding has sourceStorage === "workspace-source-records"
|
|
3382
|
+
* and a non-empty sourceId. The refresh button is inert when this list is empty.
|
|
3383
|
+
*/
|
|
3384
|
+
const liveSourceIds = useMemo(() => {
|
|
3385
|
+
const ids = new Set();
|
|
3386
|
+
for (const widget of activeWidgets) {
|
|
3387
|
+
const binding = widget?.config?.binding;
|
|
3388
|
+
if (binding?.sourceStorage === "workspace-source-records" && typeof binding.sourceId === "string" && binding.sourceId.trim()) {
|
|
3389
|
+
ids.add(binding.sourceId.trim());
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
return Array.from(ids);
|
|
3393
|
+
}, [activeWidgets]);
|
|
3394
|
+
|
|
3395
|
+
const refreshSources = useCallback(async () => {
|
|
3396
|
+
if (refreshing || liveSourceIds.length === 0) return;
|
|
3397
|
+
setRefreshing(true);
|
|
3398
|
+
setRefreshResult(null);
|
|
3399
|
+
try {
|
|
3400
|
+
const response = await fetch("/api/workspace/refresh-sources", {
|
|
3401
|
+
method: "POST",
|
|
3402
|
+
headers: { "content-type": "application/json" },
|
|
3403
|
+
body: JSON.stringify({ sourceIds: liveSourceIds })
|
|
3404
|
+
});
|
|
3405
|
+
if (response.ok) {
|
|
3406
|
+
const data = await response.json();
|
|
3407
|
+
setRefreshResult({ refreshed: data.refreshed?.length || 0, skipped: data.skipped?.length || 0 });
|
|
3408
|
+
} else {
|
|
3409
|
+
setRefreshResult({ error: true });
|
|
3410
|
+
}
|
|
3411
|
+
} catch {
|
|
3412
|
+
setRefreshResult({ error: true });
|
|
3413
|
+
} finally {
|
|
3414
|
+
setRefreshing(false);
|
|
3415
|
+
}
|
|
3416
|
+
}, [refreshing, liveSourceIds]);
|
|
3417
|
+
|
|
2278
3418
|
const addWidget = useCallback((kind) => {
|
|
2279
3419
|
setConfig((prev) => {
|
|
2280
3420
|
const prevTabs = getTabs(prev.canvas);
|
|
@@ -2826,6 +3966,25 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2826
3966
|
setInspectorPath(SUB_PANEL_ROOT);
|
|
2827
3967
|
setPanelOpen(true);
|
|
2828
3968
|
}, []);
|
|
3969
|
+
// Fetches all records from a resolver and persists them into the data model object,
|
|
3970
|
+
// then syncs the updated dataModel into local React state.
|
|
3971
|
+
const handleRefreshDataModelObject = useCallback(async (binding, objectId) => {
|
|
3972
|
+
const integrationId = binding?.integrationId;
|
|
3973
|
+
if (!integrationId) throw new Error("No integrationId in binding");
|
|
3974
|
+
const res = await fetch("/api/workspace/refresh-source", {
|
|
3975
|
+
method: "POST",
|
|
3976
|
+
headers: { "content-type": "application/json" },
|
|
3977
|
+
body: JSON.stringify({ integrationId, binding, objectId: objectId || null }),
|
|
3978
|
+
});
|
|
3979
|
+
const data = await res.json();
|
|
3980
|
+
if (!data.ok) throw new Error(data.error || data.reason || "Refresh failed");
|
|
3981
|
+
// Sync updated dataModel into local state so the widget immediately reflects new rows
|
|
3982
|
+
if (data.dataModel) {
|
|
3983
|
+
setConfig((prev) => ({ ...prev, dataModel: data.dataModel }));
|
|
3984
|
+
}
|
|
3985
|
+
return data;
|
|
3986
|
+
}, []);
|
|
3987
|
+
|
|
2829
3988
|
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
2830
3989
|
if (!selectedWidgetId) return;
|
|
2831
3990
|
setConfig((prev) => {
|
|
@@ -3100,7 +4259,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3100
4259
|
});
|
|
3101
4260
|
list.push({
|
|
3102
4261
|
id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
|
|
3103
|
-
run: () =>
|
|
4262
|
+
run: () => { window.location.href = "/settings/general"; }
|
|
3104
4263
|
});
|
|
3105
4264
|
list.push({
|
|
3106
4265
|
id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
|
|
@@ -3110,10 +4269,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3110
4269
|
id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
|
|
3111
4270
|
run: () => showDashboardHome()
|
|
3112
4271
|
});
|
|
3113
|
-
|
|
3114
|
-
id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
|
|
3115
|
-
run: () => { window.location.href = "/settings/integrations"; }
|
|
3116
|
-
});
|
|
4272
|
+
|
|
3117
4273
|
return list;
|
|
3118
4274
|
}, [
|
|
3119
4275
|
activeDashboard,
|
|
@@ -3137,15 +4293,19 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3137
4293
|
return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
|
|
3138
4294
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
3139
4295
|
<div className="workspace-brand">
|
|
3140
|
-
<span className="workspace-mark"
|
|
3141
|
-
|
|
4296
|
+
<span className="workspace-mark" style={{
|
|
4297
|
+
background: branding.logoUrl ? undefined : branding.accent || undefined,
|
|
4298
|
+
color: branding.logoUrl ? undefined : textColorForAccent(branding.accent)
|
|
4299
|
+
}}>
|
|
4300
|
+
{branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : (branding.name || config.name || "Growthub Workspace").slice(0, 1).toUpperCase()}
|
|
4301
|
+
</span>
|
|
4302
|
+
<span>{branding.name || config.name || "Growthub Workspace"}</span>
|
|
3142
4303
|
</div>
|
|
3143
4304
|
<nav className="workspace-nav">
|
|
3144
4305
|
<button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
|
|
3145
4306
|
<Link href="/data-model">Data Model</Link>
|
|
3146
|
-
<Link href="/settings/integrations">Integrations</Link>
|
|
3147
|
-
<button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
|
|
3148
4307
|
<button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
|
|
4308
|
+
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
3149
4309
|
</nav>
|
|
3150
4310
|
<div className="workspace-rail-status">
|
|
3151
4311
|
<span className="status-dot" />
|
|
@@ -3278,6 +4438,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3278
4438
|
</button>)}
|
|
3279
4439
|
<button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
|
|
3280
4440
|
<button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
|
|
4441
|
+
<button
|
|
4442
|
+
type="button"
|
|
4443
|
+
className={`workspace-tab-refresh${liveSourceIds.length === 0 ? " inert" : ""}${refreshing ? " loading" : ""}`}
|
|
4444
|
+
disabled={liveSourceIds.length === 0 || refreshing}
|
|
4445
|
+
onClick={refreshSources}
|
|
4446
|
+
title={liveSourceIds.length === 0 ? "No live-backed sources on this tab" : `Refresh ${liveSourceIds.length} live source${liveSourceIds.length === 1 ? "" : "s"}`}
|
|
4447
|
+
>
|
|
4448
|
+
<RefreshCw size={15} className={refreshing ? "spinning" : ""} />
|
|
4449
|
+
{refreshing ? "Refreshing…" : refreshResult?.error ? "Refresh failed" : refreshResult ? `${refreshResult.refreshed} updated` : "Refresh"}
|
|
4450
|
+
</button>
|
|
3281
4451
|
</div>
|
|
3282
4452
|
<div
|
|
3283
4453
|
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
@@ -3379,7 +4549,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3379
4549
|
</div> : null}
|
|
3380
4550
|
{selectedWidget && inspectorPath === "source" ? <SourceSubPanel
|
|
3381
4551
|
widget={selectedWidget}
|
|
3382
|
-
integrations={availableIntegrations}
|
|
3383
4552
|
dataModelTables={dataModelTables}
|
|
3384
4553
|
onChange={replaceSelectedWidgetConfig}
|
|
3385
4554
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
@@ -3400,6 +4569,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3400
4569
|
widget={selectedWidget}
|
|
3401
4570
|
integrations={availableIntegrations}
|
|
3402
4571
|
dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
|
|
4572
|
+
adapterConfig={adapterConfig}
|
|
4573
|
+
onRefreshAndSave={handleRefreshDataModelObject}
|
|
3403
4574
|
onChange={replaceSelectedWidgetConfig}
|
|
3404
4575
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3405
4576
|
/> : null}
|
|
@@ -3411,31 +4582,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3411
4582
|
{selectedWidget.kind === "chart" ? <ChartConfigPanel
|
|
3412
4583
|
widget={selectedWidget}
|
|
3413
4584
|
branding={branding}
|
|
4585
|
+
dataModelTables={dataModelTables}
|
|
3414
4586
|
onChange={replaceSelectedWidgetConfig}
|
|
3415
4587
|
onSubPage={(name) => setInspectorPath(name)}
|
|
3416
4588
|
/> : null}
|
|
3417
|
-
{selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
|
|
3418
|
-
<label>
|
|
3419
|
-
<span>Sample Values</span>
|
|
3420
|
-
<input
|
|
3421
|
-
value={serializeChartValues(selectedWidget.config?.values || [])}
|
|
3422
|
-
onChange={(event) => updateSelectedWidgetConfig({ values: normalizeChartValues(event.target.value) })}
|
|
3423
|
-
/>
|
|
3424
|
-
</label>
|
|
3425
|
-
<label>
|
|
3426
|
-
<span>Static Binding</span>
|
|
3427
|
-
<select
|
|
3428
|
-
value={selectedWidget.config?.binding?.mode || "json"}
|
|
3429
|
-
onChange={(event) => updateSelectedWidgetConfig({
|
|
3430
|
-
binding: event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.reportingJson
|
|
3431
|
-
})}
|
|
3432
|
-
>
|
|
3433
|
-
<option value="json">Sample JSON</option>
|
|
3434
|
-
<option value="csv">Sample CSV</option>
|
|
3435
|
-
{selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
|
|
3436
|
-
</select>
|
|
3437
|
-
</label>
|
|
3438
|
-
</section> : null}
|
|
3439
4589
|
{selectedWidget.kind === "iframe" ? <label className="workspace-field-with-hint">
|
|
3440
4590
|
<span>URL to Embed</span>
|
|
3441
4591
|
<input
|
|
@@ -3462,23 +4612,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3462
4612
|
</small>
|
|
3463
4613
|
</label> : null}
|
|
3464
4614
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
3465
|
-
{selectedWidget.config?.binding?.sourceType === DATA_MODEL_SOURCE_TYPE ? <div className="workspace-active-source-state">
|
|
3466
|
-
<span>Data Model object</span>
|
|
3467
|
-
<strong>{summarizeSource(selectedWidget)}</strong>
|
|
3468
|
-
<code>{selectedWidget.config?.binding?.objectId || "workspace-config"}</code>
|
|
3469
|
-
</div> : <label>
|
|
3470
|
-
<span>Manual Rows</span>
|
|
3471
|
-
<textarea
|
|
3472
|
-
value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
|
|
3473
|
-
onChange={(event) => {
|
|
3474
|
-
const columns = selectedWidget.config?.columns?.length ? selectedWidget.config.columns : ["Name", "Domain Name"];
|
|
3475
|
-
updateSelectedWidgetConfig({
|
|
3476
|
-
rows: parseManualRows(event.target.value, columns),
|
|
3477
|
-
binding: { mode: "manual", source: "Manual rows", rows: parseManualRows(event.target.value, columns) }
|
|
3478
|
-
});
|
|
3479
|
-
}}
|
|
3480
|
-
/>
|
|
3481
|
-
</label>}
|
|
3482
4615
|
<div className="workspace-settings-list" role="group" aria-label="View widget settings">
|
|
3483
4616
|
<p className="workspace-panel-label">Settings</p>
|
|
3484
4617
|
<button type="button" className="workspace-settings-row" disabled>
|
|
@@ -3498,18 +4631,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3498
4631
|
</button>
|
|
3499
4632
|
</div>
|
|
3500
4633
|
</section> : null}
|
|
3501
|
-
{selectedWidget.kind === "rich-text" ? <label>
|
|
3502
|
-
<span>Static Binding</span>
|
|
3503
|
-
<select
|
|
3504
|
-
value={selectedWidget.config?.binding?.mode || "manual"}
|
|
3505
|
-
onChange={(event) => updateSelectedWidgetConfig({
|
|
3506
|
-
binding: { mode: event.target.value, source: event.target.value === "manual" ? "Manual text" : "Sample JSON", rows: [] }
|
|
3507
|
-
})}
|
|
3508
|
-
>
|
|
3509
|
-
<option value="manual">Manual Text</option>
|
|
3510
|
-
<option value="json">Sample JSON</option>
|
|
3511
|
-
</select>
|
|
3512
|
-
</label> : null}
|
|
3513
4634
|
<div className="workspace-settings-list">
|
|
3514
4635
|
<p className="workspace-panel-label">Placement</p>
|
|
3515
4636
|
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|