@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
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
wrapWorkspaceTemplateExport
|
|
58
58
|
} from "@/lib/workspace-schema";
|
|
59
59
|
import { governedWorkspaceIntegrationCatalog } from "@/lib/domain/integrations";
|
|
60
|
+
import { listWorkspaceDataModelTables } from "@/lib/workspace-data-model";
|
|
60
61
|
|
|
61
62
|
const DEFAULT_CHART_TYPE = "bar-vertical";
|
|
62
63
|
const DEFAULT_FILTER_OP = "and";
|
|
@@ -65,6 +66,7 @@ const DEFAULT_SORT_DIRECTION = "asc";
|
|
|
65
66
|
const SUB_PANEL_ROOT = "root";
|
|
66
67
|
const MANAGED_INTEGRATION_SOURCE_TYPE = "managed-integrations";
|
|
67
68
|
const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
|
|
69
|
+
const DATA_MODEL_SOURCE_TYPE = "workspace-data-model";
|
|
68
70
|
|
|
69
71
|
const SOURCE_TYPE_OBJECTS = [
|
|
70
72
|
{
|
|
@@ -144,10 +146,20 @@ function generateId(prefix) {
|
|
|
144
146
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
function textColorForAccent(accent) {
|
|
150
|
+
const hex = String(accent || "").replace("#", "");
|
|
151
|
+
if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
|
|
152
|
+
const red = parseInt(hex.slice(0, 2), 16);
|
|
153
|
+
const green = parseInt(hex.slice(2, 4), 16);
|
|
154
|
+
const blue = parseInt(hex.slice(4, 6), 16);
|
|
155
|
+
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
|
|
156
|
+
return luminance > 0.62 ? "#252525" : "#ffffff";
|
|
157
|
+
}
|
|
158
|
+
|
|
147
159
|
function defaultTitleFor(kind) {
|
|
148
160
|
switch (kind) {
|
|
149
161
|
case "chart": return "Untitled chart";
|
|
150
|
-
case "view": return "
|
|
162
|
+
case "view": return "Untitled view";
|
|
151
163
|
case "iframe": return "Untitled iFrame";
|
|
152
164
|
case "rich-text": return "Untitled Rich Text";
|
|
153
165
|
default: return "Untitled widget";
|
|
@@ -641,6 +653,7 @@ function getChartStyle(widget) {
|
|
|
641
653
|
|
|
642
654
|
function summarizeSource(widget) {
|
|
643
655
|
const binding = widget?.config?.binding;
|
|
656
|
+
if (binding?.sourceType === DATA_MODEL_SOURCE_TYPE) return binding.source || widget?.config?.source || "Data Model object";
|
|
644
657
|
if (binding?.mode === "integration") {
|
|
645
658
|
const source = binding.source || "Integration";
|
|
646
659
|
if (binding.entityLabel) return `${source} · ${binding.entityLabel}`;
|
|
@@ -652,6 +665,7 @@ function summarizeSource(widget) {
|
|
|
652
665
|
}
|
|
653
666
|
|
|
654
667
|
function summarizeSourceType(binding) {
|
|
668
|
+
if (binding?.sourceType === DATA_MODEL_SOURCE_TYPE) return "Data Model";
|
|
655
669
|
if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
|
|
656
670
|
if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE) return "Managed Integrations";
|
|
657
671
|
return "Static data";
|
|
@@ -663,6 +677,27 @@ function resolveBindingSourceType(binding) {
|
|
|
663
677
|
return "static";
|
|
664
678
|
}
|
|
665
679
|
|
|
680
|
+
function resolveDataModelTable(dataModelTables, binding) {
|
|
681
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE) return null;
|
|
682
|
+
const tables = Array.isArray(dataModelTables) ? dataModelTables : [];
|
|
683
|
+
return tables.find((table) => table.objectId === binding.objectId || table.id === binding.objectId || table.source === binding.source) || null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function resolveViewWidget(widget, dataModelTables) {
|
|
687
|
+
if (widget?.kind !== "view") return widget;
|
|
688
|
+
const table = resolveDataModelTable(dataModelTables, widget.config?.binding);
|
|
689
|
+
if (!table) return widget;
|
|
690
|
+
return {
|
|
691
|
+
...widget,
|
|
692
|
+
config: {
|
|
693
|
+
...(widget.config || {}),
|
|
694
|
+
source: table.source,
|
|
695
|
+
columns: table.columns,
|
|
696
|
+
rows: table.rows
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
666
701
|
function summarizeFields(widget) {
|
|
667
702
|
const total = getColumnList(widget).length;
|
|
668
703
|
const hidden = getHiddenColumnSet(widget).size;
|
|
@@ -1061,7 +1096,7 @@ function EntitySelector({ integration, entities, selectedEntityId, selectedEntit
|
|
|
1061
1096
|
</div>;
|
|
1062
1097
|
}
|
|
1063
1098
|
|
|
1064
|
-
function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
1099
|
+
function SourceSubPanel({ widget, integrations, dataModelTables, onChange, onBack }) {
|
|
1065
1100
|
const binding = widget.config?.binding || {};
|
|
1066
1101
|
const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
|
|
1067
1102
|
const activeSourceType = resolveBindingSourceType(binding);
|
|
@@ -1070,6 +1105,7 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
|
1070
1105
|
const hasConnectedSource = Boolean(
|
|
1071
1106
|
binding.integrationId ||
|
|
1072
1107
|
binding.endpointRef ||
|
|
1108
|
+
binding.sourceType === DATA_MODEL_SOURCE_TYPE ||
|
|
1073
1109
|
binding.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE ||
|
|
1074
1110
|
binding.sourceType === CUSTOM_API_SOURCE_TYPE
|
|
1075
1111
|
);
|
|
@@ -1098,6 +1134,16 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
|
1098
1134
|
};
|
|
1099
1135
|
}, [integrations, laneFilter, query]);
|
|
1100
1136
|
|
|
1137
|
+
const availableDataObjects = useMemo(() => {
|
|
1138
|
+
const list = Array.isArray(dataModelTables) ? dataModelTables : [];
|
|
1139
|
+
const trimmed = query.trim().toLowerCase();
|
|
1140
|
+
return list.filter((table) => {
|
|
1141
|
+
if (table.storage !== "manual-object") return false;
|
|
1142
|
+
if (!trimmed) return true;
|
|
1143
|
+
return `${table.label} ${table.source}`.toLowerCase().includes(trimmed);
|
|
1144
|
+
});
|
|
1145
|
+
}, [dataModelTables, query]);
|
|
1146
|
+
|
|
1101
1147
|
const selectStatic = useCallback(() => {
|
|
1102
1148
|
if (!confirmSourceChange("Static rows")) return;
|
|
1103
1149
|
if (widget.kind === "chart") {
|
|
@@ -1105,12 +1151,34 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
|
1105
1151
|
} else {
|
|
1106
1152
|
onChange({
|
|
1107
1153
|
...widget.config,
|
|
1108
|
-
source: widget.config?.source || "
|
|
1109
|
-
binding:
|
|
1154
|
+
source: widget.config?.source || "Static rows",
|
|
1155
|
+
binding: { mode: "manual", source: "Static rows", rows: Array.isArray(widget.config?.rows) ? widget.config.rows : [] }
|
|
1110
1156
|
});
|
|
1111
1157
|
}
|
|
1112
1158
|
}, [confirmSourceChange, onChange, widget.config, widget.kind]);
|
|
1113
1159
|
|
|
1160
|
+
const selectDataModelObject = useCallback((table) => {
|
|
1161
|
+
if (!table || !confirmSourceChange(table.label)) return;
|
|
1162
|
+
onChange({
|
|
1163
|
+
...widget.config,
|
|
1164
|
+
source: table.source,
|
|
1165
|
+
columns: table.columns,
|
|
1166
|
+
rows: [],
|
|
1167
|
+
binding: {
|
|
1168
|
+
mode: "manual",
|
|
1169
|
+
source: table.source,
|
|
1170
|
+
sourceType: DATA_MODEL_SOURCE_TYPE,
|
|
1171
|
+
sourceAuthority: "workspace-config",
|
|
1172
|
+
objectId: table.objectId,
|
|
1173
|
+
rows: []
|
|
1174
|
+
},
|
|
1175
|
+
fieldSettings: {
|
|
1176
|
+
hidden: [],
|
|
1177
|
+
order: table.columns
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
}, [confirmSourceChange, onChange, widget.config]);
|
|
1181
|
+
|
|
1114
1182
|
const selectCustomApi = useCallback(() => {
|
|
1115
1183
|
if (!confirmSourceChange("Custom APIs/Webhooks")) return;
|
|
1116
1184
|
onChange({
|
|
@@ -1238,6 +1306,27 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
|
1238
1306
|
{activeSourceType === "static" ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1239
1307
|
</button>
|
|
1240
1308
|
</div>
|
|
1309
|
+
{widget.kind === "view" ? <>
|
|
1310
|
+
<p className="workspace-panel-label">Data Model objects</p>
|
|
1311
|
+
<div className="workspace-source-list">
|
|
1312
|
+
{availableDataObjects.length ? availableDataObjects.map((table) => {
|
|
1313
|
+
const isActive = binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId === table.objectId;
|
|
1314
|
+
return <button
|
|
1315
|
+
key={table.id}
|
|
1316
|
+
type="button"
|
|
1317
|
+
className={`workspace-source-row${isActive ? " active" : ""}`}
|
|
1318
|
+
onClick={() => selectDataModelObject(table)}
|
|
1319
|
+
>
|
|
1320
|
+
<span className="workspace-source-icon" aria-hidden="true"><Database size={15} /></span>
|
|
1321
|
+
<span className="workspace-source-meta">
|
|
1322
|
+
<strong>{table.label}</strong>
|
|
1323
|
+
<em>{table.columns.length} fields · {table.rows.length} records · workspace config</em>
|
|
1324
|
+
</span>
|
|
1325
|
+
{isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
|
|
1326
|
+
</button>;
|
|
1327
|
+
}) : <p className="workspace-entity-empty">No manual Data Model objects yet.</p>}
|
|
1328
|
+
</div>
|
|
1329
|
+
</> : null}
|
|
1241
1330
|
{Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
|
|
1242
1331
|
<p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
|
|
1243
1332
|
<div className="workspace-source-list">
|
|
@@ -1289,22 +1378,24 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
|
|
|
1289
1378
|
</section>;
|
|
1290
1379
|
}
|
|
1291
1380
|
|
|
1292
|
-
function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
1293
|
-
const
|
|
1294
|
-
const
|
|
1381
|
+
function FieldsSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
1382
|
+
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1383
|
+
const ordered = getOrderedColumns(viewWidget);
|
|
1384
|
+
const hidden = getHiddenColumnSet(viewWidget);
|
|
1295
1385
|
const visible = ordered.filter((name) => !hidden.has(name));
|
|
1296
1386
|
const hiddenList = ordered.filter((name) => hidden.has(name));
|
|
1297
1387
|
const [hiddenOpen, setHiddenOpen] = useState(true);
|
|
1298
1388
|
const [draftField, setDraftField] = useState("");
|
|
1299
1389
|
const move = (fieldId, direction) => {
|
|
1300
|
-
const next = reorderColumn(
|
|
1390
|
+
const next = reorderColumn(viewWidget, fieldId, direction);
|
|
1301
1391
|
onChange({ ...widget.config, fieldSettings: next });
|
|
1302
1392
|
};
|
|
1303
1393
|
const toggle = (fieldId) => {
|
|
1304
|
-
const next = toggleColumnHidden(
|
|
1394
|
+
const next = toggleColumnHidden(viewWidget, fieldId);
|
|
1305
1395
|
onChange({ ...widget.config, fieldSettings: next });
|
|
1306
1396
|
};
|
|
1307
1397
|
const removeColumn = (fieldId) => {
|
|
1398
|
+
if (dataModelTable) return;
|
|
1308
1399
|
const nextColumns = ordered.filter((name) => name !== fieldId);
|
|
1309
1400
|
const fs = widget.config?.fieldSettings || {};
|
|
1310
1401
|
onChange({
|
|
@@ -1317,6 +1408,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
|
1317
1408
|
});
|
|
1318
1409
|
};
|
|
1319
1410
|
const addColumn = () => {
|
|
1411
|
+
if (dataModelTable) return;
|
|
1320
1412
|
const trimmed = draftField.trim();
|
|
1321
1413
|
if (!trimmed || ordered.includes(trimmed)) return;
|
|
1322
1414
|
onChange({ ...widget.config, columns: [...ordered, trimmed] });
|
|
@@ -1324,6 +1416,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
|
1324
1416
|
};
|
|
1325
1417
|
return <section className="workspace-widget-subpanel">
|
|
1326
1418
|
<SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
|
|
1419
|
+
{dataModelTable ? <p className="workspace-panel-hint">This View is bound to a Data Model object. Field order and visibility are widget-local; add or remove object fields on the Data Model page.</p> : null}
|
|
1327
1420
|
<p className="workspace-panel-label">Visible fields</p>
|
|
1328
1421
|
<div className="workspace-field-rows">
|
|
1329
1422
|
{visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
|
|
@@ -1335,7 +1428,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
|
1335
1428
|
<button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>↑</button>
|
|
1336
1429
|
<button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>↓</button>
|
|
1337
1430
|
<button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>👁</button>
|
|
1338
|
-
<button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
|
|
1431
|
+
<button type="button" aria-label={`Remove ${name}`} disabled={Boolean(dataModelTable)} onClick={() => removeColumn(name)}>✕</button>
|
|
1339
1432
|
</span>
|
|
1340
1433
|
</div>)}
|
|
1341
1434
|
</div>
|
|
@@ -1355,7 +1448,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
|
1355
1448
|
<span className="workspace-field-row-name">{name}</span>
|
|
1356
1449
|
<span className="workspace-field-row-actions">
|
|
1357
1450
|
<button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>👁</button>
|
|
1358
|
-
<button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
|
|
1451
|
+
<button type="button" aria-label={`Remove ${name}`} disabled={Boolean(dataModelTable)} onClick={() => removeColumn(name)}>✕</button>
|
|
1359
1452
|
</span>
|
|
1360
1453
|
</div>)}
|
|
1361
1454
|
</div> : null}
|
|
@@ -1372,14 +1465,15 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
|
|
|
1372
1465
|
}
|
|
1373
1466
|
}}
|
|
1374
1467
|
/>
|
|
1375
|
-
<button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
|
|
1468
|
+
<button type="button" onClick={addColumn} disabled={Boolean(dataModelTable) || !draftField.trim()}>Add</button>
|
|
1376
1469
|
</div>
|
|
1377
1470
|
</section>;
|
|
1378
1471
|
}
|
|
1379
1472
|
|
|
1380
|
-
function SortSubPanel({ widget, onChange, onBack }) {
|
|
1473
|
+
function SortSubPanel({ widget, dataModelTable, onChange, onBack }) {
|
|
1474
|
+
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1381
1475
|
const sort = getSortClauses(widget);
|
|
1382
|
-
const columns = getColumnList(
|
|
1476
|
+
const columns = getColumnList(viewWidget);
|
|
1383
1477
|
const updateSort = (next) => onChange({ ...widget.config, sort: next });
|
|
1384
1478
|
const addClause = () => {
|
|
1385
1479
|
const fieldId = columns[0] || "";
|
|
@@ -1423,12 +1517,13 @@ function SortSubPanel({ widget, onChange, onBack }) {
|
|
|
1423
1517
|
</section>;
|
|
1424
1518
|
}
|
|
1425
1519
|
|
|
1426
|
-
function FilterSubPanel({ widget, integrations, onChange, onBack }) {
|
|
1520
|
+
function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack }) {
|
|
1521
|
+
const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
|
|
1427
1522
|
const binding = widget.config?.binding || {};
|
|
1428
1523
|
const filter = getFilterConfig(widget);
|
|
1429
1524
|
const [entities, setEntities] = useState([]);
|
|
1430
1525
|
const [entitiesLoading, setEntitiesLoading] = useState(false);
|
|
1431
|
-
const fieldChoices = getFilterFieldChoices(
|
|
1526
|
+
const fieldChoices = getFilterFieldChoices(viewWidget, entities);
|
|
1432
1527
|
const columns = fieldChoices.map((field) => field.id);
|
|
1433
1528
|
const setFilter = (next) => onChange({ ...widget.config, filter: next });
|
|
1434
1529
|
const setOp = (op) => setFilter({ ...filter, op });
|
|
@@ -2000,7 +2095,7 @@ function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integratio
|
|
|
2000
2095
|
Inspect-only. Sourced from <code>growthub.config.json</code> + <code>GET /api/workspace</code>.
|
|
2001
2096
|
Edit branding by updating <code>growthub.config.json</code> inside your governed fork.
|
|
2002
2097
|
The builder itself never holds tokens, never executes hosted workflows, and never bypasses the PATCH allowlist
|
|
2003
|
-
(<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>).
|
|
2098
|
+
(<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>, <code>dataModel</code>).
|
|
2004
2099
|
</p>
|
|
2005
2100
|
<div className="workspace-readiness">
|
|
2006
2101
|
<article className="workspace-readiness-section">
|
|
@@ -2081,7 +2176,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
2081
2176
|
</article>
|
|
2082
2177
|
<article className="workspace-readiness-section">
|
|
2083
2178
|
<h3>API</h3>
|
|
2084
|
-
<div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas</code></div>
|
|
2179
|
+
<div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas | dataModel</code></div>
|
|
2085
2180
|
<div className="workspace-readiness-row"><span>Unknown field</span><code>400</code></div>
|
|
2086
2181
|
<div className="workspace-readiness-row"><span>Read-only runtime</span><code>409 + guidance</code></div>
|
|
2087
2182
|
<div className="workspace-readiness-row"><span>Can save now</span>
|
|
@@ -2175,6 +2270,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
2175
2270
|
const addSlot = dragPreview || selectedPosition;
|
|
2176
2271
|
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
|
|
2177
2272
|
const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
|
|
2273
|
+
const dataModelTables = useMemo(() => listWorkspaceDataModelTables(config), [config]);
|
|
2274
|
+
const selectedResolvedWidget = selectedWidget ? resolveViewWidget(selectedWidget, dataModelTables) : null;
|
|
2178
2275
|
const branding = config.branding || {};
|
|
2179
2276
|
const occupiedCells = useMemo(() => {
|
|
2180
2277
|
const cells = new Set();
|
|
@@ -3013,7 +3110,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3013
3110
|
});
|
|
3014
3111
|
list.push({
|
|
3015
3112
|
id: "workspace.settings", group: "Workspace", icon: Settings, label: "Go to Workspace Settings", shortcut: "G S",
|
|
3016
|
-
run: () =>
|
|
3113
|
+
run: () => { window.location.href = "/settings/general"; }
|
|
3017
3114
|
});
|
|
3018
3115
|
list.push({
|
|
3019
3116
|
id: "workspace.management", group: "Workspace", icon: Bolt, label: "Go to Management",
|
|
@@ -3050,14 +3147,20 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3050
3147
|
return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
|
|
3051
3148
|
<aside className="workspace-rail" aria-label="Workspace navigation">
|
|
3052
3149
|
<div className="workspace-brand">
|
|
3053
|
-
<span className="workspace-mark"
|
|
3054
|
-
|
|
3150
|
+
<span className="workspace-mark" style={{
|
|
3151
|
+
background: branding.logoUrl ? undefined : branding.accent || undefined,
|
|
3152
|
+
color: branding.logoUrl ? undefined : textColorForAccent(branding.accent)
|
|
3153
|
+
}}>
|
|
3154
|
+
{branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : (branding.name || config.name || "Growthub Workspace").slice(0, 1).toUpperCase()}
|
|
3155
|
+
</span>
|
|
3156
|
+
<span>{branding.name || config.name || "Growthub Workspace"}</span>
|
|
3055
3157
|
</div>
|
|
3056
3158
|
<nav className="workspace-nav">
|
|
3057
3159
|
<button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
|
|
3160
|
+
<Link href="/data-model">Data Model</Link>
|
|
3058
3161
|
<Link href="/settings/integrations">Integrations</Link>
|
|
3059
|
-
<button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
|
|
3060
3162
|
<button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
|
|
3163
|
+
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
3061
3164
|
</nav>
|
|
3062
3165
|
<div className="workspace-rail-status">
|
|
3063
3166
|
<span className="status-dot" />
|
|
@@ -3246,7 +3349,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3246
3349
|
onSelect={() => selectWidget(widget.id)}
|
|
3247
3350
|
onExpandIframe={setExpandedIframeWidget}
|
|
3248
3351
|
selected={widget.id === selectedWidgetId}
|
|
3249
|
-
widget={widget}
|
|
3352
|
+
widget={resolveViewWidget(widget, dataModelTables)}
|
|
3250
3353
|
/>)}
|
|
3251
3354
|
</div>
|
|
3252
3355
|
</section> : null}
|
|
@@ -3292,22 +3395,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3292
3395
|
{selectedWidget && inspectorPath === "source" ? <SourceSubPanel
|
|
3293
3396
|
widget={selectedWidget}
|
|
3294
3397
|
integrations={availableIntegrations}
|
|
3398
|
+
dataModelTables={dataModelTables}
|
|
3295
3399
|
onChange={replaceSelectedWidgetConfig}
|
|
3296
3400
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3297
3401
|
/> : null}
|
|
3298
3402
|
{selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
|
|
3299
3403
|
widget={selectedWidget}
|
|
3404
|
+
dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
|
|
3300
3405
|
onChange={replaceSelectedWidgetConfig}
|
|
3301
3406
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3302
3407
|
/> : null}
|
|
3303
3408
|
{selectedWidget && inspectorPath === "sort" ? <SortSubPanel
|
|
3304
3409
|
widget={selectedWidget}
|
|
3410
|
+
dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
|
|
3305
3411
|
onChange={replaceSelectedWidgetConfig}
|
|
3306
3412
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3307
3413
|
/> : null}
|
|
3308
3414
|
{selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
|
|
3309
3415
|
widget={selectedWidget}
|
|
3310
3416
|
integrations={availableIntegrations}
|
|
3417
|
+
dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
|
|
3311
3418
|
onChange={replaceSelectedWidgetConfig}
|
|
3312
3419
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
3313
3420
|
/> : null}
|
|
@@ -3370,7 +3477,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3370
3477
|
</small>
|
|
3371
3478
|
</label> : null}
|
|
3372
3479
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
3373
|
-
<
|
|
3480
|
+
{selectedWidget.config?.binding?.sourceType === DATA_MODEL_SOURCE_TYPE ? <div className="workspace-active-source-state">
|
|
3481
|
+
<span>Data Model object</span>
|
|
3482
|
+
<strong>{summarizeSource(selectedWidget)}</strong>
|
|
3483
|
+
<code>{selectedWidget.config?.binding?.objectId || "workspace-config"}</code>
|
|
3484
|
+
</div> : <label>
|
|
3374
3485
|
<span>Manual Rows</span>
|
|
3375
3486
|
<textarea
|
|
3376
3487
|
value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
|
|
@@ -3382,7 +3493,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3382
3493
|
});
|
|
3383
3494
|
}}
|
|
3384
3495
|
/>
|
|
3385
|
-
</label>
|
|
3496
|
+
</label>}
|
|
3386
3497
|
<div className="workspace-settings-list" role="group" aria-label="View widget settings">
|
|
3387
3498
|
<p className="workspace-panel-label">Settings</p>
|
|
3388
3499
|
<button type="button" className="workspace-settings-row" disabled>
|
|
@@ -3392,13 +3503,13 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3392
3503
|
<span>Source</span><code>{summarizeSource(selectedWidget)}</code>
|
|
3393
3504
|
</button>
|
|
3394
3505
|
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
|
|
3395
|
-
<span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
|
|
3506
|
+
<span>Fields</span><code>{summarizeFields(selectedResolvedWidget || selectedWidget)}</code>
|
|
3396
3507
|
</button>
|
|
3397
3508
|
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
|
|
3398
|
-
<span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
|
|
3509
|
+
<span>Filter</span><code>{summarizeFilter(selectedResolvedWidget || selectedWidget)}</code>
|
|
3399
3510
|
</button>
|
|
3400
3511
|
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
|
|
3401
|
-
<span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
|
|
3512
|
+
<span>Sort</span><code>{summarizeSort(selectedResolvedWidget || selectedWidget)}</code>
|
|
3402
3513
|
</button>
|
|
3403
3514
|
</div>
|
|
3404
3515
|
</section> : null}
|
|
@@ -93,6 +93,7 @@ function applyPatch(currentConfig, patch) {
|
|
|
93
93
|
const next = { ...currentConfig };
|
|
94
94
|
if (patch.dashboards !== undefined) next.dashboards = patch.dashboards;
|
|
95
95
|
if (patch.widgetTypes !== undefined) next.widgetTypes = patch.widgetTypes;
|
|
96
|
+
if (patch.dataModel !== undefined) next.dataModel = patch.dataModel;
|
|
96
97
|
if (patch.canvas !== undefined && patch.canvas !== null) {
|
|
97
98
|
const patchCanvas = { ...patch.canvas };
|
|
98
99
|
if (Array.isArray(patchCanvas.tabs)) {
|
|
@@ -140,7 +141,8 @@ async function writeWorkspaceConfig(patch) {
|
|
|
140
141
|
validateWorkspaceConfig({
|
|
141
142
|
dashboards: next.dashboards,
|
|
142
143
|
widgetTypes: next.widgetTypes,
|
|
143
|
-
canvas: next.canvas
|
|
144
|
+
canvas: next.canvas,
|
|
145
|
+
dataModel: next.dataModel
|
|
144
146
|
});
|
|
145
147
|
const configPath = resolveWorkspaceConfigPath();
|
|
146
148
|
const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
|
|
@@ -153,6 +155,189 @@ async function writeWorkspaceConfig(patch) {
|
|
|
153
155
|
return next;
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
function normalizeWorkspaceIdentityPatch(patch) {
|
|
159
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
160
|
+
const error = new Error("settings patch must be a plain object");
|
|
161
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const allowed = new Set(["name", "branding"]);
|
|
166
|
+
const unknown = Object.keys(patch).filter((key) => !allowed.has(key));
|
|
167
|
+
if (unknown.length) {
|
|
168
|
+
const error = new Error("settings patch contains unknown fields");
|
|
169
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
170
|
+
error.details = unknown;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const normalized = {};
|
|
175
|
+
if (Object.prototype.hasOwnProperty.call(patch, "name")) {
|
|
176
|
+
if (typeof patch.name !== "string") {
|
|
177
|
+
const error = new Error("name must be a string");
|
|
178
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
normalized.name = patch.name.trim() || "Growthub Workspace";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (Object.prototype.hasOwnProperty.call(patch, "branding")) {
|
|
185
|
+
if (!patch.branding || typeof patch.branding !== "object" || Array.isArray(patch.branding)) {
|
|
186
|
+
const error = new Error("branding must be a plain object");
|
|
187
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const brandingAllowed = new Set(["name", "logoUrl", "accent"]);
|
|
191
|
+
const brandingUnknown = Object.keys(patch.branding).filter((key) => !brandingAllowed.has(key));
|
|
192
|
+
if (brandingUnknown.length) {
|
|
193
|
+
const error = new Error("branding patch contains unknown fields");
|
|
194
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
195
|
+
error.details = brandingUnknown.map((key) => `branding.${key}`);
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
normalized.branding = {};
|
|
199
|
+
for (const key of brandingAllowed) {
|
|
200
|
+
if (Object.prototype.hasOwnProperty.call(patch.branding, key)) {
|
|
201
|
+
if (typeof patch.branding[key] !== "string") {
|
|
202
|
+
const error = new Error(`branding.${key} must be a string`);
|
|
203
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
normalized.branding[key] = patch.branding[key].trim();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return normalized;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function writeWorkspaceIdentitySettings(patch) {
|
|
215
|
+
const persistence = describePersistenceMode();
|
|
216
|
+
const adapter = readAdapterConfig();
|
|
217
|
+
if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
|
|
218
|
+
const error = new Error(persistence.reason);
|
|
219
|
+
error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
|
|
220
|
+
error.adapter = adapter.integrationAdapter;
|
|
221
|
+
error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const normalized = normalizeWorkspaceIdentityPatch(patch);
|
|
226
|
+
const current = await readWorkspaceConfig();
|
|
227
|
+
const next = { ...current };
|
|
228
|
+
if (normalized.name !== undefined) {
|
|
229
|
+
next.name = normalized.name;
|
|
230
|
+
}
|
|
231
|
+
if (normalized.branding) {
|
|
232
|
+
next.branding = {
|
|
233
|
+
...(current.branding && typeof current.branding === "object" && !Array.isArray(current.branding)
|
|
234
|
+
? current.branding
|
|
235
|
+
: {}),
|
|
236
|
+
...normalized.branding
|
|
237
|
+
};
|
|
238
|
+
if (!next.branding.name) {
|
|
239
|
+
next.branding.name = next.name || "Growthub Workspace";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
validateWorkspaceConfig({
|
|
244
|
+
dashboards: next.dashboards,
|
|
245
|
+
widgetTypes: next.widgetTypes,
|
|
246
|
+
canvas: next.canvas,
|
|
247
|
+
dataModel: next.dataModel
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const configPath = resolveWorkspaceConfigPath();
|
|
251
|
+
const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
|
|
252
|
+
if (path.dirname(configPath) !== expectedDir) {
|
|
253
|
+
const error = new Error(`refused to write outside workspace cwd: ${configPath}`);
|
|
254
|
+
error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
258
|
+
return next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeApiWebhookRefs(refs) {
|
|
262
|
+
if (!Array.isArray(refs)) {
|
|
263
|
+
const error = new Error("refs must be an array");
|
|
264
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
return refs
|
|
268
|
+
.map((item, index) => {
|
|
269
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
270
|
+
const error = new Error("each ref must be a plain object");
|
|
271
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
const allowed = new Set(["id", "label", "kind", "endpointRef", "status", "hasSecret", "url"]);
|
|
275
|
+
const unknown = Object.keys(item).filter((key) => !allowed.has(key));
|
|
276
|
+
if (unknown.length) {
|
|
277
|
+
const error = new Error("ref contains unknown fields");
|
|
278
|
+
error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
|
|
279
|
+
error.details = unknown;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
const kind = item.kind === "webhook" ? "webhook" : "api";
|
|
283
|
+
const label = typeof item.label === "string" ? item.label.trim() : "";
|
|
284
|
+
const endpointRef = typeof item.endpointRef === "string" ? item.endpointRef.trim() : "";
|
|
285
|
+
const url = typeof item.url === "string" ? item.url.trim() : "";
|
|
286
|
+
if (!label && !endpointRef && !url && item.hasSecret !== true) return null;
|
|
287
|
+
return {
|
|
288
|
+
id: typeof item.id === "string" && item.id.trim() ? item.id.trim() : `custom-${kind}-${index + 1}`,
|
|
289
|
+
label: label || endpointRef,
|
|
290
|
+
kind,
|
|
291
|
+
sourceType: "custom-api-webhooks",
|
|
292
|
+
endpointRef,
|
|
293
|
+
url,
|
|
294
|
+
status: typeof item.status === "string" && item.status.trim() ? item.status.trim() : "configured",
|
|
295
|
+
hasSecret: item.hasSecret === true
|
|
296
|
+
};
|
|
297
|
+
})
|
|
298
|
+
.filter(Boolean);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function writeWorkspaceApiWebhookSettings(patch) {
|
|
302
|
+
const persistence = describePersistenceMode();
|
|
303
|
+
const adapter = readAdapterConfig();
|
|
304
|
+
if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
|
|
305
|
+
const error = new Error(persistence.reason);
|
|
306
|
+
error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
|
|
307
|
+
error.adapter = adapter.integrationAdapter;
|
|
308
|
+
error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const refs = normalizeApiWebhookRefs(patch?.refs);
|
|
313
|
+
const current = await readWorkspaceConfig();
|
|
314
|
+
const existing = Array.isArray(current.integrations) ? current.integrations : [];
|
|
315
|
+
const next = {
|
|
316
|
+
...current,
|
|
317
|
+
integrations: [
|
|
318
|
+
...existing.filter((item) => item?.sourceType !== "custom-api-webhooks"),
|
|
319
|
+
...refs
|
|
320
|
+
]
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
validateWorkspaceConfig({
|
|
324
|
+
dashboards: next.dashboards,
|
|
325
|
+
widgetTypes: next.widgetTypes,
|
|
326
|
+
canvas: next.canvas,
|
|
327
|
+
dataModel: next.dataModel
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const configPath = resolveWorkspaceConfigPath();
|
|
331
|
+
const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
|
|
332
|
+
if (path.dirname(configPath) !== expectedDir) {
|
|
333
|
+
const error = new Error(`refused to write outside workspace cwd: ${configPath}`);
|
|
334
|
+
error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
338
|
+
return next.integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
|
|
339
|
+
}
|
|
340
|
+
|
|
156
341
|
export {
|
|
157
342
|
GRID_COLUMNS,
|
|
158
343
|
GRID_ROWS,
|
|
@@ -163,5 +348,7 @@ export {
|
|
|
163
348
|
readWorkspaceConfig,
|
|
164
349
|
resolveWorkspaceConfigPath,
|
|
165
350
|
validateWorkspaceConfig,
|
|
166
|
-
writeWorkspaceConfig
|
|
351
|
+
writeWorkspaceConfig,
|
|
352
|
+
writeWorkspaceApiWebhookSettings,
|
|
353
|
+
writeWorkspaceIdentitySettings
|
|
167
354
|
};
|