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