@growthub/cli 0.9.13 → 0.9.14

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