@growthub/cli 0.9.13 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +17 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +1349 -222
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1048 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1540 -433
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +141 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +211 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +126 -7
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +16 -0
  33. package/dist/index.js +1764 -40677
  34. package/package.json +2 -2
@@ -1,54 +1,190 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { Database } from "lucide-react";
4
+ import {
5
+ Activity,
6
+ AlertCircle,
7
+ ArrowRight,
8
+ BarChart2,
9
+ Box,
10
+ Building2,
11
+ Calendar,
12
+ CheckSquare,
13
+ ChevronDown,
14
+ ChevronRight,
15
+ Code2,
16
+ Database,
17
+ FileText,
18
+ Globe,
19
+ Hash,
20
+ Layers,
21
+ Link2,
22
+ List,
23
+ Mail,
24
+ Maximize2,
25
+ Plus,
26
+ Play,
27
+ Search,
28
+ ShoppingCart,
29
+ Star,
30
+ Tag,
31
+ Terminal,
32
+ ToggleLeft,
33
+ Type,
34
+ Users,
35
+ X,
36
+ Zap,
37
+ } from "lucide-react";
5
38
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
39
  import {
40
+ OBJECT_TYPE_PRESETS,
7
41
  addTableField,
8
42
  addTableRow,
9
43
  appendRowsToTable,
10
- createManualBusinessObject,
11
- deleteTableRow,
44
+ createTypedBusinessObject,
12
45
  describeBindingLane,
13
- describeBindingMode,
14
- duplicateTableRow,
15
46
  exportTableAsCsv,
16
47
  importTableFromCsv,
48
+ listSavedEnvRefs,
17
49
  listWorkspaceDataModelTables,
50
+ parseSandboxAllowList,
51
+ parseSandboxEnvRefs,
18
52
  replaceTableContent,
19
- updateTableCell
53
+ updateTableCell,
20
54
  } from "@/lib/workspace-data-model";
21
55
 
22
- const TABS = ["Fields", "Records", "Bindings", "Usage"];
23
- const LANE_META = {
24
- manual: { label: "Manual", cls: "dm-badge-manual" },
25
- "data-source": { label: "Data Source", cls: "dm-badge-datasource" },
26
- "workspace-integration": { label: "Workspace Tool", cls: "dm-badge-integration" },
27
- integration: { label: "Integration", cls: "dm-badge-integration" }
56
+ // ─── Icon system ─────────────────────────────────────────────────────────────
57
+
58
+ const LUCIDE_MAP = {
59
+ Activity, BarChart2, Box, Building2, Calendar, CheckSquare, Code2,
60
+ Database, FileText, Globe, Hash, Layers, Link2, List, Mail, Plus,
61
+ ShoppingCart, Star, Tag, Terminal, Type, Users, Zap,
28
62
  };
29
63
 
30
- function pluralize(count, word) {
31
- return `${count} ${count === 1 ? word : `${word}s`}`;
64
+ const ICON_PICKER_SET = [
65
+ "Database", "Globe", "Code2", "Users", "CheckSquare", "Building2",
66
+ "Tag", "Star", "Zap", "FileText", "Mail", "BarChart2",
67
+ "Layers", "Box", "Activity", "ShoppingCart", "Terminal",
68
+ ];
69
+
70
+ function LucideIcon({ name, size = 14, className, style }) {
71
+ const Icon = LUCIDE_MAP[name] || Database;
72
+ return <Icon size={size} className={className} style={style} aria-hidden="true" />;
32
73
  }
33
74
 
34
- function laneMeta(binding) {
35
- return LANE_META[describeBindingLane(binding)] || LANE_META.manual;
75
+ // ─── Object type definitions for the type-picker step ────────────────────────
76
+
77
+ const OBJECT_TYPE_DEFS = [
78
+ {
79
+ type: "data-source",
80
+ icon: Globe,
81
+ label: "Data Source",
82
+ description: "Custom API, webhook, or external feed. Linked to a resolver via API Registry.",
83
+ },
84
+ {
85
+ type: "api-registry",
86
+ icon: Code2,
87
+ label: "API Registry",
88
+ description: "Resolver adapters — integrationId + fetch functions that power Data Sources.",
89
+ },
90
+ {
91
+ type: "people",
92
+ icon: Users,
93
+ label: "People",
94
+ description: "Contacts, leads, or team members with standard CRM fields.",
95
+ },
96
+ {
97
+ type: "tasks",
98
+ icon: CheckSquare,
99
+ label: "Tasks",
100
+ description: "Action items, to-dos, and work tracking.",
101
+ },
102
+ {
103
+ type: "sandbox-environment",
104
+ icon: Terminal,
105
+ label: "Sandbox Environment",
106
+ description: "Localized py/node/bash terminal sandbox or local agent host (Claude / Codex / Cursor / Gemini / Hermes). Server-side execution with versioned run history. Cannot bind directly to a widget.",
107
+ },
108
+ {
109
+ type: "custom",
110
+ icon: Plus,
111
+ label: "Custom",
112
+ description: "Blank table — define your own fields from scratch.",
113
+ },
114
+ ];
115
+
116
+ // ─── Lane / badge meta ────────────────────────────────────────────────────────
117
+
118
+ const OBJECT_TYPE_BADGE = {
119
+ "data-source": { label: "Data Source", cls: "dm-badge-datasource" },
120
+ "api-registry": { label: "API Registry", cls: "dm-badge-registry" },
121
+ "sandbox-environment": { label: "Sandbox Environment", cls: "dm-badge-sandbox" },
122
+ people: { label: "People", cls: "dm-badge-people" },
123
+ tasks: { label: "Tasks", cls: "dm-badge-tasks" },
124
+ custom: { label: "Custom", cls: "dm-badge-manual" },
125
+ };
126
+
127
+ const SANDBOX_ROW_FIELDS = new Set([
128
+ "Name",
129
+ "lifecycleStatus",
130
+ "version",
131
+ "runLocality",
132
+ "schedulerRegistryId",
133
+ "runtime",
134
+ "adapter",
135
+ "agentHost",
136
+ "envRefs",
137
+ "networkAllow",
138
+ "allowList",
139
+ "instructions",
140
+ "command",
141
+ "timeoutMs",
142
+ "status",
143
+ "lastTested",
144
+ "lastRunId",
145
+ "lastSourceId",
146
+ "lastResponse"
147
+ ]);
148
+
149
+ const SANDBOX_RUNTIME_OPTIONS = ["python", "node", "bash"];
150
+
151
+ const FIELD_TYPE_ICON_NAMES = {
152
+ text: "Type", number: "Hash", date: "Calendar", url: "Link2", select: "List", boolean: "ToggleLeft",
153
+ };
154
+
155
+ function inferFieldType(name) {
156
+ const n = name.toLowerCase();
157
+ if (n.includes("date") || n.includes("_at") || n.includes("created") || n.includes("updated")) return "date";
158
+ if (n.includes("url") || n.includes("link") || n.includes("website") || n === "endpoint" || n === "baseurl") return "url";
159
+ if (n.includes("count") || n.includes("num") || n.includes("amount") || n.includes("arr") || n.includes("price")) return "number";
160
+ if (n === "status" || n === "stage" || n === "type" || n === "icp" || n === "priority" || n === "authtype" || n === "method") return "select";
161
+ if (n.startsWith("is_") || n.includes("active") || n.includes("enabled")) return "boolean";
162
+ return "text";
36
163
  }
37
164
 
38
- function SaveToast({ saving, message }) {
39
- if (saving) return <span className="dm-toast saving">Saving...</span>;
40
- if (!message) return null;
41
- return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
165
+ function pluralize(count, word) {
166
+ return `${count} ${count === 1 ? word : `${word}s`}`;
167
+ }
168
+
169
+ function objectTypeBadge(objectType) {
170
+ return OBJECT_TYPE_BADGE[objectType] || OBJECT_TYPE_BADGE.custom;
42
171
  }
43
172
 
44
173
  function textColorForAccent(accent) {
45
174
  const hex = String(accent || "").replace("#", "");
46
175
  if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
47
- const red = parseInt(hex.slice(0, 2), 16);
48
- const green = parseInt(hex.slice(2, 4), 16);
49
- const blue = parseInt(hex.slice(4, 6), 16);
50
- const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
51
- return luminance > 0.62 ? "#252525" : "#ffffff";
176
+ const r = parseInt(hex.slice(0, 2), 16);
177
+ const g = parseInt(hex.slice(2, 4), 16);
178
+ const b = parseInt(hex.slice(4, 6), 16);
179
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62 ? "#252525" : "#ffffff";
180
+ }
181
+
182
+ // ─── Shared micro-components ──────────────────────────────────────────────────
183
+
184
+ function SaveToast({ saving, message }) {
185
+ if (saving) return <span className="dm-toast saving">Saving…</span>;
186
+ if (!message) return null;
187
+ return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
52
188
  }
53
189
 
54
190
  function NavRail({ authority, workspaceConfig }) {
@@ -57,10 +193,13 @@ function NavRail({ authority, workspaceConfig }) {
57
193
  return (
58
194
  <aside className="workspace-rail" aria-label="Workspace navigation">
59
195
  <div className="workspace-brand">
60
- <span className="workspace-mark" style={{
61
- background: branding.logoUrl ? undefined : branding.accent || undefined,
62
- color: branding.logoUrl ? undefined : textColorForAccent(branding.accent)
63
- }}>
196
+ <span
197
+ className="workspace-mark"
198
+ style={{
199
+ background: branding.logoUrl ? undefined : branding.accent || undefined,
200
+ color: branding.logoUrl ? undefined : textColorForAccent(branding.accent),
201
+ }}
202
+ >
64
203
  {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
65
204
  </span>
66
205
  <span>{workspaceName}</span>
@@ -68,81 +207,857 @@ function NavRail({ authority, workspaceConfig }) {
68
207
  <nav className="workspace-nav">
69
208
  <Link href="/">Dashboards</Link>
70
209
  <Link className="active" href="/data-model">Data Model</Link>
71
- <Link href="/settings/integrations">Integrations</Link>
72
210
  <span className="workspace-nav-static">Management</span>
73
211
  <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
74
212
  </nav>
75
- <div className="workspace-rail-status"><span className="status-dot" />{authority || "local-catalog"}</div>
213
+ <div className="workspace-rail-status">
214
+ <span className="status-dot" />
215
+ {authority || "local-catalog"}
216
+ </div>
76
217
  </aside>
77
218
  );
78
219
  }
79
220
 
221
+ // ─── Object list row ──────────────────────────────────────────────────────────
222
+
80
223
  function ObjectRow({ table, selected, onSelect }) {
81
- const meta = laneMeta(table.binding);
224
+ const badge = objectTypeBadge(table.objectType);
225
+ const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
82
226
  return (
83
- <button type="button" className={`dm-object-row${selected ? " active" : ""}`} onClick={onSelect}>
84
- <div className="dm-object-row-top">
85
- <Database className="dm-object-icon" size={13} aria-hidden="true" />
86
- <strong className="dm-object-name">{table.label}</strong>
87
- <span className={`dm-badge ${meta.cls}`}>{meta.label}</span>
88
- </div>
89
- <div className="dm-object-row-meta">
90
- <span>{pluralize(table.rows.length, "record")}</span>
91
- <span>{pluralize(table.columns.length, "field")}</span>
92
- <span>{pluralize(table.widgetRefs.length, "widget")}</span>
93
- </div>
227
+ <button type="button" className={`dm-obj-row${selected ? " active" : ""}`} onClick={onSelect}>
228
+ <LucideIcon name={iconName} size={13} className="dm-obj-icon" />
229
+ <span className="dm-obj-name">{table.label}</span>
230
+ <span className={`dm-badge ${badge.cls}`}>{badge.label}</span>
94
231
  </button>
95
232
  );
96
233
  }
97
234
 
98
- function FieldsTab({ table, saving, onSave }) {
99
- const [fieldName, setFieldName] = useState("");
100
- const [error, setError] = useState("");
235
+ // ─── Source validation banner ─────────────────────────────────────────────────
101
236
 
102
- function addField() {
103
- const name = fieldName.trim();
104
- if (!name) return;
105
- if (table.columns.includes(name)) {
106
- setError(`${name} already exists.`);
107
- return;
237
+ function SourceValidationBanner({ table }) {
238
+ const lane = describeBindingLane(table?.binding);
239
+ if (!table || lane === "manual") return null;
240
+ const hasRef = table.binding?.integrationId || table.binding?.sourceKey || table.binding?.entityId;
241
+ if (hasRef) return null;
242
+ return (
243
+ <div className="dm-validation-banner">
244
+ <AlertCircle size={13} />
245
+ <span>Source binding incomplete — configure the source in widget source controls before data loads.</span>
246
+ </div>
247
+ );
248
+ }
249
+
250
+ // ─── Database surface ─────────────────────────────────────────────────────────
251
+
252
+ function formatCellValue(value, column) {
253
+ if (value === null || value === undefined || value === "") return "";
254
+ const text = typeof value === "string" ? value : JSON.stringify(value);
255
+ if (column === "lastResponse" && text.length > 90) return `${text.slice(0, 90)}…`;
256
+ return text;
257
+ }
258
+
259
+ function ConnectionPill({ value }) {
260
+ const status = String(value || "untested").toLowerCase();
261
+ const ok = ["connected", "approved", "ok", "success"].includes(status);
262
+ const bad = ["failed", "error", "disconnected"].includes(status);
263
+ return (
264
+ <span className={`dm-db-status ${ok ? "ok" : bad ? "bad" : ""}`}>
265
+ <span />
266
+ {value || "untested"}
267
+ </span>
268
+ );
269
+ }
270
+
271
+ function relationForColumn(table, column) {
272
+ return (table?.relations || []).find((relation) => relation.field === column) || null;
273
+ }
274
+
275
+ function referenceOptions(tables, relation) {
276
+ if (!relation) return [];
277
+ return (tables || [])
278
+ .filter((candidate) => candidate.objectType === relation.targetObjectType)
279
+ .flatMap((candidate) => (candidate.rows || []).map((row, index) => {
280
+ const value = row?.integrationId || row?.id || row?.Name || `${candidate.objectId}:${index}`;
281
+ const label = row?.Name || row?.integrationId || row?.description || `${candidate.label} row ${index + 1}`;
282
+ return { value, label, source: candidate.label };
283
+ }));
284
+ }
285
+
286
+ function ReferenceSelect({ value, options, disabled, onChange }) {
287
+ const normalizedOptions = useMemo(() => options.map((option) => ({
288
+ value: String(option.value ?? ""),
289
+ label: String(option.label ?? option.value ?? ""),
290
+ source: option.source ? String(option.source) : ""
291
+ })), [options]);
292
+ return (
293
+ <SearchableSelect
294
+ value={value || ""}
295
+ options={normalizedOptions}
296
+ disabled={disabled}
297
+ placeholder="Select reference..."
298
+ onChange={onChange}
299
+ />
300
+ );
301
+ }
302
+
303
+ function SearchableSelect({ value, options, disabled, placeholder = "Select...", onChange, pageSize = 8 }) {
304
+ const [open, setOpen] = useState(false);
305
+ const [query, setQuery] = useState("");
306
+ const [page, setPage] = useState(0);
307
+ const selected = options.find((option) => option.value === String(value || ""));
308
+ const filtered = useMemo(() => {
309
+ const needle = query.trim().toLowerCase();
310
+ if (!needle) return options;
311
+ return options.filter((option) => `${option.label} ${option.value} ${option.source}`.toLowerCase().includes(needle));
312
+ }, [options, query]);
313
+ const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
314
+ const currentPage = Math.min(page, pageCount - 1);
315
+ const visibleOptions = filtered.slice(currentPage * pageSize, currentPage * pageSize + pageSize);
316
+
317
+ useEffect(() => {
318
+ setPage(0);
319
+ }, [query, options.length]);
320
+
321
+ return (
322
+ <div
323
+ className={`dm-select${open ? " open" : ""}${disabled ? " disabled" : ""}`}
324
+ onClick={(event) => event.stopPropagation()}
325
+ onBlur={(event) => {
326
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
327
+ }}
328
+ >
329
+ <button
330
+ type="button"
331
+ className="dm-select-trigger"
332
+ disabled={disabled}
333
+ aria-haspopup="listbox"
334
+ aria-expanded={open}
335
+ onClick={() => setOpen((current) => !current)}
336
+ >
337
+ <span className={selected ? "" : "empty"}>{selected?.label || placeholder}</span>
338
+ <ChevronDown size={15} aria-hidden="true" />
339
+ </button>
340
+ {open && (
341
+ <div className="dm-select-popover">
342
+ <label className="dm-select-search">
343
+ <Search size={14} aria-hidden="true" />
344
+ <input
345
+ autoFocus
346
+ value={query}
347
+ placeholder="Search..."
348
+ onChange={(event) => setQuery(event.target.value)}
349
+ />
350
+ </label>
351
+ <div className="dm-select-list" role="listbox">
352
+ <button
353
+ type="button"
354
+ className={`dm-select-option${!value ? " selected" : ""}`}
355
+ role="option"
356
+ aria-selected={!value}
357
+ onClick={() => {
358
+ onChange("");
359
+ setOpen(false);
360
+ }}
361
+ >
362
+ <span>{placeholder}</span>
363
+ </button>
364
+ {visibleOptions.map((option) => (
365
+ <button
366
+ type="button"
367
+ key={`${option.source}:${option.value}`}
368
+ className={`dm-select-option${option.value === String(value || "") ? " selected" : ""}`}
369
+ role="option"
370
+ aria-selected={option.value === String(value || "")}
371
+ onClick={() => {
372
+ onChange(option.value);
373
+ setOpen(false);
374
+ setQuery("");
375
+ }}
376
+ >
377
+ <span>{option.label}</span>
378
+ {option.source && <em>{option.source}</em>}
379
+ </button>
380
+ ))}
381
+ {visibleOptions.length === 0 && <p className="dm-select-empty">No matches</p>}
382
+ </div>
383
+ {filtered.length > pageSize && (
384
+ <div className="dm-select-pager">
385
+ <button type="button" disabled={currentPage === 0} onClick={() => setPage((next) => Math.max(0, next - 1))}>Prev</button>
386
+ <span>{currentPage + 1} / {pageCount}</span>
387
+ <button type="button" disabled={currentPage >= pageCount - 1} onClick={() => setPage((next) => Math.min(pageCount - 1, next + 1))}>Next</button>
388
+ </div>
389
+ )}
390
+ </div>
391
+ )}
392
+ </div>
393
+ );
394
+ }
395
+
396
+ function StaticSelect({ value, options, disabled, onChange, placeholder = "Select..." }) {
397
+ const normalizedOptions = useMemo(() => options.map((option) => (
398
+ typeof option === "string" ? { value: option, label: option } : option
399
+ )), [options]);
400
+ return (
401
+ <SearchableSelect
402
+ value={value || ""}
403
+ options={normalizedOptions}
404
+ disabled={disabled}
405
+ placeholder={placeholder}
406
+ onChange={onChange}
407
+ />
408
+ );
409
+ }
410
+
411
+ function DrawerSection({ title, children, defaultOpen = false }) {
412
+ const [open, setOpen] = useState(defaultOpen);
413
+ return (
414
+ <section className={`dm-drawer-section${open ? " open" : ""}`}>
415
+ <button type="button" className="dm-drawer-section-toggle" onClick={() => setOpen((current) => !current)}>
416
+ <ChevronRight size={14} aria-hidden="true" />
417
+ <span>{title}</span>
418
+ </button>
419
+ {open && <div className="dm-drawer-section-body">{children}</div>}
420
+ </section>
421
+ );
422
+ }
423
+
424
+ const GENERIC_FIELD_SECTIONS = [
425
+ {
426
+ title: "Identity",
427
+ columns: new Set(["Name", "name", "id", "integrationId", "registryId", "authRef"])
428
+ },
429
+ {
430
+ title: "Connection",
431
+ columns: new Set(["baseUrl", "endpoint", "method", "schedulerRegistryId"])
432
+ },
433
+ {
434
+ title: "Status & Response",
435
+ columns: new Set(["status", "lastTested", "lastRunId", "lastSourceId", "lastResponse"])
436
+ }
437
+ ];
438
+
439
+ function groupRecordColumns(columns) {
440
+ const groups = GENERIC_FIELD_SECTIONS.map((section) => ({
441
+ title: section.title,
442
+ columns: columns.filter((column) => section.columns.has(column))
443
+ })).filter((section) => section.columns.length > 0);
444
+ const grouped = new Set(groups.flatMap((section) => section.columns));
445
+ const otherColumns = columns.filter((column) => !grouped.has(column));
446
+ if (otherColumns.length) groups.push({ title: "Details", columns: otherColumns });
447
+ return groups;
448
+ }
449
+
450
+ function RecordFieldEditor({ table, tables, column, value, saving, onDraft, onCommit, onExpandJson }) {
451
+ const relation = relationForColumn(table, column);
452
+ const options = referenceOptions(tables, relation);
453
+ const large = column === "lastResponse" || value.length > 120;
454
+ if (relation) {
455
+ return (
456
+ <label className="dm-record-field">
457
+ <span>{column}</span>
458
+ <ReferenceSelect
459
+ value={value}
460
+ options={options}
461
+ disabled={!table.mutable || saving}
462
+ onChange={(nextValue) => onCommit(column, nextValue)}
463
+ />
464
+ </label>
465
+ );
466
+ }
467
+ if (column === "lastResponse") {
468
+ return (
469
+ <label className="dm-record-field dm-json-field">
470
+ <span>{column}</span>
471
+ <button
472
+ type="button"
473
+ className="dm-json-expand"
474
+ aria-label="Expand lastResponse JSON"
475
+ title="Expand JSON"
476
+ disabled={!value}
477
+ onClick={onExpandJson}
478
+ >
479
+ <Maximize2 size={14} aria-hidden="true" />
480
+ </button>
481
+ <textarea
482
+ value={value}
483
+ rows={10}
484
+ disabled={!table.mutable || saving}
485
+ onChange={(event) => onDraft(column, event.target.value)}
486
+ onBlur={(event) => onCommit(column, event.target.value)}
487
+ />
488
+ </label>
489
+ );
490
+ }
491
+ return (
492
+ <label className="dm-record-field">
493
+ <span>{column}</span>
494
+ {large ? (
495
+ <textarea
496
+ value={value}
497
+ rows={4}
498
+ disabled={!table.mutable || saving}
499
+ onChange={(event) => onDraft(column, event.target.value)}
500
+ onBlur={(event) => onCommit(column, event.target.value)}
501
+ />
502
+ ) : (
503
+ <input
504
+ value={value}
505
+ disabled={!table.mutable || saving}
506
+ onChange={(event) => onDraft(column, event.target.value)}
507
+ onBlur={(event) => onCommit(column, event.target.value)}
508
+ />
509
+ )}
510
+ </label>
511
+ );
512
+ }
513
+
514
+ function SandboxRecordFields({
515
+ draft,
516
+ setDraft,
517
+ table,
518
+ tables,
519
+ workspaceConfig,
520
+ saving,
521
+ onSave,
522
+ rowIndex,
523
+ sandboxHistory,
524
+ sandboxHistoryMessage,
525
+ loadingSandboxHistory,
526
+ onLoadSandboxHistory,
527
+ onExpandLastResponse
528
+ }) {
529
+ const [sandboxAdapters, setSandboxAdapters] = useState([]);
530
+ useEffect(() => {
531
+ fetch("/api/workspace/sandbox-adapters", { cache: "no-store" })
532
+ .then((res) => res.json())
533
+ .then((payload) => setSandboxAdapters(Array.isArray(payload.adapters) ? payload.adapters : []))
534
+ .catch(() => setSandboxAdapters([]));
535
+ }, []);
536
+
537
+ const locality = String(draft.runLocality || "local").trim().toLowerCase() === "serverless" ? "serverless" : "local";
538
+ const savedEnvRefs = useMemo(() => listSavedEnvRefs(workspaceConfig || {}), [workspaceConfig]);
539
+ const selectedEnvSlugs = useMemo(() => new Set(parseSandboxEnvRefs(draft.envRefs)), [draft.envRefs]);
540
+ const schedulerRelation = relationForColumn(table, "schedulerRegistryId");
541
+ const schedulerOptions = referenceOptions(tables, schedulerRelation);
542
+ const selectedAdapterMeta = sandboxAdapters.find((a) => a.id === String(draft.adapter || "").trim());
543
+
544
+ function patchFields(fields) {
545
+ setDraft((c) => ({ ...c, ...fields }));
546
+ onSave((cfg) => Object.entries(fields).reduce(
547
+ (acc, [column, value]) => updateTableCell(acc, table, rowIndex, column, value),
548
+ cfg
549
+ ));
550
+ }
551
+
552
+ function setRunLocality(next) {
553
+ const fields = { runLocality: next };
554
+ if (next === "serverless" && String(draft.adapter || "").trim() === "local-agent-host") {
555
+ fields.adapter = "local-process";
108
556
  }
109
- setError("");
110
- setFieldName("");
111
- onSave((config) => addTableField(config, table, name));
557
+ patchFields(fields);
558
+ }
559
+
560
+ function toggleEnvRef(slug) {
561
+ const next = new Set(selectedEnvSlugs);
562
+ if (next.has(slug)) next.delete(slug);
563
+ else next.add(slug);
564
+ patchFields({ envRefs: [...next].join(",") });
112
565
  }
113
566
 
567
+ const netOn = ["true", "1", "on", "yes"].includes(String(draft.networkAllow || "").trim().toLowerCase());
568
+
114
569
  return (
115
- <div>
116
- <div className="dm-tab-toolbar">
117
- <p className="dm-tab-stat">{pluralize(table.columns.length, "field")}</p>
118
- <div className="dm-inline-add">
119
- <input className="dm-input" value={fieldName} disabled={!table.mutable} placeholder="New field" onChange={(event) => setFieldName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") addField(); }} />
120
- <button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !fieldName.trim()} onClick={addField}>+ Add field</button>
570
+ <div className="dm-sandbox-config">
571
+ <DrawerSection title="Identity & Mode">
572
+ <label className="dm-record-field">
573
+ <span>Name</span>
574
+ <input
575
+ value={draft.Name ?? ""}
576
+ disabled={!table.mutable || saving}
577
+ onChange={(event) => setDraft((c) => ({ ...c, Name: event.target.value }))}
578
+ onBlur={(event) => patchFields({ Name: event.target.value })}
579
+ />
580
+ </label>
581
+
582
+ <label className="dm-record-field">
583
+ <span>Status mode</span>
584
+ <StaticSelect
585
+ value={String(draft.lifecycleStatus || "draft").trim().toLowerCase() === "live" ? "live" : "draft"}
586
+ disabled={!table.mutable || saving}
587
+ options={["draft", "live"]}
588
+ onChange={(nextValue) => patchFields({ lifecycleStatus: nextValue })}
589
+ />
590
+ </label>
591
+
592
+ <label className="dm-record-field">
593
+ <span>Version</span>
594
+ <input
595
+ value={draft.version ?? ""}
596
+ disabled={!table.mutable || saving}
597
+ onChange={(event) => setDraft((c) => ({ ...c, version: event.target.value }))}
598
+ onBlur={(event) => patchFields({ version: event.target.value })}
599
+ />
600
+ </label>
601
+ </DrawerSection>
602
+
603
+ <DrawerSection title="Execution Target">
604
+ <div className="dm-record-field">
605
+ <span>Where it runs</span>
606
+ <div className="dm-radio-row">
607
+ <label>
608
+ <input
609
+ type="radio"
610
+ name="sandbox-run-locality"
611
+ checked={locality === "local"}
612
+ disabled={!table.mutable || saving}
613
+ onChange={() => setRunLocality("local")}
614
+ />
615
+ <span>Local - process sandbox or Paperclip local agent host on this machine</span>
616
+ </label>
617
+ <label>
618
+ <input
619
+ type="radio"
620
+ name="sandbox-run-locality"
621
+ checked={locality === "serverless"}
622
+ disabled={!table.mutable || saving}
623
+ onChange={() => setRunLocality("serverless")}
624
+ />
625
+ <span>Serverless - delegate to scheduler URL (API Registry); no local agent CLI</span>
626
+ </label>
627
+ </div>
121
628
  </div>
122
- </div>
123
- {error ? <p className="dm-field-error">{error}</p> : null}
124
- {!table.mutable ? <p className="dm-hint-block">This object is an integration reference. Select and configure its source object in the existing View widget source controls.</p> : null}
125
- <div className="dm-field-list">
126
- {table.columns.map((column) => <div key={column} className="dm-field-item"><span className="dm-field-icon">::</span><strong>{column}</strong></div>)}
127
- </div>
629
+
630
+ {locality === "serverless" && (
631
+ <label className="dm-record-field">
632
+ <span>Scheduler (API Registry)</span>
633
+ <ReferenceSelect
634
+ value={draft.schedulerRegistryId || ""}
635
+ options={schedulerOptions}
636
+ disabled={!table.mutable || saving}
637
+ onChange={(nextValue) => patchFields({ schedulerRegistryId: nextValue })}
638
+ />
639
+ <span className="dm-cell-empty" style={{ fontSize: 11, marginTop: 4, display: "block" }}>
640
+ POST sends <code>growthub-sandbox-run-v1</code> JSON; auth from registry <code>authRef</code> (server env only).
641
+ </span>
642
+ </label>
643
+ )}
644
+
645
+ <label className="dm-record-field">
646
+ <span>Execution adapter</span>
647
+ <StaticSelect
648
+ value={String(draft.adapter || "local-process").trim() || "local-process"}
649
+ disabled={!table.mutable || saving}
650
+ options={sandboxAdapters.length === 0 ? [{ value: "local-process", label: "local-process" }] : sandboxAdapters.map((a) => ({ value: a.id, label: a.label }))}
651
+ onChange={(nextValue) => patchFields({ adapter: nextValue })}
652
+ />
653
+ </label>
654
+
655
+ {locality === "local" && String(draft.adapter || "").trim() === "local-agent-host" && (
656
+ <label className="dm-record-field">
657
+ <span>Agent host (Paperclip)</span>
658
+ <StaticSelect
659
+ value={draft.agentHost || ""}
660
+ disabled={!table.mutable || saving}
661
+ placeholder="Select host..."
662
+ options={(selectedAdapterMeta?.hostCatalog || []).map((h) => ({ value: h.slug, label: h.label }))}
663
+ onChange={(nextValue) => patchFields({ agentHost: nextValue })}
664
+ />
665
+ </label>
666
+ )}
667
+
668
+ <label className="dm-record-field">
669
+ <span>Runtime</span>
670
+ <StaticSelect
671
+ value={draft.runtime || "node"}
672
+ disabled={!table.mutable || saving}
673
+ options={SANDBOX_RUNTIME_OPTIONS}
674
+ onChange={(nextValue) => patchFields({ runtime: nextValue })}
675
+ />
676
+ </label>
677
+ </DrawerSection>
678
+
679
+ <DrawerSection title="Environment & Network">
680
+ <div className="dm-record-field">
681
+ <span>Env key references</span>
682
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
683
+ {savedEnvRefs.length === 0 ? (
684
+ <span className="dm-cell-empty">Add keys under Settings -&gt; APIs &amp; Webhooks.</span>
685
+ ) : savedEnvRefs.map((ref) => (
686
+ <button
687
+ key={ref.endpointRef}
688
+ type="button"
689
+ className={`dm-btn-ghost${selectedEnvSlugs.has(ref.endpointRef) ? " dm-chip-active" : ""}`}
690
+ style={{ padding: "2px 8px", borderRadius: 999, fontSize: 11 }}
691
+ disabled={!table.mutable || saving}
692
+ onClick={() => toggleEnvRef(ref.endpointRef)}
693
+ >
694
+ {ref.endpointRef}
695
+ </button>
696
+ ))}
697
+ </div>
698
+ </div>
699
+
700
+ <label className="dm-check-row">
701
+ <input
702
+ type="checkbox"
703
+ checked={netOn}
704
+ disabled={!table.mutable || saving}
705
+ onChange={(event) => patchFields({ networkAllow: event.target.checked ? "true" : "false" })}
706
+ />
707
+ <span>Network allow-list mode (locals see <code>GROWTHUB_SANDBOX_NET_*</code>)</span>
708
+ </label>
709
+
710
+ <label className="dm-record-field">
711
+ <span>Allow list (comma-separated hosts)</span>
712
+ <input
713
+ value={draft.allowList ?? ""}
714
+ disabled={!table.mutable || saving}
715
+ onChange={(event) => setDraft((c) => ({ ...c, allowList: event.target.value }))}
716
+ onBlur={(event) => patchFields({ allowList: event.target.value })}
717
+ />
718
+ </label>
719
+ </DrawerSection>
720
+
721
+ <DrawerSection title="Prompt & Limits">
722
+ <label className="dm-record-field">
723
+ <span>Instructions</span>
724
+ <textarea
725
+ rows={5}
726
+ value={draft.instructions ?? ""}
727
+ disabled={!table.mutable || saving}
728
+ onChange={(event) => setDraft((c) => ({ ...c, instructions: event.target.value }))}
729
+ onBlur={(event) => patchFields({ instructions: event.target.value })}
730
+ />
731
+ </label>
732
+
733
+ <label className="dm-record-field">
734
+ <span>Command / prompt</span>
735
+ <textarea
736
+ rows={6}
737
+ value={draft.command ?? ""}
738
+ disabled={!table.mutable || saving}
739
+ onChange={(event) => setDraft((c) => ({ ...c, command: event.target.value }))}
740
+ onBlur={(event) => patchFields({ command: event.target.value })}
741
+ />
742
+ </label>
743
+
744
+ <label className="dm-record-field">
745
+ <span>timeoutMs</span>
746
+ <input
747
+ type="number"
748
+ min={1000}
749
+ max={600000}
750
+ value={draft.timeoutMs ?? ""}
751
+ disabled={!table.mutable || saving}
752
+ onChange={(event) => setDraft((c) => ({ ...c, timeoutMs: event.target.value }))}
753
+ onBlur={(event) => patchFields({ timeoutMs: event.target.value })}
754
+ />
755
+ </label>
756
+ </DrawerSection>
757
+
758
+ <DrawerSection title="Response & History">
759
+ <label className="dm-record-field">
760
+ <span>lastRunId</span>
761
+ <input readOnly value={draft.lastRunId ?? ""} />
762
+ </label>
763
+
764
+ <label className="dm-record-field">
765
+ <span>lastSourceId</span>
766
+ <input readOnly value={draft.lastSourceId ?? ""} />
767
+ </label>
768
+
769
+ <label className="dm-record-field dm-json-field">
770
+ <span>lastResponse</span>
771
+ <button
772
+ type="button"
773
+ className="dm-json-expand"
774
+ aria-label="Expand lastResponse JSON"
775
+ title="Expand JSON"
776
+ disabled={!draft.lastResponse}
777
+ onClick={onExpandLastResponse}
778
+ >
779
+ <Maximize2 size={14} aria-hidden="true" />
780
+ </button>
781
+ <textarea rows={10} readOnly value={draft.lastResponse ?? ""} />
782
+ </label>
783
+
784
+ <div className="dm-record-field">
785
+ <span>Run history</span>
786
+ <button type="button" className="dm-btn-ghost" disabled={loadingSandboxHistory} onClick={onLoadSandboxHistory}>
787
+ {loadingSandboxHistory ? "Loading..." : "Load previous runs"}
788
+ </button>
789
+ {sandboxHistoryMessage && <span className="dm-cell-empty">{sandboxHistoryMessage}</span>}
790
+ {Array.isArray(sandboxHistory) && sandboxHistory.length > 0 && (
791
+ <div style={{ display: "grid", gap: 8, marginTop: 8 }}>
792
+ {sandboxHistory.slice(0, 8).map((record) => (
793
+ <pre key={record.runId || record.ranAt} className="dm-source-preview" style={{ margin: 0, maxHeight: 160, overflow: "auto" }}>
794
+ {JSON.stringify({
795
+ runId: record.runId,
796
+ ranAt: record.ranAt,
797
+ lifecycleStatus: record.lifecycleStatus,
798
+ version: record.version,
799
+ status: record.exitCode === 0 && !record.error ? "connected" : "failed",
800
+ stdout: record.stdout,
801
+ error: record.error
802
+ }, null, 2)}
803
+ </pre>
804
+ ))}
805
+ </div>
806
+ )}
807
+ </div>
808
+ </DrawerSection>
128
809
  </div>
129
810
  );
130
811
  }
131
812
 
132
- function RecordsTab({ table, saving, onSave }) {
133
- const [editing, setEditing] = useState(null);
134
- const [draft, setDraft] = useState("");
813
+ function DataModelRecordDrawer({ table, tables, workspaceConfig, rowIndex, row, saving, onClose, onSave }) {
814
+ const [draft, setDraft] = useState(row || {});
815
+ const [testing, setTesting] = useState(false);
816
+ const [testMessage, setTestMessage] = useState("");
817
+ const [sandboxRunning, setSandboxRunning] = useState(false);
818
+ const [sandboxMessage, setSandboxMessage] = useState("");
819
+ const [sandboxHistory, setSandboxHistory] = useState([]);
820
+ const [sandboxHistoryMessage, setSandboxHistoryMessage] = useState("");
821
+ const [loadingSandboxHistory, setLoadingSandboxHistory] = useState(false);
822
+ const [expandedJson, setExpandedJson] = useState(null);
823
+
824
+ useEffect(() => {
825
+ setDraft(row || {});
826
+ setTestMessage("");
827
+ setSandboxMessage("");
828
+ setSandboxHistory([]);
829
+ setSandboxHistoryMessage("");
830
+ setExpandedJson(null);
831
+ }, [row, rowIndex]);
832
+
833
+ if (rowIndex === null || rowIndex === undefined || !row) return null;
834
+
835
+ const isSandbox = table.objectType === "sandbox-environment";
836
+
837
+ function updateField(column, value) {
838
+ setDraft((current) => ({ ...current, [column]: value }));
839
+ onSave((config) => updateTableCell(config, table, rowIndex, column, value));
840
+ }
841
+
842
+ async function testApiRecord() {
843
+ setTesting(true);
844
+ setTestMessage("");
845
+ try {
846
+ const res = await fetch("/api/workspace/test-api-record", {
847
+ method: "POST",
848
+ headers: { "content-type": "application/json" },
849
+ body: JSON.stringify(table.objectType === "data-source" ? { dataSourceRecord: draft } : { record: draft }),
850
+ });
851
+ const payload = await res.json();
852
+ const status = payload.ok ? "connected" : "failed";
853
+ const responseText = JSON.stringify(payload.response ?? payload, null, 2);
854
+ onSave((config) => {
855
+ let next = updateTableCell(config, table, rowIndex, "status", status);
856
+ next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
857
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
858
+ return next;
859
+ });
860
+ setDraft((current) => ({ ...current, status, lastTested: new Date().toISOString(), lastResponse: responseText }));
861
+ setTestMessage(payload.ok ? "Connected" : payload.error || "Connection failed");
862
+ } catch (err) {
863
+ const responseText = JSON.stringify({ error: err.message || "Connection failed" }, null, 2);
864
+ onSave((config) => {
865
+ let next = updateTableCell(config, table, rowIndex, "status", "failed");
866
+ next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
867
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
868
+ return next;
869
+ });
870
+ setTestMessage(err.message || "Connection failed");
871
+ } finally {
872
+ setTesting(false);
873
+ }
874
+ }
875
+
876
+ async function runSandbox() {
877
+ if (!table.objectId) {
878
+ setSandboxMessage("Missing object id for this sandbox table.");
879
+ return;
880
+ }
881
+ const rowName = String(draft?.Name ?? "").trim();
882
+ if (!rowName) {
883
+ setSandboxMessage("Row Name is required.");
884
+ return;
885
+ }
886
+ setSandboxRunning(true);
887
+ setSandboxMessage("");
888
+ try {
889
+ const res = await fetch("/api/workspace/sandbox-run", {
890
+ method: "POST",
891
+ headers: { "content-type": "application/json" },
892
+ body: JSON.stringify({ objectId: table.objectId, name: rowName }),
893
+ });
894
+ const payload = await res.json();
895
+ const responseText = JSON.stringify(payload.response ?? payload, null, 2);
896
+ const status = String(payload.status || "").toLowerCase() === "connected" ? "connected" : "failed";
897
+ const testedAt = payload.response?.ranAt || new Date().toISOString();
898
+ const lastRunId = payload.runId || payload.response?.runId || "";
899
+ const lastSourceId = payload.sourceId || payload.response?.sourceId || "";
900
+ onSave((config) => {
901
+ let next = updateTableCell(config, table, rowIndex, "status", status);
902
+ next = updateTableCell(next, table, rowIndex, "lastTested", testedAt);
903
+ next = updateTableCell(next, table, rowIndex, "lastRunId", lastRunId);
904
+ next = updateTableCell(next, table, rowIndex, "lastSourceId", lastSourceId);
905
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
906
+ return next;
907
+ });
908
+ setDraft((current) => ({ ...current, status, lastTested: testedAt, lastRunId, lastSourceId, lastResponse: responseText }));
909
+ setSandboxHistory((current) => payload.response ? [payload.response, ...current].slice(0, 25) : current);
910
+ setSandboxMessage(payload.ok ? "Sandbox run recorded" : (payload.response?.error || payload.error || "Run failed"));
911
+ } catch (err) {
912
+ setSandboxMessage(err.message || "Sandbox run failed");
913
+ } finally {
914
+ setSandboxRunning(false);
915
+ }
916
+ }
917
+
918
+ async function loadSandboxHistory() {
919
+ if (!table.objectId || !String(draft?.Name || "").trim()) {
920
+ setSandboxHistoryMessage("Sandbox Name is required.");
921
+ return;
922
+ }
923
+ setLoadingSandboxHistory(true);
924
+ setSandboxHistoryMessage("");
925
+ try {
926
+ const params = new URLSearchParams({ objectId: table.objectId, name: String(draft.Name || "").trim() });
927
+ const res = await fetch(`/api/workspace/sandbox-run?${params.toString()}`, { cache: "no-store" });
928
+ const payload = await res.json();
929
+ if (!payload.ok) throw new Error(payload.error || "Could not load run history");
930
+ setSandboxHistory(Array.isArray(payload.records) ? payload.records : []);
931
+ setSandboxHistoryMessage(`${payload.recordCount || 0} saved run${payload.recordCount === 1 ? "" : "s"} · ${payload.sourceId || ""}`);
932
+ } catch (err) {
933
+ setSandboxHistory([]);
934
+ setSandboxHistoryMessage(err.message || "Could not load run history");
935
+ } finally {
936
+ setLoadingSandboxHistory(false);
937
+ }
938
+ }
939
+
940
+ function expandLastResponse() {
941
+ const text = String(draft.lastResponse || "");
942
+ if (!text) return;
943
+ try {
944
+ setExpandedJson(JSON.stringify(JSON.parse(text), null, 2));
945
+ } catch {
946
+ setExpandedJson(text);
947
+ }
948
+ }
949
+
950
+ return (
951
+ <>
952
+ <div className="dm-record-backdrop" onClick={onClose} />
953
+ <aside className="dm-record-drawer" aria-label="Record details">
954
+ <header className="dm-record-drawer-head">
955
+ <div>
956
+ <p>Record</p>
957
+ <h2>{draft.Name || draft.integrationId || draft.id || `Row ${rowIndex + 1}`}</h2>
958
+ </div>
959
+ <button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
960
+ <X size={16} />
961
+ </button>
962
+ </header>
963
+ {(table.objectType === "api-registry" || table.objectType === "data-source") && (
964
+ <div className="dm-record-testbar">
965
+ <ConnectionPill value={draft.status} />
966
+ <button type="button" className="dm-btn-primary-sm" disabled={testing || saving} onClick={testApiRecord}>
967
+ {testing ? "Testing…" : "Test connection"}
968
+ </button>
969
+ {testMessage && <span>{testMessage}</span>}
970
+ </div>
971
+ )}
972
+ {isSandbox && (
973
+ <div className="dm-record-testbar">
974
+ <ConnectionPill value={draft.status} />
975
+ <button type="button" className="dm-btn-primary-sm" disabled={sandboxRunning || saving || !String(draft.Name || "").trim()} onClick={runSandbox}>
976
+ {sandboxRunning ? "Running…" : (<><Play size={13} aria-hidden /> Run sandbox</>)}
977
+ </button>
978
+ {sandboxMessage && <span>{sandboxMessage}</span>}
979
+ </div>
980
+ )}
981
+ <div className="dm-record-fields">
982
+ {isSandbox ? (
983
+ <SandboxRecordFields
984
+ draft={draft}
985
+ setDraft={setDraft}
986
+ table={table}
987
+ tables={tables}
988
+ workspaceConfig={workspaceConfig}
989
+ saving={saving}
990
+ onSave={onSave}
991
+ rowIndex={rowIndex}
992
+ sandboxHistory={sandboxHistory}
993
+ sandboxHistoryMessage={sandboxHistoryMessage}
994
+ loadingSandboxHistory={loadingSandboxHistory}
995
+ onLoadSandboxHistory={loadSandboxHistory}
996
+ onExpandLastResponse={expandLastResponse}
997
+ />
998
+ ) : groupRecordColumns(table.columns || []).map((section) => (
999
+ <DrawerSection key={section.title} title={section.title}>
1000
+ {section.columns.map((column) => (
1001
+ <RecordFieldEditor
1002
+ key={column}
1003
+ table={table}
1004
+ tables={tables}
1005
+ column={column}
1006
+ value={String(draft?.[column] ?? "")}
1007
+ saving={saving}
1008
+ onDraft={(field, nextValue) => setDraft((current) => ({ ...current, [field]: nextValue }))}
1009
+ onCommit={updateField}
1010
+ onExpandJson={expandLastResponse}
1011
+ />
1012
+ ))}
1013
+ </DrawerSection>
1014
+ ))}
1015
+ </div>
1016
+ </aside>
1017
+ {expandedJson !== null && (
1018
+ <div className="dm-json-modal-backdrop" onClick={() => setExpandedJson(null)}>
1019
+ <section className="dm-json-modal" role="dialog" aria-modal="true" aria-label="lastResponse JSON" onClick={(event) => event.stopPropagation()}>
1020
+ <header>
1021
+ <div>
1022
+ <p>lastResponse</p>
1023
+ <h2>{draft.Name || draft.integrationId || "Record response"}</h2>
1024
+ </div>
1025
+ <button type="button" className="dm-sidebar-close" onClick={() => setExpandedJson(null)} aria-label="Close expanded JSON">
1026
+ <X size={16} />
1027
+ </button>
1028
+ </header>
1029
+ <pre>{expandedJson}</pre>
1030
+ </section>
1031
+ </div>
1032
+ )}
1033
+ </>
1034
+ );
1035
+ }
1036
+
1037
+ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave }) {
1038
+ const [selectedRow, setSelectedRow] = useState(null);
1039
+ const [addingField, setAddingField] = useState(false);
1040
+ const [fieldName, setFieldName] = useState("");
135
1041
  const [csvOpen, setCsvOpen] = useState(false);
136
1042
  const [csvText, setCsvText] = useState("");
137
1043
  const [mode, setMode] = useState("append");
138
- const inputRef = useRef(null);
1044
+ const fieldInputRef = useRef(null);
139
1045
 
140
- useEffect(() => { inputRef.current?.focus(); }, [editing]);
1046
+ useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
1047
+ useEffect(() => { setSelectedRow(null); }, [table.id]);
141
1048
 
142
- function commit() {
143
- if (!editing) return;
144
- onSave((config) => updateTableCell(config, table, editing.row, editing.column, draft));
145
- setEditing(null);
1049
+ function commitField() {
1050
+ const name = fieldName.trim();
1051
+ if (!name) {
1052
+ setAddingField(false);
1053
+ setFieldName("");
1054
+ return;
1055
+ }
1056
+ if (!table.columns.includes(name)) {
1057
+ onSave((config) => addTableField(config, table, name));
1058
+ }
1059
+ setAddingField(false);
1060
+ setFieldName("");
146
1061
  }
147
1062
 
148
1063
  function importCsv() {
@@ -154,150 +1069,300 @@ function RecordsTab({ table, saving, onSave }) {
154
1069
  setCsvOpen(false);
155
1070
  }
156
1071
 
1072
+ const selectedRecord = selectedRow === null ? null : table.rows[selectedRow];
1073
+
157
1074
  return (
158
- <div>
159
- <div className="dm-tab-toolbar">
160
- <p className="dm-tab-stat">{pluralize(table.rows.length, "record")}</p>
161
- <div className="dm-tab-toolbar-actions">
162
- <button type="button" className="dm-btn" disabled={!table.rows.length} onClick={() => {
163
- const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
164
- const url = URL.createObjectURL(blob);
165
- const link = document.createElement("a");
166
- link.href = url;
167
- link.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
168
- link.click();
169
- URL.revokeObjectURL(url);
170
- }}>Export CSV</button>
171
- <button type="button" className="dm-btn" disabled={!table.mutable} onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>
172
- <button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !table.columns.length} onClick={() => onSave((config) => addTableRow(config, table))}>+ Add row</button>
1075
+ <div className="dm-db-surface">
1076
+ {!table.mutable && (
1077
+ <div className="dm-source-notice">
1078
+ <AlertCircle size={13} />
1079
+ <span>Dynamic integration records are resolved at runtime.</span>
1080
+ </div>
1081
+ )}
1082
+ <div className="dm-db-toolbar">
1083
+ <div className="dm-db-toolbar-title">
1084
+ <strong>{table.label}</strong>
1085
+ <span>{pluralize(table.columns.length, "field")} · {pluralize(table.rows.length, "record")}</span>
1086
+ </div>
1087
+ <div className="dm-records-actions">
1088
+ {table.rows.length > 0 && (
1089
+ <button type="button" className="dm-btn-ghost" onClick={() => {
1090
+ const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
1091
+ const url = URL.createObjectURL(blob);
1092
+ const a = document.createElement("a");
1093
+ a.href = url; a.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
1094
+ a.click(); URL.revokeObjectURL(url);
1095
+ }}>Export CSV</button>
1096
+ )}
1097
+ {table.mutable && <button type="button" className="dm-btn-ghost" onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>}
1098
+ {table.mutable && (
1099
+ <button type="button" className="dm-btn-primary-sm" disabled={saving} onClick={() => onSave((config) => addTableRow(config, table))}>
1100
+ <Plus size={13} />Add record
1101
+ </button>
1102
+ )}
173
1103
  </div>
174
1104
  </div>
175
- {!table.mutable ? <p className="dm-hint-block">Dynamic integration records are resolved by the governed integration path. The selected source object reference is shown here but provider rows are not stored in browser config.</p> : null}
176
- {csvOpen ? (
1105
+ {csvOpen && (
177
1106
  <div className="dm-csv-panel">
178
- <textarea className="dm-csv-textarea" rows={5} value={csvText} onChange={(event) => setCsvText(event.target.value)} placeholder="Name,Status&#10;Acme,Active" />
179
- <div className="dm-csv-options">
1107
+ <textarea className="dm-csv-textarea" rows={4} value={csvText} onChange={(e) => setCsvText(e.target.value)} placeholder={"Name,Status\nAcme,Active"} />
1108
+ <div className="dm-csv-opts">
180
1109
  <label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
181
1110
  <label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
182
- <button type="button" className="dm-btn primary" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
1111
+ <button type="button" className="dm-btn-primary-sm" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
183
1112
  </div>
184
1113
  </div>
185
- ) : null}
186
- {!table.columns.length ? <div className="dm-empty-inline">No fields are defined for this object.</div> : (
187
- <div className="dm-records-scroll">
188
- <table className="dm-records-table">
189
- <thead><tr><th>#</th>{table.columns.map((column) => <th key={column}>{column}</th>)}<th /></tr></thead>
190
- <tbody>
191
- {table.rows.map((row, rowIndex) => (
192
- <tr key={rowIndex}>
193
- <td>{rowIndex + 1}</td>
194
- {table.columns.map((column) => {
195
- const active = editing?.row === rowIndex && editing?.column === column;
196
- const value = String(row?.[column] ?? "");
197
- return <td key={column}>{active ? <input ref={inputRef} className="dm-cell-input" value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === "Enter") commit(); if (event.key === "Escape") setEditing(null); }} /> : <button type="button" className="dm-cell-btn" disabled={!table.mutable} onClick={() => { setEditing({ row: rowIndex, column }); setDraft(value); }}>{value || <span className="dm-cell-empty">-</span>}</button>}</td>;
198
- })}
199
- <td>
200
- <button type="button" className="dm-icon-btn" disabled={saving || !table.mutable} onClick={() => onSave((config) => duplicateTableRow(config, table, rowIndex))}>⎘</button>
201
- <button type="button" className="dm-icon-btn danger" disabled={saving || !table.mutable} onClick={() => onSave((config) => deleteTableRow(config, table, rowIndex))}>x</button>
202
- </td>
203
- </tr>
204
- ))}
205
- </tbody>
206
- </table>
207
- </div>
208
1114
  )}
209
- </div>
210
- );
211
- }
212
-
213
- function BindingsTab({ table }) {
214
- const binding = table.binding || {};
215
- const mode = describeBindingMode(binding);
216
- const meta = laneMeta(binding);
217
- return (
218
- <div>
219
- <div className="dm-binding-header"><span className={`dm-badge ${meta.cls}`}>{mode.label}</span><p>{mode.description}</p></div>
220
- <div className="dm-binding-rows">
221
- <div className="dm-binding-row"><span>Source</span><code>{table.source}</code></div>
222
- <div className="dm-binding-row"><span>Config surface</span><code>{table.storage === "view" ? "view.config" : "widget.config.binding"}</code></div>
223
- <div className="dm-binding-row"><span>Mode</span><code>{binding.mode || "manual"}</code></div>
224
- {binding.integrationId ? <div className="dm-binding-row"><span>Integration</span><code>{binding.integrationId}</code></div> : null}
225
- {binding.entityId ? <div className="dm-binding-row"><span>Entity ID</span><code>{binding.entityId}</code></div> : null}
226
- {binding.entityLabel ? <div className="dm-binding-row"><span>Entity label</span><code>{binding.entityLabel}</code></div> : null}
1115
+ <div className="dm-db-grid-wrap">
1116
+ <table className="dm-db-grid">
1117
+ <thead>
1118
+ <tr>
1119
+ <th className="dm-db-rownum">#</th>
1120
+ {table.columns.map((column) => (
1121
+ <th key={column}>
1122
+ <span className="dm-db-field-type"><LucideIcon name={FIELD_TYPE_ICON_NAMES[inferFieldType(column)] || "Type"} size={12} /></span>
1123
+ {column}
1124
+ </th>
1125
+ ))}
1126
+ {table.mutable && (
1127
+ <th className="dm-db-add-field">
1128
+ {addingField ? (
1129
+ <input
1130
+ ref={fieldInputRef}
1131
+ value={fieldName}
1132
+ placeholder="Field name"
1133
+ onChange={(event) => setFieldName(event.target.value)}
1134
+ onBlur={commitField}
1135
+ onKeyDown={(event) => {
1136
+ if (event.key === "Enter") commitField();
1137
+ if (event.key === "Escape") { setAddingField(false); setFieldName(""); }
1138
+ }}
1139
+ />
1140
+ ) : (
1141
+ <button type="button" onClick={() => setAddingField(true)}>
1142
+ <Plus size={13} />Field
1143
+ </button>
1144
+ )}
1145
+ </th>
1146
+ )}
1147
+ </tr>
1148
+ </thead>
1149
+ <tbody>
1150
+ {table.rows.map((row, rowIndex) => (
1151
+ <tr key={rowIndex} className={selectedRow === rowIndex ? "selected" : ""} onClick={() => setSelectedRow(rowIndex)}>
1152
+ <td className="dm-db-rownum">{rowIndex + 1}</td>
1153
+ {table.columns.map((column) => {
1154
+ const relation = relationForColumn(table, column);
1155
+ const options = referenceOptions(tables, relation);
1156
+ return (
1157
+ <td key={column}>
1158
+ {relation ? (
1159
+ <ReferenceSelect
1160
+ value={String(row?.[column] || "")}
1161
+ options={options}
1162
+ disabled={!table.mutable || saving}
1163
+ onChange={(nextValue) => onSave((config) => updateTableCell(config, table, rowIndex, column, nextValue))}
1164
+ />
1165
+ ) : column.toLowerCase() === "status" ? (
1166
+ <ConnectionPill value={row?.[column]} />
1167
+ ) : (
1168
+ <span className={row?.[column] ? "" : "dm-cell-empty"}>
1169
+ {formatCellValue(row?.[column], column) || "—"}
1170
+ </span>
1171
+ )}
1172
+ </td>
1173
+ );})}
1174
+ {table.mutable && <td className="dm-db-empty-cell" />}
1175
+ </tr>
1176
+ ))}
1177
+ {table.mutable && (
1178
+ <tr className="dm-db-new-row" onClick={() => onSave((config) => addTableRow(config, table))}>
1179
+ <td className="dm-db-rownum">+</td>
1180
+ <td colSpan={Math.max(table.columns.length, 1) + 1}>Add record</td>
1181
+ </tr>
1182
+ )}
1183
+ </tbody>
1184
+ </table>
227
1185
  </div>
1186
+ <DataModelRecordDrawer
1187
+ table={table}
1188
+ tables={tables}
1189
+ workspaceConfig={workspaceConfig}
1190
+ rowIndex={selectedRow}
1191
+ row={selectedRecord}
1192
+ saving={saving}
1193
+ onClose={() => setSelectedRow(null)}
1194
+ onSave={onSave}
1195
+ />
228
1196
  </div>
229
1197
  );
230
1198
  }
231
1199
 
232
- function UsageTab({ table }) {
233
- if (!table.widgetRefs.length) return <div className="dm-empty-inline">Manual data object. It is available to the Data Model and can be selected by existing View widget source controls without being auto-added to a dashboard.</div>;
234
- return <div className="dm-usage-list">{table.widgetRefs.map((ref) => <div key={ref.widgetId} className="dm-usage-item"><strong>{ref.widgetTitle}</strong><span>{ref.widgetKind}</span><code>{ref.dashboardName || "Canvas"}</code></div>)}</div>;
235
- }
1200
+ // ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
236
1201
 
237
- function Summary({ tables }) {
1202
+ function IconPicker({ value, onChange }) {
238
1203
  return (
239
- <div className="dm-summary-cards">
240
- <div className="dm-summary-card"><span>Objects</span><strong>{tables.length}</strong></div>
241
- <div className="dm-summary-card"><span>Fields</span><strong>{tables.reduce((sum, table) => sum + table.columns.length, 0)}</strong></div>
242
- <div className="dm-summary-card"><span>Records</span><strong>{tables.reduce((sum, table) => sum + table.rows.length, 0)}</strong></div>
243
- <div className="dm-summary-card"><span>Integrations</span><strong>{tables.filter((table) => describeBindingLane(table.binding) !== "manual").length}</strong></div>
1204
+ <div className="dm-icon-picker">
1205
+ {ICON_PICKER_SET.map((name) => (
1206
+ <button
1207
+ key={name}
1208
+ type="button"
1209
+ className={`dm-icon-picker-btn${value === name ? " active" : ""}`}
1210
+ title={name}
1211
+ onClick={() => onChange(name)}
1212
+ >
1213
+ <LucideIcon name={name} size={16} />
1214
+ </button>
1215
+ ))}
244
1216
  </div>
245
1217
  );
246
1218
  }
247
1219
 
248
- function AddObjectDialog({ open, saving, onClose, onCreate }) {
1220
+ function AddObjectSidebar({ open, saving, onClose, onCreate, allTables }) {
1221
+ const [step, setStep] = useState(0); // 0 = type picker, 1 = name + icon
1222
+ const [selectedType, setSelectedType] = useState(null);
249
1223
  const [name, setName] = useState("");
250
- const [fields, setFields] = useState("Name");
1224
+ const [icon, setIcon] = useState(null);
251
1225
  const [error, setError] = useState("");
1226
+ const inputRef = useRef(null);
252
1227
 
253
1228
  useEffect(() => {
254
1229
  if (!open) return;
1230
+ setStep(0);
1231
+ setSelectedType(null);
255
1232
  setName("");
256
- setFields("Name");
1233
+ setIcon(null);
257
1234
  setError("");
258
1235
  }, [open]);
259
1236
 
260
- if (!open) return null;
1237
+ useEffect(() => {
1238
+ if (step === 1) setTimeout(() => inputRef.current?.focus(), 80);
1239
+ }, [step]);
1240
+
1241
+ function pickType(typeDef) {
1242
+ setSelectedType(typeDef);
1243
+ setIcon(typeDef.icon.displayName || OBJECT_TYPE_PRESETS[typeDef.type]?.icon || "Database");
1244
+ setStep(1);
1245
+ }
261
1246
 
262
- function submit(event) {
263
- event.preventDefault();
1247
+ function submit(e) {
1248
+ e.preventDefault();
264
1249
  const objectName = name.trim();
265
- const fieldList = fields.split(",").map((field) => field.trim()).filter(Boolean);
266
- if (!objectName) {
267
- setError("Object name is required.");
268
- return;
269
- }
270
- if (!fieldList.length) {
271
- setError("Add at least one field.");
272
- return;
273
- }
1250
+ if (!objectName) { setError("Object name is required."); return; }
274
1251
  setError("");
275
- onCreate({ name: objectName, fields: fieldList });
1252
+ onCreate({ name: objectName, objectType: selectedType.type, icon });
276
1253
  }
277
1254
 
278
1255
  return (
279
- <div className="dm-dialog-shell" role="dialog" aria-modal="true" aria-labelledby="dm-add-object-title">
280
- <div className="dm-dialog-backdrop" onClick={onClose} />
281
- <form className="dm-dialog" onSubmit={submit}>
282
- <div className="dm-dialog-head">
283
- <h2 id="dm-add-object-title">Add business object</h2>
284
- <button type="button" className="dm-icon-btn" onClick={onClose}>x</button>
285
- </div>
286
- <div className="dm-dialog-body">
287
- <p className="dm-dialog-copy">Creates a manual governed data object. This does not add a widget, change a dashboard, or write to canvas.</p>
288
- <label className="dm-field-label">Object name<input className="dm-input" value={name} placeholder="Companies, Clients, Leads" onChange={(event) => setName(event.target.value)} /></label>
289
- <label className="dm-field-label">Fields <span>comma-separated</span><input className="dm-input" value={fields} placeholder="Name, Status, Owner" onChange={(event) => setFields(event.target.value)} /></label>
290
- {error ? <p className="dm-field-error">{error}</p> : null}
291
- </div>
292
- <div className="dm-dialog-actions">
293
- <button type="button" className="dm-btn" onClick={onClose}>Cancel</button>
294
- <button type="submit" className="dm-btn primary" disabled={saving}>Create object</button>
1256
+ <>
1257
+ {open && <div className="dm-sidebar-backdrop" onClick={onClose} />}
1258
+ <aside className={`dm-add-sidebar${open ? " open" : ""}`} role="dialog" aria-label="New object" aria-modal="true">
1259
+ <div className="dm-add-sidebar-head">
1260
+ <div className="dm-add-sidebar-head-left">
1261
+ {step === 1 && (
1262
+ <button type="button" className="dm-sidebar-back" onClick={() => setStep(0)}>
1263
+
1264
+ </button>
1265
+ )}
1266
+ <h2>{step === 0 ? "New object" : `New ${selectedType?.label}`}</h2>
1267
+ </div>
1268
+ <button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
1269
+ <X size={16} />
1270
+ </button>
295
1271
  </div>
296
- </form>
297
- </div>
1272
+
1273
+ {step === 0 && (
1274
+ <div className="dm-type-picker">
1275
+ <p className="dm-type-picker-hint">Choose an object type to start with the right fields and relation bindings.</p>
1276
+ <div className="dm-type-picker-list">
1277
+ {OBJECT_TYPE_DEFS.map((def) => {
1278
+ const Icon = def.icon;
1279
+ return (
1280
+ <button key={def.type} type="button" className="dm-type-card" onClick={() => pickType(def)}>
1281
+ <div className="dm-type-card-icon">
1282
+ <Icon size={18} />
1283
+ </div>
1284
+ <div className="dm-type-card-body">
1285
+ <strong>{def.label}</strong>
1286
+ <span>{def.description}</span>
1287
+ </div>
1288
+ <ChevronRight size={14} className="dm-type-card-arrow" />
1289
+ </button>
1290
+ );
1291
+ })}
1292
+ </div>
1293
+ </div>
1294
+ )}
1295
+
1296
+ {step === 1 && selectedType && (
1297
+ <form className="dm-add-sidebar-body" onSubmit={submit}>
1298
+ <div className="dm-add-type-preview">
1299
+ <div className="dm-add-type-icon">
1300
+ <LucideIcon name={icon || OBJECT_TYPE_PRESETS[selectedType.type]?.icon || "Database"} size={20} />
1301
+ </div>
1302
+ <div>
1303
+ <p className="dm-add-type-label">{selectedType.label}</p>
1304
+ <p className="dm-add-sidebar-hint">{selectedType.description}</p>
1305
+ </div>
1306
+ </div>
1307
+
1308
+ <label className="dm-field-label-v2">
1309
+ <span>Object name</span>
1310
+ <input
1311
+ ref={inputRef}
1312
+ className="dm-input-v2"
1313
+ value={name}
1314
+ placeholder={selectedType.type === "data-source" ? "My Analytics API, Salesforce Feed…" : selectedType.type === "api-registry" ? "GA4 Resolver, Stripe Adapter…" : "Name this object…"}
1315
+ onChange={(e) => setName(e.target.value)}
1316
+ />
1317
+ </label>
1318
+
1319
+ <label className="dm-field-label-v2">
1320
+ <span>Icon</span>
1321
+ <IconPicker value={icon} onChange={setIcon} />
1322
+ </label>
1323
+
1324
+ {OBJECT_TYPE_PRESETS[selectedType.type]?.columns?.length > 0 && (
1325
+ <div className="dm-preset-fields-preview">
1326
+ <p className="dm-usage-label">Pre-populated fields</p>
1327
+ <div className="dm-preset-fields-list">
1328
+ {OBJECT_TYPE_PRESETS[selectedType.type].columns.map((col) => (
1329
+ <span key={col} className="dm-preset-field-chip">{col}</span>
1330
+ ))}
1331
+ </div>
1332
+ </div>
1333
+ )}
1334
+
1335
+ {OBJECT_TYPE_PRESETS[selectedType.type]?.relations?.length > 0 && (
1336
+ <div className="dm-preset-relations-preview">
1337
+ <p className="dm-usage-label">Built-in relations</p>
1338
+ {OBJECT_TYPE_PRESETS[selectedType.type].relations.map((rel) => (
1339
+ <div key={rel.id} className="dm-preset-relation-row">
1340
+ <Zap size={12} />
1341
+ <span>{rel.name}</span>
1342
+ <ArrowRight size={11} />
1343
+ <span className="dm-preset-rel-target">{OBJECT_TYPE_PRESETS[rel.targetObjectType]?.label || rel.targetObjectType}</span>
1344
+ </div>
1345
+ ))}
1346
+ </div>
1347
+ )}
1348
+
1349
+ {error && <p className="dm-field-error">{error}</p>}
1350
+
1351
+ <div className="dm-add-sidebar-actions">
1352
+ <button type="button" className="dm-btn-outline" onClick={onClose}>Cancel</button>
1353
+ <button type="submit" className="dm-btn-primary" disabled={saving || !name.trim()}>
1354
+ Create object
1355
+ </button>
1356
+ </div>
1357
+ </form>
1358
+ )}
1359
+ </aside>
1360
+ </>
298
1361
  );
299
1362
  }
300
1363
 
1364
+ // ─── Page ─────────────────────────────────────────────────────────────────────
1365
+
301
1366
  export default function DataModelPage() {
302
1367
  const [workspaceConfig, setWorkspaceConfig] = useState(null);
303
1368
  const [authority, setAuthority] = useState(null);
@@ -306,16 +1371,15 @@ export default function DataModelPage() {
306
1371
  const [saving, setSaving] = useState(false);
307
1372
  const [message, setMessage] = useState("");
308
1373
  const [selectedSource, setSelectedSource] = useState("");
309
- const [activeTab, setActiveTab] = useState("Fields");
310
1374
  const [addOpen, setAddOpen] = useState(false);
311
1375
 
312
1376
  const load = useCallback(async () => {
313
1377
  setLoading(true);
314
1378
  setError("");
315
1379
  try {
316
- const response = await fetch("/api/workspace", { cache: "no-store" });
317
- const payload = await response.json();
318
- if (!response.ok) throw new Error(payload.error || "Failed to load workspace");
1380
+ const res = await fetch("/api/workspace", { cache: "no-store" });
1381
+ const payload = await res.json();
1382
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
319
1383
  setWorkspaceConfig(payload.workspaceConfig);
320
1384
  setAuthority(payload.adapters?.integrations?.authority || null);
321
1385
  } catch (err) {
@@ -327,9 +1391,16 @@ export default function DataModelPage() {
327
1391
 
328
1392
  useEffect(() => { load(); }, [load]);
329
1393
 
330
- const tables = useMemo(() => workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : [], [workspaceConfig]);
331
- const selectedTable = tables.find((table) => table.source === selectedSource) || tables[0] || null;
332
- useEffect(() => { if (!selectedSource && tables[0]) setSelectedSource(tables[0].source); }, [selectedSource, tables]);
1394
+ const tables = useMemo(
1395
+ () => (workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : []),
1396
+ [workspaceConfig],
1397
+ );
1398
+
1399
+ const selectedTable = tables.find((t) => t.source === selectedSource) || tables[0] || null;
1400
+
1401
+ useEffect(() => {
1402
+ if (!selectedSource && tables[0]) setSelectedSource(tables[0].source);
1403
+ }, [selectedSource, tables]);
333
1404
 
334
1405
  const save = useCallback(async (mutate) => {
335
1406
  if (!workspaceConfig) return;
@@ -341,13 +1412,13 @@ export default function DataModelPage() {
341
1412
  for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
342
1413
  if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
343
1414
  }
344
- const response = await fetch("/api/workspace", {
1415
+ const res = await fetch("/api/workspace", {
345
1416
  method: "PATCH",
346
1417
  headers: { "content-type": "application/json" },
347
- body: JSON.stringify(patch)
1418
+ body: JSON.stringify(patch),
348
1419
  });
349
- const payload = await response.json();
350
- if (!response.ok) throw new Error(payload.error || "Save failed");
1420
+ const payload = await res.json();
1421
+ if (!res.ok) throw new Error(payload.error || "Save failed");
351
1422
  setWorkspaceConfig(payload.workspaceConfig);
352
1423
  setMessage("Saved");
353
1424
  } catch (err) {
@@ -357,49 +1428,105 @@ export default function DataModelPage() {
357
1428
  }
358
1429
  }, [workspaceConfig]);
359
1430
 
360
- const createObject = useCallback(({ name, fields }) => {
361
- save((config) => createManualBusinessObject(config, { name, fields }));
1431
+ const createObject = useCallback(({ name, objectType, icon }) => {
1432
+ save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
362
1433
  setSelectedSource(name);
363
- setActiveTab("Records");
364
1434
  setAddOpen(false);
365
1435
  }, [save]);
366
1436
 
367
1437
  return (
368
1438
  <main className="workspace-builder workspace-settings-page">
369
1439
  <NavRail authority={authority} workspaceConfig={workspaceConfig} />
1440
+
370
1441
  <section className="workspace-surface">
371
1442
  <header className="workspace-toolbar">
372
1443
  <div><p>Workspace</p><h1>Data Model</h1></div>
373
- <div className="workspace-toolbar-actions"><SaveToast saving={saving} message={message} /><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div>
1444
+ <div className="workspace-toolbar-actions">
1445
+ <SaveToast saving={saving} message={message} />
1446
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
1447
+ <Plus size={14} />New object
1448
+ </button>
1449
+ </div>
374
1450
  </header>
375
- <AddObjectDialog open={addOpen} saving={saving} onClose={() => setAddOpen(false)} onCreate={createObject} />
376
- {loading ? <div className="dm-loading">Loading workspace...</div> : null}
377
- {error ? <div className="dm-error-state"><strong>Could not load workspace</strong><p>{error}</p><button type="button" className="dm-btn primary" onClick={load}>Retry</button></div> : null}
378
- {!loading && !error && tables.length ? (
379
- <>
380
- <Summary tables={tables} />
381
- <div className="dm-layout">
382
- <aside className="dm-object-list">
383
- <div className="dm-object-list-head"><p>{pluralize(tables.length, "object")}</p></div>
384
- <div className="dm-object-list-body">{tables.map((table) => <ObjectRow key={`${table.source}-${table.id}`} table={table} selected={selectedTable?.id === table.id} onSelect={() => { setSelectedSource(table.source); setActiveTab("Fields"); }} />)}</div>
385
- </aside>
386
- <section className="dm-detail-panel">
387
- <div className="dm-detail-header">
388
- <div className="dm-detail-title-row"><Database size={15} /><h2>{selectedTable.label}</h2><span className={`dm-badge ${laneMeta(selectedTable.binding).cls}`}>{laneMeta(selectedTable.binding).label}</span></div>
389
- <div className="dm-detail-meta-row"><code>{selectedTable.source}</code><span>{pluralize(selectedTable.columns.length, "field")} · {pluralize(selectedTable.rows.length, "record")} · {pluralize(selectedTable.widgetRefs.length, "widget")}</span></div>
390
- </div>
391
- <div className="dm-tabs">{TABS.map((tab) => <button key={tab} type="button" className={`dm-tab${activeTab === tab ? " active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
392
- <div className="dm-tab-content">
393
- {activeTab === "Fields" ? <FieldsTab table={selectedTable} saving={saving} onSave={save} /> : null}
394
- {activeTab === "Records" ? <RecordsTab table={selectedTable} saving={saving} onSave={save} /> : null}
395
- {activeTab === "Bindings" ? <BindingsTab table={selectedTable} /> : null}
396
- {activeTab === "Usage" ? <UsageTab table={selectedTable} /> : null}
1451
+
1452
+ <AddObjectSidebar
1453
+ open={addOpen}
1454
+ saving={saving}
1455
+ onClose={() => setAddOpen(false)}
1456
+ onCreate={createObject}
1457
+ allTables={tables}
1458
+ />
1459
+
1460
+ {loading && <div className="dm-loading">Loading workspace…</div>}
1461
+
1462
+ {error && (
1463
+ <div className="dm-error-state">
1464
+ <AlertCircle size={28} />
1465
+ <strong>Could not load workspace</strong>
1466
+ <p>{error}</p>
1467
+ <button type="button" className="dm-btn-primary" onClick={load}>Retry</button>
1468
+ </div>
1469
+ )}
1470
+
1471
+ {!loading && !error && tables.length > 0 && (
1472
+ <div className="dm-layout-v2">
1473
+ <aside className="dm-obj-col">
1474
+ <div className="dm-obj-col-head">
1475
+ <span>{pluralize(tables.length, "object")}</span>
1476
+ </div>
1477
+ <div className="dm-obj-col-body">
1478
+ {tables.map((table) => (
1479
+ <ObjectRow
1480
+ key={`${table.source}-${table.id}`}
1481
+ table={table}
1482
+ selected={selectedTable?.id === table.id}
1483
+ onSelect={() => setSelectedSource(table.source)}
1484
+ />
1485
+ ))}
1486
+ </div>
1487
+ <div className="dm-obj-col-foot">
1488
+ <button type="button" className="dm-obj-add-btn" onClick={() => setAddOpen(true)}>
1489
+ <Plus size={13} />New object
1490
+ </button>
1491
+ </div>
1492
+ </aside>
1493
+
1494
+ {selectedTable && (
1495
+ <section className="dm-detail-v2">
1496
+ <div className="dm-detail-v2-head">
1497
+ <div className="dm-detail-v2-title">
1498
+ <LucideIcon
1499
+ name={selectedTable.icon || OBJECT_TYPE_PRESETS[selectedTable.objectType]?.icon || "Database"}
1500
+ size={14}
1501
+ className="dm-detail-icon"
1502
+ />
1503
+ <h2>{selectedTable.label}</h2>
1504
+ <span className={`dm-badge ${objectTypeBadge(selectedTable.objectType).cls}`}>
1505
+ {objectTypeBadge(selectedTable.objectType).label}
1506
+ </span>
1507
+ </div>
1508
+ <div className="dm-detail-v2-meta">
1509
+ <code>{selectedTable.source}</code>
1510
+ <span>{pluralize(selectedTable.columns.length, "field")} · {pluralize(selectedTable.rows.length, "record")}</span>
1511
+ </div>
1512
+ <SourceValidationBanner table={selectedTable} />
397
1513
  </div>
1514
+ <DataModelTableSurface workspaceConfig={workspaceConfig} table={selectedTable} tables={tables} saving={saving} onSave={save} />
398
1515
  </section>
399
- </div>
400
- </>
401
- ) : null}
402
- {!loading && !error && !tables.length ? <div className="dm-page-empty"><Database size={28} /><strong>No business objects yet</strong><p>Create a manual governed object here, or expose existing View widget data when dashboards already define it.</p><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div> : null}
1516
+ )}
1517
+ </div>
1518
+ )}
1519
+
1520
+ {!loading && !error && tables.length === 0 && (
1521
+ <div className="dm-page-empty">
1522
+ <Database size={32} />
1523
+ <strong>No objects yet</strong>
1524
+ <p>Create a Data Source, API Registry, People, Tasks, or Custom object to get started.</p>
1525
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
1526
+ <Plus size={14} />New object
1527
+ </button>
1528
+ </div>
1529
+ )}
403
1530
  </section>
404
1531
  </main>
405
1532
  );