@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.
Files changed (21) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/apis-webhooks/route.js +59 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +1 -1
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +406 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +767 -6
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +23 -3
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +139 -28
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +189 -2
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +433 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +58 -7
  20. package/dist/index.js +3 -1
  21. 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 "Companies";
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 || "Companies",
1109
- binding: SAMPLE_DATA_BINDINGS.companiesManual
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 ordered = getOrderedColumns(widget);
1294
- const hidden = getHiddenColumnSet(widget);
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(widget, fieldId, direction);
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(widget, fieldId);
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(widget);
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(widget, entities);
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: () => setSettingsOpen(true)
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">G</span>
3054
- <span>Growthub Workspace</span>
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
- <label>
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
  };