@growthub/cli 0.9.12 → 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/settings/apis-webhooks/route.js +59 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -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 +700 -214
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +2468 -793
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +22 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1558 -437
- 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 +264 -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 -40675
- package/package.json +1 -1
|
@@ -1,131 +1,406 @@
|
|
|
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
|
|
|
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" />;
|
|
65
|
+
}
|
|
66
|
+
|
|
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";
|
|
124
|
+
}
|
|
125
|
+
|
|
30
126
|
function pluralize(count, word) {
|
|
31
127
|
return `${count} ${count === 1 ? word : `${word}s`}`;
|
|
32
128
|
}
|
|
33
129
|
|
|
34
|
-
function
|
|
35
|
-
return
|
|
130
|
+
function objectTypeBadge(objectType) {
|
|
131
|
+
return OBJECT_TYPE_BADGE[objectType] || OBJECT_TYPE_BADGE.custom;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function textColorForAccent(accent) {
|
|
135
|
+
const hex = String(accent || "").replace("#", "");
|
|
136
|
+
if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
|
|
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";
|
|
36
141
|
}
|
|
37
142
|
|
|
143
|
+
// ─── Shared micro-components ──────────────────────────────────────────────────
|
|
144
|
+
|
|
38
145
|
function SaveToast({ saving, message }) {
|
|
39
|
-
if (saving) return <span className="dm-toast saving">Saving
|
|
146
|
+
if (saving) return <span className="dm-toast saving">Saving…</span>;
|
|
40
147
|
if (!message) return null;
|
|
41
148
|
return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
|
|
42
149
|
}
|
|
43
150
|
|
|
44
|
-
function NavRail({ authority }) {
|
|
151
|
+
function NavRail({ authority, workspaceConfig }) {
|
|
152
|
+
const branding = workspaceConfig?.branding || {};
|
|
153
|
+
const workspaceName = branding.name || workspaceConfig?.name || "Growthub Workspace";
|
|
45
154
|
return (
|
|
46
155
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
47
156
|
<div className="workspace-brand">
|
|
48
|
-
<span
|
|
49
|
-
|
|
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
|
+
>
|
|
164
|
+
{branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
|
|
165
|
+
</span>
|
|
166
|
+
<span>{workspaceName}</span>
|
|
50
167
|
</div>
|
|
51
168
|
<nav className="workspace-nav">
|
|
52
169
|
<Link href="/">Dashboards</Link>
|
|
53
170
|
<Link className="active" href="/data-model">Data Model</Link>
|
|
54
|
-
<Link href="/settings/integrations">Integrations</Link>
|
|
55
|
-
<span className="workspace-nav-static">Workspace Settings</span>
|
|
56
171
|
<span className="workspace-nav-static">Management</span>
|
|
172
|
+
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
57
173
|
</nav>
|
|
58
|
-
<div className="workspace-rail-status"
|
|
174
|
+
<div className="workspace-rail-status">
|
|
175
|
+
<span className="status-dot" />
|
|
176
|
+
{authority || "local-catalog"}
|
|
177
|
+
</div>
|
|
59
178
|
</aside>
|
|
60
179
|
);
|
|
61
180
|
}
|
|
62
181
|
|
|
182
|
+
// ─── Object list row ──────────────────────────────────────────────────────────
|
|
183
|
+
|
|
63
184
|
function ObjectRow({ table, selected, onSelect }) {
|
|
64
|
-
const
|
|
185
|
+
const badge = objectTypeBadge(table.objectType);
|
|
186
|
+
const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
|
|
65
187
|
return (
|
|
66
|
-
<button type="button" className={`dm-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<span className={`dm-badge ${meta.cls}`}>{meta.label}</span>
|
|
71
|
-
</div>
|
|
72
|
-
<div className="dm-object-row-meta">
|
|
73
|
-
<span>{pluralize(table.rows.length, "record")}</span>
|
|
74
|
-
<span>{pluralize(table.columns.length, "field")}</span>
|
|
75
|
-
<span>{pluralize(table.widgetRefs.length, "widget")}</span>
|
|
76
|
-
</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>
|
|
77
192
|
</button>
|
|
78
193
|
);
|
|
79
194
|
}
|
|
80
195
|
|
|
81
|
-
|
|
82
|
-
const [fieldName, setFieldName] = useState("");
|
|
83
|
-
const [error, setError] = useState("");
|
|
196
|
+
// ─── Source validation banner ─────────────────────────────────────────────────
|
|
84
197
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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);
|
|
91
314
|
}
|
|
92
|
-
setError("");
|
|
93
|
-
setFieldName("");
|
|
94
|
-
onSave((config) => addTableField(config, table, name));
|
|
95
315
|
}
|
|
96
316
|
|
|
97
317
|
return (
|
|
98
|
-
|
|
99
|
-
<div className="dm-
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
|
|
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
|
+
})}
|
|
104
374
|
</div>
|
|
105
|
-
</
|
|
106
|
-
|
|
107
|
-
{!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}
|
|
108
|
-
<div className="dm-field-list">
|
|
109
|
-
{table.columns.map((column) => <div key={column} className="dm-field-item"><span className="dm-field-icon">::</span><strong>{column}</strong></div>)}
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
375
|
+
</aside>
|
|
376
|
+
</>
|
|
112
377
|
);
|
|
113
378
|
}
|
|
114
379
|
|
|
115
|
-
function
|
|
116
|
-
const [
|
|
117
|
-
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("");
|
|
118
384
|
const [csvOpen, setCsvOpen] = useState(false);
|
|
119
385
|
const [csvText, setCsvText] = useState("");
|
|
120
386
|
const [mode, setMode] = useState("append");
|
|
121
|
-
const
|
|
387
|
+
const fieldInputRef = useRef(null);
|
|
122
388
|
|
|
123
|
-
useEffect(() => {
|
|
389
|
+
useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
|
|
390
|
+
useEffect(() => { setSelectedRow(null); }, [table.id]);
|
|
124
391
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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("");
|
|
129
404
|
}
|
|
130
405
|
|
|
131
406
|
function importCsv() {
|
|
@@ -137,150 +412,299 @@ function RecordsTab({ table, saving, onSave }) {
|
|
|
137
412
|
setCsvOpen(false);
|
|
138
413
|
}
|
|
139
414
|
|
|
415
|
+
const selectedRecord = selectedRow === null ? null : table.rows[selectedRow];
|
|
416
|
+
|
|
140
417
|
return (
|
|
141
|
-
<div>
|
|
142
|
-
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
)}
|
|
156
446
|
</div>
|
|
157
447
|
</div>
|
|
158
|
-
{
|
|
159
|
-
{csvOpen ? (
|
|
448
|
+
{csvOpen && (
|
|
160
449
|
<div className="dm-csv-panel">
|
|
161
|
-
<textarea className="dm-csv-textarea" rows={
|
|
162
|
-
<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">
|
|
163
452
|
<label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
|
|
164
453
|
<label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
|
|
165
|
-
<button type="button" className="dm-btn
|
|
454
|
+
<button type="button" className="dm-btn-primary-sm" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
|
|
166
455
|
</div>
|
|
167
456
|
</div>
|
|
168
|
-
) : null}
|
|
169
|
-
{!table.columns.length ? <div className="dm-empty-inline">No fields are defined for this object.</div> : (
|
|
170
|
-
<div className="dm-records-scroll">
|
|
171
|
-
<table className="dm-records-table">
|
|
172
|
-
<thead><tr><th>#</th>{table.columns.map((column) => <th key={column}>{column}</th>)}<th /></tr></thead>
|
|
173
|
-
<tbody>
|
|
174
|
-
{table.rows.map((row, rowIndex) => (
|
|
175
|
-
<tr key={rowIndex}>
|
|
176
|
-
<td>{rowIndex + 1}</td>
|
|
177
|
-
{table.columns.map((column) => {
|
|
178
|
-
const active = editing?.row === rowIndex && editing?.column === column;
|
|
179
|
-
const value = String(row?.[column] ?? "");
|
|
180
|
-
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>;
|
|
181
|
-
})}
|
|
182
|
-
<td>
|
|
183
|
-
<button type="button" className="dm-icon-btn" disabled={saving || !table.mutable} onClick={() => onSave((config) => duplicateTableRow(config, table, rowIndex))}>⎘</button>
|
|
184
|
-
<button type="button" className="dm-icon-btn danger" disabled={saving || !table.mutable} onClick={() => onSave((config) => deleteTableRow(config, table, rowIndex))}>x</button>
|
|
185
|
-
</td>
|
|
186
|
-
</tr>
|
|
187
|
-
))}
|
|
188
|
-
</tbody>
|
|
189
|
-
</table>
|
|
190
|
-
</div>
|
|
191
457
|
)}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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>
|
|
210
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
|
+
/>
|
|
211
538
|
</div>
|
|
212
539
|
);
|
|
213
540
|
}
|
|
214
541
|
|
|
215
|
-
|
|
216
|
-
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>;
|
|
217
|
-
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>;
|
|
218
|
-
}
|
|
542
|
+
// ─── Add Object Sidebar — two-step (type picker → name + icon) ────────────────
|
|
219
543
|
|
|
220
|
-
function
|
|
544
|
+
function IconPicker({ value, onChange }) {
|
|
221
545
|
return (
|
|
222
|
-
<div className="dm-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
))}
|
|
227
558
|
</div>
|
|
228
559
|
);
|
|
229
560
|
}
|
|
230
561
|
|
|
231
|
-
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);
|
|
232
565
|
const [name, setName] = useState("");
|
|
233
|
-
const [
|
|
566
|
+
const [icon, setIcon] = useState(null);
|
|
234
567
|
const [error, setError] = useState("");
|
|
568
|
+
const inputRef = useRef(null);
|
|
235
569
|
|
|
236
570
|
useEffect(() => {
|
|
237
571
|
if (!open) return;
|
|
572
|
+
setStep(0);
|
|
573
|
+
setSelectedType(null);
|
|
238
574
|
setName("");
|
|
239
|
-
|
|
575
|
+
setIcon(null);
|
|
240
576
|
setError("");
|
|
241
577
|
}, [open]);
|
|
242
578
|
|
|
243
|
-
|
|
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
|
+
}
|
|
244
588
|
|
|
245
|
-
function submit(
|
|
246
|
-
|
|
589
|
+
function submit(e) {
|
|
590
|
+
e.preventDefault();
|
|
247
591
|
const objectName = name.trim();
|
|
248
|
-
|
|
249
|
-
if (!objectName) {
|
|
250
|
-
setError("Object name is required.");
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
if (!fieldList.length) {
|
|
254
|
-
setError("Add at least one field.");
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
592
|
+
if (!objectName) { setError("Object name is required."); return; }
|
|
257
593
|
setError("");
|
|
258
|
-
onCreate({ name: objectName,
|
|
594
|
+
onCreate({ name: objectName, objectType: selectedType.type, icon });
|
|
259
595
|
}
|
|
260
596
|
|
|
261
597
|
return (
|
|
262
|
-
|
|
263
|
-
<div className="dm-
|
|
264
|
-
<
|
|
265
|
-
<div className="dm-
|
|
266
|
-
<
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
<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>
|
|
278
613
|
</div>
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
</>
|
|
281
703
|
);
|
|
282
704
|
}
|
|
283
705
|
|
|
706
|
+
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
707
|
+
|
|
284
708
|
export default function DataModelPage() {
|
|
285
709
|
const [workspaceConfig, setWorkspaceConfig] = useState(null);
|
|
286
710
|
const [authority, setAuthority] = useState(null);
|
|
@@ -289,16 +713,15 @@ export default function DataModelPage() {
|
|
|
289
713
|
const [saving, setSaving] = useState(false);
|
|
290
714
|
const [message, setMessage] = useState("");
|
|
291
715
|
const [selectedSource, setSelectedSource] = useState("");
|
|
292
|
-
const [activeTab, setActiveTab] = useState("Fields");
|
|
293
716
|
const [addOpen, setAddOpen] = useState(false);
|
|
294
717
|
|
|
295
718
|
const load = useCallback(async () => {
|
|
296
719
|
setLoading(true);
|
|
297
720
|
setError("");
|
|
298
721
|
try {
|
|
299
|
-
const
|
|
300
|
-
const payload = await
|
|
301
|
-
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");
|
|
302
725
|
setWorkspaceConfig(payload.workspaceConfig);
|
|
303
726
|
setAuthority(payload.adapters?.integrations?.authority || null);
|
|
304
727
|
} catch (err) {
|
|
@@ -310,9 +733,16 @@ export default function DataModelPage() {
|
|
|
310
733
|
|
|
311
734
|
useEffect(() => { load(); }, [load]);
|
|
312
735
|
|
|
313
|
-
const tables = useMemo(
|
|
314
|
-
|
|
315
|
-
|
|
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]);
|
|
316
746
|
|
|
317
747
|
const save = useCallback(async (mutate) => {
|
|
318
748
|
if (!workspaceConfig) return;
|
|
@@ -324,13 +754,13 @@ export default function DataModelPage() {
|
|
|
324
754
|
for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
|
|
325
755
|
if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
|
|
326
756
|
}
|
|
327
|
-
const
|
|
757
|
+
const res = await fetch("/api/workspace", {
|
|
328
758
|
method: "PATCH",
|
|
329
759
|
headers: { "content-type": "application/json" },
|
|
330
|
-
body: JSON.stringify(patch)
|
|
760
|
+
body: JSON.stringify(patch),
|
|
331
761
|
});
|
|
332
|
-
const payload = await
|
|
333
|
-
if (!
|
|
762
|
+
const payload = await res.json();
|
|
763
|
+
if (!res.ok) throw new Error(payload.error || "Save failed");
|
|
334
764
|
setWorkspaceConfig(payload.workspaceConfig);
|
|
335
765
|
setMessage("Saved");
|
|
336
766
|
} catch (err) {
|
|
@@ -340,49 +770,105 @@ export default function DataModelPage() {
|
|
|
340
770
|
}
|
|
341
771
|
}, [workspaceConfig]);
|
|
342
772
|
|
|
343
|
-
const createObject = useCallback(({ name,
|
|
344
|
-
save((config) =>
|
|
773
|
+
const createObject = useCallback(({ name, objectType, icon }) => {
|
|
774
|
+
save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
|
|
345
775
|
setSelectedSource(name);
|
|
346
|
-
setActiveTab("Records");
|
|
347
776
|
setAddOpen(false);
|
|
348
777
|
}, [save]);
|
|
349
778
|
|
|
350
779
|
return (
|
|
351
780
|
<main className="workspace-builder workspace-settings-page">
|
|
352
|
-
<NavRail authority={authority} />
|
|
781
|
+
<NavRail authority={authority} workspaceConfig={workspaceConfig} />
|
|
782
|
+
|
|
353
783
|
<section className="workspace-surface">
|
|
354
784
|
<header className="workspace-toolbar">
|
|
355
785
|
<div><p>Workspace</p><h1>Data Model</h1></div>
|
|
356
|
-
<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>
|
|
357
792
|
</header>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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} />
|
|
380
855
|
</div>
|
|
856
|
+
<DataModelTableSurface table={selectedTable} tables={tables} saving={saving} onSave={save} />
|
|
381
857
|
</section>
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
)
|
|
385
|
-
|
|
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
|
+
)}
|
|
386
872
|
</section>
|
|
387
873
|
</main>
|
|
388
874
|
);
|