@growthub/cli 0.9.13 → 0.9.16

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