@growthub/cli 0.9.17 → 0.10.0

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 (50) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/reference-options/route.js +62 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +13 -2
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolver-templates/route.js +23 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +35 -5
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +15 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +2048 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataTable.jsx +1 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/FieldEditor.jsx +1 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/FieldManager.jsx +9 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ObjectSidebar.jsx +41 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/RecordDrawer.jsx +1 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ReferencePicker.jsx +244 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxRunPanel.jsx +21 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SourceTestPanel.jsx +15 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/StatusPill.jsx +13 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ToggleField.jsx +41 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/dm-shared.jsx +99 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +2 -1528
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +66 -5
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/connector-template-authoring.md +8 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-model-reference-fields.md +15 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/mcp-chrome-tool-connectors.md +12 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/resolver-template-library.md +17 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +13 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/README.md +12 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/chrome-bridge.js +22 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/custom-http.js +23 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/generic-commerce.js +22 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/generic-crm.js +23 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/generic-project-management.js +22 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/generic-spreadsheet.js +22 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/mcp-tool.js +22 -0
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/template-registry.js +50 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/templates/webhook.js +22 -0
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/references/collect-reference-options.js +133 -0
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/references/reference-resolver-registry.js +17 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/references/resolver-loader.js +6 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/references/resolvers/README.md +8 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/references/resolvers/local-data-model.js +11 -0
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/references/resolvers/source-records.js +34 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +5 -3
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +203 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +81 -0
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/reference-option-schema.js +59 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/reference-options.js +29 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +527 -23
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +131 -1
  49. package/dist/index.js +3043 -1340
  50. package/package.json +1 -1
@@ -1,1533 +1,7 @@
1
1
  "use client";
2
2
 
3
- import Link from "next/link";
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";
38
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
39
- import {
40
- OBJECT_TYPE_PRESETS,
41
- addTableField,
42
- addTableRow,
43
- appendRowsToTable,
44
- createTypedBusinessObject,
45
- describeBindingLane,
46
- exportTableAsCsv,
47
- importTableFromCsv,
48
- listSavedEnvRefs,
49
- listWorkspaceDataModelTables,
50
- parseSandboxAllowList,
51
- parseSandboxEnvRefs,
52
- replaceTableContent,
53
- updateTableCell,
54
- } from "@/lib/workspace-data-model";
55
-
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,
62
- };
63
-
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" />;
73
- }
74
-
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";
163
- }
164
-
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;
171
- }
172
-
173
- function textColorForAccent(accent) {
174
- const hex = String(accent || "").replace("#", "");
175
- if (!/^[0-9a-f]{6}$/i.test(hex)) return "#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>;
188
- }
189
-
190
- function NavRail({ authority, workspaceConfig }) {
191
- const branding = workspaceConfig?.branding || {};
192
- const workspaceName = branding.name || workspaceConfig?.name || "Growthub Workspace";
193
- return (
194
- <aside className="workspace-rail" aria-label="Workspace navigation">
195
- <div className="workspace-brand">
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
- >
203
- {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
204
- </span>
205
- <span>{workspaceName}</span>
206
- </div>
207
- <nav className="workspace-nav">
208
- <Link href="/">Dashboards</Link>
209
- <Link className="active" href="/data-model">Data Model</Link>
210
- <span className="workspace-nav-static">Management</span>
211
- <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
212
- </nav>
213
- <div className="workspace-rail-status">
214
- <span className="status-dot" />
215
- {authority || "local-catalog"}
216
- </div>
217
- </aside>
218
- );
219
- }
220
-
221
- // ─── Object list row ──────────────────────────────────────────────────────────
222
-
223
- function ObjectRow({ table, selected, onSelect }) {
224
- const badge = objectTypeBadge(table.objectType);
225
- const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
226
- return (
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>
231
- </button>
232
- );
233
- }
234
-
235
- // ─── Source validation banner ─────────────────────────────────────────────────
236
-
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";
556
- }
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(",") });
565
- }
566
-
567
- const netOn = ["true", "1", "on", "yes"].includes(String(draft.networkAllow || "").trim().toLowerCase());
568
-
569
- return (
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>
628
- </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>
809
- </div>
810
- );
811
- }
812
-
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("");
1041
- const [csvOpen, setCsvOpen] = useState(false);
1042
- const [csvText, setCsvText] = useState("");
1043
- const [mode, setMode] = useState("append");
1044
- const fieldInputRef = useRef(null);
1045
-
1046
- useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
1047
- useEffect(() => { setSelectedRow(null); }, [table.id]);
1048
-
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("");
1061
- }
1062
-
1063
- function importCsv() {
1064
- const parsed = importTableFromCsv(csvText);
1065
- if (!parsed.columns.length) return;
1066
- if (mode === "replace") onSave((config) => replaceTableContent(config, table, parsed));
1067
- else onSave((config) => appendRowsToTable(config, table, parsed.rows));
1068
- setCsvText("");
1069
- setCsvOpen(false);
1070
- }
1071
-
1072
- const selectedRecord = selectedRow === null ? null : table.rows[selectedRow];
1073
-
1074
- return (
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
- )}
1103
- </div>
1104
- </div>
1105
- {csvOpen && (
1106
- <div className="dm-csv-panel">
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">
1109
- <label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
1110
- <label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
1111
- <button type="button" className="dm-btn-primary-sm" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
1112
- </div>
1113
- </div>
1114
- )}
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>
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
- />
1196
- </div>
1197
- );
1198
- }
1199
-
1200
- // ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
1201
-
1202
- function IconPicker({ value, onChange }) {
1203
- return (
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
- ))}
1216
- </div>
1217
- );
1218
- }
1219
-
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);
1223
- const [name, setName] = useState("");
1224
- const [icon, setIcon] = useState(null);
1225
- const [error, setError] = useState("");
1226
- const inputRef = useRef(null);
1227
-
1228
- useEffect(() => {
1229
- if (!open) return;
1230
- setStep(0);
1231
- setSelectedType(null);
1232
- setName("");
1233
- setIcon(null);
1234
- setError("");
1235
- }, [open]);
1236
-
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
- }
1246
-
1247
- function submit(e) {
1248
- e.preventDefault();
1249
- const objectName = name.trim();
1250
- if (!objectName) { setError("Object name is required."); return; }
1251
- setError("");
1252
- onCreate({ name: objectName, objectType: selectedType.type, icon });
1253
- }
1254
-
1255
- return (
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>
1271
- </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
- </>
1361
- );
1362
- }
1363
-
1364
- // ─── Page ─────────────────────────────────────────────────────────────────────
3
+ import DataModelShell from "./components/DataModelShell.jsx";
1365
4
 
1366
5
  export default function DataModelPage() {
1367
- const [workspaceConfig, setWorkspaceConfig] = useState(null);
1368
- const [authority, setAuthority] = useState(null);
1369
- const [loading, setLoading] = useState(true);
1370
- const [error, setError] = useState("");
1371
- const [saving, setSaving] = useState(false);
1372
- const [message, setMessage] = useState("");
1373
- const [selectedSource, setSelectedSource] = useState("");
1374
- const [addOpen, setAddOpen] = useState(false);
1375
-
1376
- const load = useCallback(async () => {
1377
- setLoading(true);
1378
- setError("");
1379
- try {
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");
1383
- setWorkspaceConfig(payload.workspaceConfig);
1384
- setAuthority(payload.adapters?.integrations?.authority || null);
1385
- } catch (err) {
1386
- setError(err.message || "Failed to load workspace");
1387
- } finally {
1388
- setLoading(false);
1389
- }
1390
- }, []);
1391
-
1392
- useEffect(() => { load(); }, [load]);
1393
-
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]);
1404
-
1405
- const save = useCallback(async (mutate) => {
1406
- if (!workspaceConfig) return;
1407
- setSaving(true);
1408
- setMessage("");
1409
- const next = mutate(workspaceConfig);
1410
- try {
1411
- const patch = {};
1412
- for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
1413
- if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
1414
- }
1415
- const res = await fetch("/api/workspace", {
1416
- method: "PATCH",
1417
- headers: { "content-type": "application/json" },
1418
- body: JSON.stringify(patch),
1419
- });
1420
- const payload = await res.json();
1421
- if (!res.ok) throw new Error(payload.error || "Save failed");
1422
- setWorkspaceConfig(payload.workspaceConfig);
1423
- setMessage("Saved");
1424
- } catch (err) {
1425
- setMessage(`Error: ${err.message || "Save failed"}`);
1426
- } finally {
1427
- setSaving(false);
1428
- }
1429
- }, [workspaceConfig]);
1430
-
1431
- const createObject = useCallback(({ name, objectType, icon }) => {
1432
- save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
1433
- setSelectedSource(name);
1434
- setAddOpen(false);
1435
- }, [save]);
1436
-
1437
- return (
1438
- <main className="workspace-builder workspace-settings-page">
1439
- <NavRail authority={authority} workspaceConfig={workspaceConfig} />
1440
-
1441
- <section className="workspace-surface">
1442
- <header className="workspace-toolbar">
1443
- <div><p>Workspace</p><h1>Data Model</h1></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>
1450
- </header>
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} />
1513
- </div>
1514
- <DataModelTableSurface workspaceConfig={workspaceConfig} table={selectedTable} tables={tables} saving={saving} onSave={save} />
1515
- </section>
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
- )}
1530
- </section>
1531
- </main>
1532
- );
6
+ return <DataModelShell />;
1533
7
  }