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