@growthub/cli 0.9.13 → 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/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 +692 -223
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +996 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1539 -433
- 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 +79 -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 -40677
- 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;
|
|
@@ -542,7 +670,7 @@ function serializeLineList(values) {
|
|
|
542
670
|
}
|
|
543
671
|
|
|
544
672
|
function parseManualRows(value, columns) {
|
|
545
|
-
const activeColumns = columns.length ? columns : [
|
|
673
|
+
const activeColumns = columns.length ? columns : [];
|
|
546
674
|
return String(value)
|
|
547
675
|
.split("\n")
|
|
548
676
|
.map((row) => row.trim())
|
|
@@ -557,7 +685,7 @@ function parseManualRows(value, columns) {
|
|
|
557
685
|
}
|
|
558
686
|
|
|
559
687
|
function serializeManualRows(rows, columns) {
|
|
560
|
-
const activeColumns = columns.length ? columns : [
|
|
688
|
+
const activeColumns = columns.length ? columns : [];
|
|
561
689
|
return (Array.isArray(rows) ? rows : [])
|
|
562
690
|
.map((row) => activeColumns.map((column) => row?.[column] || "").join(" | "))
|
|
563
691
|
.join("\n");
|
|
@@ -667,11 +795,14 @@ function summarizeSource(widget) {
|
|
|
667
795
|
function summarizeSourceType(binding) {
|
|
668
796
|
if (binding?.sourceType === DATA_MODEL_SOURCE_TYPE) return "Data Model";
|
|
669
797
|
if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
|
|
670
|
-
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";
|
|
671
799
|
return "Static data";
|
|
672
800
|
}
|
|
673
801
|
|
|
674
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;
|
|
675
806
|
if (binding?.sourceType) return binding.sourceType;
|
|
676
807
|
if (binding?.mode === "integration") return MANAGED_INTEGRATION_SOURCE_TYPE;
|
|
677
808
|
return "static";
|
|
@@ -1096,378 +1227,1034 @@ function EntitySelector({ integration, entities, selectedEntityId, selectedEntit
|
|
|
1096
1227
|
</div>;
|
|
1097
1228
|
}
|
|
1098
1229
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
+
}
|
|
1117
1273
|
|
|
1118
|
-
|
|
1119
|
-
if (
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
+
}
|
|
1123
1302
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
+
}
|
|
1130
1319
|
});
|
|
1131
|
-
|
|
1132
|
-
"data-source": filtered.filter((item) => item.lane === "data-source"),
|
|
1133
|
-
"workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
|
|
1134
|
-
};
|
|
1135
|
-
}, [integrations, laneFilter, query]);
|
|
1320
|
+
}
|
|
1136
1321
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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;
|
|
1145
1562
|
}, [dataModelTables, query]);
|
|
1146
1563
|
|
|
1147
|
-
|
|
1148
|
-
if (
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
} else {
|
|
1152
|
-
onChange({
|
|
1153
|
-
...widget.config,
|
|
1154
|
-
source: widget.config?.source || "Static rows",
|
|
1155
|
-
binding: { mode: "manual", source: "Static rows", rows: Array.isArray(widget.config?.rows) ? widget.config.rows : [] }
|
|
1156
|
-
});
|
|
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";
|
|
1157
1568
|
}
|
|
1158
|
-
|
|
1569
|
+
return "Select source…";
|
|
1570
|
+
})();
|
|
1159
1571
|
|
|
1160
|
-
|
|
1161
|
-
if (!table || !confirmSourceChange(table.label)) return;
|
|
1572
|
+
function selectObject(table) {
|
|
1162
1573
|
onChange({
|
|
1163
1574
|
...widget.config,
|
|
1164
1575
|
source: table.source,
|
|
1165
1576
|
columns: table.columns,
|
|
1166
1577
|
rows: [],
|
|
1167
|
-
binding: {
|
|
1168
|
-
|
|
1169
|
-
source: table.source,
|
|
1170
|
-
sourceType: DATA_MODEL_SOURCE_TYPE,
|
|
1171
|
-
sourceAuthority: "workspace-config",
|
|
1172
|
-
objectId: table.objectId,
|
|
1173
|
-
rows: []
|
|
1174
|
-
},
|
|
1175
|
-
fieldSettings: {
|
|
1176
|
-
hidden: [],
|
|
1177
|
-
order: table.columns
|
|
1178
|
-
}
|
|
1578
|
+
binding: { mode: "manual", source: table.source, sourceType: DATA_MODEL_SOURCE_TYPE, sourceAuthority: "workspace-config", objectId: table.objectId },
|
|
1579
|
+
fieldSettings: { hidden: [], order: table.columns }
|
|
1179
1580
|
});
|
|
1180
|
-
|
|
1581
|
+
setOpen(false);
|
|
1582
|
+
setQuery("");
|
|
1583
|
+
}
|
|
1181
1584
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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,
|
|
1196
1710
|
});
|
|
1197
|
-
|
|
1711
|
+
}
|
|
1198
1712
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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({
|
|
1202
1986
|
...widget.config,
|
|
1203
1987
|
binding: {
|
|
1204
|
-
...binding,
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
fields
|
|
1988
|
+
...widget.config?.binding,
|
|
1989
|
+
sourceStorage: LIVE_SOURCE_TYPE,
|
|
1990
|
+
sourceId: integrationId,
|
|
1991
|
+
entityType: entityType.trim() || undefined,
|
|
1992
|
+
entityId: entityId.trim() || undefined,
|
|
1210
1993
|
}
|
|
1211
1994
|
});
|
|
1212
|
-
}
|
|
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
|
+
}
|
|
1213
2006
|
|
|
1214
|
-
|
|
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
|
+
}
|
|
1215
2080
|
onChange({
|
|
1216
2081
|
...widget.config,
|
|
2082
|
+
source: table.source,
|
|
2083
|
+
columns: table.columns,
|
|
2084
|
+
rows: [],
|
|
1217
2085
|
binding: {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
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 }
|
|
1225
2093
|
});
|
|
1226
2094
|
}, [binding, onChange, widget.config]);
|
|
1227
2095
|
|
|
1228
|
-
|
|
1229
|
-
if (binding.integrationId && binding.integrationId !== integration.id && !confirmSourceChange(integration.label)) return;
|
|
1230
|
-
onChange({
|
|
1231
|
-
...widget.config,
|
|
1232
|
-
source: integration.label,
|
|
1233
|
-
binding: {
|
|
1234
|
-
mode: "integration",
|
|
1235
|
-
source: integration.label,
|
|
1236
|
-
sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
|
|
1237
|
-
sourceAuthority: "growthub-bridge",
|
|
1238
|
-
integrationId: integration.id,
|
|
1239
|
-
lane: integration.lane,
|
|
1240
|
-
provider: integration.provider
|
|
1241
|
-
}
|
|
1242
|
-
});
|
|
1243
|
-
}, [binding.integrationId, confirmSourceChange, onChange, widget.config]);
|
|
2096
|
+
const activeObjectId = binding.sourceType === DATA_MODEL_SOURCE_TYPE ? binding.objectId : null;
|
|
1244
2097
|
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
{SOURCE_TYPE_OBJECTS.map((sourceType) => {
|
|
1251
|
-
const isActive = activeSourceType === sourceType.id;
|
|
1252
|
-
return <button
|
|
1253
|
-
key={sourceType.id}
|
|
1254
|
-
type="button"
|
|
1255
|
-
className={`workspace-source-object-row${isActive ? " active" : ""}`}
|
|
1256
|
-
onClick={sourceType.id === CUSTOM_API_SOURCE_TYPE ? selectCustomApi : undefined}
|
|
1257
|
-
disabled={sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE}
|
|
1258
|
-
>
|
|
1259
|
-
<span className="workspace-source-object-icon" aria-hidden="true">
|
|
1260
|
-
{sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE ? <Database size={15} /> : <LinkIcon size={15} />}
|
|
1261
|
-
</span>
|
|
1262
|
-
<span className="workspace-source-meta">
|
|
1263
|
-
<strong>{sourceType.label}</strong>
|
|
1264
|
-
<em>{sourceType.authority} · {sourceType.description}</em>
|
|
1265
|
-
</span>
|
|
1266
|
-
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1267
|
-
</button>;
|
|
1268
|
-
})}
|
|
1269
|
-
</div>
|
|
1270
|
-
<div className="workspace-source-controls">
|
|
1271
|
-
<label>
|
|
1272
|
-
<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" />
|
|
1273
2103
|
<input
|
|
1274
|
-
|
|
1275
|
-
placeholder="Search connectors"
|
|
2104
|
+
placeholder="Search objects…"
|
|
1276
2105
|
value={query}
|
|
1277
|
-
onChange={(
|
|
2106
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2107
|
+
aria-label="Search data objects"
|
|
1278
2108
|
/>
|
|
1279
2109
|
</label>
|
|
1280
|
-
<
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
{widget.kind === "view" ? <>
|
|
1310
|
-
<p className="workspace-panel-label">Data Model objects</p>
|
|
1311
|
-
<div className="workspace-source-list">
|
|
1312
|
-
{availableDataObjects.length ? availableDataObjects.map((table) => {
|
|
1313
|
-
const isActive = binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId === table.objectId;
|
|
1314
|
-
return <button
|
|
1315
|
-
key={table.id}
|
|
1316
|
-
type="button"
|
|
1317
|
-
className={`workspace-source-row${isActive ? " active" : ""}`}
|
|
1318
|
-
onClick={() => selectDataModelObject(table)}
|
|
1319
|
-
>
|
|
1320
|
-
<span className="workspace-source-icon" aria-hidden="true"><Database size={15} /></span>
|
|
1321
|
-
<span className="workspace-source-meta">
|
|
1322
|
-
<strong>{table.label}</strong>
|
|
1323
|
-
<em>{table.columns.length} fields · {table.rows.length} records · workspace config</em>
|
|
1324
|
-
</span>
|
|
1325
|
-
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1326
|
-
</button>;
|
|
1327
|
-
}) : <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
|
+
)}
|
|
1328
2139
|
</div>
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
+
)}
|
|
1350
2172
|
</div>
|
|
1351
|
-
</div>
|
|
1352
|
-
|
|
1353
|
-
<label>
|
|
1354
|
-
<span>Endpoint reference</span>
|
|
1355
|
-
<input
|
|
1356
|
-
value={binding.endpointRef || ""}
|
|
1357
|
-
placeholder="api.clients.primary"
|
|
1358
|
-
onChange={(event) => updateEndpointRef(event.target.value)}
|
|
1359
|
-
/>
|
|
1360
|
-
</label>
|
|
1361
|
-
<label>
|
|
1362
|
-
<span>Available fields</span>
|
|
1363
|
-
<input
|
|
1364
|
-
value={(Array.isArray(binding.fields) ? binding.fields : []).join(", ")}
|
|
1365
|
-
placeholder="entityId, status, createdAt"
|
|
1366
|
-
onChange={(event) => updateCustomFields(event.target.value)}
|
|
1367
|
-
/>
|
|
1368
|
-
</label>
|
|
1369
|
-
</div> : null}
|
|
1370
|
-
{currentMode === "integration" && binding.integrationId ? <div className="workspace-active-source-state">
|
|
1371
|
-
<span>Active source</span>
|
|
1372
|
-
<strong>{activeIntegration?.label || binding.source || binding.integrationId}</strong>
|
|
1373
|
-
<code>{binding.integrationId}</code>
|
|
1374
|
-
</div> : null}
|
|
1375
|
-
<p className="workspace-panel-hint">
|
|
1376
|
-
Selecting a source writes a binding reference only. The browser only calls local workspace routes and never stores source credentials.
|
|
1377
|
-
</p>
|
|
1378
|
-
</section>;
|
|
2173
|
+
</div>
|
|
2174
|
+
);
|
|
1379
2175
|
}
|
|
1380
2176
|
|
|
1381
2177
|
function FieldsSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
1382
2178
|
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1383
2179
|
const ordered = getOrderedColumns(viewWidget);
|
|
1384
|
-
const
|
|
1385
|
-
const visible = ordered.filter((
|
|
1386
|
-
const hiddenList = ordered.filter((
|
|
1387
|
-
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);
|
|
1388
2184
|
const [draftField, setDraftField] = useState("");
|
|
1389
|
-
const
|
|
1390
|
-
|
|
1391
|
-
onChange({ ...widget.config, fieldSettings: next });
|
|
1392
|
-
};
|
|
2185
|
+
const isBound = Boolean(dataModelTable);
|
|
2186
|
+
|
|
1393
2187
|
const toggle = (fieldId) => {
|
|
1394
|
-
|
|
1395
|
-
onChange({ ...widget.config, fieldSettings: next });
|
|
2188
|
+
onChange({ ...widget.config, fieldSettings: toggleColumnHidden(viewWidget, fieldId) });
|
|
1396
2189
|
};
|
|
1397
2190
|
const removeColumn = (fieldId) => {
|
|
1398
|
-
if (
|
|
1399
|
-
const nextColumns = ordered.filter((name) => name !== fieldId);
|
|
2191
|
+
if (isBound) return;
|
|
1400
2192
|
const fs = widget.config?.fieldSettings || {};
|
|
1401
2193
|
onChange({
|
|
1402
2194
|
...widget.config,
|
|
1403
|
-
columns:
|
|
2195
|
+
columns: ordered.filter((n) => n !== fieldId),
|
|
1404
2196
|
fieldSettings: {
|
|
1405
|
-
hidden: (fs.hidden || []).filter((
|
|
1406
|
-
order: (fs.order || []).filter((
|
|
2197
|
+
hidden: (fs.hidden || []).filter((n) => n !== fieldId),
|
|
2198
|
+
order: (fs.order || []).filter((n) => n !== fieldId)
|
|
1407
2199
|
}
|
|
1408
2200
|
});
|
|
1409
2201
|
};
|
|
1410
2202
|
const addColumn = () => {
|
|
1411
|
-
if (
|
|
1412
|
-
const
|
|
1413
|
-
if (!
|
|
1414
|
-
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] });
|
|
1415
2207
|
setDraftField("");
|
|
1416
2208
|
};
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
}}
|
|
1467
|
-
/>
|
|
1468
|
-
<button type="button" onClick={addColumn} disabled={Boolean(dataModelTable) || !draftField.trim()}>Add</button>
|
|
1469
|
-
</div>
|
|
1470
|
-
</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
|
+
);
|
|
1471
2258
|
}
|
|
1472
2259
|
|
|
1473
2260
|
function SortSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
@@ -1517,12 +2304,20 @@ function SortSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
|
1517
2304
|
</section>;
|
|
1518
2305
|
}
|
|
1519
2306
|
|
|
1520
|
-
function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack }) {
|
|
2307
|
+
function FilterSubPanel({ widget, integrations, dataModelTable, adapterConfig, onRefreshAndSave, onChange, onBack }) {
|
|
1521
2308
|
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1522
2309
|
const binding = widget.config?.binding || {};
|
|
1523
2310
|
const filter = getFilterConfig(widget);
|
|
1524
2311
|
const [entities, setEntities] = useState([]);
|
|
1525
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);
|
|
1526
2321
|
const fieldChoices = getFilterFieldChoices(viewWidget, entities);
|
|
1527
2322
|
const columns = fieldChoices.map((field) => field.id);
|
|
1528
2323
|
const setFilter = (next) => onChange({ ...widget.config, filter: next });
|
|
@@ -1533,6 +2328,48 @@ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack
|
|
|
1533
2328
|
return list.find((item) => item.id === binding.integrationId) || null;
|
|
1534
2329
|
}, [binding.integrationId, binding.mode, integrations]);
|
|
1535
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
|
+
|
|
1536
2373
|
useEffect(() => {
|
|
1537
2374
|
if (!binding.integrationId || binding.mode !== "integration") {
|
|
1538
2375
|
setEntities([]);
|
|
@@ -1593,6 +2430,58 @@ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack
|
|
|
1593
2430
|
onSelect={selectEntity}
|
|
1594
2431
|
loading={entitiesLoading}
|
|
1595
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}
|
|
1596
2485
|
{binding.sourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-filter-source-state">
|
|
1597
2486
|
<span>Custom endpoint</span>
|
|
1598
2487
|
<code>{binding.endpointRef || "No endpoint reference set"}</code>
|
|
@@ -1660,7 +2549,7 @@ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack
|
|
|
1660
2549
|
</section>;
|
|
1661
2550
|
}
|
|
1662
2551
|
|
|
1663
|
-
function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
2552
|
+
function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPage }) {
|
|
1664
2553
|
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
1665
2554
|
const xAxis = getChartAxis(widget, "xAxis");
|
|
1666
2555
|
const yAxis = getChartAxis(widget, "yAxis");
|
|
@@ -1670,6 +2559,18 @@ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
|
1670
2559
|
const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
|
|
1671
2560
|
const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
|
|
1672
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
|
+
|
|
1673
2574
|
return <section className="workspace-chart-config">
|
|
1674
2575
|
<p className="workspace-panel-label">Chart type</p>
|
|
1675
2576
|
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
@@ -1689,82 +2590,74 @@ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
|
|
|
1689
2590
|
</button>;
|
|
1690
2591
|
})}
|
|
1691
2592
|
</div>
|
|
2593
|
+
|
|
2594
|
+
<p className="workspace-panel-label">Data</p>
|
|
1692
2595
|
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
|
|
1693
|
-
<span>Source</span><code>{
|
|
2596
|
+
<span>Source</span><code>{summarizeSource(widget) || "None"}</code>
|
|
1694
2597
|
</button>
|
|
1695
2598
|
<button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
|
|
1696
2599
|
<span>Filter</span><code>{summarizeFilter(widget)}</code>
|
|
1697
2600
|
</button>
|
|
1698
|
-
|
|
1699
|
-
id: widget.config.binding.entityId,
|
|
1700
|
-
label: widget.config.binding.entityLabel || widget.config.binding.entityId,
|
|
1701
|
-
secondaryLabel: widget.config.binding.entityId,
|
|
1702
|
-
entityType: widget.config.binding.entityType
|
|
1703
|
-
}} /> : null}
|
|
2601
|
+
|
|
1704
2602
|
<p className="workspace-panel-label">X axis</p>
|
|
1705
|
-
<
|
|
2603
|
+
<div className="workspace-settings-row-field">
|
|
1706
2604
|
<span>Data on display</span>
|
|
1707
|
-
<
|
|
2605
|
+
<FieldDropdown
|
|
2606
|
+
fields={sourceFields}
|
|
1708
2607
|
value={xAxis.field || ""}
|
|
1709
|
-
|
|
1710
|
-
|
|
2608
|
+
onChange={(field) => setXAxis({ field })}
|
|
2609
|
+
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2610
|
+
disabled={!hasSource}
|
|
1711
2611
|
/>
|
|
1712
|
-
</
|
|
1713
|
-
<
|
|
2612
|
+
</div>
|
|
2613
|
+
<div className="workspace-settings-row-field">
|
|
1714
2614
|
<span>Sort by</span>
|
|
1715
2615
|
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
1716
|
-
<option value="position">
|
|
1717
|
-
<option value="asc">Value
|
|
1718
|
-
<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>
|
|
1719
2619
|
</select>
|
|
1720
|
-
</
|
|
2620
|
+
</div>
|
|
1721
2621
|
<label className="workspace-toggle-row">
|
|
1722
2622
|
<span>Omit zero values</span>
|
|
1723
|
-
<input
|
|
1724
|
-
type="checkbox"
|
|
1725
|
-
checked={Boolean(xAxis.omitZero)}
|
|
1726
|
-
onChange={(event) => setXAxis({ omitZero: event.target.checked })}
|
|
1727
|
-
/>
|
|
2623
|
+
<input type="checkbox" checked={Boolean(xAxis.omitZero)} onChange={(event) => setXAxis({ omitZero: event.target.checked })} />
|
|
1728
2624
|
</label>
|
|
2625
|
+
|
|
1729
2626
|
<p className="workspace-panel-label">Y axis</p>
|
|
1730
|
-
<
|
|
1731
|
-
<span>Aggregation</span>
|
|
1732
|
-
<select value={yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ aggregation: event.target.value })}>
|
|
1733
|
-
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
1734
|
-
</select>
|
|
1735
|
-
</label>
|
|
1736
|
-
<label>
|
|
2627
|
+
<div className="workspace-settings-row-field">
|
|
1737
2628
|
<span>Data on display</span>
|
|
1738
|
-
<
|
|
2629
|
+
<FieldDropdown
|
|
2630
|
+
fields={sourceFields}
|
|
1739
2631
|
value={yAxis.field || ""}
|
|
1740
|
-
|
|
1741
|
-
|
|
2632
|
+
onChange={(field) => setYAxis({ field })}
|
|
2633
|
+
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2634
|
+
disabled={!hasSource}
|
|
1742
2635
|
/>
|
|
1743
|
-
</
|
|
1744
|
-
<
|
|
2636
|
+
</div>
|
|
2637
|
+
<div className="workspace-settings-row-field">
|
|
1745
2638
|
<span>Group by</span>
|
|
1746
|
-
<
|
|
2639
|
+
<FieldDropdown
|
|
2640
|
+
fields={sourceFields}
|
|
1747
2641
|
value={yAxis.groupBy || ""}
|
|
1748
|
-
|
|
1749
|
-
|
|
2642
|
+
onChange={(field) => setYAxis({ groupBy: field })}
|
|
2643
|
+
placeholder="None"
|
|
2644
|
+
disabled={!hasSource}
|
|
1750
2645
|
/>
|
|
1751
|
-
</
|
|
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>
|
|
1752
2653
|
<div className="workspace-axis-range">
|
|
1753
2654
|
<label>
|
|
1754
2655
|
<span>Min range</span>
|
|
1755
|
-
<input
|
|
1756
|
-
value={yAxis.min ?? ""}
|
|
1757
|
-
placeholder="Min"
|
|
1758
|
-
onChange={(event) => setYAxis({ min: event.target.value })}
|
|
1759
|
-
/>
|
|
2656
|
+
<input value={yAxis.min ?? ""} placeholder="Min" onChange={(event) => setYAxis({ min: event.target.value })} />
|
|
1760
2657
|
</label>
|
|
1761
2658
|
<label>
|
|
1762
2659
|
<span>Max range</span>
|
|
1763
|
-
<input
|
|
1764
|
-
value={yAxis.max ?? ""}
|
|
1765
|
-
placeholder="Max"
|
|
1766
|
-
onChange={(event) => setYAxis({ max: event.target.value })}
|
|
1767
|
-
/>
|
|
2660
|
+
<input value={yAxis.max ?? ""} placeholder="Max" onChange={(event) => setYAxis({ max: event.target.value })} />
|
|
1768
2661
|
</label>
|
|
1769
2662
|
</div>
|
|
1770
2663
|
<p className="workspace-panel-label">Style</p>
|
|
@@ -1970,10 +2863,10 @@ function IframePreviewModal({ widget, onClose }) {
|
|
|
1970
2863
|
}
|
|
1971
2864
|
|
|
1972
2865
|
function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
|
|
1973
|
-
const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : [
|
|
2866
|
+
const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : [];
|
|
1974
2867
|
const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
|
|
1975
2868
|
const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
|
|
1976
|
-
const viewRows = widget.config?.rows
|
|
2869
|
+
const viewRows = Array.isArray(widget.config?.rows) ? widget.config.rows : [];
|
|
1977
2870
|
const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
|
|
1978
2871
|
const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
|
|
1979
2872
|
const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
|
|
@@ -2020,6 +2913,7 @@ function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRe
|
|
|
2020
2913
|
{viewRows.slice(0, 6).map((row, rowIndex) => <div key={rowIndex}>
|
|
2021
2914
|
{viewColumns.map((column) => <span key={column}>{row?.[column] || ""}</span>)}
|
|
2022
2915
|
</div>)}
|
|
2916
|
+
{!viewColumns.length && !viewRows.length ? <div className="workspace-view-empty">Select a source</div> : null}
|
|
2023
2917
|
<footer>Calculate</footer>
|
|
2024
2918
|
</div> : null}
|
|
2025
2919
|
{widget.kind === "iframe" ? <div className="workspace-iframe-preview">
|
|
@@ -2146,6 +3040,200 @@ function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integratio
|
|
|
2146
3040
|
</div>;
|
|
2147
3041
|
}
|
|
2148
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
|
+
|
|
2149
3237
|
function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose }) {
|
|
2150
3238
|
const persist = persistence || DEFAULT_PERSISTENCE;
|
|
2151
3239
|
const pipelines = Array.isArray(config?.pipelines) ? config.pipelines : [];
|
|
@@ -2209,6 +3297,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
2209
3297
|
<div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
|
|
2210
3298
|
{persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
|
|
2211
3299
|
</article>
|
|
3300
|
+
<ResolverManagementSection canSave={persist.canSave} config={config} />
|
|
2212
3301
|
</div>
|
|
2213
3302
|
</section>
|
|
2214
3303
|
</div>;
|
|
@@ -2264,6 +3353,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2264
3353
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
2265
3354
|
const [templateFilter, setTemplateFilter] = useState({ category: "all", tag: "all", query: "" });
|
|
2266
3355
|
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
3356
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
3357
|
+
const [refreshResult, setRefreshResult] = useState(null);
|
|
2267
3358
|
const resizeDragRef = useRef(null);
|
|
2268
3359
|
const moveDragRef = useRef(null);
|
|
2269
3360
|
const importInputRef = useRef(null);
|
|
@@ -2285,6 +3376,45 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2285
3376
|
return cells;
|
|
2286
3377
|
}, [activeWidgets]);
|
|
2287
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
|
+
|
|
2288
3418
|
const addWidget = useCallback((kind) => {
|
|
2289
3419
|
setConfig((prev) => {
|
|
2290
3420
|
const prevTabs = getTabs(prev.canvas);
|
|
@@ -2836,6 +3966,25 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2836
3966
|
setInspectorPath(SUB_PANEL_ROOT);
|
|
2837
3967
|
setPanelOpen(true);
|
|
2838
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
|
+
|
|
2839
3988
|
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
2840
3989
|
if (!selectedWidgetId) return;
|
|
2841
3990
|
setConfig((prev) => {
|
|
@@ -3120,10 +4269,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3120
4269
|
id: "workspace.dashboards", group: "Navigation", icon: Home, label: "Go to Dashboards",
|
|
3121
4270
|
run: () => showDashboardHome()
|
|
3122
4271
|
});
|
|
3123
|
-
|
|
3124
|
-
id: "workspace.integrations", group: "Navigation", icon: LayoutDashboard, label: "Go to Integrations",
|
|
3125
|
-
run: () => { window.location.href = "/settings/integrations"; }
|
|
3126
|
-
});
|
|
4272
|
+
|
|
3127
4273
|
return list;
|
|
3128
4274
|
}, [
|
|
3129
4275
|
activeDashboard,
|
|
@@ -3158,7 +4304,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3158
4304
|
<nav className="workspace-nav">
|
|
3159
4305
|
<button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
|
|
3160
4306
|
<Link href="/data-model">Data Model</Link>
|
|
3161
|
-
<Link href="/settings/integrations">Integrations</Link>
|
|
3162
4307
|
<button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
|
|
3163
4308
|
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
3164
4309
|
</nav>
|
|
@@ -3293,6 +4438,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3293
4438
|
</button>)}
|
|
3294
4439
|
<button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
|
|
3295
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>
|
|
3296
4451
|
</div>
|
|
3297
4452
|
<div
|
|
3298
4453
|
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
@@ -3394,7 +4549,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3394
4549
|
</div> : null}
|
|
3395
4550
|
{selectedWidget && inspectorPath === "source" ? <SourceSubPanel
|
|
3396
4551
|
widget={selectedWidget}
|
|
3397
|
-
integrations={availableIntegrations}
|
|
3398
4552
|
dataModelTables={dataModelTables}
|
|
3399
4553
|
onChange={replaceSelectedWidgetConfig}
|
|
3400
4554
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
@@ -3415,6 +4569,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3415
4569
|
widget={selectedWidget}
|
|
3416
4570
|
integrations={availableIntegrations}
|
|
3417
4571
|
dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
|
|
4572
|
+
adapterConfig={adapterConfig}
|
|
4573
|
+
onRefreshAndSave={handleRefreshDataModelObject}
|
|
3418
4574
|
onChange={replaceSelectedWidgetConfig}
|
|
3419
4575
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3420
4576
|
/> : null}
|
|
@@ -3426,31 +4582,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3426
4582
|
{selectedWidget.kind === "chart" ? <ChartConfigPanel
|
|
3427
4583
|
widget={selectedWidget}
|
|
3428
4584
|
branding={branding}
|
|
4585
|
+
dataModelTables={dataModelTables}
|
|
3429
4586
|
onChange={replaceSelectedWidgetConfig}
|
|
3430
4587
|
onSubPage={(name) => setInspectorPath(name)}
|
|
3431
4588
|
/> : null}
|
|
3432
|
-
{selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
|
|
3433
|
-
<label>
|
|
3434
|
-
<span>Sample Values</span>
|
|
3435
|
-
<input
|
|
3436
|
-
value={serializeChartValues(selectedWidget.config?.values || [])}
|
|
3437
|
-
onChange={(event) => updateSelectedWidgetConfig({ values: normalizeChartValues(event.target.value) })}
|
|
3438
|
-
/>
|
|
3439
|
-
</label>
|
|
3440
|
-
<label>
|
|
3441
|
-
<span>Static Binding</span>
|
|
3442
|
-
<select
|
|
3443
|
-
value={selectedWidget.config?.binding?.mode || "json"}
|
|
3444
|
-
onChange={(event) => updateSelectedWidgetConfig({
|
|
3445
|
-
binding: event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.reportingJson
|
|
3446
|
-
})}
|
|
3447
|
-
>
|
|
3448
|
-
<option value="json">Sample JSON</option>
|
|
3449
|
-
<option value="csv">Sample CSV</option>
|
|
3450
|
-
{selectedWidget.config?.binding?.mode === "integration" ? <option value="integration">Integration reference</option> : null}
|
|
3451
|
-
</select>
|
|
3452
|
-
</label>
|
|
3453
|
-
</section> : null}
|
|
3454
4589
|
{selectedWidget.kind === "iframe" ? <label className="workspace-field-with-hint">
|
|
3455
4590
|
<span>URL to Embed</span>
|
|
3456
4591
|
<input
|
|
@@ -3477,23 +4612,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3477
4612
|
</small>
|
|
3478
4613
|
</label> : null}
|
|
3479
4614
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
3480
|
-
{selectedWidget.config?.binding?.sourceType === DATA_MODEL_SOURCE_TYPE ? <div className="workspace-active-source-state">
|
|
3481
|
-
<span>Data Model object</span>
|
|
3482
|
-
<strong>{summarizeSource(selectedWidget)}</strong>
|
|
3483
|
-
<code>{selectedWidget.config?.binding?.objectId || "workspace-config"}</code>
|
|
3484
|
-
</div> : <label>
|
|
3485
|
-
<span>Manual Rows</span>
|
|
3486
|
-
<textarea
|
|
3487
|
-
value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
|
|
3488
|
-
onChange={(event) => {
|
|
3489
|
-
const columns = selectedWidget.config?.columns?.length ? selectedWidget.config.columns : ["Name", "Domain Name"];
|
|
3490
|
-
updateSelectedWidgetConfig({
|
|
3491
|
-
rows: parseManualRows(event.target.value, columns),
|
|
3492
|
-
binding: { mode: "manual", source: "Manual rows", rows: parseManualRows(event.target.value, columns) }
|
|
3493
|
-
});
|
|
3494
|
-
}}
|
|
3495
|
-
/>
|
|
3496
|
-
</label>}
|
|
3497
4615
|
<div className="workspace-settings-list" role="group" aria-label="View widget settings">
|
|
3498
4616
|
<p className="workspace-panel-label">Settings</p>
|
|
3499
4617
|
<button type="button" className="workspace-settings-row" disabled>
|
|
@@ -3513,18 +4631,6 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3513
4631
|
</button>
|
|
3514
4632
|
</div>
|
|
3515
4633
|
</section> : null}
|
|
3516
|
-
{selectedWidget.kind === "rich-text" ? <label>
|
|
3517
|
-
<span>Static Binding</span>
|
|
3518
|
-
<select
|
|
3519
|
-
value={selectedWidget.config?.binding?.mode || "manual"}
|
|
3520
|
-
onChange={(event) => updateSelectedWidgetConfig({
|
|
3521
|
-
binding: { mode: event.target.value, source: event.target.value === "manual" ? "Manual text" : "Sample JSON", rows: [] }
|
|
3522
|
-
})}
|
|
3523
|
-
>
|
|
3524
|
-
<option value="manual">Manual Text</option>
|
|
3525
|
-
<option value="json">Sample JSON</option>
|
|
3526
|
-
</select>
|
|
3527
|
-
</label> : null}
|
|
3528
4634
|
<div className="workspace-settings-list">
|
|
3529
4635
|
<p className="workspace-panel-label">Placement</p>
|
|
3530
4636
|
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|