@growthub/cli 0.9.10 → 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/README.md +1 -1
- package/assets/worker-kits/creative-strategist-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-agency-portal-starter-v1/kit.json +4 -1
- package/assets/worker-kits/growthub-ai-website-cloner-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-creative-video-pipeline-v1/kit.json +4 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +50 -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 +389 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +362 -15
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +5 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +5 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +625 -56
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js +12 -16
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +61 -11
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +31 -1
- 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 +112 -14
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-email-marketing-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-geo-seo-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-hyperframes-studio-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-marketing-skills-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-open-higgsfield-studio-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-open-montage-studio-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-postiz-social-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-twenty-crm-v1/kit.json +6 -3
- package/assets/worker-kits/growthub-video-use-studio-v1/kit.json +5 -2
- package/assets/worker-kits/growthub-zernio-social-v1/kit.json +5 -2
- package/dist/index.js +1750 -433
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`@growthub/cli` is the CLI control plane for Growthub Local.
|
|
4
4
|
|
|
5
|
-
It creates governed **Workspaces** from any source — repo, skill, kit, template, or
|
|
5
|
+
It creates governed **Workspaces** from any source — repo, skill, kit, template, or starter. The Workspace is the top-level product object; this CLI is the local executor that creates, customizes, and inspects them.
|
|
6
6
|
|
|
7
7
|
## Start here: create a governed Workspace
|
|
8
8
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"kit": {
|
|
4
4
|
"id": "creative-strategist-v1",
|
|
5
5
|
"version": "1.0.0",
|
|
6
|
-
"name": "Growthub Agent Worker Kit
|
|
6
|
+
"name": "Growthub Agent Worker Kit \u2014 Creative Strategist",
|
|
7
7
|
"description": "Frozen Creative Strategist worker kit for exporting a working-directory-ready video creative brief worker.",
|
|
8
8
|
"type": "worker",
|
|
9
9
|
"visibility": "public-open-source",
|
|
@@ -99,5 +99,8 @@
|
|
|
99
99
|
"provenance": {
|
|
100
100
|
"sourceRepo": "claude-workers",
|
|
101
101
|
"frozenAt": "2026-04-09T00:00:00.000Z"
|
|
102
|
-
}
|
|
102
|
+
},
|
|
103
|
+
"workspaceVisibility": "addon",
|
|
104
|
+
"workspaceCompatible": true,
|
|
105
|
+
"workspaceCategory": "creative-strategy"
|
|
103
106
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"kit": {
|
|
4
4
|
"id": "growthub-ai-website-cloner-v1",
|
|
5
5
|
"version": "1.0.0",
|
|
6
|
-
"name": "Growthub Agent Worker Kit
|
|
7
|
-
"description": "Self-contained local workspace for cloning any website into a clean Next.js 16 + shadcn/ui + Tailwind CSS v4 codebase using AI coding agents. Produces pixel-perfect component clones from a target URL via multi-phase reconnaissance, spec writing, parallel builder dispatch, and visual QA
|
|
6
|
+
"name": "Growthub Agent Worker Kit \u2014 AI Website Cloner",
|
|
7
|
+
"description": "Self-contained local workspace for cloning any website into a clean Next.js 16 + shadcn/ui + Tailwind CSS v4 codebase using AI coding agents. Produces pixel-perfect component clones from a target URL via multi-phase reconnaissance, spec writing, parallel builder dispatch, and visual QA \u2014 all driven by the ai-website-cloner-template fork.",
|
|
8
8
|
"type": "worker",
|
|
9
9
|
"visibility": "public-open-source",
|
|
10
10
|
"sourceRepo": "growthub-local",
|
|
@@ -113,5 +113,8 @@
|
|
|
113
113
|
"provenance": {
|
|
114
114
|
"sourceRepo": "growthub-local",
|
|
115
115
|
"frozenAt": "2026-04-15T00:00:00.000Z"
|
|
116
|
-
}
|
|
116
|
+
},
|
|
117
|
+
"workspaceVisibility": "addon",
|
|
118
|
+
"workspaceCompatible": false,
|
|
119
|
+
"workspaceCategory": "studio"
|
|
117
120
|
}
|
|
@@ -16,14 +16,14 @@ It intentionally depends on adapter contracts:
|
|
|
16
16
|
|
|
17
17
|
The Growthub local-first operator shell remains at `../../studio`.
|
|
18
18
|
|
|
19
|
-
Settings exposes two integration lanes:
|
|
19
|
+
Settings exposes two universal integration lanes:
|
|
20
20
|
|
|
21
|
-
- Data sources
|
|
22
|
-
- Workspace integrations
|
|
21
|
+
- Data sources.
|
|
22
|
+
- Workspace integrations.
|
|
23
23
|
|
|
24
24
|
The `/settings/integrations` page is part of the official governed workspace app shell. It uses the same light workspace rail, toolbar, and product object model as the dashboard workspace, and it renders Growthub bridge account state without redirecting to or borrowing the agency portal kit.
|
|
25
25
|
|
|
26
|
-
Use `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` when the deployed app should read connection state from the Growthub GH app MCP bridge. The reusable primitive is `lib/adapters/integrations/growthub-connection-normalizer.js`; it accepts SDK/profile-style `integrations[]` payloads and GH app MCP `accounts[]` payloads, then emits the same normalized object shape used by `byo-api-key`. Keep
|
|
26
|
+
Use `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` when the deployed app should read connection state from the Growthub GH app MCP bridge. The reusable primitive is `lib/adapters/integrations/growthub-connection-normalizer.js`; it accepts SDK/profile-style `integrations[]` payloads and GH app MCP `accounts[]` payloads, then emits the same normalized object shape used by `byo-api-key`. Keep source credentials in the hosted authority layer or named env vars; this app consumes normalized connection metadata only.
|
|
27
27
|
|
|
28
28
|
For first boot, the bundled app also supports a hybrid path: keep `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` and set `WINDSOR_API_KEY` locally. That overlays connected state for Windsor AI and Google Sheets blended data without moving the rest of the workspace off the hosted bridge authority path.
|
|
29
29
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/workspace/integration-entities?integrationId=<id>
|
|
3
|
+
*
|
|
4
|
+
* Returns NormalizedIntegrationEntity[] for the requested integration when a
|
|
5
|
+
* server-side object resolver can fetch real source objects.
|
|
6
|
+
* This route is server-side only: no source credentials are forwarded to the
|
|
7
|
+
* browser, and no provider queries are executed in the client.
|
|
8
|
+
*
|
|
9
|
+
* Authority invariant (from GOVERNED_WORKSPACE_TOPOLOGY_V1.md):
|
|
10
|
+
* The browser never queries integrations, holds tokens, or resolves
|
|
11
|
+
* entity metadata directly. This route is the only server-side surface
|
|
12
|
+
* that crosses the authority boundary.
|
|
13
|
+
*
|
|
14
|
+
* Response shape:
|
|
15
|
+
* 200 { integrationId: string, entities: NormalizedIntegrationEntity[], source: "resolver", requiresObjectResolver: boolean }
|
|
16
|
+
* 400 { error: string }
|
|
17
|
+
*/
|
|
18
|
+
import { NextResponse } from "next/server";
|
|
19
|
+
import { listEntityMetadataForIntegration } from "@/lib/adapters/integrations";
|
|
20
|
+
import { readAdapterConfig } from "@/lib/adapters/env";
|
|
21
|
+
|
|
22
|
+
async function GET(request) {
|
|
23
|
+
const { searchParams } = new URL(request.url);
|
|
24
|
+
const integrationId = searchParams.get("integrationId");
|
|
25
|
+
|
|
26
|
+
if (!integrationId || typeof integrationId !== "string" || !integrationId.trim()) {
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: "integrationId query parameter is required" },
|
|
29
|
+
{ status: 400 }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = readAdapterConfig();
|
|
34
|
+
const isBridgeMode =
|
|
35
|
+
config.integrationAdapter === "growthub-bridge" &&
|
|
36
|
+
config.growthubBridge?.baseUrl &&
|
|
37
|
+
!!process.env.GROWTHUB_BRIDGE_ACCESS_TOKEN;
|
|
38
|
+
|
|
39
|
+
const entities = await listEntityMetadataForIntegration(integrationId.trim());
|
|
40
|
+
|
|
41
|
+
return NextResponse.json({
|
|
42
|
+
integrationId: integrationId.trim(),
|
|
43
|
+
entities,
|
|
44
|
+
source: entities.length ? "resolver" : "none",
|
|
45
|
+
requiresObjectResolver: entities.length === 0,
|
|
46
|
+
authority: isBridgeMode ? "growthub-bridge" : "local"
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { GET };
|
|
@@ -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
|
+
}
|