@growthub/cli 0.9.18 → 0.10.1

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 (56) 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 +2277 -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 +99 -6
  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 +534 -23
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +131 -1
  49. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
  50. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
  51. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
  52. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
  53. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
  54. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
  55. package/dist/index.js +486 -1
  56. package/package.json +1 -1
@@ -0,0 +1,2277 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import {
5
+ Activity,
6
+ AlertCircle,
7
+ ArrowDownAZ,
8
+ ArrowUpAZ,
9
+ ArrowRight,
10
+ BarChart2,
11
+ Box,
12
+ Building2,
13
+ Calendar,
14
+ CheckSquare,
15
+ ChevronDown,
16
+ ChevronRight,
17
+ Code2,
18
+ Database,
19
+ EyeOff,
20
+ FileText,
21
+ Filter,
22
+ Globe,
23
+ GripVertical,
24
+ Hash,
25
+ Layers,
26
+ Link2,
27
+ Lock,
28
+ List,
29
+ Mail,
30
+ Maximize2,
31
+ MoreHorizontal,
32
+ Plus,
33
+ Pin,
34
+ Pencil,
35
+ Search,
36
+ ShoppingCart,
37
+ Tag,
38
+ Terminal,
39
+ ToggleLeft,
40
+ Type,
41
+ Users,
42
+ X,
43
+ Zap,
44
+ } from "lucide-react";
45
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
46
+ import {
47
+ OBJECT_TYPE_PRESETS,
48
+ addTableField,
49
+ addTableRow,
50
+ appendRowsToTable,
51
+ createTypedBusinessObject,
52
+ deleteTableRow,
53
+ describeBindingLane,
54
+ effectiveRelations,
55
+ exportTableAsCsv,
56
+ importTableFromCsv,
57
+ listSavedEnvRefs,
58
+ listWorkspaceDataModelTables,
59
+ parseSandboxAllowList,
60
+ parseSandboxEnvRefs,
61
+ replaceTableContent,
62
+ snapshotTableViewState,
63
+ transformTableSchema,
64
+ updateTableFieldSettings,
65
+ updateTableCell,
66
+ } from "@/lib/workspace-data-model";
67
+ import { ReferencePicker } from "./ReferencePicker.jsx";
68
+ import { SandboxRunPanel } from "./SandboxRunPanel.jsx";
69
+ import { StatusPill } from "./StatusPill.jsx";
70
+ import { SegmentedToggle, ToggleField } from "./ToggleField.jsx";
71
+ import { SourceTestPanel } from "./SourceTestPanel.jsx";
72
+ import {
73
+ FIELD_TYPE_ICON_NAMES,
74
+ ICON_PICKER_SET,
75
+ LucideIcon,
76
+ inferFieldType,
77
+ objectTypeBadge,
78
+ pluralize,
79
+ textColorForAccent,
80
+ } from "./dm-shared.jsx";
81
+
82
+ // ─── Object type definitions for the type-picker step ────────────────────────
83
+
84
+ const OBJECT_TYPE_DEFS = [
85
+ {
86
+ type: "data-source",
87
+ icon: Globe,
88
+ label: "Data Source",
89
+ description: "Custom API, webhook, or external feed. Linked to a resolver via API Registry.",
90
+ },
91
+ {
92
+ type: "api-registry",
93
+ icon: Code2,
94
+ label: "API Registry",
95
+ description: "Resolver adapters — integrationId + fetch functions that power Data Sources.",
96
+ },
97
+ {
98
+ type: "people",
99
+ icon: Users,
100
+ label: "People",
101
+ description: "Contacts, leads, or team members with standard CRM fields.",
102
+ },
103
+ {
104
+ type: "tasks",
105
+ icon: CheckSquare,
106
+ label: "Tasks",
107
+ description: "Action items, to-dos, and work tracking.",
108
+ },
109
+ {
110
+ type: "sandbox-environment",
111
+ icon: Terminal,
112
+ label: "Sandbox Environment",
113
+ 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.",
114
+ },
115
+ {
116
+ type: "custom",
117
+ icon: Plus,
118
+ label: "Custom",
119
+ description: "Blank table — define your own fields from scratch.",
120
+ },
121
+ ];
122
+
123
+ // ─── Lane / badge meta (objectTypeBadge from dm-shared) ────────────────────────
124
+
125
+ const SANDBOX_RUNTIME_OPTIONS = ["python", "node", "bash"];
126
+ const FIELD_TYPE_CHOICES = [
127
+ { value: "text", label: "Text", icon: "Type", sample: "Field name" },
128
+ { value: "number", label: "Number", icon: "Hash", sample: "Amount" },
129
+ { value: "date", label: "Date", icon: "Calendar", sample: "Created at" },
130
+ { value: "url", label: "URL", icon: "Link2", sample: "Website" },
131
+ { value: "select", label: "Select", icon: "List", sample: "Status" },
132
+ { value: "boolean", label: "Boolean", icon: "ToggleLeft", sample: "Active" },
133
+ ];
134
+ const FILTER_OPERATOR_OPTIONS = [
135
+ { value: "eq", label: "Is" },
136
+ { value: "ne", label: "Is not" },
137
+ { value: "contains", label: "Contains" },
138
+ { value: "gt", label: "Greater than" },
139
+ { value: "lt", label: "Less than" },
140
+ { value: "isEmpty", label: "Is empty" },
141
+ { value: "isNotEmpty", label: "Is not empty" },
142
+ ];
143
+
144
+ function mergeColumnOrder(order, columns) {
145
+ return Array.from(new Set([...(order || []), ...columns])).filter((column) => columns.includes(column));
146
+ }
147
+
148
+ function isLockedObject(table) {
149
+ return Boolean(table?.objectType && table.objectType !== "custom");
150
+ }
151
+
152
+ function compareCellValues(left, right) {
153
+ const a = left ?? "";
154
+ const b = right ?? "";
155
+ const aNum = Number(a);
156
+ const bNum = Number(b);
157
+ if (Number.isFinite(aNum) && Number.isFinite(bNum) && `${a}`.trim() !== "" && `${b}`.trim() !== "") {
158
+ return aNum - bNum;
159
+ }
160
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
161
+ }
162
+
163
+ function rowMatchesFilter(row, filter) {
164
+ if (!filter?.clauses?.length) return true;
165
+ const results = filter.clauses.map((clause) => {
166
+ const raw = row?.[clause.fieldId];
167
+ const value = raw ?? "";
168
+ const text = String(value).toLowerCase();
169
+ const needle = String(clause.value ?? "").toLowerCase();
170
+ switch (clause.operator) {
171
+ case "ne": return text !== needle;
172
+ case "contains": return text.includes(needle);
173
+ case "gt": return compareCellValues(value, clause.value) > 0;
174
+ case "lt": return compareCellValues(value, clause.value) < 0;
175
+ case "isEmpty": return value === null || value === undefined || value === "";
176
+ case "isNotEmpty": return !(value === null || value === undefined || value === "");
177
+ case "eq":
178
+ default:
179
+ return text === needle;
180
+ }
181
+ });
182
+ return filter.op === "or" ? results.some(Boolean) : results.every(Boolean);
183
+ }
184
+
185
+ function applyRowsView(rows, settings) {
186
+ const filtered = (rows || []).filter((row) => rowMatchesFilter(row, settings.filter));
187
+ if (!settings.sort?.length) return filtered;
188
+ const clauses = settings.sort;
189
+ return [...filtered].sort((left, right) => {
190
+ for (const clause of clauses) {
191
+ const direction = clause.direction === "desc" ? -1 : 1;
192
+ const diff = compareCellValues(left?.[clause.fieldId], right?.[clause.fieldId]);
193
+ if (diff !== 0) return diff * direction;
194
+ }
195
+ return 0;
196
+ });
197
+ }
198
+
199
+ function ObjectViewPicker({ tables, selectedTable, saving, onSelectSource, onSave }) {
200
+ const pickerRef = useRef(null);
201
+ const [open, setOpen] = useState(false);
202
+ const [mode, setMode] = useState("all");
203
+ const [newViewName, setNewViewName] = useState("");
204
+ const [viewMenuId, setViewMenuId] = useState("");
205
+ const currentViews = selectedTable?.fieldSettings?.views || [];
206
+ const favoriteObjects = tables.filter((table) => table.fieldSettings?.favorite);
207
+
208
+ useEffect(() => {
209
+ function handlePointer(event) {
210
+ if (!pickerRef.current?.contains(event.target)) {
211
+ setViewMenuId("");
212
+ }
213
+ }
214
+ document.addEventListener("pointerdown", handlePointer);
215
+ return () => document.removeEventListener("pointerdown", handlePointer);
216
+ }, []);
217
+
218
+ function applyView(view) {
219
+ if (!selectedTable) return;
220
+ const nextState = view
221
+ ? { ...snapshotTableViewState(view), activeViewId: view.id }
222
+ : { activeViewId: "", hidden: [], order: selectedTable.columns, sort: [], filter: null };
223
+ onSave((config) => updateTableFieldSettings(config, selectedTable, (settings) => ({
224
+ ...settings,
225
+ ...nextState
226
+ })));
227
+ setOpen(false);
228
+ }
229
+
230
+ function createView() {
231
+ const name = newViewName.trim();
232
+ if (!selectedTable || !name) return;
233
+ const viewId = `view_${Date.now().toString(36)}`;
234
+ onSave((config) => updateTableFieldSettings(config, selectedTable, (settings) => ({
235
+ ...settings,
236
+ activeViewId: viewId,
237
+ views: [...(settings.views || []), {
238
+ id: viewId,
239
+ name,
240
+ favorite: false,
241
+ locked: false,
242
+ ...snapshotTableViewState(settings)
243
+ }]
244
+ })));
245
+ setNewViewName("");
246
+ }
247
+
248
+ function toggleViewFavorite(viewId) {
249
+ if (!selectedTable) return;
250
+ onSave((config) => updateTableFieldSettings(config, selectedTable, (settings) => ({
251
+ ...settings,
252
+ views: (settings.views || []).map((view) => view.id === viewId ? { ...view, favorite: !view.favorite } : view)
253
+ })));
254
+ }
255
+
256
+ function deleteView(viewId) {
257
+ if (!selectedTable) return;
258
+ onSave((config) => updateTableFieldSettings(config, selectedTable, (settings) => ({
259
+ ...settings,
260
+ activeViewId: settings.activeViewId === viewId ? "" : settings.activeViewId,
261
+ views: (settings.views || []).filter((view) => view.id !== viewId)
262
+ })));
263
+ setViewMenuId("");
264
+ }
265
+
266
+ function renameView(view) {
267
+ if (!selectedTable) return;
268
+ const nextName = window.prompt("Rename view", view.name);
269
+ if (!nextName?.trim()) return;
270
+ onSave((config) => updateTableFieldSettings(config, selectedTable, (settings) => ({
271
+ ...settings,
272
+ views: (settings.views || []).map((candidate) => candidate.id === view.id ? { ...candidate, name: nextName.trim() } : candidate)
273
+ })));
274
+ setViewMenuId("");
275
+ }
276
+
277
+ const activeView = currentViews.find((view) => view.id === selectedTable?.fieldSettings?.activeViewId) || null;
278
+ const objects = mode === "views" ? [] : tables;
279
+ const views = mode === "objects" ? [] : currentViews;
280
+
281
+ return (
282
+ <div
283
+ ref={pickerRef}
284
+ className={`dm-picker${open ? " open" : ""}`}
285
+ onBlur={(event) => {
286
+ if (!event.currentTarget.contains(event.relatedTarget)) {
287
+ setOpen(false);
288
+ setViewMenuId("");
289
+ }
290
+ }}
291
+ >
292
+ <button type="button" className="dm-picker-trigger" onClick={() => setOpen((current) => !current)}>
293
+ <LucideIcon name={selectedTable?.icon || OBJECT_TYPE_PRESETS[selectedTable?.objectType]?.icon || "Database"} size={14} />
294
+ <span className="dm-picker-trigger-copy">
295
+ <strong>{activeView?.name || selectedTable?.label || "Object"}</strong>
296
+ <em>{pluralize(selectedTable?.columns?.length || 0, "field")} · {pluralize(selectedTable?.rows?.length || 0, "record")}</em>
297
+ </span>
298
+ <ChevronDown size={14} />
299
+ </button>
300
+ {open && (
301
+ <div className="dm-picker-popover">
302
+ {favoriteObjects.length > 0 && (
303
+ <div className="dm-picker-section">
304
+ <p>Favorites</p>
305
+ {favoriteObjects.map((table) => (
306
+ <button key={`favorite-${table.source}`} type="button" className="dm-picker-row" onClick={() => onSelectSource(table.source)}>
307
+ <Pin size={14} />
308
+ <span>{table.label}</span>
309
+ </button>
310
+ ))}
311
+ </div>
312
+ )}
313
+ <div className="dm-picker-tabs">
314
+ {[
315
+ { id: "all", label: "All" },
316
+ { id: "objects", label: "Objects" },
317
+ { id: "views", label: "Views" },
318
+ ].map((item) => (
319
+ <button key={item.id} type="button" className={mode === item.id ? "active" : ""} onClick={() => setMode(item.id)}>
320
+ {item.label}
321
+ </button>
322
+ ))}
323
+ </div>
324
+ {objects.length > 0 && (
325
+ <div className="dm-picker-section">
326
+ <p>Objects</p>
327
+ <div className="dm-picker-scroll">
328
+ {objects.map((table) => (
329
+ <div key={table.source} className={`dm-picker-item${selectedTable?.source === table.source ? " active" : ""}`}>
330
+ <button type="button" className="dm-picker-row" onClick={() => {
331
+ onSelectSource(table.source);
332
+ setOpen(false);
333
+ }}>
334
+ <LucideIcon name={table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database"} size={14} />
335
+ <span>{table.label}</span>
336
+ {isLockedObject(table) && <Lock size={12} className="dm-picker-lock" />}
337
+ </button>
338
+ </div>
339
+ ))}
340
+ </div>
341
+ </div>
342
+ )}
343
+ {selectedTable && (
344
+ <div className="dm-picker-section">
345
+ <p>Views</p>
346
+ <button type="button" className={`dm-picker-row${!activeView ? " active" : ""}`} onClick={() => applyView(null)}>
347
+ <List size={14} />
348
+ <span>{selectedTable.label}</span>
349
+ {isLockedObject(selectedTable) && <Lock size={12} className="dm-picker-lock" />}
350
+ </button>
351
+ <div className="dm-picker-scroll">
352
+ {views.map((view) => (
353
+ <div key={view.id} className={`dm-picker-item${activeView?.id === view.id ? " active" : ""}`}>
354
+ <button type="button" className="dm-picker-row" onClick={() => applyView(view)}>
355
+ <List size={14} />
356
+ <span>{view.name}</span>
357
+ </button>
358
+ <div className="dm-picker-actions">
359
+ <button
360
+ type="button"
361
+ className="dm-picker-icon-btn"
362
+ aria-label="View actions"
363
+ onClick={(event) => {
364
+ event.stopPropagation();
365
+ setViewMenuId((current) => current === view.id ? "" : view.id);
366
+ }}
367
+ >
368
+ <MoreHorizontal size={12} style={{ transform: "rotate(90deg)" }} />
369
+ </button>
370
+ {viewMenuId === view.id && (
371
+ <div className="dm-picker-menu">
372
+ <button type="button" onClick={() => toggleViewFavorite(view.id)}>
373
+ <Pin size={13} />
374
+ {view.favorite ? "Unpin" : "Pin"}
375
+ </button>
376
+ <button type="button" onClick={() => renameView(view)}>
377
+ <Type size={13} />
378
+ Rename
379
+ </button>
380
+ {!view.locked && (
381
+ <button type="button" className="danger" onClick={() => deleteView(view.id)}>
382
+ <X size={13} />
383
+ Delete
384
+ </button>
385
+ )}
386
+ </div>
387
+ )}
388
+ </div>
389
+ </div>
390
+ ))}
391
+ </div>
392
+ <div className="dm-picker-create">
393
+ <input
394
+ value={newViewName}
395
+ placeholder="New view name"
396
+ onChange={(event) => setNewViewName(event.target.value)}
397
+ onKeyDown={(event) => {
398
+ if (event.key === "Enter") createView();
399
+ }}
400
+ />
401
+ <button type="button" className="dm-btn-outline" disabled={saving || !newViewName.trim()} onClick={createView}>
402
+ <Plus size={13} />Add view
403
+ </button>
404
+ </div>
405
+ </div>
406
+ )}
407
+ </div>
408
+ )}
409
+ </div>
410
+ );
411
+ }
412
+
413
+ // ─── Shared micro-components ──────────────────────────────────────────────────
414
+
415
+ function SaveToast({ saving, message }) {
416
+ if (saving) return <span className="dm-toast saving">Saving…</span>;
417
+ if (!message) return null;
418
+ return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
419
+ }
420
+
421
+ function NavRail({ authority, workspaceConfig }) {
422
+ const branding = workspaceConfig?.branding || {};
423
+ const workspaceName = branding.name || workspaceConfig?.name || "Growthub Workspace";
424
+ return (
425
+ <aside className="workspace-rail" aria-label="Workspace navigation">
426
+ <div className="workspace-brand">
427
+ <span
428
+ className="workspace-mark"
429
+ style={{
430
+ background: branding.logoUrl ? undefined : branding.accent || undefined,
431
+ color: branding.logoUrl ? undefined : textColorForAccent(branding.accent),
432
+ }}
433
+ >
434
+ {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
435
+ </span>
436
+ <span>{workspaceName}</span>
437
+ </div>
438
+ <nav className="workspace-nav">
439
+ <Link href="/">Dashboards</Link>
440
+ <Link className="active" href="/data-model">Data Model</Link>
441
+ <span className="workspace-nav-static">Management</span>
442
+ <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
443
+ </nav>
444
+ <div className="workspace-rail-status">
445
+ <span className="status-dot" />
446
+ {authority || "local-catalog"}
447
+ </div>
448
+ </aside>
449
+ );
450
+ }
451
+
452
+ // ─── Object list (sidebar lives in ./ObjectSidebar.jsx) ───────────────────────
453
+
454
+ function SourceValidationBanner({ table }) {
455
+ const lane = describeBindingLane(table?.binding);
456
+ if (!table || lane === "manual") return null;
457
+ const hasRef = table.binding?.integrationId || table.binding?.sourceKey || table.binding?.entityId;
458
+ if (hasRef) return null;
459
+ return (
460
+ <div className="dm-validation-banner">
461
+ <AlertCircle size={13} />
462
+ <span>Source binding incomplete — configure the source in widget source controls before data loads.</span>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ // ─── Database surface ─────────────────────────────────────────────────────────
468
+
469
+ function formatCellValue(value, column) {
470
+ if (value === null || value === undefined || value === "") return "";
471
+ const text = typeof value === "string" ? value : JSON.stringify(value);
472
+ if (column === "lastResponse" && text.length > 90) return `${text.slice(0, 90)}…`;
473
+ return text;
474
+ }
475
+
476
+ function relationForColumn(table, column) {
477
+ if (!table) return null;
478
+ return effectiveRelations(table).find((relation) => relation.field === column) || null;
479
+ }
480
+
481
+ function referenceOptions(tables, relation) {
482
+ if (!relation) return [];
483
+ return (tables || [])
484
+ .filter((candidate) => candidate.objectType === relation.targetObjectType)
485
+ .flatMap((candidate) => (candidate.rows || []).map((row, index) => {
486
+ const value = row?.integrationId || row?.id || row?.Name || `${candidate.objectId}:${index}`;
487
+ const label = row?.Name || row?.integrationId || row?.description || `${candidate.label} row ${index + 1}`;
488
+ return { value, label, source: candidate.label };
489
+ }));
490
+ }
491
+
492
+ function RelationPickerOrSelect({ table, tables, column, value, disabled, onChange }) {
493
+ const relation = relationForColumn(table, column);
494
+ if (!relation) return null;
495
+ if (table.objectId) {
496
+ return (
497
+ <ReferencePicker
498
+ objectId={table.objectId}
499
+ field={column}
500
+ value={value}
501
+ disabled={disabled}
502
+ onChange={onChange}
503
+ />
504
+ );
505
+ }
506
+ const options = referenceOptions(tables, relation);
507
+ return (
508
+ <ReferenceSelect value={value} options={options} disabled={disabled} onChange={onChange} />
509
+ );
510
+ }
511
+
512
+ function ReferenceSelect({ value, options, disabled, onChange }) {
513
+ const normalizedOptions = useMemo(() => options.map((option) => ({
514
+ value: String(option.value ?? ""),
515
+ label: String(option.label ?? option.value ?? ""),
516
+ source: option.source ? String(option.source) : ""
517
+ })), [options]);
518
+ return (
519
+ <SearchableSelect
520
+ value={value || ""}
521
+ options={normalizedOptions}
522
+ disabled={disabled}
523
+ placeholder="Select reference..."
524
+ onChange={onChange}
525
+ />
526
+ );
527
+ }
528
+
529
+ function SearchableSelect({ value, options, disabled, placeholder = "Select...", onChange, pageSize = 8 }) {
530
+ const [open, setOpen] = useState(false);
531
+ const [query, setQuery] = useState("");
532
+ const [page, setPage] = useState(0);
533
+ const selected = options.find((option) => option.value === String(value || ""));
534
+ const filtered = useMemo(() => {
535
+ const needle = query.trim().toLowerCase();
536
+ if (!needle) return options;
537
+ return options.filter((option) => `${option.label} ${option.value} ${option.source}`.toLowerCase().includes(needle));
538
+ }, [options, query]);
539
+ const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
540
+ const currentPage = Math.min(page, pageCount - 1);
541
+ const visibleOptions = filtered.slice(currentPage * pageSize, currentPage * pageSize + pageSize);
542
+
543
+ useEffect(() => {
544
+ setPage(0);
545
+ }, [query, options.length]);
546
+
547
+ return (
548
+ <div
549
+ className={`dm-select${open ? " open" : ""}${disabled ? " disabled" : ""}`}
550
+ onClick={(event) => event.stopPropagation()}
551
+ onBlur={(event) => {
552
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
553
+ }}
554
+ >
555
+ <button
556
+ type="button"
557
+ className="dm-select-trigger"
558
+ disabled={disabled}
559
+ aria-haspopup="listbox"
560
+ aria-expanded={open}
561
+ onClick={() => setOpen((current) => !current)}
562
+ >
563
+ <span className={selected ? "" : "empty"}>{selected?.label || placeholder}</span>
564
+ <ChevronDown size={15} aria-hidden="true" />
565
+ </button>
566
+ {open && (
567
+ <div className="dm-select-popover">
568
+ <label className="dm-select-search">
569
+ <Search size={14} aria-hidden="true" />
570
+ <input
571
+ autoFocus
572
+ value={query}
573
+ placeholder="Search..."
574
+ onChange={(event) => setQuery(event.target.value)}
575
+ />
576
+ </label>
577
+ <div className="dm-select-list" role="listbox">
578
+ <button
579
+ type="button"
580
+ className={`dm-select-option${!value ? " selected" : ""}`}
581
+ role="option"
582
+ aria-selected={!value}
583
+ onClick={() => {
584
+ onChange("");
585
+ setOpen(false);
586
+ }}
587
+ >
588
+ <span>{placeholder}</span>
589
+ </button>
590
+ {visibleOptions.map((option) => (
591
+ <button
592
+ type="button"
593
+ key={`${option.source}:${option.value}`}
594
+ className={`dm-select-option${option.value === String(value || "") ? " selected" : ""}`}
595
+ role="option"
596
+ aria-selected={option.value === String(value || "")}
597
+ onClick={() => {
598
+ onChange(option.value);
599
+ setOpen(false);
600
+ setQuery("");
601
+ }}
602
+ >
603
+ <span>{option.label}</span>
604
+ {option.source && <em>{option.source}</em>}
605
+ </button>
606
+ ))}
607
+ {visibleOptions.length === 0 && <p className="dm-select-empty">No matches</p>}
608
+ </div>
609
+ {filtered.length > pageSize && (
610
+ <div className="dm-select-pager">
611
+ <button type="button" disabled={currentPage === 0} onClick={() => setPage((next) => Math.max(0, next - 1))}>Prev</button>
612
+ <span>{currentPage + 1} / {pageCount}</span>
613
+ <button type="button" disabled={currentPage >= pageCount - 1} onClick={() => setPage((next) => Math.min(pageCount - 1, next + 1))}>Next</button>
614
+ </div>
615
+ )}
616
+ </div>
617
+ )}
618
+ </div>
619
+ );
620
+ }
621
+
622
+ function StaticSelect({ value, options, disabled, onChange, placeholder = "Select..." }) {
623
+ const normalizedOptions = useMemo(() => options.map((option) => (
624
+ typeof option === "string" ? { value: option, label: option } : option
625
+ )), [options]);
626
+ return (
627
+ <SearchableSelect
628
+ value={value || ""}
629
+ options={normalizedOptions}
630
+ disabled={disabled}
631
+ placeholder={placeholder}
632
+ onChange={onChange}
633
+ />
634
+ );
635
+ }
636
+
637
+ function DrawerSection({ title, children, defaultOpen = false }) {
638
+ const [open, setOpen] = useState(defaultOpen);
639
+ return (
640
+ <section className={`dm-drawer-section${open ? " open" : ""}`}>
641
+ <button type="button" className="dm-drawer-section-toggle" onClick={() => setOpen((current) => !current)}>
642
+ <ChevronRight size={14} aria-hidden="true" />
643
+ <span>{title}</span>
644
+ </button>
645
+ {open && <div className="dm-drawer-section-body">{children}</div>}
646
+ </section>
647
+ );
648
+ }
649
+
650
+ const GENERIC_FIELD_SECTIONS = [
651
+ {
652
+ title: "Identity",
653
+ columns: new Set(["Name", "name", "id", "integrationId", "registryId", "authRef"])
654
+ },
655
+ {
656
+ title: "Connection",
657
+ columns: new Set(["baseUrl", "endpoint", "method", "schedulerRegistryId"])
658
+ },
659
+ {
660
+ title: "Status & Response",
661
+ columns: new Set(["status", "lastTested", "lastRunId", "lastSourceId", "lastResponse"])
662
+ }
663
+ ];
664
+
665
+ function groupRecordColumns(columns) {
666
+ const groups = GENERIC_FIELD_SECTIONS.map((section) => ({
667
+ title: section.title,
668
+ columns: columns.filter((column) => section.columns.has(column))
669
+ })).filter((section) => section.columns.length > 0);
670
+ const grouped = new Set(groups.flatMap((section) => section.columns));
671
+ const otherColumns = columns.filter((column) => !grouped.has(column));
672
+ if (otherColumns.length) groups.push({ title: "Details", columns: otherColumns });
673
+ return groups;
674
+ }
675
+
676
+ function RecordFieldEditor({ table, tables, column, value, saving, editable, onDraft, onCommit, onExpandJson }) {
677
+ const relation = relationForColumn(table, column);
678
+ const large = column === "lastResponse" || String(value ?? "").length > 120;
679
+ if (relation) {
680
+ return (
681
+ <label className="dm-record-field">
682
+ <span>{column}</span>
683
+ <RelationPickerOrSelect
684
+ table={table}
685
+ tables={tables}
686
+ column={column}
687
+ value={value}
688
+ disabled={!table.mutable || saving}
689
+ onChange={(nextValue) => onCommit(column, nextValue)}
690
+ />
691
+ </label>
692
+ );
693
+ }
694
+ if (column === "lastResponse") {
695
+ return (
696
+ <label className="dm-record-field dm-json-field">
697
+ <span>{column}</span>
698
+ <button
699
+ type="button"
700
+ className="dm-json-expand"
701
+ aria-label="Expand lastResponse JSON"
702
+ title="Expand JSON"
703
+ disabled={!value}
704
+ onClick={onExpandJson}
705
+ >
706
+ <Maximize2 size={14} aria-hidden="true" />
707
+ </button>
708
+ <textarea
709
+ value={value}
710
+ rows={10}
711
+ readOnly={!editable}
712
+ onChange={(event) => onDraft(column, event.target.value)}
713
+ />
714
+ </label>
715
+ );
716
+ }
717
+ return (
718
+ <label className="dm-record-field">
719
+ <span>{column}</span>
720
+ {large ? (
721
+ <textarea
722
+ value={value}
723
+ rows={4}
724
+ readOnly={!editable}
725
+ onChange={(event) => onDraft(column, event.target.value)}
726
+ />
727
+ ) : (
728
+ <input
729
+ value={value}
730
+ readOnly={!editable}
731
+ onChange={(event) => onDraft(column, event.target.value)}
732
+ />
733
+ )}
734
+ </label>
735
+ );
736
+ }
737
+
738
+ function SandboxRecordFields({
739
+ draft,
740
+ setDraft,
741
+ table,
742
+ tables,
743
+ workspaceConfig,
744
+ saving,
745
+ onSave,
746
+ rowIndex,
747
+ sandboxHistory,
748
+ sandboxHistoryMessage,
749
+ loadingSandboxHistory,
750
+ onLoadSandboxHistory,
751
+ onExpandLastResponse
752
+ }) {
753
+ const [sandboxAdapters, setSandboxAdapters] = useState([]);
754
+ useEffect(() => {
755
+ fetch("/api/workspace/sandbox-adapters", { cache: "no-store" })
756
+ .then((res) => res.json())
757
+ .then((payload) => setSandboxAdapters(Array.isArray(payload.adapters) ? payload.adapters : []))
758
+ .catch(() => setSandboxAdapters([]));
759
+ }, []);
760
+
761
+ const locality = String(draft.runLocality || "local").trim().toLowerCase() === "serverless" ? "serverless" : "local";
762
+ const savedEnvRefs = useMemo(() => listSavedEnvRefs(workspaceConfig || {}), [workspaceConfig]);
763
+ const selectedEnvSlugs = useMemo(() => new Set(parseSandboxEnvRefs(draft.envRefs)), [draft.envRefs]);
764
+ const selectedAdapterMeta = sandboxAdapters.find((a) => a.id === String(draft.adapter || "").trim());
765
+
766
+ function patchFields(fields) {
767
+ setDraft((c) => ({ ...c, ...fields }));
768
+ onSave((cfg) => Object.entries(fields).reduce(
769
+ (acc, [column, value]) => updateTableCell(acc, table, rowIndex, column, value),
770
+ cfg
771
+ ));
772
+ }
773
+
774
+ function setRunLocality(next) {
775
+ const fields = { runLocality: next };
776
+ if (next === "serverless" && ["local-agent-host", "local-intelligence"].includes(String(draft.adapter || "").trim())) {
777
+ fields.adapter = "local-process";
778
+ }
779
+ patchFields(fields);
780
+ }
781
+
782
+ function toggleEnvRef(slug) {
783
+ const next = new Set(selectedEnvSlugs);
784
+ if (next.has(slug)) next.delete(slug);
785
+ else next.add(slug);
786
+ patchFields({ envRefs: [...next].join(",") });
787
+ }
788
+
789
+ const netOn = ["true", "1", "on", "yes"].includes(String(draft.networkAllow || "").trim().toLowerCase());
790
+
791
+ return (
792
+ <div className="dm-sandbox-config">
793
+ <DrawerSection title="Identity & Mode">
794
+ <label className="dm-record-field">
795
+ <span>Name</span>
796
+ <input
797
+ value={draft.Name ?? ""}
798
+ disabled={!table.mutable || saving}
799
+ onChange={(event) => setDraft((c) => ({ ...c, Name: event.target.value }))}
800
+ onBlur={(event) => patchFields({ Name: event.target.value })}
801
+ />
802
+ </label>
803
+
804
+ <label className="dm-record-field">
805
+ <span>Status mode</span>
806
+ <StaticSelect
807
+ value={String(draft.lifecycleStatus || "draft").trim().toLowerCase() === "live" ? "live" : "draft"}
808
+ disabled={!table.mutable || saving}
809
+ options={["draft", "live"]}
810
+ onChange={(nextValue) => patchFields({ lifecycleStatus: nextValue })}
811
+ />
812
+ </label>
813
+
814
+ <label className="dm-record-field">
815
+ <span>Version</span>
816
+ <input
817
+ value={draft.version ?? ""}
818
+ disabled={!table.mutable || saving}
819
+ onChange={(event) => setDraft((c) => ({ ...c, version: event.target.value }))}
820
+ onBlur={(event) => patchFields({ version: event.target.value })}
821
+ />
822
+ </label>
823
+ </DrawerSection>
824
+
825
+ <DrawerSection title="Execution Target">
826
+ <SegmentedToggle
827
+ name="sandbox-run-locality"
828
+ label="Where it runs"
829
+ value={locality}
830
+ options={["local", "serverless"]}
831
+ disabled={!table.mutable || saving}
832
+ onChange={setRunLocality}
833
+ />
834
+ <p className="dm-cell-empty" style={{ fontSize: 11, marginTop: 6 }}>
835
+ Local uses process sandbox or Paperclip agent host on this machine. Serverless delegates to an API Registry URL (no local agent CLI).
836
+ </p>
837
+
838
+ {locality === "serverless" && table.objectId && (
839
+ <label className="dm-record-field">
840
+ <span>Scheduler (API Registry)</span>
841
+ <ReferencePicker
842
+ objectId={table.objectId}
843
+ field="schedulerRegistryId"
844
+ value={draft.schedulerRegistryId || ""}
845
+ disabled={!table.mutable || saving}
846
+ onChange={(nextValue) => patchFields({ schedulerRegistryId: nextValue })}
847
+ />
848
+ <span className="dm-cell-empty" style={{ fontSize: 11, marginTop: 4, display: "block" }}>
849
+ POST sends <code>growthub-sandbox-run-v1</code> JSON; auth from registry <code>authRef</code> (server env only).
850
+ </span>
851
+ </label>
852
+ )}
853
+
854
+ {locality === "serverless" && !table.objectId && (
855
+ <p className="dm-field-error">This sandbox table is missing a stable object id — cannot load scheduler list.</p>
856
+ )}
857
+
858
+ <label className="dm-record-field">
859
+ <span>Execution adapter</span>
860
+ <StaticSelect
861
+ value={String(draft.adapter || "local-process").trim() || "local-process"}
862
+ disabled={!table.mutable || saving}
863
+ options={sandboxAdapters.length === 0 ? [{ value: "local-process", label: "local-process" }] : sandboxAdapters.map((a) => ({ value: a.id, label: a.label }))}
864
+ onChange={(nextValue) => patchFields({ adapter: nextValue })}
865
+ />
866
+ </label>
867
+
868
+ {locality === "local" && String(draft.adapter || "").trim() === "local-agent-host" && (
869
+ <label className="dm-record-field">
870
+ <span>Agent host (Paperclip)</span>
871
+ <StaticSelect
872
+ value={draft.agentHost || ""}
873
+ disabled={!table.mutable || saving}
874
+ placeholder="Select host..."
875
+ options={(selectedAdapterMeta?.hostCatalog || []).map((h) => ({ value: h.slug, label: h.label }))}
876
+ onChange={(nextValue) => patchFields({ agentHost: nextValue })}
877
+ />
878
+ </label>
879
+ )}
880
+
881
+ {locality === "local" && String(draft.adapter || "").trim() === "local-intelligence" && (
882
+ <div className="dm-sandbox-local-intel" style={{ display: "grid", gap: 10 }}>
883
+ <label className="dm-record-field">
884
+ <span>Concrete model id</span>
885
+ <input
886
+ value={draft.localModel ?? ""}
887
+ disabled={!table.mutable || saving}
888
+ placeholder="gemma3:4b"
889
+ onChange={(event) => setDraft((c) => ({ ...c, localModel: event.target.value }))}
890
+ onBlur={(event) => patchFields({ localModel: event.target.value })}
891
+ />
892
+ <span className="dm-cell-empty" style={{ fontSize: 11, marginTop: 4, display: "block" }}>
893
+ Open-ended tag aligned with CLI Local Intelligence. Falls back to <code>NATIVE_INTELLIGENCE_LOCAL_MODEL</code> or <code>OLLAMA_MODEL</code>.
894
+ </span>
895
+ </label>
896
+
897
+ <label className="dm-record-field">
898
+ <span>Chat completions URL (optional)</span>
899
+ <input
900
+ value={draft.localEndpoint ?? ""}
901
+ disabled={!table.mutable || saving}
902
+ placeholder="http://127.0.0.1:11434/v1/chat/completions"
903
+ onChange={(event) => setDraft((c) => ({ ...c, localEndpoint: event.target.value }))}
904
+ onBlur={(event) => patchFields({ localEndpoint: event.target.value })}
905
+ />
906
+ </label>
907
+
908
+ <label className="dm-record-field">
909
+ <span>Resolver mode</span>
910
+ <StaticSelect
911
+ value={String(draft.intelligenceAdapterMode || "ollama").trim().toLowerCase()}
912
+ disabled={!table.mutable || saving}
913
+ options={[
914
+ { value: "ollama", label: "ollama (OLLAMA_BASE_URL + /v1/chat/completions)" },
915
+ { value: "lmstudio", label: "lmstudio (LMSTUDIO_BASE_URL)" },
916
+ { value: "vllm", label: "vllm (VLLM_BASE_URL required)" },
917
+ { value: "custom-openai-compatible", label: "custom (use Chat completions URL above)" }
918
+ ]}
919
+ onChange={(nextValue) => patchFields({ intelligenceAdapterMode: nextValue })}
920
+ />
921
+ </label>
922
+
923
+ <p className="dm-cell-empty" style={{ fontSize: 11, marginTop: 0 }}>
924
+ Uses <strong>Instructions</strong> + <strong>Command</strong> as the task payload. Tool intents in the JSON response are proposals only and are not executed by the workspace.
925
+ </p>
926
+ </div>
927
+ )}
928
+
929
+ <label className="dm-record-field">
930
+ <span>Runtime</span>
931
+ <StaticSelect
932
+ value={draft.runtime || "node"}
933
+ disabled={!table.mutable || saving}
934
+ options={SANDBOX_RUNTIME_OPTIONS}
935
+ onChange={(nextValue) => patchFields({ runtime: nextValue })}
936
+ />
937
+ </label>
938
+ </DrawerSection>
939
+
940
+ <DrawerSection title="Environment & Network">
941
+ <div className="dm-record-field">
942
+ <span>Env key references</span>
943
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
944
+ {savedEnvRefs.length === 0 ? (
945
+ <span className="dm-cell-empty">Add keys under Settings -&gt; APIs &amp; Webhooks.</span>
946
+ ) : savedEnvRefs.map((ref) => (
947
+ <button
948
+ key={ref.endpointRef}
949
+ type="button"
950
+ className={`dm-btn-ghost${selectedEnvSlugs.has(ref.endpointRef) ? " dm-chip-active" : ""}`}
951
+ style={{ padding: "2px 8px", borderRadius: 999, fontSize: 11 }}
952
+ disabled={!table.mutable || saving}
953
+ onClick={() => toggleEnvRef(ref.endpointRef)}
954
+ >
955
+ {ref.endpointRef}
956
+ </button>
957
+ ))}
958
+ </div>
959
+ </div>
960
+
961
+ <ToggleField
962
+ checked={netOn}
963
+ disabled={!table.mutable || saving}
964
+ label="Network allow-list mode"
965
+ description="When enabled, local runs honor GROWTHUB_SANDBOX_NET_* and the allow list below."
966
+ onChange={(on) => patchFields({ networkAllow: on ? "true" : "false" })}
967
+ />
968
+
969
+ <label className="dm-record-field">
970
+ <span>Allow list (comma-separated hosts)</span>
971
+ <input
972
+ value={draft.allowList ?? ""}
973
+ disabled={!table.mutable || saving}
974
+ onChange={(event) => setDraft((c) => ({ ...c, allowList: event.target.value }))}
975
+ onBlur={(event) => patchFields({ allowList: event.target.value })}
976
+ />
977
+ </label>
978
+ </DrawerSection>
979
+
980
+ <DrawerSection title="Prompt & Limits">
981
+ <label className="dm-record-field">
982
+ <span>Instructions</span>
983
+ <textarea
984
+ rows={5}
985
+ value={draft.instructions ?? ""}
986
+ disabled={!table.mutable || saving}
987
+ onChange={(event) => setDraft((c) => ({ ...c, instructions: event.target.value }))}
988
+ onBlur={(event) => patchFields({ instructions: event.target.value })}
989
+ />
990
+ </label>
991
+
992
+ <label className="dm-record-field">
993
+ <span>Command / prompt</span>
994
+ <textarea
995
+ rows={6}
996
+ value={draft.command ?? ""}
997
+ disabled={!table.mutable || saving}
998
+ onChange={(event) => setDraft((c) => ({ ...c, command: event.target.value }))}
999
+ onBlur={(event) => patchFields({ command: event.target.value })}
1000
+ />
1001
+ </label>
1002
+
1003
+ <label className="dm-record-field">
1004
+ <span>timeoutMs</span>
1005
+ <input
1006
+ type="number"
1007
+ min={1000}
1008
+ max={600000}
1009
+ value={draft.timeoutMs ?? ""}
1010
+ disabled={!table.mutable || saving}
1011
+ onChange={(event) => setDraft((c) => ({ ...c, timeoutMs: event.target.value }))}
1012
+ onBlur={(event) => patchFields({ timeoutMs: event.target.value })}
1013
+ />
1014
+ </label>
1015
+ </DrawerSection>
1016
+
1017
+ <DrawerSection title="Response & History">
1018
+ <label className="dm-record-field">
1019
+ <span>lastRunId</span>
1020
+ <input readOnly value={draft.lastRunId ?? ""} />
1021
+ </label>
1022
+
1023
+ <label className="dm-record-field">
1024
+ <span>lastSourceId</span>
1025
+ <input readOnly value={draft.lastSourceId ?? ""} />
1026
+ </label>
1027
+
1028
+ <label className="dm-record-field dm-json-field">
1029
+ <span>lastResponse</span>
1030
+ <button
1031
+ type="button"
1032
+ className="dm-json-expand"
1033
+ aria-label="Expand lastResponse JSON"
1034
+ title="Expand JSON"
1035
+ disabled={!draft.lastResponse}
1036
+ onClick={onExpandLastResponse}
1037
+ >
1038
+ <Maximize2 size={14} aria-hidden="true" />
1039
+ </button>
1040
+ <textarea rows={10} readOnly value={draft.lastResponse ?? ""} />
1041
+ </label>
1042
+
1043
+ <div className="dm-record-field">
1044
+ <span>Run history</span>
1045
+ <button type="button" className="dm-btn-ghost" disabled={loadingSandboxHistory} onClick={onLoadSandboxHistory}>
1046
+ {loadingSandboxHistory ? "Loading..." : "Load previous runs"}
1047
+ </button>
1048
+ {sandboxHistoryMessage && <span className="dm-cell-empty">{sandboxHistoryMessage}</span>}
1049
+ {Array.isArray(sandboxHistory) && sandboxHistory.length > 0 && (
1050
+ <div style={{ display: "grid", gap: 8, marginTop: 8 }}>
1051
+ {sandboxHistory.slice(0, 8).map((record) => (
1052
+ <pre key={record.runId || record.ranAt} className="dm-source-preview" style={{ margin: 0, maxHeight: 160, overflow: "auto" }}>
1053
+ {JSON.stringify({
1054
+ runId: record.runId,
1055
+ ranAt: record.ranAt,
1056
+ lifecycleStatus: record.lifecycleStatus,
1057
+ version: record.version,
1058
+ status: record.exitCode === 0 && !record.error ? "connected" : "failed",
1059
+ stdout: record.stdout,
1060
+ error: record.error
1061
+ }, null, 2)}
1062
+ </pre>
1063
+ ))}
1064
+ </div>
1065
+ )}
1066
+ </div>
1067
+ </DrawerSection>
1068
+ </div>
1069
+ );
1070
+ }
1071
+
1072
+ function DataModelRecordDrawer({ table, tables, workspaceConfig, rowIndex, row, saving, onClose, onSave }) {
1073
+ const [draft, setDraft] = useState(row || {});
1074
+ const [editMode, setEditMode] = useState(false);
1075
+ const [pendingColumns, setPendingColumns] = useState(table.columns || []);
1076
+ const [pendingHidden, setPendingHidden] = useState(table.fieldSettings?.hidden || []);
1077
+ const [testing, setTesting] = useState(false);
1078
+ const [testMessage, setTestMessage] = useState("");
1079
+ const [sandboxRunning, setSandboxRunning] = useState(false);
1080
+ const [sandboxMessage, setSandboxMessage] = useState("");
1081
+ const [sandboxHistory, setSandboxHistory] = useState([]);
1082
+ const [sandboxHistoryMessage, setSandboxHistoryMessage] = useState("");
1083
+ const [loadingSandboxHistory, setLoadingSandboxHistory] = useState(false);
1084
+ const [expandedJson, setExpandedJson] = useState(null);
1085
+
1086
+ useEffect(() => {
1087
+ setDraft(row || {});
1088
+ setEditMode(false);
1089
+ setPendingColumns(table.columns || []);
1090
+ setPendingHidden(table.fieldSettings?.hidden || []);
1091
+ setTestMessage("");
1092
+ setSandboxMessage("");
1093
+ setSandboxHistory([]);
1094
+ setSandboxHistoryMessage("");
1095
+ setExpandedJson(null);
1096
+ }, [row, rowIndex]);
1097
+
1098
+ if (rowIndex === null || rowIndex === undefined || !row) return null;
1099
+
1100
+ const isSandbox = table.objectType === "sandbox-environment";
1101
+ const isDirty = JSON.stringify(draft || {}) !== JSON.stringify(row || {}) || JSON.stringify(pendingColumns) !== JSON.stringify(table.columns || []) || JSON.stringify(pendingHidden) !== JSON.stringify(table.fieldSettings?.hidden || []);
1102
+
1103
+ function updateField(column, value) {
1104
+ setDraft((current) => ({ ...current, [column]: value }));
1105
+ }
1106
+
1107
+ function movePendingColumn(index, direction) {
1108
+ setPendingColumns((current) => {
1109
+ const next = [...current];
1110
+ const target = direction === "up" ? index - 1 : index + 1;
1111
+ if (target < 0 || target >= next.length) return current;
1112
+ [next[index], next[target]] = [next[target], next[index]];
1113
+ return next;
1114
+ });
1115
+ }
1116
+
1117
+ function renamePendingColumn(index, nextName) {
1118
+ setPendingColumns((current) => current.map((column, columnIndex) => columnIndex === index ? nextName : column));
1119
+ }
1120
+
1121
+ function cancelEdits() {
1122
+ if (isDirty && !window.confirm("Discard unsaved drawer changes?")) return;
1123
+ setDraft(row || {});
1124
+ setPendingColumns(table.columns || []);
1125
+ setPendingHidden(table.fieldSettings?.hidden || []);
1126
+ setEditMode(false);
1127
+ }
1128
+
1129
+ function closeDrawer() {
1130
+ if (editMode && isDirty && !window.confirm("You have unsaved drawer changes. Close without saving?")) return;
1131
+ onClose();
1132
+ }
1133
+
1134
+ function saveDrawerEdits() {
1135
+ const cleanColumns = pendingColumns.map((column) => String(column || "").trim()).filter(Boolean);
1136
+ if (!cleanColumns.length) return;
1137
+ const uniqueColumns = Array.from(new Set(cleanColumns));
1138
+ const renameMap = {};
1139
+ (table.columns || []).forEach((column, index) => {
1140
+ const nextColumn = uniqueColumns[index];
1141
+ if (nextColumn && nextColumn !== column) renameMap[column] = nextColumn;
1142
+ });
1143
+ onSave((config) => {
1144
+ let next = transformTableSchema(config, table, { columns: uniqueColumns, renameMap });
1145
+ next = updateTableFieldSettings(next, { ...table, columns: uniqueColumns }, (settings) => ({
1146
+ ...settings,
1147
+ hidden: pendingHidden.filter((column) => uniqueColumns.includes(column))
1148
+ }));
1149
+ uniqueColumns.forEach((column) => {
1150
+ next = updateTableCell(next, { ...table, columns: uniqueColumns }, rowIndex, column, draft?.[column] ?? draft?.[Object.keys(renameMap).find((key) => renameMap[key] === column) || column] ?? "");
1151
+ });
1152
+ return next;
1153
+ });
1154
+ setEditMode(false);
1155
+ }
1156
+
1157
+ async function testApiRecord() {
1158
+ setTesting(true);
1159
+ setTestMessage("");
1160
+ try {
1161
+ const res = await fetch("/api/workspace/test-api-record", {
1162
+ method: "POST",
1163
+ headers: { "content-type": "application/json" },
1164
+ body: JSON.stringify(table.objectType === "data-source" ? { dataSourceRecord: draft } : { record: draft }),
1165
+ });
1166
+ const payload = await res.json();
1167
+ const status = payload.ok ? "connected" : "failed";
1168
+ const responseText = JSON.stringify(payload.response ?? payload, null, 2);
1169
+ onSave((config) => {
1170
+ let next = updateTableCell(config, table, rowIndex, "status", status);
1171
+ next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
1172
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
1173
+ return next;
1174
+ });
1175
+ setDraft((current) => ({ ...current, status, lastTested: new Date().toISOString(), lastResponse: responseText }));
1176
+ setTestMessage(payload.ok ? "Connected" : payload.error || "Connection failed");
1177
+ } catch (err) {
1178
+ const responseText = JSON.stringify({ error: err.message || "Connection failed" }, null, 2);
1179
+ onSave((config) => {
1180
+ let next = updateTableCell(config, table, rowIndex, "status", "failed");
1181
+ next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
1182
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
1183
+ return next;
1184
+ });
1185
+ setTestMessage(err.message || "Connection failed");
1186
+ } finally {
1187
+ setTesting(false);
1188
+ }
1189
+ }
1190
+
1191
+ async function runSandbox() {
1192
+ if (!table.objectId) {
1193
+ setSandboxMessage("Missing object id for this sandbox table.");
1194
+ return;
1195
+ }
1196
+ const rowName = String(draft?.Name ?? "").trim();
1197
+ if (!rowName) {
1198
+ setSandboxMessage("Row Name is required.");
1199
+ return;
1200
+ }
1201
+ setSandboxRunning(true);
1202
+ setSandboxMessage("");
1203
+ try {
1204
+ const res = await fetch("/api/workspace/sandbox-run", {
1205
+ method: "POST",
1206
+ headers: { "content-type": "application/json" },
1207
+ body: JSON.stringify({ objectId: table.objectId, name: rowName }),
1208
+ });
1209
+ const payload = await res.json();
1210
+ const responseText = JSON.stringify(payload.response ?? payload, null, 2);
1211
+ const status = String(payload.status || "").toLowerCase() === "connected" ? "connected" : "failed";
1212
+ const testedAt = payload.response?.ranAt || new Date().toISOString();
1213
+ const lastRunId = payload.runId || payload.response?.runId || "";
1214
+ const lastSourceId = payload.sourceId || payload.response?.sourceId || "";
1215
+ onSave((config) => {
1216
+ let next = updateTableCell(config, table, rowIndex, "status", status);
1217
+ next = updateTableCell(next, table, rowIndex, "lastTested", testedAt);
1218
+ next = updateTableCell(next, table, rowIndex, "lastRunId", lastRunId);
1219
+ next = updateTableCell(next, table, rowIndex, "lastSourceId", lastSourceId);
1220
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
1221
+ return next;
1222
+ });
1223
+ setDraft((current) => ({ ...current, status, lastTested: testedAt, lastRunId, lastSourceId, lastResponse: responseText }));
1224
+ setSandboxHistory((current) => payload.response ? [payload.response, ...current].slice(0, 25) : current);
1225
+ setSandboxMessage(payload.ok ? "Sandbox run recorded" : (payload.response?.error || payload.error || "Run failed"));
1226
+ } catch (err) {
1227
+ setSandboxMessage(err.message || "Sandbox run failed");
1228
+ } finally {
1229
+ setSandboxRunning(false);
1230
+ }
1231
+ }
1232
+
1233
+ async function loadSandboxHistory() {
1234
+ if (!table.objectId || !String(draft?.Name || "").trim()) {
1235
+ setSandboxHistoryMessage("Sandbox Name is required.");
1236
+ return;
1237
+ }
1238
+ setLoadingSandboxHistory(true);
1239
+ setSandboxHistoryMessage("");
1240
+ try {
1241
+ const params = new URLSearchParams({ objectId: table.objectId, name: String(draft.Name || "").trim() });
1242
+ const res = await fetch(`/api/workspace/sandbox-run?${params.toString()}`, { cache: "no-store" });
1243
+ const payload = await res.json();
1244
+ if (!payload.ok) throw new Error(payload.error || "Could not load run history");
1245
+ setSandboxHistory(Array.isArray(payload.records) ? payload.records : []);
1246
+ setSandboxHistoryMessage(`${payload.recordCount || 0} saved run${payload.recordCount === 1 ? "" : "s"} · ${payload.sourceId || ""}`);
1247
+ } catch (err) {
1248
+ setSandboxHistory([]);
1249
+ setSandboxHistoryMessage(err.message || "Could not load run history");
1250
+ } finally {
1251
+ setLoadingSandboxHistory(false);
1252
+ }
1253
+ }
1254
+
1255
+ function expandLastResponse() {
1256
+ const text = String(draft.lastResponse || "");
1257
+ if (!text) return;
1258
+ try {
1259
+ setExpandedJson(JSON.stringify(JSON.parse(text), null, 2));
1260
+ } catch {
1261
+ setExpandedJson(text);
1262
+ }
1263
+ }
1264
+
1265
+ return (
1266
+ <>
1267
+ <div className="dm-record-backdrop" onClick={onClose} />
1268
+ <aside className="dm-record-drawer" aria-label="Record details">
1269
+ <header className="dm-record-drawer-head">
1270
+ <div>
1271
+ <p>Record</p>
1272
+ <h2>{draft.Name || draft.integrationId || draft.id || `Row ${rowIndex + 1}`}</h2>
1273
+ </div>
1274
+ <div className="dm-record-drawer-actions">
1275
+ {!isSandbox && (
1276
+ <button type="button" className="dm-sidebar-close" onClick={() => setEditMode((current) => !current)} aria-label="Toggle edit mode">
1277
+ <Pencil size={16} />
1278
+ </button>
1279
+ )}
1280
+ <button type="button" className="dm-sidebar-close" onClick={closeDrawer} aria-label="Close">
1281
+ <X size={16} />
1282
+ </button>
1283
+ </div>
1284
+ </header>
1285
+ {(table.objectType === "api-registry" || table.objectType === "data-source") && (
1286
+ <SourceTestPanel
1287
+ status={draft.status}
1288
+ testing={testing}
1289
+ testMessage={testMessage}
1290
+ disabled={saving}
1291
+ onTest={testApiRecord}
1292
+ />
1293
+ )}
1294
+ {isSandbox && (
1295
+ <SandboxRunPanel
1296
+ status={draft.status}
1297
+ sandboxRunning={sandboxRunning}
1298
+ sandboxMessage={sandboxMessage}
1299
+ disabled={saving}
1300
+ canRun={Boolean(String(draft.Name || "").trim())}
1301
+ onRun={runSandbox}
1302
+ />
1303
+ )}
1304
+ <div className="dm-record-fields">
1305
+ {isSandbox ? (
1306
+ <SandboxRecordFields
1307
+ draft={draft}
1308
+ setDraft={setDraft}
1309
+ table={table}
1310
+ tables={tables}
1311
+ workspaceConfig={workspaceConfig}
1312
+ saving={saving}
1313
+ onSave={onSave}
1314
+ rowIndex={rowIndex}
1315
+ sandboxHistory={sandboxHistory}
1316
+ sandboxHistoryMessage={sandboxHistoryMessage}
1317
+ loadingSandboxHistory={loadingSandboxHistory}
1318
+ onLoadSandboxHistory={loadSandboxHistory}
1319
+ onExpandLastResponse={expandLastResponse}
1320
+ />
1321
+ ) : groupRecordColumns(table.columns || []).map((section) => (
1322
+ <DrawerSection key={section.title} title={section.title}>
1323
+ {section.columns.map((column) => (
1324
+ <RecordFieldEditor
1325
+ key={column}
1326
+ table={table}
1327
+ tables={tables}
1328
+ column={column}
1329
+ value={String(draft?.[column] ?? "")}
1330
+ saving={saving}
1331
+ editable={editMode}
1332
+ onDraft={(field, nextValue) => editMode && setDraft((current) => ({ ...current, [field]: nextValue }))}
1333
+ onCommit={updateField}
1334
+ onExpandJson={expandLastResponse}
1335
+ />
1336
+ ))}
1337
+ </DrawerSection>
1338
+ ))}
1339
+ {!isSandbox && editMode && (
1340
+ <DrawerSection title="Fields" defaultOpen>
1341
+ <div className="dm-drawer-field-editor">
1342
+ {pendingColumns.map((column, index) => (
1343
+ <div key={`${column}-${index}`} className="dm-drawer-field-row">
1344
+ <input value={column} onChange={(event) => renamePendingColumn(index, event.target.value)} />
1345
+ <button type="button" className="dm-btn-ghost" onClick={() => setPendingHidden((current) => current.includes(column) ? current.filter((item) => item !== column) : [...current, column])}>
1346
+ {pendingHidden.includes(column) ? "Show" : "Hide"}
1347
+ </button>
1348
+ <button type="button" className="dm-btn-ghost" disabled={index === 0} onClick={() => movePendingColumn(index, "up")}>Up</button>
1349
+ <button type="button" className="dm-btn-ghost" disabled={index === pendingColumns.length - 1} onClick={() => movePendingColumn(index, "down")}>Down</button>
1350
+ </div>
1351
+ ))}
1352
+ {pendingHidden.length > 0 && (
1353
+ <div className="dm-drawer-hidden-fields">
1354
+ <span>Hidden fields</span>
1355
+ <div className="dm-drawer-hidden-list">
1356
+ {pendingHidden.map((column) => (
1357
+ <button key={`hidden-${column}`} type="button" className="dm-filter-chip" onClick={() => setPendingHidden((current) => current.filter((item) => item !== column))}>
1358
+ <span>{column}</span>
1359
+ <X size={12} />
1360
+ </button>
1361
+ ))}
1362
+ </div>
1363
+ </div>
1364
+ )}
1365
+ </div>
1366
+ </DrawerSection>
1367
+ )}
1368
+ </div>
1369
+ {!isSandbox && editMode && (
1370
+ <footer className="dm-record-drawer-foot">
1371
+ <button type="button" className="dm-btn-outline" onClick={cancelEdits}>Cancel</button>
1372
+ <button type="button" className="dm-btn-primary-sm" disabled={saving || !isDirty} onClick={saveDrawerEdits}>Save changes</button>
1373
+ </footer>
1374
+ )}
1375
+ </aside>
1376
+ {expandedJson !== null && (
1377
+ <div className="dm-json-modal-backdrop" onClick={() => setExpandedJson(null)}>
1378
+ <section className="dm-json-modal" role="dialog" aria-modal="true" aria-label="lastResponse JSON" onClick={(event) => event.stopPropagation()}>
1379
+ <header>
1380
+ <div>
1381
+ <p>lastResponse</p>
1382
+ <h2>{draft.Name || draft.integrationId || "Record response"}</h2>
1383
+ </div>
1384
+ <button type="button" className="dm-sidebar-close" onClick={() => setExpandedJson(null)} aria-label="Close expanded JSON">
1385
+ <X size={16} />
1386
+ </button>
1387
+ </header>
1388
+ <pre>{expandedJson}</pre>
1389
+ </section>
1390
+ </div>
1391
+ )}
1392
+ </>
1393
+ );
1394
+ }
1395
+
1396
+ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave }) {
1397
+ const [selectedRow, setSelectedRow] = useState(null);
1398
+ const [fieldName, setFieldName] = useState("");
1399
+ const [fieldType, setFieldType] = useState("text");
1400
+ const [addingField, setAddingField] = useState(false);
1401
+ const [csvOpen, setCsvOpen] = useState(false);
1402
+ const [csvText, setCsvText] = useState("");
1403
+ const [mode, setMode] = useState("append");
1404
+ const [filterDraft, setFilterDraft] = useState({ fieldId: "", operator: "eq", value: "" });
1405
+ const [filterTarget, setFilterTarget] = useState("");
1406
+ const [menuColumn, setMenuColumn] = useState("");
1407
+ const [selectedRows, setSelectedRows] = useState(() => new Set());
1408
+ const [confirmDeleteSelection, setConfirmDeleteSelection] = useState(false);
1409
+ const [lastSelectedRowIndex, setLastSelectedRowIndex] = useState(null);
1410
+ const [selectMenuOpen, setSelectMenuOpen] = useState(false);
1411
+ const [pageSize, setPageSize] = useState(15);
1412
+ const [pageIndex, setPageIndex] = useState(0);
1413
+ const fieldInputRef = useRef(null);
1414
+
1415
+ useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
1416
+ useEffect(() => {
1417
+ setSelectedRow(null);
1418
+ setSelectedRows(new Set());
1419
+ setConfirmDeleteSelection(false);
1420
+ setLastSelectedRowIndex(null);
1421
+ setSelectMenuOpen(false);
1422
+ setPageIndex(0);
1423
+ }, [table.id]);
1424
+ useEffect(() => {
1425
+ setFieldName("");
1426
+ setFieldType("text");
1427
+ setFilterDraft({ fieldId: table.columns[0] || "", operator: "eq", value: "" });
1428
+ }, [table.id, table.columns]);
1429
+
1430
+ const settings = table.fieldSettings || { hidden: [], order: table.columns, sort: [], filter: null, views: [], activeViewId: "" };
1431
+ const orderedColumns = useMemo(() => mergeColumnOrder(settings.order, table.columns), [settings.order, table.columns]);
1432
+ const visibleColumns = useMemo(() => orderedColumns.filter((column) => !settings.hidden.includes(column)), [orderedColumns, settings.hidden]);
1433
+ const rowEntries = useMemo(() => {
1434
+ const indexed = (table.rows || []).map((row, originalIndex) => ({ row, originalIndex }));
1435
+ const filtered = indexed.filter((entry) => rowMatchesFilter(entry.row, settings.filter));
1436
+ if (!settings.sort?.length) return filtered;
1437
+ const clauses = settings.sort;
1438
+ return [...filtered].sort((left, right) => {
1439
+ for (const clause of clauses) {
1440
+ const direction = clause.direction === "desc" ? -1 : 1;
1441
+ const diff = compareCellValues(left.row?.[clause.fieldId], right.row?.[clause.fieldId]);
1442
+ if (diff !== 0) return diff * direction;
1443
+ }
1444
+ return 0;
1445
+ });
1446
+ }, [table.rows, settings]);
1447
+ const activeView = useMemo(
1448
+ () => (settings.views || []).find((view) => view.id === settings.activeViewId) || null,
1449
+ [settings.views, settings.activeViewId]
1450
+ );
1451
+ const selectedRowCount = selectedRows.size;
1452
+ const pageCount = Math.max(1, Math.ceil(rowEntries.length / pageSize));
1453
+ const safePageIndex = Math.min(pageIndex, pageCount - 1);
1454
+ const pageStart = safePageIndex * pageSize;
1455
+ const pageEntries = rowEntries.slice(pageStart, pageStart + pageSize);
1456
+ const pageEnd = Math.min(pageStart + pageSize, rowEntries.length);
1457
+ const pageSelectedCount = pageEntries.filter((entry) => selectedRows.has(entry.originalIndex)).length;
1458
+ const allPageSelected = pageEntries.length > 0 && pageSelectedCount === pageEntries.length;
1459
+
1460
+ useEffect(() => {
1461
+ setPageIndex((current) => Math.min(current, pageCount - 1));
1462
+ }, [pageCount]);
1463
+
1464
+ useEffect(() => {
1465
+ setPageIndex(0);
1466
+ setSelectedRow(null);
1467
+ setLastSelectedRowIndex(null);
1468
+ setSelectMenuOpen(false);
1469
+ }, [settings.filter, settings.sort, pageSize]);
1470
+
1471
+ function commitField() {
1472
+ const name = fieldName.trim();
1473
+ if (!name) {
1474
+ setAddingField(false);
1475
+ setFieldName("");
1476
+ return;
1477
+ }
1478
+ if (!table.columns.includes(name)) {
1479
+ onSave((config) => addTableField(config, table, { name, type: fieldType }));
1480
+ }
1481
+ setAddingField(false);
1482
+ setFieldName("");
1483
+ setFieldType("text");
1484
+ }
1485
+
1486
+ function importCsv() {
1487
+ const parsed = importTableFromCsv(csvText);
1488
+ if (!parsed.columns.length) return;
1489
+ if (mode === "replace") onSave((config) => replaceTableContent(config, table, parsed));
1490
+ else onSave((config) => appendRowsToTable(config, table, parsed.rows));
1491
+ setCsvText("");
1492
+ setCsvOpen(false);
1493
+ }
1494
+
1495
+ function updateSettings(updater) {
1496
+ onSave((config) => updateTableFieldSettings(config, table, updater));
1497
+ }
1498
+
1499
+ function moveColumn(column, direction) {
1500
+ updateSettings((current) => {
1501
+ const order = [...mergeColumnOrder(current.order, table.columns)];
1502
+ const index = order.indexOf(column);
1503
+ const nextIndex = direction === "left" ? index - 1 : index + 1;
1504
+ if (index < 0 || nextIndex < 0 || nextIndex >= order.length) return current;
1505
+ [order[index], order[nextIndex]] = [order[nextIndex], order[index]];
1506
+ return { ...current, order };
1507
+ });
1508
+ }
1509
+
1510
+ function toggleColumnHidden(column) {
1511
+ updateSettings((current) => ({
1512
+ ...current,
1513
+ hidden: current.hidden.includes(column)
1514
+ ? current.hidden.filter((item) => item !== column)
1515
+ : [...current.hidden, column]
1516
+ }));
1517
+ }
1518
+
1519
+ function setSort(column, direction) {
1520
+ updateSettings((current) => ({ ...current, sort: [{ fieldId: column, direction }] }));
1521
+ setMenuColumn("");
1522
+ }
1523
+
1524
+ function applyFilter() {
1525
+ if (!filterDraft.fieldId) return;
1526
+ updateSettings((current) => ({
1527
+ ...current,
1528
+ filter: {
1529
+ op: "and",
1530
+ clauses: [
1531
+ ...((current.filter?.clauses || []).filter((clause) => clause.fieldId !== filterDraft.fieldId)),
1532
+ ...(filterDraft.operator === "isEmpty" || filterDraft.operator === "isNotEmpty"
1533
+ ? [{ fieldId: filterDraft.fieldId, operator: filterDraft.operator }]
1534
+ : filterDraft.value !== ""
1535
+ ? [{ fieldId: filterDraft.fieldId, operator: filterDraft.operator, value: filterDraft.value }]
1536
+ : [])
1537
+ ]
1538
+ }
1539
+ }));
1540
+ setFilterTarget("");
1541
+ }
1542
+
1543
+ function removeFilter(fieldId) {
1544
+ updateSettings((current) => {
1545
+ const clauses = (current.filter?.clauses || []).filter((clause) => clause.fieldId !== fieldId);
1546
+ return { ...current, filter: clauses.length ? { op: "and", clauses } : null };
1547
+ });
1548
+ }
1549
+
1550
+ function resetView() {
1551
+ updateSettings((current) => ({
1552
+ ...current,
1553
+ hidden: [],
1554
+ order: table.columns,
1555
+ sort: [],
1556
+ filter: null,
1557
+ activeViewId: ""
1558
+ }));
1559
+ }
1560
+
1561
+ function saveCurrentAsNewView() {
1562
+ const name = window.prompt("View name");
1563
+ if (!name?.trim()) return;
1564
+ const viewId = `view_${Date.now().toString(36)}`;
1565
+ updateSettings((current) => ({
1566
+ ...current,
1567
+ activeViewId: viewId,
1568
+ views: [...(current.views || []), { id: viewId, name: name.trim(), favorite: false, locked: false, ...snapshotTableViewState(current) }]
1569
+ }));
1570
+ }
1571
+
1572
+ function updateCurrentView() {
1573
+ if (!activeView) return;
1574
+ updateSettings((current) => ({
1575
+ ...current,
1576
+ views: (current.views || []).map((view) => view.id === activeView.id ? { ...view, ...snapshotTableViewState(current) } : view)
1577
+ }));
1578
+ }
1579
+
1580
+ function toggleRowSelection(originalIndex, visibleIndex, event) {
1581
+ setConfirmDeleteSelection(false);
1582
+ setSelectMenuOpen(false);
1583
+ setSelectedRows((current) => {
1584
+ const next = new Set(current);
1585
+ if (event?.shiftKey && lastSelectedRowIndex !== null) {
1586
+ const start = Math.min(lastSelectedRowIndex, visibleIndex);
1587
+ const end = Math.max(lastSelectedRowIndex, visibleIndex);
1588
+ rowEntries.slice(start, end + 1).forEach((entry) => next.add(entry.originalIndex));
1589
+ } else if (next.has(originalIndex)) {
1590
+ next.delete(originalIndex);
1591
+ } else {
1592
+ next.add(originalIndex);
1593
+ }
1594
+ return next;
1595
+ });
1596
+ setLastSelectedRowIndex(visibleIndex);
1597
+ }
1598
+
1599
+ function clearRowSelection() {
1600
+ setSelectedRows(new Set());
1601
+ setConfirmDeleteSelection(false);
1602
+ setLastSelectedRowIndex(null);
1603
+ setSelectMenuOpen(false);
1604
+ }
1605
+
1606
+ function selectCurrentPage() {
1607
+ setConfirmDeleteSelection(false);
1608
+ setSelectedRows((current) => {
1609
+ const next = new Set(current);
1610
+ pageEntries.forEach((entry) => next.add(entry.originalIndex));
1611
+ return next;
1612
+ });
1613
+ setLastSelectedRowIndex(pageEntries.length ? pageStart : null);
1614
+ setSelectMenuOpen(false);
1615
+ }
1616
+
1617
+ function toggleCurrentPageSelection() {
1618
+ setConfirmDeleteSelection(false);
1619
+ setSelectedRows((current) => {
1620
+ const next = new Set(current);
1621
+ if (allPageSelected) pageEntries.forEach((entry) => next.delete(entry.originalIndex));
1622
+ else pageEntries.forEach((entry) => next.add(entry.originalIndex));
1623
+ return next;
1624
+ });
1625
+ setLastSelectedRowIndex(pageEntries.length ? pageStart : null);
1626
+ setSelectMenuOpen(false);
1627
+ }
1628
+
1629
+ function selectAllFilteredRows() {
1630
+ setConfirmDeleteSelection(false);
1631
+ setSelectedRows((current) => {
1632
+ const next = new Set(current);
1633
+ rowEntries.forEach((entry) => next.add(entry.originalIndex));
1634
+ return next;
1635
+ });
1636
+ setLastSelectedRowIndex(rowEntries.length ? 0 : null);
1637
+ setSelectMenuOpen(false);
1638
+ }
1639
+
1640
+ function deleteSelectedRows() {
1641
+ if (!selectedRows.size) return;
1642
+ if (!confirmDeleteSelection) {
1643
+ setConfirmDeleteSelection(true);
1644
+ return;
1645
+ }
1646
+ const rowIndexes = Array.from(selectedRows).sort((a, b) => b - a);
1647
+ onSave((config) => rowIndexes.reduce((nextConfig, rowIndex) => deleteTableRow(nextConfig, table, rowIndex), config));
1648
+ setSelectedRow(null);
1649
+ clearRowSelection();
1650
+ }
1651
+
1652
+ const selectedEntry = selectedRow === null ? null : rowEntries[selectedRow];
1653
+ const selectedRecord = selectedEntry?.row || null;
1654
+
1655
+ return (
1656
+ <div className="dm-db-surface">
1657
+ {!table.mutable && (
1658
+ <div className="dm-source-notice">
1659
+ <AlertCircle size={13} />
1660
+ <span>Dynamic integration records are resolved at runtime.</span>
1661
+ </div>
1662
+ )}
1663
+ <div className="dm-db-toolbar">
1664
+ <div className="dm-filter-chip-row">
1665
+ {selectedRowCount > 0 && (
1666
+ <span className="dm-filter-chip dm-selection-count">
1667
+ {pluralize(selectedRowCount, "record")} selected
1668
+ </span>
1669
+ )}
1670
+ {settings.filter?.clauses?.map((clause) => (
1671
+ <button key={`${clause.fieldId}:${clause.operator}`} type="button" className="dm-filter-chip" onClick={() => removeFilter(clause.fieldId)}>
1672
+ <LucideIcon name={FIELD_TYPE_ICON_NAMES[settings.types?.[clause.fieldId] || inferFieldType(clause.fieldId)] || "Type"} size={12} />
1673
+ <span>{clause.fieldId}: {clause.operator}{clause.value !== undefined ? ` ${clause.value}` : ""}</span>
1674
+ <X size={12} />
1675
+ </button>
1676
+ ))}
1677
+ </div>
1678
+ <div className="dm-records-actions">
1679
+ <span className="dm-filter-anchor">
1680
+ <button type="button" className="dm-btn-ghost" onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
1681
+ <Filter size={13} />Filter
1682
+ </button>
1683
+ {filterTarget === "toolbar" && (
1684
+ <div className="dm-filter-popover dm-filter-popover-toolbar">
1685
+ <StaticSelect value={filterDraft.fieldId} options={visibleColumns.map((column) => ({ value: column, label: column }))} onChange={(next) => setFilterDraft((current) => ({ ...current, fieldId: next }))} />
1686
+ <StaticSelect value={filterDraft.operator} options={FILTER_OPERATOR_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} onChange={(next) => setFilterDraft((current) => ({ ...current, operator: next }))} />
1687
+ {!["isEmpty", "isNotEmpty"].includes(filterDraft.operator) && (
1688
+ <input value={filterDraft.value} placeholder="Value" onChange={(event) => setFilterDraft((current) => ({ ...current, value: event.target.value }))} />
1689
+ )}
1690
+ <div className="dm-filter-popover-actions">
1691
+ <button type="button" className="dm-btn-outline" onClick={() => setFilterTarget("")}>Cancel</button>
1692
+ <button type="button" className="dm-btn-primary-sm" onClick={applyFilter}>Apply</button>
1693
+ </div>
1694
+ </div>
1695
+ )}
1696
+ </span>
1697
+ {activeView ? (
1698
+ <button type="button" className="dm-btn-ghost" onClick={updateCurrentView}>
1699
+ Update view
1700
+ </button>
1701
+ ) : (
1702
+ <button type="button" className="dm-btn-ghost" onClick={saveCurrentAsNewView}>
1703
+ Save as new view
1704
+ </button>
1705
+ )}
1706
+ {table.rows.length > 0 && (
1707
+ <button type="button" className="dm-btn-ghost" onClick={() => {
1708
+ const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
1709
+ const url = URL.createObjectURL(blob);
1710
+ const a = document.createElement("a");
1711
+ a.href = url; a.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
1712
+ a.click(); URL.revokeObjectURL(url);
1713
+ }}>Export CSV</button>
1714
+ )}
1715
+ {table.mutable && <button type="button" className="dm-btn-ghost" onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>}
1716
+ {table.mutable && (
1717
+ <button type="button" className="dm-btn-primary-sm" disabled={saving} onClick={() => onSave((config) => addTableRow(config, table))}>
1718
+ <Plus size={13} />Add record
1719
+ </button>
1720
+ )}
1721
+ {table.mutable && selectedRowCount > 0 && (
1722
+ <>
1723
+ <button type="button" className="dm-btn-ghost" disabled={saving} onClick={clearRowSelection}>Cancel selection</button>
1724
+ <button type="button" className="dm-btn-danger-sm" disabled={saving} onClick={deleteSelectedRows}>
1725
+ {confirmDeleteSelection ? `Confirm delete ${selectedRowCount}` : "Delete"}
1726
+ </button>
1727
+ </>
1728
+ )}
1729
+ </div>
1730
+ </div>
1731
+ {csvOpen && (
1732
+ <div className="dm-csv-panel">
1733
+ <textarea className="dm-csv-textarea" rows={4} value={csvText} onChange={(e) => setCsvText(e.target.value)} placeholder={"Name,Status\nAcme,Active"} />
1734
+ <div className="dm-csv-opts">
1735
+ <label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
1736
+ <label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
1737
+ <button type="button" className="dm-btn-primary-sm" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
1738
+ </div>
1739
+ </div>
1740
+ )}
1741
+ <div className="dm-db-grid-wrap">
1742
+ <div className="dm-db-grid-scroll">
1743
+ <table className="dm-db-grid">
1744
+ <thead>
1745
+ <tr>
1746
+ <th className="dm-db-rownum dm-db-rownum-head">
1747
+ {table.mutable ? (
1748
+ <div className="dm-row-select-head-wrap">
1749
+ <button type="button" className="dm-row-select dm-row-select-all" aria-label={allPageSelected ? "Clear page selection" : "Select current page"} aria-pressed={allPageSelected} onClick={(event) => { event.stopPropagation(); toggleCurrentPageSelection(); }}>
1750
+ <span className="dm-row-select-box" />
1751
+ <span className="dm-row-number">#</span>
1752
+ </button>
1753
+ <button type="button" className="dm-row-select-menu-btn" aria-label="Selection options" aria-expanded={selectMenuOpen} onClick={(event) => { event.stopPropagation(); setSelectMenuOpen((open) => !open); }}>
1754
+ <ChevronDown size={11} />
1755
+ </button>
1756
+ {selectMenuOpen && (
1757
+ <div className="dm-row-select-menu">
1758
+ <button type="button" onClick={selectCurrentPage}>Select page</button>
1759
+ <button type="button" onClick={selectAllFilteredRows}>Select all filtered</button>
1760
+ <button type="button" disabled={!selectedRowCount} onClick={clearRowSelection}>Clear selection</button>
1761
+ </div>
1762
+ )}
1763
+ </div>
1764
+ ) : "#"}
1765
+ </th>
1766
+ {visibleColumns.map((column) => (
1767
+ <th key={column}>
1768
+ <button type="button" className="dm-db-head-btn" onClick={() => setMenuColumn((current) => current === column ? "" : column)}>
1769
+ <span className="dm-db-field-type"><LucideIcon name={FIELD_TYPE_ICON_NAMES[settings.types?.[column] || inferFieldType(column)] || "Type"} size={12} /></span>
1770
+ {column}
1771
+ {settings.sort?.[0]?.fieldId === column && (settings.sort[0].direction === "desc" ? <ArrowDownAZ size={12} /> : <ArrowUpAZ size={12} />)}
1772
+ <MoreHorizontal size={12} />
1773
+ </button>
1774
+ {menuColumn === column && (
1775
+ <div className="dm-col-menu">
1776
+ <button type="button" onClick={() => {
1777
+ setFilterDraft({ fieldId: column, operator: "eq", value: "" });
1778
+ setFilterTarget(column);
1779
+ setMenuColumn("");
1780
+ }}><Filter size={13} />Filter</button>
1781
+ <button type="button" onClick={() => setSort(column, "asc")}><ArrowUpAZ size={13} />Sort ascending</button>
1782
+ <button type="button" onClick={() => setSort(column, "desc")}><ArrowDownAZ size={13} />Sort descending</button>
1783
+ <button type="button" onClick={() => moveColumn(column, "left")}><ArrowRight size={13} style={{ transform: "rotate(180deg)" }} />Move left</button>
1784
+ <button type="button" onClick={() => moveColumn(column, "right")}><ArrowRight size={13} />Move right</button>
1785
+ <button type="button" onClick={() => toggleColumnHidden(column)}><EyeOff size={13} />Hide</button>
1786
+ </div>
1787
+ )}
1788
+ {filterTarget === column && (
1789
+ <div className="dm-filter-popover dm-filter-popover-column">
1790
+ <StaticSelect value={filterDraft.fieldId} options={visibleColumns.map((item) => ({ value: item, label: item }))} onChange={(next) => setFilterDraft((current) => ({ ...current, fieldId: next }))} />
1791
+ <StaticSelect value={filterDraft.operator} options={FILTER_OPERATOR_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} onChange={(next) => setFilterDraft((current) => ({ ...current, operator: next }))} />
1792
+ {!["isEmpty", "isNotEmpty"].includes(filterDraft.operator) && (
1793
+ <input value={filterDraft.value} placeholder="Value" onChange={(event) => setFilterDraft((current) => ({ ...current, value: event.target.value }))} />
1794
+ )}
1795
+ <div className="dm-filter-popover-actions">
1796
+ <button type="button" className="dm-btn-outline" onClick={() => setFilterTarget("")}>Cancel</button>
1797
+ <button type="button" className="dm-btn-primary-sm" onClick={applyFilter}>Apply</button>
1798
+ </div>
1799
+ </div>
1800
+ )}
1801
+ </th>
1802
+ ))}
1803
+ {table.mutable && (
1804
+ <th className="dm-db-add-field">
1805
+ <button type="button" onClick={() => setAddingField(true)}>
1806
+ <Plus size={13} />Field
1807
+ </button>
1808
+ {addingField && (
1809
+ <div className="dm-field-creator-popover">
1810
+ <div className="dm-field-creator">
1811
+ <input
1812
+ ref={fieldInputRef}
1813
+ value={fieldName}
1814
+ placeholder={FIELD_TYPE_CHOICES.find((choice) => choice.value === fieldType)?.sample || "Field name"}
1815
+ onChange={(event) => setFieldName(event.target.value)}
1816
+ onKeyDown={(event) => {
1817
+ if (event.key === "Enter") commitField();
1818
+ if (event.key === "Escape") { setAddingField(false); setFieldName(""); }
1819
+ }}
1820
+ />
1821
+ <div className="dm-field-type-grid">
1822
+ {FIELD_TYPE_CHOICES.map((choice) => (
1823
+ <button key={choice.value} type="button" className={fieldType === choice.value ? "active" : ""} onClick={() => setFieldType(choice.value)}>
1824
+ <LucideIcon name={choice.icon} size={12} />
1825
+ <span>{choice.label}</span>
1826
+ </button>
1827
+ ))}
1828
+ </div>
1829
+ <div className="dm-field-creator-actions">
1830
+ <button type="button" className="dm-btn-outline" onClick={() => { setAddingField(false); setFieldName(""); }}>Cancel</button>
1831
+ <button type="button" className="dm-btn-primary-sm" onClick={commitField}>Create</button>
1832
+ </div>
1833
+ </div>
1834
+ </div>
1835
+ )}
1836
+ </th>
1837
+ )}
1838
+ </tr>
1839
+ </thead>
1840
+ <tbody>
1841
+ {pageEntries.map(({ row, originalIndex }, rowIndex) => {
1842
+ const visibleIndex = pageStart + rowIndex;
1843
+ const displayIndex = visibleIndex + 1;
1844
+ return (
1845
+ <tr key={`${originalIndex}:${visibleIndex}`} className={`${selectedRow === visibleIndex ? "selected" : ""}${selectedRows.has(originalIndex) ? " multi-selected" : ""}`} onClick={() => setSelectedRow(visibleIndex)}>
1846
+ <td className="dm-db-rownum">
1847
+ {table.mutable ? (
1848
+ <button type="button" className="dm-row-select" aria-label={selectedRows.has(originalIndex) ? `Deselect row ${displayIndex}` : `Select row ${displayIndex}`} aria-pressed={selectedRows.has(originalIndex)} onClick={(event) => { event.stopPropagation(); toggleRowSelection(originalIndex, visibleIndex, event); }}>
1849
+ <span className="dm-row-select-box" />
1850
+ <span className="dm-row-number">{displayIndex}</span>
1851
+ </button>
1852
+ ) : displayIndex}
1853
+ </td>
1854
+ {visibleColumns.map((column) => {
1855
+ const relation = relationForColumn(table, column);
1856
+ return (
1857
+ <td key={column}>
1858
+ {relation ? (
1859
+ <RelationPickerOrSelect
1860
+ table={table}
1861
+ tables={tables}
1862
+ column={column}
1863
+ value={String(row?.[column] || "")}
1864
+ disabled={!table.mutable || saving}
1865
+ onChange={(nextValue) => onSave((config) => updateTableCell(config, table, originalIndex, column, nextValue))}
1866
+ />
1867
+ ) : column.toLowerCase() === "status" ? (
1868
+ <StatusPill value={row?.[column]} />
1869
+ ) : (
1870
+ <span className={row?.[column] ? "" : "dm-cell-empty"}>
1871
+ {formatCellValue(row?.[column], column) || "—"}
1872
+ </span>
1873
+ )}
1874
+ </td>
1875
+ );})}
1876
+ {table.mutable && <td className="dm-db-empty-cell" />}
1877
+ </tr>
1878
+ );})}
1879
+ {table.mutable && (
1880
+ <tr className="dm-db-new-row" onClick={() => onSave((config) => addTableRow(config, table))}>
1881
+ <td className="dm-db-rownum">+</td>
1882
+ <td colSpan={Math.max(visibleColumns.length, 1) + 1}>Add record</td>
1883
+ </tr>
1884
+ )}
1885
+ </tbody>
1886
+ </table>
1887
+ </div>
1888
+ <div className="dm-pagination-bar">
1889
+ <span className="dm-pagination-summary">Showing {rowEntries.length ? pageStart + 1 : 0}-{pageEnd} of {rowEntries.length}</span>
1890
+ <div className="dm-pagination-controls">
1891
+ <label className="dm-page-size-control">
1892
+ <span>Rows</span>
1893
+ <select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPageIndex(0); }}>
1894
+ <option value={15}>15</option>
1895
+ <option value={25}>25</option>
1896
+ <option value={50}>50</option>
1897
+ <option value={100}>100</option>
1898
+ </select>
1899
+ </label>
1900
+ <button type="button" className="dm-pagination-btn" disabled={safePageIndex === 0} onClick={() => setPageIndex((current) => Math.max(0, current - 1))}>Previous</button>
1901
+ <span className="dm-pagination-page">{safePageIndex + 1} / {pageCount}</span>
1902
+ <button type="button" className="dm-pagination-btn" disabled={safePageIndex >= pageCount - 1} onClick={() => setPageIndex((current) => Math.min(pageCount - 1, current + 1))}>Next</button>
1903
+ </div>
1904
+ </div>
1905
+ </div>
1906
+ <DataModelRecordDrawer
1907
+ table={table}
1908
+ tables={tables}
1909
+ workspaceConfig={workspaceConfig}
1910
+ rowIndex={selectedEntry?.originalIndex ?? null}
1911
+ row={selectedRecord}
1912
+ saving={saving}
1913
+ onClose={() => setSelectedRow(null)}
1914
+ onSave={onSave}
1915
+ />
1916
+ </div>
1917
+ );
1918
+ }
1919
+
1920
+ // ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
1921
+
1922
+ function IconPicker({ value, onChange }) {
1923
+ return (
1924
+ <div className="dm-icon-picker">
1925
+ {ICON_PICKER_SET.map((name) => (
1926
+ <button
1927
+ key={name}
1928
+ type="button"
1929
+ className={`dm-icon-picker-btn${value === name ? " active" : ""}`}
1930
+ title={name}
1931
+ onClick={() => onChange(name)}
1932
+ >
1933
+ <LucideIcon name={name} size={16} />
1934
+ </button>
1935
+ ))}
1936
+ </div>
1937
+ );
1938
+ }
1939
+
1940
+ function AddObjectSidebar({ open, saving, onClose, onCreate, allTables }) {
1941
+ const [step, setStep] = useState(0); // 0 = type picker, 1 = name + icon
1942
+ const [selectedType, setSelectedType] = useState(null);
1943
+ const [name, setName] = useState("");
1944
+ const [icon, setIcon] = useState(null);
1945
+ const [error, setError] = useState("");
1946
+ const inputRef = useRef(null);
1947
+
1948
+ useEffect(() => {
1949
+ if (!open) return;
1950
+ setStep(0);
1951
+ setSelectedType(null);
1952
+ setName("");
1953
+ setIcon(null);
1954
+ setError("");
1955
+ }, [open]);
1956
+
1957
+ useEffect(() => {
1958
+ if (step === 1) setTimeout(() => inputRef.current?.focus(), 80);
1959
+ }, [step]);
1960
+
1961
+ function pickType(typeDef) {
1962
+ setSelectedType(typeDef);
1963
+ setIcon(typeDef.icon.displayName || OBJECT_TYPE_PRESETS[typeDef.type]?.icon || "Database");
1964
+ setStep(1);
1965
+ }
1966
+
1967
+ function submit(e) {
1968
+ e.preventDefault();
1969
+ const objectName = name.trim();
1970
+ if (!objectName) { setError("Object name is required."); return; }
1971
+ setError("");
1972
+ onCreate({ name: objectName, objectType: selectedType.type, icon });
1973
+ }
1974
+
1975
+ return (
1976
+ <>
1977
+ {open && <div className="dm-sidebar-backdrop" onClick={onClose} />}
1978
+ <aside className={`dm-add-sidebar${open ? " open" : ""}`} role="dialog" aria-label="New object" aria-modal="true">
1979
+ <div className="dm-add-sidebar-head">
1980
+ <div className="dm-add-sidebar-head-left">
1981
+ {step === 1 && (
1982
+ <button type="button" className="dm-sidebar-back" onClick={() => setStep(0)}>
1983
+
1984
+ </button>
1985
+ )}
1986
+ <h2>{step === 0 ? "New object" : `New ${selectedType?.label}`}</h2>
1987
+ </div>
1988
+ <button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
1989
+ <X size={16} />
1990
+ </button>
1991
+ </div>
1992
+
1993
+ {step === 0 && (
1994
+ <div className="dm-type-picker">
1995
+ <p className="dm-type-picker-hint">Choose an object type to start with the right fields and relation bindings.</p>
1996
+ <div className="dm-type-picker-list">
1997
+ {OBJECT_TYPE_DEFS.map((def) => {
1998
+ const Icon = def.icon;
1999
+ return (
2000
+ <button key={def.type} type="button" className="dm-type-card" onClick={() => pickType(def)}>
2001
+ <div className="dm-type-card-icon">
2002
+ <Icon size={18} />
2003
+ </div>
2004
+ <div className="dm-type-card-body">
2005
+ <strong>{def.label}</strong>
2006
+ <span>{def.description}</span>
2007
+ </div>
2008
+ <ChevronRight size={14} className="dm-type-card-arrow" />
2009
+ </button>
2010
+ );
2011
+ })}
2012
+ </div>
2013
+ </div>
2014
+ )}
2015
+
2016
+ {step === 1 && selectedType && (
2017
+ <form className="dm-add-sidebar-body" onSubmit={submit}>
2018
+ <div className="dm-add-type-preview">
2019
+ <div className="dm-add-type-icon">
2020
+ <LucideIcon name={icon || OBJECT_TYPE_PRESETS[selectedType.type]?.icon || "Database"} size={20} />
2021
+ </div>
2022
+ <div>
2023
+ <p className="dm-add-type-label">{selectedType.label}</p>
2024
+ <p className="dm-add-sidebar-hint">{selectedType.description}</p>
2025
+ </div>
2026
+ </div>
2027
+
2028
+ <label className="dm-field-label-v2">
2029
+ <span>Object name</span>
2030
+ <input
2031
+ ref={inputRef}
2032
+ className="dm-input-v2"
2033
+ value={name}
2034
+ placeholder={selectedType.type === "data-source" ? "My Analytics API, Salesforce Feed…" : selectedType.type === "api-registry" ? "GA4 Resolver, Stripe Adapter…" : "Name this object…"}
2035
+ onChange={(e) => setName(e.target.value)}
2036
+ />
2037
+ </label>
2038
+
2039
+ <label className="dm-field-label-v2">
2040
+ <span>Icon</span>
2041
+ <IconPicker value={icon} onChange={setIcon} />
2042
+ </label>
2043
+
2044
+ {OBJECT_TYPE_PRESETS[selectedType.type]?.columns?.length > 0 && (
2045
+ <div className="dm-preset-fields-preview">
2046
+ <p className="dm-usage-label">Pre-populated fields</p>
2047
+ <div className="dm-preset-fields-list">
2048
+ {OBJECT_TYPE_PRESETS[selectedType.type].columns.map((col) => (
2049
+ <span key={col} className="dm-preset-field-chip">{col}</span>
2050
+ ))}
2051
+ </div>
2052
+ </div>
2053
+ )}
2054
+
2055
+ {OBJECT_TYPE_PRESETS[selectedType.type]?.relations?.length > 0 && (
2056
+ <div className="dm-preset-relations-preview">
2057
+ <p className="dm-usage-label">Built-in relations</p>
2058
+ {OBJECT_TYPE_PRESETS[selectedType.type].relations.map((rel) => (
2059
+ <div key={rel.id} className="dm-preset-relation-row">
2060
+ <Zap size={12} />
2061
+ <span>{rel.name}</span>
2062
+ <ArrowRight size={11} />
2063
+ <span className="dm-preset-rel-target">{OBJECT_TYPE_PRESETS[rel.targetObjectType]?.label || rel.targetObjectType}</span>
2064
+ </div>
2065
+ ))}
2066
+ </div>
2067
+ )}
2068
+
2069
+ {error && <p className="dm-field-error">{error}</p>}
2070
+
2071
+ <div className="dm-add-sidebar-actions">
2072
+ <button type="button" className="dm-btn-outline" onClick={onClose}>Cancel</button>
2073
+ <button type="submit" className="dm-btn-primary" disabled={saving || !name.trim()}>
2074
+ Create object
2075
+ </button>
2076
+ </div>
2077
+ </form>
2078
+ )}
2079
+ </aside>
2080
+ </>
2081
+ );
2082
+ }
2083
+
2084
+ // ─── Page ─────────────────────────────────────────────────────────────────────
2085
+
2086
+ // Auto-save tempo: hold local edits in memory + localStorage, only PATCH the
2087
+ // server after this idle window. Keeps growthub.config.json from rewriting on
2088
+ // every keystroke and lets the UI stay snappy on slow disks.
2089
+ const SAVE_DEBOUNCE_MS = 20000;
2090
+ const LOCAL_CACHE_KEY = "growthub.workspace.dataModel.localDraft.v1";
2091
+
2092
+ export default function DataModelShell() {
2093
+ const [workspaceConfig, setWorkspaceConfig] = useState(null);
2094
+ const [authority, setAuthority] = useState(null);
2095
+ const [loading, setLoading] = useState(true);
2096
+ const [error, setError] = useState("");
2097
+ const [saving, setSaving] = useState(false);
2098
+ const [message, setMessage] = useState("");
2099
+ const [selectedSource, setSelectedSource] = useState("");
2100
+ const [addOpen, setAddOpen] = useState(false);
2101
+ const pendingPatchRef = useRef({});
2102
+ const saveTimerRef = useRef(null);
2103
+
2104
+ const load = useCallback(async () => {
2105
+ setLoading(true);
2106
+ setError("");
2107
+ try {
2108
+ const res = await fetch("/api/workspace", { cache: "no-store" });
2109
+ const payload = await res.json();
2110
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
2111
+ setWorkspaceConfig(payload.workspaceConfig);
2112
+ setAuthority(payload.adapters?.integrations?.authority || null);
2113
+ } catch (err) {
2114
+ setError(err.message || "Failed to load workspace");
2115
+ } finally {
2116
+ setLoading(false);
2117
+ }
2118
+ }, []);
2119
+
2120
+ useEffect(() => { load(); }, [load]);
2121
+
2122
+ const tables = useMemo(
2123
+ () => (workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : []),
2124
+ [workspaceConfig],
2125
+ );
2126
+
2127
+ const selectedTable = tables.find((t) => t.source === selectedSource) || tables[0] || null;
2128
+
2129
+ useEffect(() => {
2130
+ if (!selectedSource && tables[0]) setSelectedSource(tables[0].source);
2131
+ }, [selectedSource, tables]);
2132
+
2133
+ // Flush any accumulated patch keys to the server. Called by the debounce
2134
+ // timer and on visibilitychange/beforeunload so no local edit is lost.
2135
+ const flushPendingPatch = useCallback(async () => {
2136
+ const patch = pendingPatchRef.current;
2137
+ pendingPatchRef.current = {};
2138
+ if (saveTimerRef.current) {
2139
+ clearTimeout(saveTimerRef.current);
2140
+ saveTimerRef.current = null;
2141
+ }
2142
+ if (Object.keys(patch).length === 0) return;
2143
+ setSaving(true);
2144
+ setMessage("");
2145
+ try {
2146
+ const res = await fetch("/api/workspace", {
2147
+ method: "PATCH",
2148
+ headers: { "content-type": "application/json" },
2149
+ body: JSON.stringify(patch),
2150
+ });
2151
+ const payload = await res.json();
2152
+ if (!res.ok) throw new Error(payload.error || "Save failed");
2153
+ setWorkspaceConfig(payload.workspaceConfig);
2154
+ setMessage("Saved");
2155
+ try { window.localStorage.removeItem(LOCAL_CACHE_KEY); } catch {}
2156
+ } catch (err) {
2157
+ setMessage(`Error: ${err.message || "Save failed"}`);
2158
+ } finally {
2159
+ setSaving(false);
2160
+ }
2161
+ }, []);
2162
+
2163
+ // Mutate-in-memory immediately so the UI feels instant, persist a draft to
2164
+ // localStorage every change, and only PATCH the server after SAVE_DEBOUNCE_MS
2165
+ // of idleness. Sandbox-environment objects' lastRunId/lastResponse fields
2166
+ // bypass the debounce (they need durability for run telemetry).
2167
+ const save = useCallback((mutate) => {
2168
+ setWorkspaceConfig((current) => {
2169
+ if (!current) return current;
2170
+ const next = mutate(current);
2171
+ const patch = pendingPatchRef.current;
2172
+ let touchedSandboxRun = false;
2173
+ for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
2174
+ if (next[key] !== current[key]) patch[key] = next[key];
2175
+ }
2176
+ try {
2177
+ const sandboxKey = JSON.stringify((next.dataModel?.objects || []).find((o) => o.objectType === "sandbox-environment")?.rows || []);
2178
+ const prevSandboxKey = JSON.stringify((current.dataModel?.objects || []).find((o) => o.objectType === "sandbox-environment")?.rows || []);
2179
+ if (sandboxKey !== prevSandboxKey) touchedSandboxRun = true;
2180
+ } catch {}
2181
+ try {
2182
+ window.localStorage.setItem(LOCAL_CACHE_KEY, JSON.stringify({ savedAt: Date.now(), patch }));
2183
+ } catch {}
2184
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
2185
+ if (touchedSandboxRun) {
2186
+ // immediate flush: durable sandbox run state must persist
2187
+ Promise.resolve().then(flushPendingPatch);
2188
+ } else {
2189
+ saveTimerRef.current = setTimeout(flushPendingPatch, SAVE_DEBOUNCE_MS);
2190
+ }
2191
+ return next;
2192
+ });
2193
+ }, [flushPendingPatch]);
2194
+
2195
+ // Flush before navigation / tab close so the 20s window never silently drops a draft.
2196
+ useEffect(() => {
2197
+ function handleBeforeUnload() { flushPendingPatch(); }
2198
+ function handleVisibility() { if (document.visibilityState === "hidden") flushPendingPatch(); }
2199
+ window.addEventListener("beforeunload", handleBeforeUnload);
2200
+ document.addEventListener("visibilitychange", handleVisibility);
2201
+ return () => {
2202
+ window.removeEventListener("beforeunload", handleBeforeUnload);
2203
+ document.removeEventListener("visibilitychange", handleVisibility);
2204
+ flushPendingPatch();
2205
+ };
2206
+ }, [flushPendingPatch]);
2207
+
2208
+ const createObject = useCallback(({ name, objectType, icon }) => {
2209
+ save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
2210
+ setSelectedSource(name);
2211
+ setAddOpen(false);
2212
+ }, [save]);
2213
+
2214
+ return (
2215
+ <main className="workspace-builder workspace-settings-page">
2216
+ <NavRail authority={authority} workspaceConfig={workspaceConfig} />
2217
+
2218
+ <section className="workspace-surface">
2219
+ <header className="workspace-toolbar">
2220
+ <div><p>Workspace</p><h1>Data Model</h1></div>
2221
+ <div className="workspace-toolbar-actions">
2222
+ <SaveToast saving={saving} message={message} />
2223
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
2224
+ <Plus size={14} />New object
2225
+ </button>
2226
+ </div>
2227
+ </header>
2228
+
2229
+ <AddObjectSidebar
2230
+ open={addOpen}
2231
+ saving={saving}
2232
+ onClose={() => setAddOpen(false)}
2233
+ onCreate={createObject}
2234
+ allTables={tables}
2235
+ />
2236
+
2237
+ {loading && <div className="dm-loading">Loading workspace…</div>}
2238
+
2239
+ {error && (
2240
+ <div className="dm-error-state">
2241
+ <AlertCircle size={28} />
2242
+ <strong>Could not load workspace</strong>
2243
+ <p>{error}</p>
2244
+ <button type="button" className="dm-btn-primary" onClick={load}>Retry</button>
2245
+ </div>
2246
+ )}
2247
+
2248
+ {!loading && !error && tables.length > 0 && (
2249
+ selectedTable && (
2250
+ <section className="dm-detail-v2 dm-detail-v3">
2251
+ <div className="dm-detail-v2-head dm-detail-v3-head">
2252
+ <div className="dm-detail-v2-title">
2253
+ <ObjectViewPicker tables={tables} selectedTable={selectedTable} saving={saving} onSelectSource={setSelectedSource} onSave={save} />
2254
+ </div>
2255
+ <SourceValidationBanner table={selectedTable} />
2256
+ </div>
2257
+ <DataModelTableSurface workspaceConfig={workspaceConfig} table={selectedTable} tables={tables} saving={saving} onSave={save} />
2258
+ </section>
2259
+ )
2260
+ )}
2261
+
2262
+ {!loading && !error && tables.length === 0 && (
2263
+ <div className="dm-page-empty">
2264
+ <Database size={32} />
2265
+ <strong>No objects yet</strong>
2266
+ <p>Create a Data Source, API Registry, People, Tasks, or Custom object to get started.</p>
2267
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
2268
+ <Plus size={14} />New object
2269
+ </button>
2270
+ </div>
2271
+ )}
2272
+ </section>
2273
+ </main>
2274
+ );
2275
+ }
2276
+
2277
+ export { DataModelTableSurface, DataModelRecordDrawer, RecordFieldEditor };