@growthub/cli 0.9.13 → 0.9.14
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/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/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 +692 -223
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +996 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1539 -433
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -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/workspace-config.js +79 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
- 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 +7 -0
- package/dist/index.js +1764 -40677
- package/package.json +1 -1
|
@@ -1,54 +1,151 @@
|
|
|
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
|
+
ChevronRight,
|
|
14
|
+
Code2,
|
|
15
|
+
Database,
|
|
16
|
+
FileText,
|
|
17
|
+
Globe,
|
|
18
|
+
Hash,
|
|
19
|
+
Layers,
|
|
20
|
+
Link2,
|
|
21
|
+
List,
|
|
22
|
+
Mail,
|
|
23
|
+
Plus,
|
|
24
|
+
ShoppingCart,
|
|
25
|
+
Star,
|
|
26
|
+
Tag,
|
|
27
|
+
ToggleLeft,
|
|
28
|
+
Type,
|
|
29
|
+
Users,
|
|
30
|
+
X,
|
|
31
|
+
Zap,
|
|
32
|
+
} from "lucide-react";
|
|
5
33
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
34
|
import {
|
|
35
|
+
OBJECT_TYPE_PRESETS,
|
|
7
36
|
addTableField,
|
|
8
37
|
addTableRow,
|
|
9
38
|
appendRowsToTable,
|
|
10
|
-
|
|
11
|
-
deleteTableRow,
|
|
39
|
+
createTypedBusinessObject,
|
|
12
40
|
describeBindingLane,
|
|
13
|
-
describeBindingMode,
|
|
14
|
-
duplicateTableRow,
|
|
15
41
|
exportTableAsCsv,
|
|
16
42
|
importTableFromCsv,
|
|
17
43
|
listWorkspaceDataModelTables,
|
|
18
44
|
replaceTableContent,
|
|
19
|
-
updateTableCell
|
|
45
|
+
updateTableCell,
|
|
20
46
|
} from "@/lib/workspace-data-model";
|
|
21
47
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
// ─── Icon system ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const LUCIDE_MAP = {
|
|
51
|
+
Activity, BarChart2, Box, Building2, Calendar, CheckSquare, Code2,
|
|
52
|
+
Database, FileText, Globe, Hash, Layers, Link2, List, Mail, Plus,
|
|
53
|
+
ShoppingCart, Star, Tag, Type, Users, Zap,
|
|
28
54
|
};
|
|
29
55
|
|
|
30
|
-
|
|
31
|
-
|
|
56
|
+
const ICON_PICKER_SET = [
|
|
57
|
+
"Database", "Globe", "Code2", "Users", "CheckSquare", "Building2",
|
|
58
|
+
"Tag", "Star", "Zap", "FileText", "Mail", "BarChart2",
|
|
59
|
+
"Layers", "Box", "Activity", "ShoppingCart",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function LucideIcon({ name, size = 14, className, style }) {
|
|
63
|
+
const Icon = LUCIDE_MAP[name] || Database;
|
|
64
|
+
return <Icon size={size} className={className} style={style} aria-hidden="true" />;
|
|
32
65
|
}
|
|
33
66
|
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
// ─── Object type definitions for the type-picker step ────────────────────────
|
|
68
|
+
|
|
69
|
+
const OBJECT_TYPE_DEFS = [
|
|
70
|
+
{
|
|
71
|
+
type: "data-source",
|
|
72
|
+
icon: Globe,
|
|
73
|
+
label: "Data Source",
|
|
74
|
+
description: "Custom API, webhook, or external feed. Linked to a resolver via API Registry.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "api-registry",
|
|
78
|
+
icon: Code2,
|
|
79
|
+
label: "API Registry",
|
|
80
|
+
description: "Resolver adapters — integrationId + fetch functions that power Data Sources.",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: "people",
|
|
84
|
+
icon: Users,
|
|
85
|
+
label: "People",
|
|
86
|
+
description: "Contacts, leads, or team members with standard CRM fields.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: "tasks",
|
|
90
|
+
icon: CheckSquare,
|
|
91
|
+
label: "Tasks",
|
|
92
|
+
description: "Action items, to-dos, and work tracking.",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: "custom",
|
|
96
|
+
icon: Plus,
|
|
97
|
+
label: "Custom",
|
|
98
|
+
description: "Blank table — define your own fields from scratch.",
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// ─── Lane / badge meta ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const OBJECT_TYPE_BADGE = {
|
|
105
|
+
"data-source": { label: "Data Source", cls: "dm-badge-datasource" },
|
|
106
|
+
"api-registry": { label: "API Registry", cls: "dm-badge-registry" },
|
|
107
|
+
people: { label: "People", cls: "dm-badge-people" },
|
|
108
|
+
tasks: { label: "Tasks", cls: "dm-badge-tasks" },
|
|
109
|
+
custom: { label: "Custom", cls: "dm-badge-manual" },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const FIELD_TYPE_ICON_NAMES = {
|
|
113
|
+
text: "Type", number: "Hash", date: "Calendar", url: "Link2", select: "List", boolean: "ToggleLeft",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function inferFieldType(name) {
|
|
117
|
+
const n = name.toLowerCase();
|
|
118
|
+
if (n.includes("date") || n.includes("_at") || n.includes("created") || n.includes("updated")) return "date";
|
|
119
|
+
if (n.includes("url") || n.includes("link") || n.includes("website") || n === "endpoint" || n === "baseurl") return "url";
|
|
120
|
+
if (n.includes("count") || n.includes("num") || n.includes("amount") || n.includes("arr") || n.includes("price")) return "number";
|
|
121
|
+
if (n === "status" || n === "stage" || n === "type" || n === "icp" || n === "priority" || n === "authtype" || n === "method") return "select";
|
|
122
|
+
if (n.startsWith("is_") || n.includes("active") || n.includes("enabled")) return "boolean";
|
|
123
|
+
return "text";
|
|
36
124
|
}
|
|
37
125
|
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
126
|
+
function pluralize(count, word) {
|
|
127
|
+
return `${count} ${count === 1 ? word : `${word}s`}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function objectTypeBadge(objectType) {
|
|
131
|
+
return OBJECT_TYPE_BADGE[objectType] || OBJECT_TYPE_BADGE.custom;
|
|
42
132
|
}
|
|
43
133
|
|
|
44
134
|
function textColorForAccent(accent) {
|
|
45
135
|
const hex = String(accent || "").replace("#", "");
|
|
46
136
|
if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
137
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
138
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
139
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
140
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62 ? "#252525" : "#ffffff";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Shared micro-components ──────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function SaveToast({ saving, message }) {
|
|
146
|
+
if (saving) return <span className="dm-toast saving">Saving…</span>;
|
|
147
|
+
if (!message) return null;
|
|
148
|
+
return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
|
|
52
149
|
}
|
|
53
150
|
|
|
54
151
|
function NavRail({ authority, workspaceConfig }) {
|
|
@@ -57,10 +154,13 @@ function NavRail({ authority, workspaceConfig }) {
|
|
|
57
154
|
return (
|
|
58
155
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
59
156
|
<div className="workspace-brand">
|
|
60
|
-
<span
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
157
|
+
<span
|
|
158
|
+
className="workspace-mark"
|
|
159
|
+
style={{
|
|
160
|
+
background: branding.logoUrl ? undefined : branding.accent || undefined,
|
|
161
|
+
color: branding.logoUrl ? undefined : textColorForAccent(branding.accent),
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
64
164
|
{branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
|
|
65
165
|
</span>
|
|
66
166
|
<span>{workspaceName}</span>
|
|
@@ -68,81 +168,239 @@ function NavRail({ authority, workspaceConfig }) {
|
|
|
68
168
|
<nav className="workspace-nav">
|
|
69
169
|
<Link href="/">Dashboards</Link>
|
|
70
170
|
<Link className="active" href="/data-model">Data Model</Link>
|
|
71
|
-
<Link href="/settings/integrations">Integrations</Link>
|
|
72
171
|
<span className="workspace-nav-static">Management</span>
|
|
73
172
|
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
74
173
|
</nav>
|
|
75
|
-
<div className="workspace-rail-status"
|
|
174
|
+
<div className="workspace-rail-status">
|
|
175
|
+
<span className="status-dot" />
|
|
176
|
+
{authority || "local-catalog"}
|
|
177
|
+
</div>
|
|
76
178
|
</aside>
|
|
77
179
|
);
|
|
78
180
|
}
|
|
79
181
|
|
|
182
|
+
// ─── Object list row ──────────────────────────────────────────────────────────
|
|
183
|
+
|
|
80
184
|
function ObjectRow({ table, selected, onSelect }) {
|
|
81
|
-
const
|
|
185
|
+
const badge = objectTypeBadge(table.objectType);
|
|
186
|
+
const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
|
|
82
187
|
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>
|
|
188
|
+
<button type="button" className={`dm-obj-row${selected ? " active" : ""}`} onClick={onSelect}>
|
|
189
|
+
<LucideIcon name={iconName} size={13} className="dm-obj-icon" />
|
|
190
|
+
<span className="dm-obj-name">{table.label}</span>
|
|
191
|
+
<span className={`dm-badge ${badge.cls}`}>{badge.label}</span>
|
|
94
192
|
</button>
|
|
95
193
|
);
|
|
96
194
|
}
|
|
97
195
|
|
|
98
|
-
|
|
99
|
-
const [fieldName, setFieldName] = useState("");
|
|
100
|
-
const [error, setError] = useState("");
|
|
196
|
+
// ─── Source validation banner ─────────────────────────────────────────────────
|
|
101
197
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
198
|
+
function SourceValidationBanner({ table }) {
|
|
199
|
+
const lane = describeBindingLane(table?.binding);
|
|
200
|
+
if (!table || lane === "manual") return null;
|
|
201
|
+
const hasRef = table.binding?.integrationId || table.binding?.sourceKey || table.binding?.entityId;
|
|
202
|
+
if (hasRef) return null;
|
|
203
|
+
return (
|
|
204
|
+
<div className="dm-validation-banner">
|
|
205
|
+
<AlertCircle size={13} />
|
|
206
|
+
<span>Source binding incomplete — configure the source in widget source controls before data loads.</span>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Database surface ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
function formatCellValue(value, column) {
|
|
214
|
+
if (value === null || value === undefined || value === "") return "";
|
|
215
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
216
|
+
if (column === "lastResponse" && text.length > 90) return `${text.slice(0, 90)}…`;
|
|
217
|
+
return text;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ConnectionPill({ value }) {
|
|
221
|
+
const status = String(value || "untested").toLowerCase();
|
|
222
|
+
const ok = ["connected", "approved", "ok", "success"].includes(status);
|
|
223
|
+
const bad = ["failed", "error", "disconnected"].includes(status);
|
|
224
|
+
return (
|
|
225
|
+
<span className={`dm-db-status ${ok ? "ok" : bad ? "bad" : ""}`}>
|
|
226
|
+
<span />
|
|
227
|
+
{value || "untested"}
|
|
228
|
+
</span>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function relationForColumn(table, column) {
|
|
233
|
+
return (table?.relations || []).find((relation) => relation.field === column) || null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function referenceOptions(tables, relation) {
|
|
237
|
+
if (!relation) return [];
|
|
238
|
+
return (tables || [])
|
|
239
|
+
.filter((candidate) => candidate.objectType === relation.targetObjectType)
|
|
240
|
+
.flatMap((candidate) => (candidate.rows || []).map((row, index) => {
|
|
241
|
+
const value = row?.integrationId || row?.id || row?.Name || `${candidate.objectId}:${index}`;
|
|
242
|
+
const label = row?.Name || row?.integrationId || row?.description || `${candidate.label} row ${index + 1}`;
|
|
243
|
+
return { value, label, source: candidate.label };
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ReferenceSelect({ value, options, disabled, onChange }) {
|
|
248
|
+
return (
|
|
249
|
+
<select
|
|
250
|
+
className="dm-reference-select"
|
|
251
|
+
value={value || ""}
|
|
252
|
+
disabled={disabled}
|
|
253
|
+
onClick={(event) => event.stopPropagation()}
|
|
254
|
+
onChange={(event) => onChange(event.target.value)}
|
|
255
|
+
>
|
|
256
|
+
<option value="">Select reference…</option>
|
|
257
|
+
{options.map((option) => (
|
|
258
|
+
<option key={`${option.source}:${option.value}`} value={option.value}>
|
|
259
|
+
{option.label}
|
|
260
|
+
</option>
|
|
261
|
+
))}
|
|
262
|
+
</select>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function DataModelRecordDrawer({ table, tables, rowIndex, row, saving, onClose, onSave }) {
|
|
267
|
+
const [draft, setDraft] = useState(row || {});
|
|
268
|
+
const [testing, setTesting] = useState(false);
|
|
269
|
+
const [testMessage, setTestMessage] = useState("");
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
setDraft(row || {});
|
|
273
|
+
setTestMessage("");
|
|
274
|
+
}, [row, rowIndex]);
|
|
275
|
+
|
|
276
|
+
if (rowIndex === null || rowIndex === undefined || !row) return null;
|
|
277
|
+
|
|
278
|
+
function updateField(column, value) {
|
|
279
|
+
setDraft((current) => ({ ...current, [column]: value }));
|
|
280
|
+
onSave((config) => updateTableCell(config, table, rowIndex, column, value));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function testApiRecord() {
|
|
284
|
+
setTesting(true);
|
|
285
|
+
setTestMessage("");
|
|
286
|
+
try {
|
|
287
|
+
const res = await fetch("/api/workspace/test-api-record", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "content-type": "application/json" },
|
|
290
|
+
body: JSON.stringify(table.objectType === "data-source" ? { dataSourceRecord: draft } : { record: draft }),
|
|
291
|
+
});
|
|
292
|
+
const payload = await res.json();
|
|
293
|
+
const status = payload.ok ? "connected" : "failed";
|
|
294
|
+
const responseText = JSON.stringify(payload.response ?? payload, null, 2);
|
|
295
|
+
onSave((config) => {
|
|
296
|
+
let next = updateTableCell(config, table, rowIndex, "status", status);
|
|
297
|
+
next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
|
|
298
|
+
next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
|
|
299
|
+
return next;
|
|
300
|
+
});
|
|
301
|
+
setDraft((current) => ({ ...current, status, lastTested: new Date().toISOString(), lastResponse: responseText }));
|
|
302
|
+
setTestMessage(payload.ok ? "Connected" : payload.error || "Connection failed");
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const responseText = JSON.stringify({ error: err.message || "Connection failed" }, null, 2);
|
|
305
|
+
onSave((config) => {
|
|
306
|
+
let next = updateTableCell(config, table, rowIndex, "status", "failed");
|
|
307
|
+
next = updateTableCell(next, table, rowIndex, "lastTested", new Date().toISOString());
|
|
308
|
+
next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
|
|
309
|
+
return next;
|
|
310
|
+
});
|
|
311
|
+
setTestMessage(err.message || "Connection failed");
|
|
312
|
+
} finally {
|
|
313
|
+
setTesting(false);
|
|
108
314
|
}
|
|
109
|
-
setError("");
|
|
110
|
-
setFieldName("");
|
|
111
|
-
onSave((config) => addTableField(config, table, name));
|
|
112
315
|
}
|
|
113
316
|
|
|
114
317
|
return (
|
|
115
|
-
|
|
116
|
-
<div className="dm-
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
<
|
|
120
|
-
|
|
318
|
+
<>
|
|
319
|
+
<div className="dm-record-backdrop" onClick={onClose} />
|
|
320
|
+
<aside className="dm-record-drawer" aria-label="Record details">
|
|
321
|
+
<header className="dm-record-drawer-head">
|
|
322
|
+
<div>
|
|
323
|
+
<p>Record</p>
|
|
324
|
+
<h2>{draft.Name || draft.integrationId || draft.id || `Row ${rowIndex + 1}`}</h2>
|
|
325
|
+
</div>
|
|
326
|
+
<button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
|
|
327
|
+
<X size={16} />
|
|
328
|
+
</button>
|
|
329
|
+
</header>
|
|
330
|
+
{(table.objectType === "api-registry" || table.objectType === "data-source") && (
|
|
331
|
+
<div className="dm-record-testbar">
|
|
332
|
+
<ConnectionPill value={draft.status} />
|
|
333
|
+
<button type="button" className="dm-btn-primary-sm" disabled={testing || saving} onClick={testApiRecord}>
|
|
334
|
+
{testing ? "Testing…" : "Test connection"}
|
|
335
|
+
</button>
|
|
336
|
+
{testMessage && <span>{testMessage}</span>}
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
<div className="dm-record-fields">
|
|
340
|
+
{table.columns.map((column) => {
|
|
341
|
+
const value = String(draft?.[column] ?? "");
|
|
342
|
+
const large = column === "lastResponse" || value.length > 120;
|
|
343
|
+
const relation = relationForColumn(table, column);
|
|
344
|
+
const options = referenceOptions(tables, relation);
|
|
345
|
+
return (
|
|
346
|
+
<label key={column} className="dm-record-field">
|
|
347
|
+
<span>{column}</span>
|
|
348
|
+
{relation ? (
|
|
349
|
+
<ReferenceSelect
|
|
350
|
+
value={value}
|
|
351
|
+
options={options}
|
|
352
|
+
disabled={!table.mutable || saving}
|
|
353
|
+
onChange={(nextValue) => updateField(column, nextValue)}
|
|
354
|
+
/>
|
|
355
|
+
) : large ? (
|
|
356
|
+
<textarea
|
|
357
|
+
value={value}
|
|
358
|
+
rows={column === "lastResponse" ? 10 : 4}
|
|
359
|
+
disabled={!table.mutable || saving}
|
|
360
|
+
onChange={(event) => setDraft((current) => ({ ...current, [column]: event.target.value }))}
|
|
361
|
+
onBlur={(event) => updateField(column, event.target.value)}
|
|
362
|
+
/>
|
|
363
|
+
) : (
|
|
364
|
+
<input
|
|
365
|
+
value={value}
|
|
366
|
+
disabled={!table.mutable || saving}
|
|
367
|
+
onChange={(event) => setDraft((current) => ({ ...current, [column]: event.target.value }))}
|
|
368
|
+
onBlur={(event) => updateField(column, event.target.value)}
|
|
369
|
+
/>
|
|
370
|
+
)}
|
|
371
|
+
</label>
|
|
372
|
+
);
|
|
373
|
+
})}
|
|
121
374
|
</div>
|
|
122
|
-
</
|
|
123
|
-
|
|
124
|
-
{!table.mutable ? <p className="dm-hint-block">This object is an integration reference. Select and configure its source object in the existing View widget source controls.</p> : null}
|
|
125
|
-
<div className="dm-field-list">
|
|
126
|
-
{table.columns.map((column) => <div key={column} className="dm-field-item"><span className="dm-field-icon">::</span><strong>{column}</strong></div>)}
|
|
127
|
-
</div>
|
|
128
|
-
</div>
|
|
375
|
+
</aside>
|
|
376
|
+
</>
|
|
129
377
|
);
|
|
130
378
|
}
|
|
131
379
|
|
|
132
|
-
function
|
|
133
|
-
const [
|
|
134
|
-
const [
|
|
380
|
+
function DataModelTableSurface({ table, tables, saving, onSave }) {
|
|
381
|
+
const [selectedRow, setSelectedRow] = useState(null);
|
|
382
|
+
const [addingField, setAddingField] = useState(false);
|
|
383
|
+
const [fieldName, setFieldName] = useState("");
|
|
135
384
|
const [csvOpen, setCsvOpen] = useState(false);
|
|
136
385
|
const [csvText, setCsvText] = useState("");
|
|
137
386
|
const [mode, setMode] = useState("append");
|
|
138
|
-
const
|
|
387
|
+
const fieldInputRef = useRef(null);
|
|
139
388
|
|
|
140
|
-
useEffect(() => {
|
|
389
|
+
useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
|
|
390
|
+
useEffect(() => { setSelectedRow(null); }, [table.id]);
|
|
141
391
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
392
|
+
function commitField() {
|
|
393
|
+
const name = fieldName.trim();
|
|
394
|
+
if (!name) {
|
|
395
|
+
setAddingField(false);
|
|
396
|
+
setFieldName("");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!table.columns.includes(name)) {
|
|
400
|
+
onSave((config) => addTableField(config, table, name));
|
|
401
|
+
}
|
|
402
|
+
setAddingField(false);
|
|
403
|
+
setFieldName("");
|
|
146
404
|
}
|
|
147
405
|
|
|
148
406
|
function importCsv() {
|
|
@@ -154,150 +412,299 @@ function RecordsTab({ table, saving, onSave }) {
|
|
|
154
412
|
setCsvOpen(false);
|
|
155
413
|
}
|
|
156
414
|
|
|
415
|
+
const selectedRecord = selectedRow === null ? null : table.rows[selectedRow];
|
|
416
|
+
|
|
157
417
|
return (
|
|
158
|
-
<div>
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
418
|
+
<div className="dm-db-surface">
|
|
419
|
+
{!table.mutable && (
|
|
420
|
+
<div className="dm-source-notice">
|
|
421
|
+
<AlertCircle size={13} />
|
|
422
|
+
<span>Dynamic integration records are resolved at runtime.</span>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
<div className="dm-db-toolbar">
|
|
426
|
+
<div className="dm-db-toolbar-title">
|
|
427
|
+
<strong>{table.label}</strong>
|
|
428
|
+
<span>{pluralize(table.columns.length, "field")} · {pluralize(table.rows.length, "record")}</span>
|
|
429
|
+
</div>
|
|
430
|
+
<div className="dm-records-actions">
|
|
431
|
+
{table.rows.length > 0 && (
|
|
432
|
+
<button type="button" className="dm-btn-ghost" onClick={() => {
|
|
433
|
+
const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
|
|
434
|
+
const url = URL.createObjectURL(blob);
|
|
435
|
+
const a = document.createElement("a");
|
|
436
|
+
a.href = url; a.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
|
|
437
|
+
a.click(); URL.revokeObjectURL(url);
|
|
438
|
+
}}>Export CSV</button>
|
|
439
|
+
)}
|
|
440
|
+
{table.mutable && <button type="button" className="dm-btn-ghost" onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>}
|
|
441
|
+
{table.mutable && (
|
|
442
|
+
<button type="button" className="dm-btn-primary-sm" disabled={saving} onClick={() => onSave((config) => addTableRow(config, table))}>
|
|
443
|
+
<Plus size={13} />Add record
|
|
444
|
+
</button>
|
|
445
|
+
)}
|
|
173
446
|
</div>
|
|
174
447
|
</div>
|
|
175
|
-
{
|
|
176
|
-
{csvOpen ? (
|
|
448
|
+
{csvOpen && (
|
|
177
449
|
<div className="dm-csv-panel">
|
|
178
|
-
<textarea className="dm-csv-textarea" rows={
|
|
179
|
-
<div className="dm-csv-
|
|
450
|
+
<textarea className="dm-csv-textarea" rows={4} value={csvText} onChange={(e) => setCsvText(e.target.value)} placeholder={"Name,Status\nAcme,Active"} />
|
|
451
|
+
<div className="dm-csv-opts">
|
|
180
452
|
<label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
|
|
181
453
|
<label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
|
|
182
|
-
<button type="button" className="dm-btn
|
|
454
|
+
<button type="button" className="dm-btn-primary-sm" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
|
|
183
455
|
</div>
|
|
184
456
|
</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
457
|
)}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
458
|
+
<div className="dm-db-grid-wrap">
|
|
459
|
+
<table className="dm-db-grid">
|
|
460
|
+
<thead>
|
|
461
|
+
<tr>
|
|
462
|
+
<th className="dm-db-rownum">#</th>
|
|
463
|
+
{table.columns.map((column) => (
|
|
464
|
+
<th key={column}>
|
|
465
|
+
<span className="dm-db-field-type"><LucideIcon name={FIELD_TYPE_ICON_NAMES[inferFieldType(column)] || "Type"} size={12} /></span>
|
|
466
|
+
{column}
|
|
467
|
+
</th>
|
|
468
|
+
))}
|
|
469
|
+
{table.mutable && (
|
|
470
|
+
<th className="dm-db-add-field">
|
|
471
|
+
{addingField ? (
|
|
472
|
+
<input
|
|
473
|
+
ref={fieldInputRef}
|
|
474
|
+
value={fieldName}
|
|
475
|
+
placeholder="Field name"
|
|
476
|
+
onChange={(event) => setFieldName(event.target.value)}
|
|
477
|
+
onBlur={commitField}
|
|
478
|
+
onKeyDown={(event) => {
|
|
479
|
+
if (event.key === "Enter") commitField();
|
|
480
|
+
if (event.key === "Escape") { setAddingField(false); setFieldName(""); }
|
|
481
|
+
}}
|
|
482
|
+
/>
|
|
483
|
+
) : (
|
|
484
|
+
<button type="button" onClick={() => setAddingField(true)}>
|
|
485
|
+
<Plus size={13} />Field
|
|
486
|
+
</button>
|
|
487
|
+
)}
|
|
488
|
+
</th>
|
|
489
|
+
)}
|
|
490
|
+
</tr>
|
|
491
|
+
</thead>
|
|
492
|
+
<tbody>
|
|
493
|
+
{table.rows.map((row, rowIndex) => (
|
|
494
|
+
<tr key={rowIndex} className={selectedRow === rowIndex ? "selected" : ""} onClick={() => setSelectedRow(rowIndex)}>
|
|
495
|
+
<td className="dm-db-rownum">{rowIndex + 1}</td>
|
|
496
|
+
{table.columns.map((column) => {
|
|
497
|
+
const relation = relationForColumn(table, column);
|
|
498
|
+
const options = referenceOptions(tables, relation);
|
|
499
|
+
return (
|
|
500
|
+
<td key={column}>
|
|
501
|
+
{relation ? (
|
|
502
|
+
<ReferenceSelect
|
|
503
|
+
value={String(row?.[column] || "")}
|
|
504
|
+
options={options}
|
|
505
|
+
disabled={!table.mutable || saving}
|
|
506
|
+
onChange={(nextValue) => onSave((config) => updateTableCell(config, table, rowIndex, column, nextValue))}
|
|
507
|
+
/>
|
|
508
|
+
) : column.toLowerCase() === "status" ? (
|
|
509
|
+
<ConnectionPill value={row?.[column]} />
|
|
510
|
+
) : (
|
|
511
|
+
<span className={row?.[column] ? "" : "dm-cell-empty"}>
|
|
512
|
+
{formatCellValue(row?.[column], column) || "—"}
|
|
513
|
+
</span>
|
|
514
|
+
)}
|
|
515
|
+
</td>
|
|
516
|
+
);})}
|
|
517
|
+
{table.mutable && <td className="dm-db-empty-cell" />}
|
|
518
|
+
</tr>
|
|
519
|
+
))}
|
|
520
|
+
{table.mutable && (
|
|
521
|
+
<tr className="dm-db-new-row" onClick={() => onSave((config) => addTableRow(config, table))}>
|
|
522
|
+
<td className="dm-db-rownum">+</td>
|
|
523
|
+
<td colSpan={Math.max(table.columns.length, 1) + 1}>Add record</td>
|
|
524
|
+
</tr>
|
|
525
|
+
)}
|
|
526
|
+
</tbody>
|
|
527
|
+
</table>
|
|
227
528
|
</div>
|
|
529
|
+
<DataModelRecordDrawer
|
|
530
|
+
table={table}
|
|
531
|
+
tables={tables}
|
|
532
|
+
rowIndex={selectedRow}
|
|
533
|
+
row={selectedRecord}
|
|
534
|
+
saving={saving}
|
|
535
|
+
onClose={() => setSelectedRow(null)}
|
|
536
|
+
onSave={onSave}
|
|
537
|
+
/>
|
|
228
538
|
</div>
|
|
229
539
|
);
|
|
230
540
|
}
|
|
231
541
|
|
|
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
|
-
}
|
|
542
|
+
// ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
|
|
236
543
|
|
|
237
|
-
function
|
|
544
|
+
function IconPicker({ value, onChange }) {
|
|
238
545
|
return (
|
|
239
|
-
<div className="dm-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
546
|
+
<div className="dm-icon-picker">
|
|
547
|
+
{ICON_PICKER_SET.map((name) => (
|
|
548
|
+
<button
|
|
549
|
+
key={name}
|
|
550
|
+
type="button"
|
|
551
|
+
className={`dm-icon-picker-btn${value === name ? " active" : ""}`}
|
|
552
|
+
title={name}
|
|
553
|
+
onClick={() => onChange(name)}
|
|
554
|
+
>
|
|
555
|
+
<LucideIcon name={name} size={16} />
|
|
556
|
+
</button>
|
|
557
|
+
))}
|
|
244
558
|
</div>
|
|
245
559
|
);
|
|
246
560
|
}
|
|
247
561
|
|
|
248
|
-
function
|
|
562
|
+
function AddObjectSidebar({ open, saving, onClose, onCreate, allTables }) {
|
|
563
|
+
const [step, setStep] = useState(0); // 0 = type picker, 1 = name + icon
|
|
564
|
+
const [selectedType, setSelectedType] = useState(null);
|
|
249
565
|
const [name, setName] = useState("");
|
|
250
|
-
const [
|
|
566
|
+
const [icon, setIcon] = useState(null);
|
|
251
567
|
const [error, setError] = useState("");
|
|
568
|
+
const inputRef = useRef(null);
|
|
252
569
|
|
|
253
570
|
useEffect(() => {
|
|
254
571
|
if (!open) return;
|
|
572
|
+
setStep(0);
|
|
573
|
+
setSelectedType(null);
|
|
255
574
|
setName("");
|
|
256
|
-
|
|
575
|
+
setIcon(null);
|
|
257
576
|
setError("");
|
|
258
577
|
}, [open]);
|
|
259
578
|
|
|
260
|
-
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
if (step === 1) setTimeout(() => inputRef.current?.focus(), 80);
|
|
581
|
+
}, [step]);
|
|
582
|
+
|
|
583
|
+
function pickType(typeDef) {
|
|
584
|
+
setSelectedType(typeDef);
|
|
585
|
+
setIcon(typeDef.icon.displayName || OBJECT_TYPE_PRESETS[typeDef.type]?.icon || "Database");
|
|
586
|
+
setStep(1);
|
|
587
|
+
}
|
|
261
588
|
|
|
262
|
-
function submit(
|
|
263
|
-
|
|
589
|
+
function submit(e) {
|
|
590
|
+
e.preventDefault();
|
|
264
591
|
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
|
-
}
|
|
592
|
+
if (!objectName) { setError("Object name is required."); return; }
|
|
274
593
|
setError("");
|
|
275
|
-
onCreate({ name: objectName,
|
|
594
|
+
onCreate({ name: objectName, objectType: selectedType.type, icon });
|
|
276
595
|
}
|
|
277
596
|
|
|
278
597
|
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>
|
|
598
|
+
<>
|
|
599
|
+
{open && <div className="dm-sidebar-backdrop" onClick={onClose} />}
|
|
600
|
+
<aside className={`dm-add-sidebar${open ? " open" : ""}`} role="dialog" aria-label="New object" aria-modal="true">
|
|
601
|
+
<div className="dm-add-sidebar-head">
|
|
602
|
+
<div className="dm-add-sidebar-head-left">
|
|
603
|
+
{step === 1 && (
|
|
604
|
+
<button type="button" className="dm-sidebar-back" onClick={() => setStep(0)}>
|
|
605
|
+
←
|
|
606
|
+
</button>
|
|
607
|
+
)}
|
|
608
|
+
<h2>{step === 0 ? "New object" : `New ${selectedType?.label}`}</h2>
|
|
609
|
+
</div>
|
|
610
|
+
<button type="button" className="dm-sidebar-close" onClick={onClose} aria-label="Close">
|
|
611
|
+
<X size={16} />
|
|
612
|
+
</button>
|
|
295
613
|
</div>
|
|
296
|
-
|
|
297
|
-
|
|
614
|
+
|
|
615
|
+
{step === 0 && (
|
|
616
|
+
<div className="dm-type-picker">
|
|
617
|
+
<p className="dm-type-picker-hint">Choose an object type to start with the right fields and relation bindings.</p>
|
|
618
|
+
<div className="dm-type-picker-list">
|
|
619
|
+
{OBJECT_TYPE_DEFS.map((def) => {
|
|
620
|
+
const Icon = def.icon;
|
|
621
|
+
return (
|
|
622
|
+
<button key={def.type} type="button" className="dm-type-card" onClick={() => pickType(def)}>
|
|
623
|
+
<div className="dm-type-card-icon">
|
|
624
|
+
<Icon size={18} />
|
|
625
|
+
</div>
|
|
626
|
+
<div className="dm-type-card-body">
|
|
627
|
+
<strong>{def.label}</strong>
|
|
628
|
+
<span>{def.description}</span>
|
|
629
|
+
</div>
|
|
630
|
+
<ChevronRight size={14} className="dm-type-card-arrow" />
|
|
631
|
+
</button>
|
|
632
|
+
);
|
|
633
|
+
})}
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
)}
|
|
637
|
+
|
|
638
|
+
{step === 1 && selectedType && (
|
|
639
|
+
<form className="dm-add-sidebar-body" onSubmit={submit}>
|
|
640
|
+
<div className="dm-add-type-preview">
|
|
641
|
+
<div className="dm-add-type-icon">
|
|
642
|
+
<LucideIcon name={icon || OBJECT_TYPE_PRESETS[selectedType.type]?.icon || "Database"} size={20} />
|
|
643
|
+
</div>
|
|
644
|
+
<div>
|
|
645
|
+
<p className="dm-add-type-label">{selectedType.label}</p>
|
|
646
|
+
<p className="dm-add-sidebar-hint">{selectedType.description}</p>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
<label className="dm-field-label-v2">
|
|
651
|
+
<span>Object name</span>
|
|
652
|
+
<input
|
|
653
|
+
ref={inputRef}
|
|
654
|
+
className="dm-input-v2"
|
|
655
|
+
value={name}
|
|
656
|
+
placeholder={selectedType.type === "data-source" ? "My Analytics API, Salesforce Feed…" : selectedType.type === "api-registry" ? "GA4 Resolver, Stripe Adapter…" : "Name this object…"}
|
|
657
|
+
onChange={(e) => setName(e.target.value)}
|
|
658
|
+
/>
|
|
659
|
+
</label>
|
|
660
|
+
|
|
661
|
+
<label className="dm-field-label-v2">
|
|
662
|
+
<span>Icon</span>
|
|
663
|
+
<IconPicker value={icon} onChange={setIcon} />
|
|
664
|
+
</label>
|
|
665
|
+
|
|
666
|
+
{OBJECT_TYPE_PRESETS[selectedType.type]?.columns?.length > 0 && (
|
|
667
|
+
<div className="dm-preset-fields-preview">
|
|
668
|
+
<p className="dm-usage-label">Pre-populated fields</p>
|
|
669
|
+
<div className="dm-preset-fields-list">
|
|
670
|
+
{OBJECT_TYPE_PRESETS[selectedType.type].columns.map((col) => (
|
|
671
|
+
<span key={col} className="dm-preset-field-chip">{col}</span>
|
|
672
|
+
))}
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
676
|
+
|
|
677
|
+
{OBJECT_TYPE_PRESETS[selectedType.type]?.relations?.length > 0 && (
|
|
678
|
+
<div className="dm-preset-relations-preview">
|
|
679
|
+
<p className="dm-usage-label">Built-in relations</p>
|
|
680
|
+
{OBJECT_TYPE_PRESETS[selectedType.type].relations.map((rel) => (
|
|
681
|
+
<div key={rel.id} className="dm-preset-relation-row">
|
|
682
|
+
<Zap size={12} />
|
|
683
|
+
<span>{rel.name}</span>
|
|
684
|
+
<ArrowRight size={11} />
|
|
685
|
+
<span className="dm-preset-rel-target">{OBJECT_TYPE_PRESETS[rel.targetObjectType]?.label || rel.targetObjectType}</span>
|
|
686
|
+
</div>
|
|
687
|
+
))}
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
|
|
691
|
+
{error && <p className="dm-field-error">{error}</p>}
|
|
692
|
+
|
|
693
|
+
<div className="dm-add-sidebar-actions">
|
|
694
|
+
<button type="button" className="dm-btn-outline" onClick={onClose}>Cancel</button>
|
|
695
|
+
<button type="submit" className="dm-btn-primary" disabled={saving || !name.trim()}>
|
|
696
|
+
Create object
|
|
697
|
+
</button>
|
|
698
|
+
</div>
|
|
699
|
+
</form>
|
|
700
|
+
)}
|
|
701
|
+
</aside>
|
|
702
|
+
</>
|
|
298
703
|
);
|
|
299
704
|
}
|
|
300
705
|
|
|
706
|
+
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
707
|
+
|
|
301
708
|
export default function DataModelPage() {
|
|
302
709
|
const [workspaceConfig, setWorkspaceConfig] = useState(null);
|
|
303
710
|
const [authority, setAuthority] = useState(null);
|
|
@@ -306,16 +713,15 @@ export default function DataModelPage() {
|
|
|
306
713
|
const [saving, setSaving] = useState(false);
|
|
307
714
|
const [message, setMessage] = useState("");
|
|
308
715
|
const [selectedSource, setSelectedSource] = useState("");
|
|
309
|
-
const [activeTab, setActiveTab] = useState("Fields");
|
|
310
716
|
const [addOpen, setAddOpen] = useState(false);
|
|
311
717
|
|
|
312
718
|
const load = useCallback(async () => {
|
|
313
719
|
setLoading(true);
|
|
314
720
|
setError("");
|
|
315
721
|
try {
|
|
316
|
-
const
|
|
317
|
-
const payload = await
|
|
318
|
-
if (!
|
|
722
|
+
const res = await fetch("/api/workspace", { cache: "no-store" });
|
|
723
|
+
const payload = await res.json();
|
|
724
|
+
if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
|
|
319
725
|
setWorkspaceConfig(payload.workspaceConfig);
|
|
320
726
|
setAuthority(payload.adapters?.integrations?.authority || null);
|
|
321
727
|
} catch (err) {
|
|
@@ -327,9 +733,16 @@ export default function DataModelPage() {
|
|
|
327
733
|
|
|
328
734
|
useEffect(() => { load(); }, [load]);
|
|
329
735
|
|
|
330
|
-
const tables = useMemo(
|
|
331
|
-
|
|
332
|
-
|
|
736
|
+
const tables = useMemo(
|
|
737
|
+
() => (workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : []),
|
|
738
|
+
[workspaceConfig],
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
const selectedTable = tables.find((t) => t.source === selectedSource) || tables[0] || null;
|
|
742
|
+
|
|
743
|
+
useEffect(() => {
|
|
744
|
+
if (!selectedSource && tables[0]) setSelectedSource(tables[0].source);
|
|
745
|
+
}, [selectedSource, tables]);
|
|
333
746
|
|
|
334
747
|
const save = useCallback(async (mutate) => {
|
|
335
748
|
if (!workspaceConfig) return;
|
|
@@ -341,13 +754,13 @@ export default function DataModelPage() {
|
|
|
341
754
|
for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
|
|
342
755
|
if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
|
|
343
756
|
}
|
|
344
|
-
const
|
|
757
|
+
const res = await fetch("/api/workspace", {
|
|
345
758
|
method: "PATCH",
|
|
346
759
|
headers: { "content-type": "application/json" },
|
|
347
|
-
body: JSON.stringify(patch)
|
|
760
|
+
body: JSON.stringify(patch),
|
|
348
761
|
});
|
|
349
|
-
const payload = await
|
|
350
|
-
if (!
|
|
762
|
+
const payload = await res.json();
|
|
763
|
+
if (!res.ok) throw new Error(payload.error || "Save failed");
|
|
351
764
|
setWorkspaceConfig(payload.workspaceConfig);
|
|
352
765
|
setMessage("Saved");
|
|
353
766
|
} catch (err) {
|
|
@@ -357,49 +770,105 @@ export default function DataModelPage() {
|
|
|
357
770
|
}
|
|
358
771
|
}, [workspaceConfig]);
|
|
359
772
|
|
|
360
|
-
const createObject = useCallback(({ name,
|
|
361
|
-
save((config) =>
|
|
773
|
+
const createObject = useCallback(({ name, objectType, icon }) => {
|
|
774
|
+
save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
|
|
362
775
|
setSelectedSource(name);
|
|
363
|
-
setActiveTab("Records");
|
|
364
776
|
setAddOpen(false);
|
|
365
777
|
}, [save]);
|
|
366
778
|
|
|
367
779
|
return (
|
|
368
780
|
<main className="workspace-builder workspace-settings-page">
|
|
369
781
|
<NavRail authority={authority} workspaceConfig={workspaceConfig} />
|
|
782
|
+
|
|
370
783
|
<section className="workspace-surface">
|
|
371
784
|
<header className="workspace-toolbar">
|
|
372
785
|
<div><p>Workspace</p><h1>Data Model</h1></div>
|
|
373
|
-
<div className="workspace-toolbar-actions"
|
|
786
|
+
<div className="workspace-toolbar-actions">
|
|
787
|
+
<SaveToast saving={saving} message={message} />
|
|
788
|
+
<button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
|
|
789
|
+
<Plus size={14} />New object
|
|
790
|
+
</button>
|
|
791
|
+
</div>
|
|
374
792
|
</header>
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
793
|
+
|
|
794
|
+
<AddObjectSidebar
|
|
795
|
+
open={addOpen}
|
|
796
|
+
saving={saving}
|
|
797
|
+
onClose={() => setAddOpen(false)}
|
|
798
|
+
onCreate={createObject}
|
|
799
|
+
allTables={tables}
|
|
800
|
+
/>
|
|
801
|
+
|
|
802
|
+
{loading && <div className="dm-loading">Loading workspace…</div>}
|
|
803
|
+
|
|
804
|
+
{error && (
|
|
805
|
+
<div className="dm-error-state">
|
|
806
|
+
<AlertCircle size={28} />
|
|
807
|
+
<strong>Could not load workspace</strong>
|
|
808
|
+
<p>{error}</p>
|
|
809
|
+
<button type="button" className="dm-btn-primary" onClick={load}>Retry</button>
|
|
810
|
+
</div>
|
|
811
|
+
)}
|
|
812
|
+
|
|
813
|
+
{!loading && !error && tables.length > 0 && (
|
|
814
|
+
<div className="dm-layout-v2">
|
|
815
|
+
<aside className="dm-obj-col">
|
|
816
|
+
<div className="dm-obj-col-head">
|
|
817
|
+
<span>{pluralize(tables.length, "object")}</span>
|
|
818
|
+
</div>
|
|
819
|
+
<div className="dm-obj-col-body">
|
|
820
|
+
{tables.map((table) => (
|
|
821
|
+
<ObjectRow
|
|
822
|
+
key={`${table.source}-${table.id}`}
|
|
823
|
+
table={table}
|
|
824
|
+
selected={selectedTable?.id === table.id}
|
|
825
|
+
onSelect={() => setSelectedSource(table.source)}
|
|
826
|
+
/>
|
|
827
|
+
))}
|
|
828
|
+
</div>
|
|
829
|
+
<div className="dm-obj-col-foot">
|
|
830
|
+
<button type="button" className="dm-obj-add-btn" onClick={() => setAddOpen(true)}>
|
|
831
|
+
<Plus size={13} />New object
|
|
832
|
+
</button>
|
|
833
|
+
</div>
|
|
834
|
+
</aside>
|
|
835
|
+
|
|
836
|
+
{selectedTable && (
|
|
837
|
+
<section className="dm-detail-v2">
|
|
838
|
+
<div className="dm-detail-v2-head">
|
|
839
|
+
<div className="dm-detail-v2-title">
|
|
840
|
+
<LucideIcon
|
|
841
|
+
name={selectedTable.icon || OBJECT_TYPE_PRESETS[selectedTable.objectType]?.icon || "Database"}
|
|
842
|
+
size={14}
|
|
843
|
+
className="dm-detail-icon"
|
|
844
|
+
/>
|
|
845
|
+
<h2>{selectedTable.label}</h2>
|
|
846
|
+
<span className={`dm-badge ${objectTypeBadge(selectedTable.objectType).cls}`}>
|
|
847
|
+
{objectTypeBadge(selectedTable.objectType).label}
|
|
848
|
+
</span>
|
|
849
|
+
</div>
|
|
850
|
+
<div className="dm-detail-v2-meta">
|
|
851
|
+
<code>{selectedTable.source}</code>
|
|
852
|
+
<span>{pluralize(selectedTable.columns.length, "field")} · {pluralize(selectedTable.rows.length, "record")}</span>
|
|
853
|
+
</div>
|
|
854
|
+
<SourceValidationBanner table={selectedTable} />
|
|
397
855
|
</div>
|
|
856
|
+
<DataModelTableSurface table={selectedTable} tables={tables} saving={saving} onSave={save} />
|
|
398
857
|
</section>
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
402
|
-
|
|
858
|
+
)}
|
|
859
|
+
</div>
|
|
860
|
+
)}
|
|
861
|
+
|
|
862
|
+
{!loading && !error && tables.length === 0 && (
|
|
863
|
+
<div className="dm-page-empty">
|
|
864
|
+
<Database size={32} />
|
|
865
|
+
<strong>No objects yet</strong>
|
|
866
|
+
<p>Create a Data Source, API Registry, People, Tasks, or Custom object to get started.</p>
|
|
867
|
+
<button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
|
|
868
|
+
<Plus size={14} />New object
|
|
869
|
+
</button>
|
|
870
|
+
</div>
|
|
871
|
+
)}
|
|
403
872
|
</section>
|
|
404
873
|
</main>
|
|
405
874
|
);
|