@growthub/cli 0.9.11 → 0.9.13
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/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/route.js +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +406 -0
- 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 +767 -6
- 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 +23 -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 +139 -28
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +189 -2
- 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/dist/index.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
describePersistenceMode,
|
|
4
|
+
readWorkspaceConfig,
|
|
5
|
+
writeWorkspaceApiWebhookSettings
|
|
6
|
+
} from "@/lib/workspace-config";
|
|
7
|
+
|
|
8
|
+
async function GET() {
|
|
9
|
+
const workspaceConfig = await readWorkspaceConfig();
|
|
10
|
+
const refs = Array.isArray(workspaceConfig.integrations)
|
|
11
|
+
? workspaceConfig.integrations.filter((item) => item?.sourceType === "custom-api-webhooks")
|
|
12
|
+
: [];
|
|
13
|
+
return NextResponse.json({
|
|
14
|
+
adapterScope: "local-workspace-config",
|
|
15
|
+
persistence: describePersistenceMode(),
|
|
16
|
+
refs
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function PATCH(request) {
|
|
21
|
+
let patch;
|
|
22
|
+
try {
|
|
23
|
+
patch = await request.json();
|
|
24
|
+
} catch {
|
|
25
|
+
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const refs = await writeWorkspaceApiWebhookSettings(patch);
|
|
30
|
+
return NextResponse.json({
|
|
31
|
+
adapterScope: "local-workspace-config",
|
|
32
|
+
refs
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{
|
|
38
|
+
error: "workspace config is read-only in this runtime",
|
|
39
|
+
reason: error.message,
|
|
40
|
+
adapter: error.adapter,
|
|
41
|
+
guidance: error.guidance
|
|
42
|
+
},
|
|
43
|
+
{ status: 409 }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (error.code === "INVALID_WORKSPACE_SETTINGS_PATCH") {
|
|
47
|
+
return NextResponse.json({ error: error.message, details: error.details || [] }, { status: 400 });
|
|
48
|
+
}
|
|
49
|
+
if (error.code === "WORKSPACE_PERSISTENCE_PATH_REFUSED") {
|
|
50
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
51
|
+
}
|
|
52
|
+
if (error.code === "INVALID_WORKSPACE_CONFIG") {
|
|
53
|
+
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
return NextResponse.json({ error: error.message || "failed to write API/Webhook settings" }, { status: 500 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { GET, PATCH };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readAdapterConfig } from "@/lib/adapters/env";
|
|
3
|
+
import { describeIntegrationAdapter } from "@/lib/adapters/integrations";
|
|
4
|
+
import {
|
|
5
|
+
describePersistenceMode,
|
|
6
|
+
readWorkspaceConfig,
|
|
7
|
+
writeWorkspaceIdentitySettings
|
|
8
|
+
} from "@/lib/workspace-config";
|
|
9
|
+
|
|
10
|
+
async function GET() {
|
|
11
|
+
const workspaceConfig = await readWorkspaceConfig();
|
|
12
|
+
return NextResponse.json({
|
|
13
|
+
adapterScope: "local-workspace-config",
|
|
14
|
+
config: readAdapterConfig(),
|
|
15
|
+
integrationAdapter: describeIntegrationAdapter(),
|
|
16
|
+
persistence: describePersistenceMode(),
|
|
17
|
+
workspace: {
|
|
18
|
+
id: workspaceConfig.id,
|
|
19
|
+
name: workspaceConfig.name,
|
|
20
|
+
branding: workspaceConfig.branding || {},
|
|
21
|
+
provenance: workspaceConfig.provenance || {}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function PATCH(request) {
|
|
27
|
+
let patch;
|
|
28
|
+
try {
|
|
29
|
+
patch = await request.json();
|
|
30
|
+
} catch {
|
|
31
|
+
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const workspaceConfig = await writeWorkspaceIdentitySettings(patch);
|
|
36
|
+
return NextResponse.json({
|
|
37
|
+
adapterScope: "local-workspace-config",
|
|
38
|
+
workspace: {
|
|
39
|
+
id: workspaceConfig.id,
|
|
40
|
+
name: workspaceConfig.name,
|
|
41
|
+
branding: workspaceConfig.branding || {},
|
|
42
|
+
provenance: workspaceConfig.provenance || {}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{
|
|
49
|
+
error: "workspace config is read-only in this runtime",
|
|
50
|
+
reason: error.message,
|
|
51
|
+
adapter: error.adapter,
|
|
52
|
+
guidance: error.guidance
|
|
53
|
+
},
|
|
54
|
+
{ status: 409 }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (error.code === "INVALID_WORKSPACE_SETTINGS_PATCH") {
|
|
58
|
+
return NextResponse.json({ error: error.message, details: error.details || [] }, { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
if (error.code === "WORKSPACE_PERSISTENCE_PATH_REFUSED") {
|
|
61
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
62
|
+
}
|
|
63
|
+
if (error.code === "INVALID_WORKSPACE_CONFIG") {
|
|
64
|
+
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
|
|
65
|
+
}
|
|
66
|
+
return NextResponse.json({ error: error.message || "failed to write workspace settings" }, { status: 500 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { GET, PATCH };
|
|
@@ -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,406 @@
|
|
|
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 textColorForAccent(accent) {
|
|
45
|
+
const hex = String(accent || "").replace("#", "");
|
|
46
|
+
if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
|
|
47
|
+
const red = parseInt(hex.slice(0, 2), 16);
|
|
48
|
+
const green = parseInt(hex.slice(2, 4), 16);
|
|
49
|
+
const blue = parseInt(hex.slice(4, 6), 16);
|
|
50
|
+
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
|
|
51
|
+
return luminance > 0.62 ? "#252525" : "#ffffff";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function NavRail({ authority, workspaceConfig }) {
|
|
55
|
+
const branding = workspaceConfig?.branding || {};
|
|
56
|
+
const workspaceName = branding.name || workspaceConfig?.name || "Growthub Workspace";
|
|
57
|
+
return (
|
|
58
|
+
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
59
|
+
<div className="workspace-brand">
|
|
60
|
+
<span className="workspace-mark" style={{
|
|
61
|
+
background: branding.logoUrl ? undefined : branding.accent || undefined,
|
|
62
|
+
color: branding.logoUrl ? undefined : textColorForAccent(branding.accent)
|
|
63
|
+
}}>
|
|
64
|
+
{branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
|
|
65
|
+
</span>
|
|
66
|
+
<span>{workspaceName}</span>
|
|
67
|
+
</div>
|
|
68
|
+
<nav className="workspace-nav">
|
|
69
|
+
<Link href="/">Dashboards</Link>
|
|
70
|
+
<Link className="active" href="/data-model">Data Model</Link>
|
|
71
|
+
<Link href="/settings/integrations">Integrations</Link>
|
|
72
|
+
<span className="workspace-nav-static">Management</span>
|
|
73
|
+
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
74
|
+
</nav>
|
|
75
|
+
<div className="workspace-rail-status"><span className="status-dot" />{authority || "local-catalog"}</div>
|
|
76
|
+
</aside>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ObjectRow({ table, selected, onSelect }) {
|
|
81
|
+
const meta = laneMeta(table.binding);
|
|
82
|
+
return (
|
|
83
|
+
<button type="button" className={`dm-object-row${selected ? " active" : ""}`} onClick={onSelect}>
|
|
84
|
+
<div className="dm-object-row-top">
|
|
85
|
+
<Database className="dm-object-icon" size={13} aria-hidden="true" />
|
|
86
|
+
<strong className="dm-object-name">{table.label}</strong>
|
|
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>
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function FieldsTab({ table, saving, onSave }) {
|
|
99
|
+
const [fieldName, setFieldName] = useState("");
|
|
100
|
+
const [error, setError] = useState("");
|
|
101
|
+
|
|
102
|
+
function addField() {
|
|
103
|
+
const name = fieldName.trim();
|
|
104
|
+
if (!name) return;
|
|
105
|
+
if (table.columns.includes(name)) {
|
|
106
|
+
setError(`${name} already exists.`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setError("");
|
|
110
|
+
setFieldName("");
|
|
111
|
+
onSave((config) => addTableField(config, table, name));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div>
|
|
116
|
+
<div className="dm-tab-toolbar">
|
|
117
|
+
<p className="dm-tab-stat">{pluralize(table.columns.length, "field")}</p>
|
|
118
|
+
<div className="dm-inline-add">
|
|
119
|
+
<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(); }} />
|
|
120
|
+
<button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !fieldName.trim()} onClick={addField}>+ Add field</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{error ? <p className="dm-field-error">{error}</p> : null}
|
|
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>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function RecordsTab({ table, saving, onSave }) {
|
|
133
|
+
const [editing, setEditing] = useState(null);
|
|
134
|
+
const [draft, setDraft] = useState("");
|
|
135
|
+
const [csvOpen, setCsvOpen] = useState(false);
|
|
136
|
+
const [csvText, setCsvText] = useState("");
|
|
137
|
+
const [mode, setMode] = useState("append");
|
|
138
|
+
const inputRef = useRef(null);
|
|
139
|
+
|
|
140
|
+
useEffect(() => { inputRef.current?.focus(); }, [editing]);
|
|
141
|
+
|
|
142
|
+
function commit() {
|
|
143
|
+
if (!editing) return;
|
|
144
|
+
onSave((config) => updateTableCell(config, table, editing.row, editing.column, draft));
|
|
145
|
+
setEditing(null);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function importCsv() {
|
|
149
|
+
const parsed = importTableFromCsv(csvText);
|
|
150
|
+
if (!parsed.columns.length) return;
|
|
151
|
+
if (mode === "replace") onSave((config) => replaceTableContent(config, table, parsed));
|
|
152
|
+
else onSave((config) => appendRowsToTable(config, table, parsed.rows));
|
|
153
|
+
setCsvText("");
|
|
154
|
+
setCsvOpen(false);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div>
|
|
159
|
+
<div className="dm-tab-toolbar">
|
|
160
|
+
<p className="dm-tab-stat">{pluralize(table.rows.length, "record")}</p>
|
|
161
|
+
<div className="dm-tab-toolbar-actions">
|
|
162
|
+
<button type="button" className="dm-btn" disabled={!table.rows.length} onClick={() => {
|
|
163
|
+
const blob = new Blob([exportTableAsCsv(table)], { type: "text/csv" });
|
|
164
|
+
const url = URL.createObjectURL(blob);
|
|
165
|
+
const link = document.createElement("a");
|
|
166
|
+
link.href = url;
|
|
167
|
+
link.download = `${table.source.replace(/\s+/g, "-").toLowerCase()}.csv`;
|
|
168
|
+
link.click();
|
|
169
|
+
URL.revokeObjectURL(url);
|
|
170
|
+
}}>Export CSV</button>
|
|
171
|
+
<button type="button" className="dm-btn" disabled={!table.mutable} onClick={() => setCsvOpen((open) => !open)}>Import CSV</button>
|
|
172
|
+
<button type="button" className="dm-btn primary" disabled={saving || !table.mutable || !table.columns.length} onClick={() => onSave((config) => addTableRow(config, table))}>+ Add row</button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
{!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}
|
|
176
|
+
{csvOpen ? (
|
|
177
|
+
<div className="dm-csv-panel">
|
|
178
|
+
<textarea className="dm-csv-textarea" rows={5} value={csvText} onChange={(event) => setCsvText(event.target.value)} placeholder="Name,Status Acme,Active" />
|
|
179
|
+
<div className="dm-csv-options">
|
|
180
|
+
<label><input type="radio" checked={mode === "append"} onChange={() => setMode("append")} /> Append</label>
|
|
181
|
+
<label><input type="radio" checked={mode === "replace"} onChange={() => setMode("replace")} /> Replace</label>
|
|
182
|
+
<button type="button" className="dm-btn primary" disabled={!csvText.trim()} onClick={importCsv}>Import</button>
|
|
183
|
+
</div>
|
|
184
|
+
</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
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function BindingsTab({ table }) {
|
|
214
|
+
const binding = table.binding || {};
|
|
215
|
+
const mode = describeBindingMode(binding);
|
|
216
|
+
const meta = laneMeta(binding);
|
|
217
|
+
return (
|
|
218
|
+
<div>
|
|
219
|
+
<div className="dm-binding-header"><span className={`dm-badge ${meta.cls}`}>{mode.label}</span><p>{mode.description}</p></div>
|
|
220
|
+
<div className="dm-binding-rows">
|
|
221
|
+
<div className="dm-binding-row"><span>Source</span><code>{table.source}</code></div>
|
|
222
|
+
<div className="dm-binding-row"><span>Config surface</span><code>{table.storage === "view" ? "view.config" : "widget.config.binding"}</code></div>
|
|
223
|
+
<div className="dm-binding-row"><span>Mode</span><code>{binding.mode || "manual"}</code></div>
|
|
224
|
+
{binding.integrationId ? <div className="dm-binding-row"><span>Integration</span><code>{binding.integrationId}</code></div> : null}
|
|
225
|
+
{binding.entityId ? <div className="dm-binding-row"><span>Entity ID</span><code>{binding.entityId}</code></div> : null}
|
|
226
|
+
{binding.entityLabel ? <div className="dm-binding-row"><span>Entity label</span><code>{binding.entityLabel}</code></div> : null}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function UsageTab({ table }) {
|
|
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
|
+
}
|
|
236
|
+
|
|
237
|
+
function Summary({ tables }) {
|
|
238
|
+
return (
|
|
239
|
+
<div className="dm-summary-cards">
|
|
240
|
+
<div className="dm-summary-card"><span>Objects</span><strong>{tables.length}</strong></div>
|
|
241
|
+
<div className="dm-summary-card"><span>Fields</span><strong>{tables.reduce((sum, table) => sum + table.columns.length, 0)}</strong></div>
|
|
242
|
+
<div className="dm-summary-card"><span>Records</span><strong>{tables.reduce((sum, table) => sum + table.rows.length, 0)}</strong></div>
|
|
243
|
+
<div className="dm-summary-card"><span>Integrations</span><strong>{tables.filter((table) => describeBindingLane(table.binding) !== "manual").length}</strong></div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function AddObjectDialog({ open, saving, onClose, onCreate }) {
|
|
249
|
+
const [name, setName] = useState("");
|
|
250
|
+
const [fields, setFields] = useState("Name");
|
|
251
|
+
const [error, setError] = useState("");
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!open) return;
|
|
255
|
+
setName("");
|
|
256
|
+
setFields("Name");
|
|
257
|
+
setError("");
|
|
258
|
+
}, [open]);
|
|
259
|
+
|
|
260
|
+
if (!open) return null;
|
|
261
|
+
|
|
262
|
+
function submit(event) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
const objectName = name.trim();
|
|
265
|
+
const fieldList = fields.split(",").map((field) => field.trim()).filter(Boolean);
|
|
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
|
+
}
|
|
274
|
+
setError("");
|
|
275
|
+
onCreate({ name: objectName, fields: fieldList });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<div className="dm-dialog-shell" role="dialog" aria-modal="true" aria-labelledby="dm-add-object-title">
|
|
280
|
+
<div className="dm-dialog-backdrop" onClick={onClose} />
|
|
281
|
+
<form className="dm-dialog" onSubmit={submit}>
|
|
282
|
+
<div className="dm-dialog-head">
|
|
283
|
+
<h2 id="dm-add-object-title">Add business object</h2>
|
|
284
|
+
<button type="button" className="dm-icon-btn" onClick={onClose}>x</button>
|
|
285
|
+
</div>
|
|
286
|
+
<div className="dm-dialog-body">
|
|
287
|
+
<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>
|
|
288
|
+
<label className="dm-field-label">Object name<input className="dm-input" value={name} placeholder="Companies, Clients, Leads" onChange={(event) => setName(event.target.value)} /></label>
|
|
289
|
+
<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>
|
|
290
|
+
{error ? <p className="dm-field-error">{error}</p> : null}
|
|
291
|
+
</div>
|
|
292
|
+
<div className="dm-dialog-actions">
|
|
293
|
+
<button type="button" className="dm-btn" onClick={onClose}>Cancel</button>
|
|
294
|
+
<button type="submit" className="dm-btn primary" disabled={saving}>Create object</button>
|
|
295
|
+
</div>
|
|
296
|
+
</form>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default function DataModelPage() {
|
|
302
|
+
const [workspaceConfig, setWorkspaceConfig] = useState(null);
|
|
303
|
+
const [authority, setAuthority] = useState(null);
|
|
304
|
+
const [loading, setLoading] = useState(true);
|
|
305
|
+
const [error, setError] = useState("");
|
|
306
|
+
const [saving, setSaving] = useState(false);
|
|
307
|
+
const [message, setMessage] = useState("");
|
|
308
|
+
const [selectedSource, setSelectedSource] = useState("");
|
|
309
|
+
const [activeTab, setActiveTab] = useState("Fields");
|
|
310
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
311
|
+
|
|
312
|
+
const load = useCallback(async () => {
|
|
313
|
+
setLoading(true);
|
|
314
|
+
setError("");
|
|
315
|
+
try {
|
|
316
|
+
const response = await fetch("/api/workspace", { cache: "no-store" });
|
|
317
|
+
const payload = await response.json();
|
|
318
|
+
if (!response.ok) throw new Error(payload.error || "Failed to load workspace");
|
|
319
|
+
setWorkspaceConfig(payload.workspaceConfig);
|
|
320
|
+
setAuthority(payload.adapters?.integrations?.authority || null);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
setError(err.message || "Failed to load workspace");
|
|
323
|
+
} finally {
|
|
324
|
+
setLoading(false);
|
|
325
|
+
}
|
|
326
|
+
}, []);
|
|
327
|
+
|
|
328
|
+
useEffect(() => { load(); }, [load]);
|
|
329
|
+
|
|
330
|
+
const tables = useMemo(() => workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : [], [workspaceConfig]);
|
|
331
|
+
const selectedTable = tables.find((table) => table.source === selectedSource) || tables[0] || null;
|
|
332
|
+
useEffect(() => { if (!selectedSource && tables[0]) setSelectedSource(tables[0].source); }, [selectedSource, tables]);
|
|
333
|
+
|
|
334
|
+
const save = useCallback(async (mutate) => {
|
|
335
|
+
if (!workspaceConfig) return;
|
|
336
|
+
setSaving(true);
|
|
337
|
+
setMessage("");
|
|
338
|
+
const next = mutate(workspaceConfig);
|
|
339
|
+
try {
|
|
340
|
+
const patch = {};
|
|
341
|
+
for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
|
|
342
|
+
if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
|
|
343
|
+
}
|
|
344
|
+
const response = await fetch("/api/workspace", {
|
|
345
|
+
method: "PATCH",
|
|
346
|
+
headers: { "content-type": "application/json" },
|
|
347
|
+
body: JSON.stringify(patch)
|
|
348
|
+
});
|
|
349
|
+
const payload = await response.json();
|
|
350
|
+
if (!response.ok) throw new Error(payload.error || "Save failed");
|
|
351
|
+
setWorkspaceConfig(payload.workspaceConfig);
|
|
352
|
+
setMessage("Saved");
|
|
353
|
+
} catch (err) {
|
|
354
|
+
setMessage(`Error: ${err.message || "Save failed"}`);
|
|
355
|
+
} finally {
|
|
356
|
+
setSaving(false);
|
|
357
|
+
}
|
|
358
|
+
}, [workspaceConfig]);
|
|
359
|
+
|
|
360
|
+
const createObject = useCallback(({ name, fields }) => {
|
|
361
|
+
save((config) => createManualBusinessObject(config, { name, fields }));
|
|
362
|
+
setSelectedSource(name);
|
|
363
|
+
setActiveTab("Records");
|
|
364
|
+
setAddOpen(false);
|
|
365
|
+
}, [save]);
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<main className="workspace-builder workspace-settings-page">
|
|
369
|
+
<NavRail authority={authority} workspaceConfig={workspaceConfig} />
|
|
370
|
+
<section className="workspace-surface">
|
|
371
|
+
<header className="workspace-toolbar">
|
|
372
|
+
<div><p>Workspace</p><h1>Data Model</h1></div>
|
|
373
|
+
<div className="workspace-toolbar-actions"><SaveToast saving={saving} message={message} /><button type="button" className="dm-btn primary" onClick={() => setAddOpen(true)}>+ Add object</button></div>
|
|
374
|
+
</header>
|
|
375
|
+
<AddObjectDialog open={addOpen} saving={saving} onClose={() => setAddOpen(false)} onCreate={createObject} />
|
|
376
|
+
{loading ? <div className="dm-loading">Loading workspace...</div> : null}
|
|
377
|
+
{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}
|
|
378
|
+
{!loading && !error && tables.length ? (
|
|
379
|
+
<>
|
|
380
|
+
<Summary tables={tables} />
|
|
381
|
+
<div className="dm-layout">
|
|
382
|
+
<aside className="dm-object-list">
|
|
383
|
+
<div className="dm-object-list-head"><p>{pluralize(tables.length, "object")}</p></div>
|
|
384
|
+
<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>
|
|
385
|
+
</aside>
|
|
386
|
+
<section className="dm-detail-panel">
|
|
387
|
+
<div className="dm-detail-header">
|
|
388
|
+
<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>
|
|
389
|
+
<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>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="dm-tabs">{TABS.map((tab) => <button key={tab} type="button" className={`dm-tab${activeTab === tab ? " active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>)}</div>
|
|
392
|
+
<div className="dm-tab-content">
|
|
393
|
+
{activeTab === "Fields" ? <FieldsTab table={selectedTable} saving={saving} onSave={save} /> : null}
|
|
394
|
+
{activeTab === "Records" ? <RecordsTab table={selectedTable} saving={saving} onSave={save} /> : null}
|
|
395
|
+
{activeTab === "Bindings" ? <BindingsTab table={selectedTable} /> : null}
|
|
396
|
+
{activeTab === "Usage" ? <UsageTab table={selectedTable} /> : null}
|
|
397
|
+
</div>
|
|
398
|
+
</section>
|
|
399
|
+
</div>
|
|
400
|
+
</>
|
|
401
|
+
) : null}
|
|
402
|
+
{!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}
|
|
403
|
+
</section>
|
|
404
|
+
</main>
|
|
405
|
+
);
|
|
406
|
+
}
|
package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
function GlobalError() {
|
|
6
|
+
return <html lang="en">
|
|
7
|
+
<body>
|
|
8
|
+
<main className="workspace-error-page">
|
|
9
|
+
<section>
|
|
10
|
+
<h1>Workspace unavailable</h1>
|
|
11
|
+
<p>Reload the workspace or return to the dashboard.</p>
|
|
12
|
+
<a href="/">Open workspace</a>
|
|
13
|
+
</section>
|
|
14
|
+
</main>
|
|
15
|
+
</body>
|
|
16
|
+
</html>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
GlobalError as default
|
|
21
|
+
};
|