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