@growthub/cli 0.9.11 → 0.9.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +389 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +78 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +120 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +433 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +58 -7
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
writeWorkspaceConfig
|
|
13
13
|
} from "@/lib/workspace-config";
|
|
14
14
|
|
|
15
|
-
const ALLOWED_PATCH_FIELDS = new Set(["dashboards", "widgetTypes", "canvas"]);
|
|
15
|
+
const ALLOWED_PATCH_FIELDS = new Set(["dashboards", "widgetTypes", "canvas", "dataModel"]);
|
|
16
16
|
|
|
17
17
|
async function GET() {
|
|
18
18
|
const integrations = await listGovernedWorkspaceIntegrations();
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { Database } from "lucide-react";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
addTableField,
|
|
8
|
+
addTableRow,
|
|
9
|
+
appendRowsToTable,
|
|
10
|
+
createManualBusinessObject,
|
|
11
|
+
deleteTableRow,
|
|
12
|
+
describeBindingLane,
|
|
13
|
+
describeBindingMode,
|
|
14
|
+
duplicateTableRow,
|
|
15
|
+
exportTableAsCsv,
|
|
16
|
+
importTableFromCsv,
|
|
17
|
+
listWorkspaceDataModelTables,
|
|
18
|
+
replaceTableContent,
|
|
19
|
+
updateTableCell
|
|
20
|
+
} from "@/lib/workspace-data-model";
|
|
21
|
+
|
|
22
|
+
const TABS = ["Fields", "Records", "Bindings", "Usage"];
|
|
23
|
+
const LANE_META = {
|
|
24
|
+
manual: { label: "Manual", cls: "dm-badge-manual" },
|
|
25
|
+
"data-source": { label: "Data Source", cls: "dm-badge-datasource" },
|
|
26
|
+
"workspace-integration": { label: "Workspace Tool", cls: "dm-badge-integration" },
|
|
27
|
+
integration: { label: "Integration", cls: "dm-badge-integration" }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function pluralize(count, word) {
|
|
31
|
+
return `${count} ${count === 1 ? word : `${word}s`}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function laneMeta(binding) {
|
|
35
|
+
return LANE_META[describeBindingLane(binding)] || LANE_META.manual;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function SaveToast({ saving, message }) {
|
|
39
|
+
if (saving) return <span className="dm-toast saving">Saving...</span>;
|
|
40
|
+
if (!message) return null;
|
|
41
|
+
return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function NavRail({ authority }) {
|
|
45
|
+
return (
|
|
46
|
+
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
47
|
+
<div className="workspace-brand">
|
|
48
|
+
<span className="workspace-mark">G</span>
|
|
49
|
+
<span>Growthub Workspace</span>
|
|
50
|
+
</div>
|
|
51
|
+
<nav className="workspace-nav">
|
|
52
|
+
<Link href="/">Dashboards</Link>
|
|
53
|
+
<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
|
+
<span className="workspace-nav-static">Management</span>
|
|
57
|
+
</nav>
|
|
58
|
+
<div className="workspace-rail-status"><span className="status-dot" />{authority || "local-catalog"}</div>
|
|
59
|
+
</aside>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ObjectRow({ table, selected, onSelect }) {
|
|
64
|
+
const meta = laneMeta(table.binding);
|
|
65
|
+
return (
|
|
66
|
+
<button type="button" className={`dm-object-row${selected ? " active" : ""}`} onClick={onSelect}>
|
|
67
|
+
<div className="dm-object-row-top">
|
|
68
|
+
<Database className="dm-object-icon" size={13} aria-hidden="true" />
|
|
69
|
+
<strong className="dm-object-name">{table.label}</strong>
|
|
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>
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function FieldsTab({ table, saving, onSave }) {
|
|
82
|
+
const [fieldName, setFieldName] = useState("");
|
|
83
|
+
const [error, setError] = useState("");
|
|
84
|
+
|
|
85
|
+
function addField() {
|
|
86
|
+
const name = fieldName.trim();
|
|
87
|
+
if (!name) return;
|
|
88
|
+
if (table.columns.includes(name)) {
|
|
89
|
+
setError(`${name} already exists.`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setError("");
|
|
93
|
+
setFieldName("");
|
|
94
|
+
onSave((config) => addTableField(config, table, name));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div>
|
|
99
|
+
<div className="dm-tab-toolbar">
|
|
100
|
+
<p className="dm-tab-stat">{pluralize(table.columns.length, "field")}</p>
|
|
101
|
+
<div className="dm-inline-add">
|
|
102
|
+
<input className="dm-input" value={fieldName} disabled={!table.mutable} placeholder="New field" onChange={(event) => setFieldName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") addField(); }} />
|
|
103
|
+
<button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !fieldName.trim()} onClick={addField}>+ Add field</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
{error ? <p className="dm-field-error">{error}</p> : null}
|
|
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>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function RecordsTab({ table, saving, onSave }) {
|
|
116
|
+
const [editing, setEditing] = useState(null);
|
|
117
|
+
const [draft, setDraft] = useState("");
|
|
118
|
+
const [csvOpen, setCsvOpen] = useState(false);
|
|
119
|
+
const [csvText, setCsvText] = useState("");
|
|
120
|
+
const [mode, setMode] = useState("append");
|
|
121
|
+
const inputRef = useRef(null);
|
|
122
|
+
|
|
123
|
+
useEffect(() => { inputRef.current?.focus(); }, [editing]);
|
|
124
|
+
|
|
125
|
+
function commit() {
|
|
126
|
+
if (!editing) return;
|
|
127
|
+
onSave((config) => updateTableCell(config, table, editing.row, editing.column, draft));
|
|
128
|
+
setEditing(null);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function importCsv() {
|
|
132
|
+
const parsed = importTableFromCsv(csvText);
|
|
133
|
+
if (!parsed.columns.length) return;
|
|
134
|
+
if (mode === "replace") onSave((config) => replaceTableContent(config, table, parsed));
|
|
135
|
+
else onSave((config) => appendRowsToTable(config, table, parsed.rows));
|
|
136
|
+
setCsvText("");
|
|
137
|
+
setCsvOpen(false);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div>
|
|
142
|
+
<div className="dm-tab-toolbar">
|
|
143
|
+
<p className="dm-tab-stat">{pluralize(table.rows.length, "record")}</p>
|
|
144
|
+
<div className="dm-tab-toolbar-actions">
|
|
145
|
+
<button type="button" className="dm-btn" disabled={!table.rows.length} onClick={() => {
|
|
146
|
+
const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
|
|
147
|
+
const url = URL.createObjectURL(blob);
|
|
148
|
+
const link = document.createElement("a");
|
|
149
|
+
link.href = url;
|
|
150
|
+
link.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
|
|
151
|
+
link.click();
|
|
152
|
+
URL.revokeObjectURL(url);
|
|
153
|
+
}}>Export CSV</button>
|
|
154
|
+
<button type="button" className="dm-btn" disabled={!table.mutable} onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>
|
|
155
|
+
<button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !table.columns.length} onClick={() => onSave((config) => addTableRow(config, table))}>+ Add row</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
{!table.mutable ? <p className="dm-hint-block">Dynamic integration records are resolved by the governed integration path. The selected source object reference is shown here but provider rows are not stored in browser config.</p> : null}
|
|
159
|
+
{csvOpen ? (
|
|
160
|
+
<div className="dm-csv-panel">
|
|
161
|
+
<textarea className="dm-csv-textarea" rows={5} value={csvText} onChange={(event) => setCsvText(event.target.value)} placeholder="Name,Status Acme,Active" />
|
|
162
|
+
<div className="dm-csv-options">
|
|
163
|
+
<label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
|
|
164
|
+
<label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
|
|
165
|
+
<button type="button" className="dm-btn primary" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
|
|
166
|
+
</div>
|
|
167
|
+
</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
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function BindingsTab({ table }) {
|
|
197
|
+
const binding = table.binding || {};
|
|
198
|
+
const mode = describeBindingMode(binding);
|
|
199
|
+
const meta = laneMeta(binding);
|
|
200
|
+
return (
|
|
201
|
+
<div>
|
|
202
|
+
<div className="dm-binding-header"><span className={`dm-badge ${meta.cls}`}>{mode.label}</span><p>{mode.description}</p></div>
|
|
203
|
+
<div className="dm-binding-rows">
|
|
204
|
+
<div className="dm-binding-row"><span>Source</span><code>{table.source}</code></div>
|
|
205
|
+
<div className="dm-binding-row"><span>Config surface</span><code>{table.storage === "view" ? "view.config" : "widget.config.binding"}</code></div>
|
|
206
|
+
<div className="dm-binding-row"><span>Mode</span><code>{binding.mode || "manual"}</code></div>
|
|
207
|
+
{binding.integrationId ? <div className="dm-binding-row"><span>Integration</span><code>{binding.integrationId}</code></div> : null}
|
|
208
|
+
{binding.entityId ? <div className="dm-binding-row"><span>Entity ID</span><code>{binding.entityId}</code></div> : null}
|
|
209
|
+
{binding.entityLabel ? <div className="dm-binding-row"><span>Entity label</span><code>{binding.entityLabel}</code></div> : null}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function UsageTab({ table }) {
|
|
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
|
+
}
|
|
219
|
+
|
|
220
|
+
function Summary({ tables }) {
|
|
221
|
+
return (
|
|
222
|
+
<div className="dm-summary-cards">
|
|
223
|
+
<div className="dm-summary-card"><span>Objects</span><strong>{tables.length}</strong></div>
|
|
224
|
+
<div className="dm-summary-card"><span>Fields</span><strong>{tables.reduce((sum, table) => sum + table.columns.length, 0)}</strong></div>
|
|
225
|
+
<div className="dm-summary-card"><span>Records</span><strong>{tables.reduce((sum, table) => sum + table.rows.length, 0)}</strong></div>
|
|
226
|
+
<div className="dm-summary-card"><span>Integrations</span><strong>{tables.filter((table) => describeBindingLane(table.binding) !== "manual").length}</strong></div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function AddObjectDialog({ open, saving, onClose, onCreate }) {
|
|
232
|
+
const [name, setName] = useState("");
|
|
233
|
+
const [fields, setFields] = useState("Name");
|
|
234
|
+
const [error, setError] = useState("");
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!open) return;
|
|
238
|
+
setName("");
|
|
239
|
+
setFields("Name");
|
|
240
|
+
setError("");
|
|
241
|
+
}, [open]);
|
|
242
|
+
|
|
243
|
+
if (!open) return null;
|
|
244
|
+
|
|
245
|
+
function submit(event) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
const objectName = name.trim();
|
|
248
|
+
const fieldList = fields.split(",").map((field) => field.trim()).filter(Boolean);
|
|
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
|
+
}
|
|
257
|
+
setError("");
|
|
258
|
+
onCreate({ name: objectName, fields: fieldList });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div className="dm-dialog-shell" role="dialog" aria-modal="true" aria-labelledby="dm-add-object-title">
|
|
263
|
+
<div className="dm-dialog-backdrop" onClick={onClose} />
|
|
264
|
+
<form className="dm-dialog" onSubmit={submit}>
|
|
265
|
+
<div className="dm-dialog-head">
|
|
266
|
+
<h2 id="dm-add-object-title">Add business object</h2>
|
|
267
|
+
<button type="button" className="dm-icon-btn" onClick={onClose}>x</button>
|
|
268
|
+
</div>
|
|
269
|
+
<div className="dm-dialog-body">
|
|
270
|
+
<p className="dm-dialog-copy">Creates a manual governed data object. This does not add a widget, change a dashboard, or write to canvas.</p>
|
|
271
|
+
<label className="dm-field-label">Object name<input className="dm-input" value={name} placeholder="Companies, Clients, Leads" onChange={(event) => setName(event.target.value)} /></label>
|
|
272
|
+
<label className="dm-field-label">Fields <span>comma-separated</span><input className="dm-input" value={fields} placeholder="Name, Status, Owner" onChange={(event) => setFields(event.target.value)} /></label>
|
|
273
|
+
{error ? <p className="dm-field-error">{error}</p> : null}
|
|
274
|
+
</div>
|
|
275
|
+
<div className="dm-dialog-actions">
|
|
276
|
+
<button type="button" className="dm-btn" onClick={onClose}>Cancel</button>
|
|
277
|
+
<button type="submit" className="dm-btn primary" disabled={saving}>Create object</button>
|
|
278
|
+
</div>
|
|
279
|
+
</form>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default function DataModelPage() {
|
|
285
|
+
const [workspaceConfig, setWorkspaceConfig] = useState(null);
|
|
286
|
+
const [authority, setAuthority] = useState(null);
|
|
287
|
+
const [loading, setLoading] = useState(true);
|
|
288
|
+
const [error, setError] = useState("");
|
|
289
|
+
const [saving, setSaving] = useState(false);
|
|
290
|
+
const [message, setMessage] = useState("");
|
|
291
|
+
const [selectedSource, setSelectedSource] = useState("");
|
|
292
|
+
const [activeTab, setActiveTab] = useState("Fields");
|
|
293
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
294
|
+
|
|
295
|
+
const load = useCallback(async () => {
|
|
296
|
+
setLoading(true);
|
|
297
|
+
setError("");
|
|
298
|
+
try {
|
|
299
|
+
const response = await fetch("/api/workspace", { cache: "no-store" });
|
|
300
|
+
const payload = await response.json();
|
|
301
|
+
if (!response.ok) throw new Error(payload.error || "Failed to load workspace");
|
|
302
|
+
setWorkspaceConfig(payload.workspaceConfig);
|
|
303
|
+
setAuthority(payload.adapters?.integrations?.authority || null);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
setError(err.message || "Failed to load workspace");
|
|
306
|
+
} finally {
|
|
307
|
+
setLoading(false);
|
|
308
|
+
}
|
|
309
|
+
}, []);
|
|
310
|
+
|
|
311
|
+
useEffect(() => { load(); }, [load]);
|
|
312
|
+
|
|
313
|
+
const tables = useMemo(() => workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : [], [workspaceConfig]);
|
|
314
|
+
const selectedTable = tables.find((table) => table.source === selectedSource) || tables[0] || null;
|
|
315
|
+
useEffect(() => { if (!selectedSource && tables[0]) setSelectedSource(tables[0].source); }, [selectedSource, tables]);
|
|
316
|
+
|
|
317
|
+
const save = useCallback(async (mutate) => {
|
|
318
|
+
if (!workspaceConfig) return;
|
|
319
|
+
setSaving(true);
|
|
320
|
+
setMessage("");
|
|
321
|
+
const next = mutate(workspaceConfig);
|
|
322
|
+
try {
|
|
323
|
+
const patch = {};
|
|
324
|
+
for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
|
|
325
|
+
if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
|
|
326
|
+
}
|
|
327
|
+
const response = await fetch("/api/workspace", {
|
|
328
|
+
method: "PATCH",
|
|
329
|
+
headers: { "content-type": "application/json" },
|
|
330
|
+
body: JSON.stringify(patch)
|
|
331
|
+
});
|
|
332
|
+
const payload = await response.json();
|
|
333
|
+
if (!response.ok) throw new Error(payload.error || "Save failed");
|
|
334
|
+
setWorkspaceConfig(payload.workspaceConfig);
|
|
335
|
+
setMessage("Saved");
|
|
336
|
+
} catch (err) {
|
|
337
|
+
setMessage(`Error: ${err.message || "Save failed"}`);
|
|
338
|
+
} finally {
|
|
339
|
+
setSaving(false);
|
|
340
|
+
}
|
|
341
|
+
}, [workspaceConfig]);
|
|
342
|
+
|
|
343
|
+
const createObject = useCallback(({ name, fields }) => {
|
|
344
|
+
save((config) => createManualBusinessObject(config, { name, fields }));
|
|
345
|
+
setSelectedSource(name);
|
|
346
|
+
setActiveTab("Records");
|
|
347
|
+
setAddOpen(false);
|
|
348
|
+
}, [save]);
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<main className="workspace-builder workspace-settings-page">
|
|
352
|
+
<NavRail authority={authority} />
|
|
353
|
+
<section className="workspace-surface">
|
|
354
|
+
<header className="workspace-toolbar">
|
|
355
|
+
<div><p>Workspace</p><h1>Data Model</h1></div>
|
|
356
|
+
<div className="workspace-toolbar-actions"><SaveToast saving={saving} message={message} /><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div>
|
|
357
|
+
</header>
|
|
358
|
+
<AddObjectDialog open={addOpen} saving={saving} onClose={() => setAddOpen(false)} onCreate={createObject} />
|
|
359
|
+
{loading ? <div className="dm-loading">Loading workspace...</div> : null}
|
|
360
|
+
{error ? <div className="dm-error-state"><strong>Could not load workspace</strong><p>{error}</p><button type="button" className="dm-btn primary" onClick={load}>Retry</button></div> : null}
|
|
361
|
+
{!loading && !error && tables.length ? (
|
|
362
|
+
<>
|
|
363
|
+
<Summary tables={tables} />
|
|
364
|
+
<div className="dm-layout">
|
|
365
|
+
<aside className="dm-object-list">
|
|
366
|
+
<div className="dm-object-list-head"><p>{pluralize(tables.length, "object")}</p></div>
|
|
367
|
+
<div className="dm-object-list-body">{tables.map((table) => <ObjectRow key={`${table.source}-${table.id}`} table={table} selected={selectedTable?.id === table.id} onSelect={() => { setSelectedSource(table.source); setActiveTab("Fields"); }} />)}</div>
|
|
368
|
+
</aside>
|
|
369
|
+
<section className="dm-detail-panel">
|
|
370
|
+
<div className="dm-detail-header">
|
|
371
|
+
<div className="dm-detail-title-row"><Database size={15} /><h2>{selectedTable.label}</h2><span className={`dm-badge ${laneMeta(selectedTable.binding).cls}`}>{laneMeta(selectedTable.binding).label}</span></div>
|
|
372
|
+
<div className="dm-detail-meta-row"><code>{selectedTable.source}</code><span>{pluralize(selectedTable.columns.length, "field")} · {pluralize(selectedTable.rows.length, "record")} · {pluralize(selectedTable.widgetRefs.length, "widget")}</span></div>
|
|
373
|
+
</div>
|
|
374
|
+
<div className="dm-tabs">{TABS.map((tab) => <button key={tab} type="button" className={`dm-tab${activeTab === tab ? " active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
|
|
375
|
+
<div className="dm-tab-content">
|
|
376
|
+
{activeTab === "Fields" ? <FieldsTab table={selectedTable} saving={saving} onSave={save} /> : null}
|
|
377
|
+
{activeTab === "Records" ? <RecordsTab table={selectedTable} saving={saving} onSave={save} /> : null}
|
|
378
|
+
{activeTab === "Bindings" ? <BindingsTab table={selectedTable} /> : null}
|
|
379
|
+
{activeTab === "Usage" ? <UsageTab table={selectedTable} /> : null}
|
|
380
|
+
</div>
|
|
381
|
+
</section>
|
|
382
|
+
</div>
|
|
383
|
+
</>
|
|
384
|
+
) : null}
|
|
385
|
+
{!loading && !error && !tables.length ? <div className="dm-page-empty"><Database size={28} /><strong>No business objects yet</strong><p>Create a manual governed object here, or expose existing View widget data when dashboards already define it.</p><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div> : null}
|
|
386
|
+
</section>
|
|
387
|
+
</main>
|
|
388
|
+
);
|
|
389
|
+
}
|
package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css
CHANGED
|
@@ -2705,3 +2705,81 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
|
|
|
2705
2705
|
grid-template-columns: 1fr;
|
|
2706
2706
|
}
|
|
2707
2707
|
}
|
|
2708
|
+
|
|
2709
|
+
.dm-toast { display: inline-flex; align-items: center; height: 28px; padding: 0 10px; border-radius: 6px; border: 1px solid #e5e7eb; font-size: 12px; color: #374151; background: #fff; }
|
|
2710
|
+
.dm-toast.ok { color: #166534; background: #f0fdf4; border-color: #bbf7d0; }
|
|
2711
|
+
.dm-toast.error { color: #991b1b; background: #fef2f2; border-color: #fecaca; }
|
|
2712
|
+
.dm-toast.saving { color: #4b5563; background: #f9fafb; }
|
|
2713
|
+
.dm-summary-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 16px; }
|
|
2714
|
+
.dm-summary-card { border: 1px solid #e8e8e8; border-radius: 8px; background: #fff; padding: 14px 16px; }
|
|
2715
|
+
.dm-summary-card span { display: block; font-size: 11px; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
|
|
2716
|
+
.dm-summary-card strong { font-size: 28px; line-height: 1; color: #1a1a2e; }
|
|
2717
|
+
.dm-layout { display: grid; grid-template-columns: 240px minmax(0, 1fr); border: 1px solid #e8e8e8; border-radius: 10px; background: #fff; overflow: hidden; min-height: 560px; }
|
|
2718
|
+
.dm-object-list { border-right: 1px solid #e8e8e8; background: #fafafa; }
|
|
2719
|
+
.dm-object-list-head { padding: 12px 14px 8px; border-bottom: 1px solid #efefef; font-size: 12px; color: #888; }
|
|
2720
|
+
.dm-object-list-body { padding: 6px; }
|
|
2721
|
+
.dm-object-row { width: 100%; border: 0; background: transparent; text-align: left; padding: 10px; border-radius: 7px; cursor: pointer; font: inherit; margin-bottom: 2px; }
|
|
2722
|
+
.dm-object-row:hover { background: #f0f0f0; }
|
|
2723
|
+
.dm-object-row.active { background: #e8f0fe; }
|
|
2724
|
+
.dm-object-row-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
|
2725
|
+
.dm-object-icon { color: #94a3b8; flex-shrink: 0; }
|
|
2726
|
+
.dm-object-name { font-size: 13px; font-weight: 600; color: #111827; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2727
|
+
.dm-object-row-meta { display: flex; gap: 8px; flex-wrap: wrap; padding-left: 18px; font-size: 11px; color: #888; }
|
|
2728
|
+
.dm-badge { display: inline-flex; align-items: center; border-radius: 999px; border: 1px solid #e2e8f0; background: #f1f5f9; color: #475569; padding: 2px 7px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
|
2729
|
+
.dm-badge-datasource { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
|
2730
|
+
.dm-badge-integration { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
|
|
2731
|
+
.dm-detail-panel { min-width: 0; }
|
|
2732
|
+
.dm-detail-header { padding: 14px 18px 10px; border-bottom: 1px solid #efefef; }
|
|
2733
|
+
.dm-detail-title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
|
2734
|
+
.dm-detail-title-row h2 { margin: 0; font-size: 16px; color: #111827; flex: 1; }
|
|
2735
|
+
.dm-detail-meta-row { display: flex; align-items: center; gap: 10px; padding-left: 22px; font-size: 12px; color: #9ca3af; }
|
|
2736
|
+
.dm-detail-meta-row code { font-size: 11px; color: #6b7280; background: #f3f4f6; border-radius: 4px; padding: 2px 6px; }
|
|
2737
|
+
.dm-tabs { display: flex; border-bottom: 1px solid #efefef; background: #fafafa; padding: 0 16px; }
|
|
2738
|
+
.dm-tab { border: 0; background: transparent; padding: 10px 12px; font: inherit; font-size: 13px; color: #6b7280; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
2739
|
+
.dm-tab.active { color: #1d4ed8; border-bottom-color: #1d4ed8; font-weight: 600; }
|
|
2740
|
+
.dm-tab-content { padding: 16px 18px; overflow-x: auto; }
|
|
2741
|
+
.dm-tab-toolbar { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin-bottom: 12px; }
|
|
2742
|
+
.dm-tab-toolbar-actions, .dm-inline-add, .dm-csv-options { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
2743
|
+
.dm-tab-stat { margin: 0; font-size: 12px; color: #888; }
|
|
2744
|
+
.dm-btn { display: inline-flex; align-items: center; gap: 5px; height: 30px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; color: #111827; font: inherit; font-size: 12px; padding: 0 11px; cursor: pointer; text-decoration: none; white-space: nowrap; box-shadow: 0 1px 2px rgba(15,23,42,.08); }
|
|
2745
|
+
.dm-btn:hover { background: #f9fafb; border-color: #9ca3af; }
|
|
2746
|
+
.dm-btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
2747
|
+
.dm-icon-btn { width: 26px; height: 26px; border: 1px solid #e5e7eb; border-radius: 5px; background: #fff; color: #6b7280; cursor: pointer; }
|
|
2748
|
+
.dm-icon-btn.danger { color: #dc2626; }
|
|
2749
|
+
.dm-input, .dm-cell-input, .dm-csv-textarea { border: 1px solid #d1d5db; border-radius: 6px; background: #fff; color: #111827; font: inherit; font-size: 13px; padding: 6px 10px; box-sizing: border-box; }
|
|
2750
|
+
.dm-input { min-height: 32px; min-width: 180px; }
|
|
2751
|
+
.dm-field-list { display: grid; gap: 4px; }
|
|
2752
|
+
.dm-field-item { display: flex; align-items: center; gap: 7px; min-height: 38px; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 7px; background: #fff; font-size: 13px; }
|
|
2753
|
+
.dm-field-icon { color: #cbd5e1; font-size: 11px; }
|
|
2754
|
+
.dm-field-error { margin: 4px 0 8px; color: #dc2626; font-size: 12px; }
|
|
2755
|
+
.dm-records-scroll { overflow-x: auto; border: 1px solid #e5e7eb; border-radius: 8px; }
|
|
2756
|
+
.dm-records-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
2757
|
+
.dm-records-table th { background: #f9fafb; border-bottom: 1px solid #e5e7eb; color: #6b7280; font-size: 11px; text-align: left; padding: 8px 10px; }
|
|
2758
|
+
.dm-records-table td { border-bottom: 1px solid #f0f0f0; padding: 5px 8px; }
|
|
2759
|
+
.dm-cell-btn { width: 100%; min-height: 24px; border: 0; background: transparent; text-align: left; font: inherit; cursor: text; color: #111827; }
|
|
2760
|
+
.dm-cell-empty { color: #d1d5db; }
|
|
2761
|
+
.dm-csv-panel, .dm-hint-block, .dm-empty-inline { border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; padding: 10px 12px; margin: 8px 0 12px; color: #6b7280; font-size: 12px; line-height: 1.5; }
|
|
2762
|
+
.dm-csv-textarea { width: 100%; margin-bottom: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
2763
|
+
.dm-binding-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
|
|
2764
|
+
.dm-binding-header p { margin: 0; color: #4b5563; font-size: 13px; line-height: 1.5; }
|
|
2765
|
+
.dm-binding-rows, .dm-usage-list { display: grid; gap: 6px; }
|
|
2766
|
+
.dm-binding-row, .dm-usage-item { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 10px; align-items: center; border: 1px solid #e5e7eb; border-radius: 7px; background: #fafafa; padding: 8px 10px; font-size: 12px; }
|
|
2767
|
+
.dm-binding-row code, .dm-usage-item code { word-break: break-all; }
|
|
2768
|
+
.dm-page-empty, .dm-loading, .dm-error-state { max-width: 520px; margin: 48px auto; text-align: center; color: #6b7280; display: grid; justify-items: center; gap: 10px; }
|
|
2769
|
+
.dm-page-empty strong, .dm-error-state strong { color: #111827; font-size: 18px; }
|
|
2770
|
+
.dm-dialog-shell { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; }
|
|
2771
|
+
.dm-dialog-backdrop { position: absolute; inset: 0; background: rgba(17,24,39,.38); }
|
|
2772
|
+
.dm-dialog { position: relative; width: min(520px, 100%); border-radius: 10px; border: 1px solid #e5e7eb; background: #fff; box-shadow: 0 24px 60px rgba(15,23,42,.22); overflow: hidden; }
|
|
2773
|
+
.dm-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #e5e7eb; }
|
|
2774
|
+
.dm-dialog-head h2 { margin: 0; font-size: 16px; color: #111827; }
|
|
2775
|
+
.dm-dialog-body { display: grid; gap: 12px; padding: 18px 20px; }
|
|
2776
|
+
.dm-dialog-copy { margin: 0; color: #4b5563; font-size: 13px; line-height: 1.45; }
|
|
2777
|
+
.dm-field-label { display: grid; gap: 6px; color: #374151; font-size: 12px; font-weight: 700; }
|
|
2778
|
+
.dm-field-label span { color: #9ca3af; font-weight: 500; }
|
|
2779
|
+
.dm-field-label .dm-input { width: 100%; }
|
|
2780
|
+
.dm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid #e5e7eb; background: #fafafa; }
|
|
2781
|
+
@media (max-width: 900px) {
|
|
2782
|
+
.dm-summary-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
2783
|
+
.dm-layout { grid-template-columns: 1fr; }
|
|
2784
|
+
.dm-object-list { border-right: 0; border-bottom: 1px solid #e8e8e8; }
|
|
2785
|
+
}
|
|
@@ -50,6 +50,7 @@ async function IntegrationsSettingsPage() {
|
|
|
50
50
|
</div>
|
|
51
51
|
<nav className="workspace-nav">
|
|
52
52
|
<Link href="/">Dashboards</Link>
|
|
53
|
+
<Link href="/data-model">Data Model</Link>
|
|
53
54
|
<Link className="active" href="/settings/integrations">Integrations</Link>
|
|
54
55
|
<span className="workspace-nav-static">Workspace Settings</span>
|
|
55
56
|
<span className="workspace-nav-static">Management</span>
|