@growthub/cli 0.9.12 → 0.9.14

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