@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.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/assets/worker-kits/creative-strategist-v1/kit.json +5 -2
  3. package/assets/worker-kits/growthub-agency-portal-starter-v1/kit.json +4 -1
  4. package/assets/worker-kits/growthub-ai-website-cloner-v1/kit.json +6 -3
  5. package/assets/worker-kits/growthub-creative-video-pipeline-v1/kit.json +4 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +50 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +1 -1
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +389 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +362 -15
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +5 -2
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +5 -5
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +625 -56
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js +12 -16
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +61 -11
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +31 -1
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +3 -1
  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 +112 -14
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +5 -2
  21. package/assets/worker-kits/growthub-email-marketing-v1/kit.json +5 -2
  22. package/assets/worker-kits/growthub-geo-seo-v1/kit.json +5 -2
  23. package/assets/worker-kits/growthub-hyperframes-studio-v1/kit.json +5 -2
  24. package/assets/worker-kits/growthub-marketing-skills-v1/kit.json +6 -3
  25. package/assets/worker-kits/growthub-open-higgsfield-studio-v1/kit.json +5 -2
  26. package/assets/worker-kits/growthub-open-montage-studio-v1/kit.json +6 -3
  27. package/assets/worker-kits/growthub-postiz-social-v1/kit.json +5 -2
  28. package/assets/worker-kits/growthub-twenty-crm-v1/kit.json +6 -3
  29. package/assets/worker-kits/growthub-video-use-studio-v1/kit.json +5 -2
  30. package/assets/worker-kits/growthub-zernio-social-v1/kit.json +5 -2
  31. package/dist/index.js +1750 -433
  32. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import {
6
6
  BarChart3,
7
7
  Bolt,
8
+ Check,
8
9
  ChevronDown,
9
10
  Code2,
10
11
  Columns3,
@@ -33,7 +34,6 @@ import {
33
34
  Settings,
34
35
  Sigma,
35
36
  SlidersHorizontal,
36
- Sparkles,
37
37
  Table2,
38
38
  Trash2,
39
39
  Type,
@@ -57,12 +57,33 @@ 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";
63
64
  const DEFAULT_FILTER_OPERATOR = "contains";
64
65
  const DEFAULT_SORT_DIRECTION = "asc";
65
66
  const SUB_PANEL_ROOT = "root";
67
+ const MANAGED_INTEGRATION_SOURCE_TYPE = "managed-integrations";
68
+ const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
69
+ const DATA_MODEL_SOURCE_TYPE = "workspace-data-model";
70
+
71
+ const SOURCE_TYPE_OBJECTS = [
72
+ {
73
+ id: MANAGED_INTEGRATION_SOURCE_TYPE,
74
+ label: "Managed Integrations",
75
+ authority: "Growthub Bridge",
76
+ description: "Bridge or BYO adapters resolve metadata server-side."
77
+ },
78
+ {
79
+ id: CUSTOM_API_SOURCE_TYPE,
80
+ label: "Custom APIs/Webhooks",
81
+ authority: "Custom endpoint",
82
+ description: "Reference a governed endpoint object without storing credentials in widget config."
83
+ }
84
+ ];
85
+
86
+ const ENTITY_REFERENCE_FIELD_IDS = ["id", "entityId"];
66
87
 
67
88
  const CHART_TYPE_LABELS = {
68
89
  "bar-vertical": "Vertical Bar",
@@ -128,7 +149,7 @@ function generateId(prefix) {
128
149
  function defaultTitleFor(kind) {
129
150
  switch (kind) {
130
151
  case "chart": return "Untitled chart";
131
- case "view": return "Companies";
152
+ case "view": return "Untitled view";
132
153
  case "iframe": return "Untitled iFrame";
133
154
  case "rich-text": return "Untitled Rich Text";
134
155
  default: return "Untitled widget";
@@ -622,11 +643,51 @@ function getChartStyle(widget) {
622
643
 
623
644
  function summarizeSource(widget) {
624
645
  const binding = widget?.config?.binding;
625
- if (binding?.mode === "integration" && binding.source) return binding.source;
646
+ if (binding?.sourceType === DATA_MODEL_SOURCE_TYPE) return binding.source || widget?.config?.source || "Data Model object";
647
+ if (binding?.mode === "integration") {
648
+ const source = binding.source || "Integration";
649
+ if (binding.entityLabel) return `${source} · ${binding.entityLabel}`;
650
+ if (binding.entityId) return `${source} · ${binding.entityId}`;
651
+ return source;
652
+ }
626
653
  if (widget?.config?.source) return widget.config.source;
627
654
  return "Static";
628
655
  }
629
656
 
657
+ function summarizeSourceType(binding) {
658
+ if (binding?.sourceType === DATA_MODEL_SOURCE_TYPE) return "Data Model";
659
+ if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
660
+ if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE) return "Managed Integrations";
661
+ return "Static data";
662
+ }
663
+
664
+ function resolveBindingSourceType(binding) {
665
+ if (binding?.sourceType) return binding.sourceType;
666
+ if (binding?.mode === "integration") return MANAGED_INTEGRATION_SOURCE_TYPE;
667
+ return "static";
668
+ }
669
+
670
+ function resolveDataModelTable(dataModelTables, binding) {
671
+ if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE) return null;
672
+ const tables = Array.isArray(dataModelTables) ? dataModelTables : [];
673
+ return tables.find((table) => table.objectId === binding.objectId || table.id === binding.objectId || table.source === binding.source) || null;
674
+ }
675
+
676
+ function resolveViewWidget(widget, dataModelTables) {
677
+ if (widget?.kind !== "view") return widget;
678
+ const table = resolveDataModelTable(dataModelTables, widget.config?.binding);
679
+ if (!table) return widget;
680
+ return {
681
+ ...widget,
682
+ config: {
683
+ ...(widget.config || {}),
684
+ source: table.source,
685
+ columns: table.columns,
686
+ rows: table.rows
687
+ }
688
+ };
689
+ }
690
+
630
691
  function summarizeFields(widget) {
631
692
  const total = getColumnList(widget).length;
632
693
  const hidden = getHiddenColumnSet(widget).size;
@@ -655,6 +716,142 @@ function describeIntegrationLane(integration) {
655
716
  return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
656
717
  }
657
718
 
719
+ function flattenIntegrationSettings(integrationSettings) {
720
+ const grouped = integrationSettings?.integrations || integrationSettings || {};
721
+ const runtime = [
722
+ ...(Array.isArray(grouped.dataSources) ? grouped.dataSources : []),
723
+ ...(Array.isArray(grouped.workspaceIntegrations) ? grouped.workspaceIntegrations : [])
724
+ ];
725
+ const byId = new Map();
726
+ for (const item of [...governedWorkspaceIntegrationCatalog, ...runtime]) {
727
+ if (item?.id) byId.set(item.id, { ...byId.get(item.id), ...item });
728
+ }
729
+ return Array.from(byId.values());
730
+ }
731
+
732
+ function getFilterFieldOptions(widget, entities = []) {
733
+ const fields = new Set(getColumnList(widget));
734
+ const binding = widget?.config?.binding || {};
735
+ if (binding.mode === "integration") {
736
+ ["id", "label", "secondaryLabel", "entityType", "provider", "lane", "status"].forEach((field) => fields.add(field));
737
+ fields.add("provider");
738
+ fields.add("lane");
739
+ for (const entity of entities) {
740
+ if (entity?.metadata && typeof entity.metadata === "object") {
741
+ Object.keys(entity.metadata).forEach((field) => fields.add(field));
742
+ }
743
+ }
744
+ }
745
+ if (binding.sourceType === CUSTOM_API_SOURCE_TYPE) {
746
+ const customFields = Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"];
747
+ customFields.forEach((field) => fields.add(field));
748
+ }
749
+ return Array.from(fields).filter(Boolean);
750
+ }
751
+
752
+ function getEntityFieldValue(entity, fieldId) {
753
+ if (!entity || !fieldId) return "";
754
+ if (fieldId === "id" || fieldId === "entityId") return entity.id || "";
755
+ if (fieldId === "label" || fieldId === "name") return entity.label || "";
756
+ if (fieldId === "secondaryLabel") return entity.secondaryLabel || "";
757
+ if (fieldId === "entityType") return entity.entityType || "";
758
+ if (fieldId === "provider") return entity.provider || "";
759
+ if (fieldId === "lane") return entity.lane || "";
760
+ if (fieldId === "status") return entity.status || "";
761
+ if (entity.metadata && typeof entity.metadata === "object" && entity.metadata[fieldId] !== undefined) {
762
+ return String(entity.metadata[fieldId]);
763
+ }
764
+ return "";
765
+ }
766
+
767
+ function getEntityFieldChoices(entities) {
768
+ const fields = new Map();
769
+ const add = (id, label) => {
770
+ if (id && !fields.has(id)) fields.set(id, { id, label });
771
+ };
772
+ add("id", "Stable ID");
773
+ add("label", "Primary label");
774
+ add("secondaryLabel", "Secondary label");
775
+ add("entityType", "Entity type");
776
+ add("provider", "Provider");
777
+ add("lane", "Lane");
778
+ add("status", "Status");
779
+ for (const entity of entities) {
780
+ if (entity?.metadata && typeof entity.metadata === "object") {
781
+ Object.keys(entity.metadata).forEach((field) => add(field, field));
782
+ }
783
+ }
784
+ return Array.from(fields.values());
785
+ }
786
+
787
+ function getFilterFieldChoices(widget, entities = []) {
788
+ const binding = widget?.config?.binding || {};
789
+ if (binding.mode === "integration" && entities.length) return getEntityFieldChoices(entities);
790
+ return getFilterFieldOptions(widget, entities).map((id) => ({ id, label: id }));
791
+ }
792
+
793
+ function getEntityValueChoices(entities, fieldId) {
794
+ const seen = new Map();
795
+ for (const entity of entities) {
796
+ const value = getEntityFieldValue(entity, fieldId);
797
+ if (!value || seen.has(value)) continue;
798
+ const label = fieldId === "id" || fieldId === "entityId"
799
+ ? `${entity.label || value} · ${value}`
800
+ : value;
801
+ seen.set(value, { value, label, entity });
802
+ }
803
+ return Array.from(seen.values());
804
+ }
805
+
806
+ function findEntityByFieldValue(entities, fieldId, value) {
807
+ if (!value) return null;
808
+ return entities.find((entity) => getEntityFieldValue(entity, fieldId) === value) || null;
809
+ }
810
+
811
+ function updateWidgetEntityBinding(widget, entity) {
812
+ const binding = widget.config?.binding || {};
813
+ const existingFilter = widget.config?.filter;
814
+ const existingClauses = Array.isArray(existingFilter?.clauses) ? existingFilter.clauses : [];
815
+
816
+ if (!entity) {
817
+ const { entityId, entityType, entityLabel, ...restBinding } = binding;
818
+ const cleanedClauses = existingClauses.filter(
819
+ (clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
820
+ );
821
+ return {
822
+ ...widget.config,
823
+ binding: restBinding,
824
+ filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: cleanedClauses }
825
+ };
826
+ }
827
+
828
+ const entityClause = { fieldId: "id", operator: "eq", value: entity.id };
829
+ const otherClauses = existingClauses.filter(
830
+ (clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
831
+ );
832
+ const nextBinding = {
833
+ ...binding,
834
+ entityId: entity.id,
835
+ entityLabel: entity.label
836
+ };
837
+ if (entity.entityType) nextBinding.entityType = entity.entityType;
838
+ else delete nextBinding.entityType;
839
+ return {
840
+ ...widget.config,
841
+ source: binding.source || entity.label,
842
+ binding: nextBinding,
843
+ filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: [entityClause, ...otherClauses] }
844
+ };
845
+ }
846
+
847
+ function resolveChartColor(style, branding) {
848
+ if (style?.colors === "manual" && style.manualColor) return style.manualColor;
849
+ if (style?.colors === "brand-local") return branding?.accent || "#3f68ff";
850
+ if (style?.colors === "brand-bridge") return branding?.bridgeAccent || branding?.accent || "#3f68ff";
851
+ if (style?.colors === "accent") return "#38bdf8";
852
+ return null;
853
+ }
854
+
658
855
  const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
659
856
  ...normalizeWorkspaceTemplate(template),
660
857
  widgets: template.widgets
@@ -809,11 +1006,111 @@ function SubPanelHeader({ title, breadcrumb, onBack }) {
809
1006
  </div>;
810
1007
  }
811
1008
 
812
- function SourceSubPanel({ widget, integrations, onChange, onBack }) {
1009
+ /**
1010
+ * EntityBadge — chip showing the selected entity on the source panel and root inspector.
1011
+ * Displays primary label + muted secondary label (stable ID). onClear is optional.
1012
+ */
1013
+ function EntityBadge({ entity, onClear }) {
1014
+ const initials = entity.entityType
1015
+ ? entity.entityType[0].toUpperCase()
1016
+ : (entity.label?.[0] || "•").toUpperCase();
1017
+ return <div className="workspace-entity-badge">
1018
+ <span className="workspace-entity-badge-icon" aria-hidden="true">{initials}</span>
1019
+ <span className="workspace-entity-badge-meta">
1020
+ <strong title={entity.label}>{entity.label}</strong>
1021
+ {entity.secondaryLabel ? <em title={entity.secondaryLabel}>{entity.secondaryLabel}</em> : null}
1022
+ </span>
1023
+ {onClear ? <button
1024
+ type="button"
1025
+ className="workspace-entity-badge-clear"
1026
+ aria-label={`Clear selected entity ${entity.label}`}
1027
+ onClick={onClear}
1028
+ >
1029
+ <X size={11} />
1030
+ </button> : null}
1031
+ </div>;
1032
+ }
1033
+
1034
+ function UniversalSourceInfoCard() {
1035
+ return <p className="workspace-source-info-card">
1036
+ Universal source objects support managed integrations and custom APIs/webhooks through normalized metadata and stable saved references.
1037
+ </p>;
1038
+ }
1039
+
1040
+ /**
1041
+ * EntitySelector — compact dropdown for picking a normalized source object after
1042
+ * an integration is selected from the SourceSubPanel.
1043
+ *
1044
+ * Governed invariant: only the object `id` is persisted. The `label` is
1045
+ * display-only and may be refreshed from adapter metadata at any time.
1046
+ * The browser never holds source credentials or executes source queries.
1047
+ */
1048
+ function EntitySelector({ integration, entities, selectedEntityId, selectedEntityLabel, selectedEntityType, onSelect, loading }) {
1049
+ const selected = entities.find((e) => e.id === selectedEntityId)
1050
+ || (selectedEntityId ? {
1051
+ id: selectedEntityId,
1052
+ label: selectedEntityLabel || selectedEntityId,
1053
+ secondaryLabel: selectedEntityId,
1054
+ entityType: selectedEntityType
1055
+ } : null);
1056
+
1057
+ const clearSelected = () => {
1058
+ if (!selectedEntityId || window.confirm("Remove the selected source object from this widget?")) {
1059
+ onSelect(null);
1060
+ }
1061
+ };
1062
+
1063
+ return <div className="workspace-entity-selector">
1064
+ <p className="workspace-panel-label">Source object</p>
1065
+ {selected ? <EntityBadge entity={selected} onClear={clearSelected} /> : null}
1066
+ {loading ? <p className="workspace-entity-empty">Loading source objects…</p> : null}
1067
+ {!loading && !entities.length ? <p className="workspace-entity-empty">
1068
+ No source objects returned. Configure a server-side API/webhook object resolver for this integration.
1069
+ </p> : null}
1070
+ {!loading && entities.length ? <label className="workspace-entity-dropdown">
1071
+ <span>Select source object</span>
1072
+ <select
1073
+ aria-label="Select source object"
1074
+ value={selectedEntityId || ""}
1075
+ onChange={(event) => {
1076
+ const entity = entities.find((item) => item.id === event.target.value);
1077
+ onSelect(entity || null);
1078
+ }}
1079
+ >
1080
+ <option value="">Choose an object</option>
1081
+ {entities.map((entity) => <option key={entity.id} value={entity.id}>
1082
+ {entity.label}{entity.secondaryLabel ? ` · ${entity.secondaryLabel}` : ""}
1083
+ </option>)}
1084
+ </select>
1085
+ </label> : null}
1086
+ </div>;
1087
+ }
1088
+
1089
+ function SourceSubPanel({ widget, integrations, dataModelTables, onChange, onBack }) {
813
1090
  const binding = widget.config?.binding || {};
814
1091
  const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
815
- const [query, setQuery] = useState("");
816
- const [laneFilter, setLaneFilter] = useState("all");
1092
+ const activeSourceType = resolveBindingSourceType(binding);
1093
+ const [query, setQuery] = useState("");
1094
+ const [laneFilter, setLaneFilter] = useState("all");
1095
+ const hasConnectedSource = Boolean(
1096
+ binding.integrationId ||
1097
+ binding.endpointRef ||
1098
+ binding.sourceType === DATA_MODEL_SOURCE_TYPE ||
1099
+ binding.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE ||
1100
+ binding.sourceType === CUSTOM_API_SOURCE_TYPE
1101
+ );
1102
+ const confirmSourceChange = useCallback((nextLabel) => {
1103
+ if (!hasConnectedSource) return true;
1104
+ const currentLabel = summarizeSource(widget);
1105
+ return window.confirm(`Change source from ${currentLabel} to ${nextLabel}? This updates the widget binding and can clear source-object filters.`);
1106
+ }, [hasConnectedSource, widget]);
1107
+
1108
+ const activeIntegration = useMemo(() => {
1109
+ if (currentMode !== "integration" || !binding.integrationId) return null;
1110
+ const list = Array.isArray(integrations) ? integrations : [];
1111
+ return list.find((item) => item.id === binding.integrationId) || null;
1112
+ }, [currentMode, binding.integrationId, integrations]);
1113
+
817
1114
  const groups = useMemo(() => {
818
1115
  const list = Array.isArray(integrations) ? integrations : [];
819
1116
  const filtered = list.filter((item) => {
@@ -826,37 +1123,146 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
826
1123
  "workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
827
1124
  };
828
1125
  }, [integrations, laneFilter, query]);
829
- const selectStatic = useCallback(() => {
830
- if (widget.kind === "chart") {
1126
+
1127
+ const availableDataObjects = useMemo(() => {
1128
+ const list = Array.isArray(dataModelTables) ? dataModelTables : [];
1129
+ const trimmed = query.trim().toLowerCase();
1130
+ return list.filter((table) => {
1131
+ if (table.storage !== "manual-object") return false;
1132
+ if (!trimmed) return true;
1133
+ return `${table.label} ${table.source}`.toLowerCase().includes(trimmed);
1134
+ });
1135
+ }, [dataModelTables, query]);
1136
+
1137
+ const selectStatic = useCallback(() => {
1138
+ if (!confirmSourceChange("Static rows")) return;
1139
+ if (widget.kind === "chart") {
831
1140
  onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
832
1141
  } else {
833
1142
  onChange({
834
1143
  ...widget.config,
835
- source: widget.config?.source || "Companies",
836
- binding: SAMPLE_DATA_BINDINGS.companiesManual
1144
+ source: widget.config?.source || "Static rows",
1145
+ binding: { mode: "manual", source: "Static rows", rows: Array.isArray(widget.config?.rows) ? widget.config.rows : [] }
837
1146
  });
838
1147
  }
839
- }, [onChange, widget.config, widget.kind]);
840
- const selectIntegration = useCallback((integration) => {
1148
+ }, [confirmSourceChange, onChange, widget.config, widget.kind]);
1149
+
1150
+ const selectDataModelObject = useCallback((table) => {
1151
+ if (!table || !confirmSourceChange(table.label)) return;
841
1152
  onChange({
842
1153
  ...widget.config,
1154
+ source: table.source,
1155
+ columns: table.columns,
1156
+ rows: [],
1157
+ binding: {
1158
+ mode: "manual",
1159
+ source: table.source,
1160
+ sourceType: DATA_MODEL_SOURCE_TYPE,
1161
+ sourceAuthority: "workspace-config",
1162
+ objectId: table.objectId,
1163
+ rows: []
1164
+ },
1165
+ fieldSettings: {
1166
+ hidden: [],
1167
+ order: table.columns
1168
+ }
1169
+ });
1170
+ }, [confirmSourceChange, onChange, widget.config]);
1171
+
1172
+ const selectCustomApi = useCallback(() => {
1173
+ if (!confirmSourceChange("Custom APIs/Webhooks")) return;
1174
+ onChange({
1175
+ ...widget.config,
1176
+ source: "Custom APIs/Webhooks",
1177
+ binding: {
1178
+ ...binding,
1179
+ mode: "json",
1180
+ source: "Custom APIs/Webhooks",
1181
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1182
+ sourceAuthority: "custom-api",
1183
+ endpointRef: binding.endpointRef || "",
1184
+ fields: Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"]
1185
+ }
1186
+ });
1187
+ }, [binding, confirmSourceChange, onChange, widget.config]);
1188
+
1189
+ const updateCustomFields = useCallback((value) => {
1190
+ const fields = value.split(",").map((item) => item.trim()).filter(Boolean);
1191
+ onChange({
1192
+ ...widget.config,
1193
+ binding: {
1194
+ ...binding,
1195
+ mode: "json",
1196
+ source: "Custom APIs/Webhooks",
1197
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1198
+ sourceAuthority: "custom-api",
1199
+ fields
1200
+ }
1201
+ });
1202
+ }, [binding, onChange, widget.config]);
1203
+
1204
+ const updateEndpointRef = useCallback((value) => {
1205
+ onChange({
1206
+ ...widget.config,
1207
+ binding: {
1208
+ ...binding,
1209
+ mode: "json",
1210
+ source: "Custom APIs/Webhooks",
1211
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1212
+ sourceAuthority: "custom-api",
1213
+ endpointRef: value
1214
+ }
1215
+ });
1216
+ }, [binding, onChange, widget.config]);
1217
+
1218
+ const selectIntegration = useCallback((integration) => {
1219
+ if (binding.integrationId && binding.integrationId !== integration.id && !confirmSourceChange(integration.label)) return;
1220
+ onChange({
1221
+ ...widget.config,
843
1222
  source: integration.label,
844
1223
  binding: {
845
1224
  mode: "integration",
846
1225
  source: integration.label,
1226
+ sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
1227
+ sourceAuthority: "growthub-bridge",
847
1228
  integrationId: integration.id,
848
- lane: integration.lane
1229
+ lane: integration.lane,
1230
+ provider: integration.provider
849
1231
  }
850
1232
  });
851
- }, [onChange, widget.config]);
852
- return <section className="workspace-widget-subpanel">
853
- <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
1233
+ }, [binding.integrationId, confirmSourceChange, onChange, widget.config]);
1234
+
1235
+ return <section className="workspace-widget-subpanel">
1236
+ <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
1237
+ <UniversalSourceInfoCard />
1238
+ <p className="workspace-panel-label">Source type</p>
1239
+ <div className="workspace-source-object-list">
1240
+ {SOURCE_TYPE_OBJECTS.map((sourceType) => {
1241
+ const isActive = activeSourceType === sourceType.id;
1242
+ return <button
1243
+ key={sourceType.id}
1244
+ type="button"
1245
+ className={`workspace-source-object-row${isActive ? " active" : ""}`}
1246
+ onClick={sourceType.id === CUSTOM_API_SOURCE_TYPE ? selectCustomApi : undefined}
1247
+ disabled={sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE}
1248
+ >
1249
+ <span className="workspace-source-object-icon" aria-hidden="true">
1250
+ {sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE ? <Database size={15} /> : <LinkIcon size={15} />}
1251
+ </span>
1252
+ <span className="workspace-source-meta">
1253
+ <strong>{sourceType.label}</strong>
1254
+ <em>{sourceType.authority} · {sourceType.description}</em>
1255
+ </span>
1256
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
1257
+ </button>;
1258
+ })}
1259
+ </div>
854
1260
  <div className="workspace-source-controls">
855
1261
  <label>
856
1262
  <Search size={14} aria-hidden="true" />
857
1263
  <input
858
1264
  aria-label="Search sources"
859
- placeholder="Search sources"
1265
+ placeholder="Search connectors"
860
1266
  value={query}
861
1267
  onChange={(event) => setQuery(event.target.value)}
862
1268
  />
@@ -868,7 +1274,7 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
868
1274
  value={laneFilter}
869
1275
  onChange={(event) => setLaneFilter(event.target.value)}
870
1276
  >
871
- <option value="all">All source types</option>
1277
+ <option value="all">All connector lanes</option>
872
1278
  <option value="data-source">Data sources</option>
873
1279
  <option value="workspace-integration">Workspace tools</option>
874
1280
  </select>
@@ -879,17 +1285,38 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
879
1285
  <div className="workspace-source-list">
880
1286
  <button
881
1287
  type="button"
882
- className={`workspace-source-row${currentMode !== "integration" ? " active" : ""}`}
1288
+ className={`workspace-source-row${activeSourceType === "static" ? " active" : ""}`}
883
1289
  onClick={selectStatic}
884
1290
  >
885
1291
  <span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
886
1292
  <span className="workspace-source-meta">
887
1293
  <strong>Static rows</strong>
888
- <em>Inline data no external authority required.</em>
1294
+ <em>Inline JSON, CSV, or manual rows remain supported.</em>
889
1295
  </span>
890
- {currentMode !== "integration" ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
1296
+ {activeSourceType === "static" ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
891
1297
  </button>
892
1298
  </div>
1299
+ {widget.kind === "view" ? <>
1300
+ <p className="workspace-panel-label">Data Model objects</p>
1301
+ <div className="workspace-source-list">
1302
+ {availableDataObjects.length ? availableDataObjects.map((table) => {
1303
+ const isActive = binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId === table.objectId;
1304
+ return <button
1305
+ key={table.id}
1306
+ type="button"
1307
+ className={`workspace-source-row${isActive ? " active" : ""}`}
1308
+ onClick={() => selectDataModelObject(table)}
1309
+ >
1310
+ <span className="workspace-source-icon" aria-hidden="true"><Database size={15} /></span>
1311
+ <span className="workspace-source-meta">
1312
+ <strong>{table.label}</strong>
1313
+ <em>{table.columns.length} fields · {table.rows.length} records · workspace config</em>
1314
+ </span>
1315
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
1316
+ </button>;
1317
+ }) : <p className="workspace-entity-empty">No manual Data Model objects yet.</p>}
1318
+ </div>
1319
+ </> : null}
893
1320
  {Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
894
1321
  <p className="workspace-panel-label">{lane === "data-source" ? "Data Sources" : "Workspace Tools"}</p>
895
1322
  <div className="workspace-source-list">
@@ -907,33 +1334,58 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
907
1334
  <strong>{integration.label}</strong>
908
1335
  <em>{describeIntegrationLane(integration)} · {connected ? "connected" : "needs connection"}</em>
909
1336
  </span>
910
- {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
1337
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
911
1338
  </button>;
912
1339
  })}
913
1340
  </div>
914
1341
  </div> : null)}
1342
+ {activeSourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-custom-source-config">
1343
+ <label>
1344
+ <span>Endpoint reference</span>
1345
+ <input
1346
+ value={binding.endpointRef || ""}
1347
+ placeholder="api.clients.primary"
1348
+ onChange={(event) => updateEndpointRef(event.target.value)}
1349
+ />
1350
+ </label>
1351
+ <label>
1352
+ <span>Available fields</span>
1353
+ <input
1354
+ value={(Array.isArray(binding.fields) ? binding.fields : []).join(", ")}
1355
+ placeholder="entityId, status, createdAt"
1356
+ onChange={(event) => updateCustomFields(event.target.value)}
1357
+ />
1358
+ </label>
1359
+ </div> : null}
1360
+ {currentMode === "integration" && binding.integrationId ? <div className="workspace-active-source-state">
1361
+ <span>Active source</span>
1362
+ <strong>{activeIntegration?.label || binding.source || binding.integrationId}</strong>
1363
+ <code>{binding.integrationId}</code>
1364
+ </div> : null}
915
1365
  <p className="workspace-panel-hint">
916
- Selecting a source writes a binding reference only. The browser does not query integrations or store tokens.
1366
+ Selecting a source writes a binding reference only. The browser only calls local workspace routes and never stores source credentials.
917
1367
  </p>
918
1368
  </section>;
919
1369
  }
920
1370
 
921
- function FieldsSubPanel({ widget, onChange, onBack }) {
922
- const ordered = getOrderedColumns(widget);
923
- const hidden = getHiddenColumnSet(widget);
1371
+ function FieldsSubPanel({ widget, dataModelTable, onChange, onBack }) {
1372
+ const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
1373
+ const ordered = getOrderedColumns(viewWidget);
1374
+ const hidden = getHiddenColumnSet(viewWidget);
924
1375
  const visible = ordered.filter((name) => !hidden.has(name));
925
1376
  const hiddenList = ordered.filter((name) => hidden.has(name));
926
1377
  const [hiddenOpen, setHiddenOpen] = useState(true);
927
1378
  const [draftField, setDraftField] = useState("");
928
1379
  const move = (fieldId, direction) => {
929
- const next = reorderColumn(widget, fieldId, direction);
1380
+ const next = reorderColumn(viewWidget, fieldId, direction);
930
1381
  onChange({ ...widget.config, fieldSettings: next });
931
1382
  };
932
1383
  const toggle = (fieldId) => {
933
- const next = toggleColumnHidden(widget, fieldId);
1384
+ const next = toggleColumnHidden(viewWidget, fieldId);
934
1385
  onChange({ ...widget.config, fieldSettings: next });
935
1386
  };
936
1387
  const removeColumn = (fieldId) => {
1388
+ if (dataModelTable) return;
937
1389
  const nextColumns = ordered.filter((name) => name !== fieldId);
938
1390
  const fs = widget.config?.fieldSettings || {};
939
1391
  onChange({
@@ -946,6 +1398,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
946
1398
  });
947
1399
  };
948
1400
  const addColumn = () => {
1401
+ if (dataModelTable) return;
949
1402
  const trimmed = draftField.trim();
950
1403
  if (!trimmed || ordered.includes(trimmed)) return;
951
1404
  onChange({ ...widget.config, columns: [...ordered, trimmed] });
@@ -953,6 +1406,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
953
1406
  };
954
1407
  return <section className="workspace-widget-subpanel">
955
1408
  <SubPanelHeader title="Fields" breadcrumb={widget.title} onBack={onBack} />
1409
+ {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}
956
1410
  <p className="workspace-panel-label">Visible fields</p>
957
1411
  <div className="workspace-field-rows">
958
1412
  {visible.length === 0 ? <p className="workspace-panel-hint">No visible fields. Add one below or unhide an existing field.</p> : null}
@@ -964,7 +1418,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
964
1418
  <button type="button" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(name, "up")}>↑</button>
965
1419
  <button type="button" aria-label={`Move ${name} down`} disabled={index === visible.length - 1} onClick={() => move(name, "down")}>↓</button>
966
1420
  <button type="button" aria-label={`Hide ${name}`} onClick={() => toggle(name)}>👁</button>
967
- <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
1421
+ <button type="button" aria-label={`Remove ${name}`} disabled={Boolean(dataModelTable)} onClick={() => removeColumn(name)}>✕</button>
968
1422
  </span>
969
1423
  </div>)}
970
1424
  </div>
@@ -984,7 +1438,7 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
984
1438
  <span className="workspace-field-row-name">{name}</span>
985
1439
  <span className="workspace-field-row-actions">
986
1440
  <button type="button" aria-label={`Show ${name}`} onClick={() => toggle(name)}>👁</button>
987
- <button type="button" aria-label={`Remove ${name}`} onClick={() => removeColumn(name)}>✕</button>
1441
+ <button type="button" aria-label={`Remove ${name}`} disabled={Boolean(dataModelTable)} onClick={() => removeColumn(name)}>✕</button>
988
1442
  </span>
989
1443
  </div>)}
990
1444
  </div> : null}
@@ -1001,14 +1455,15 @@ function FieldsSubPanel({ widget, onChange, onBack }) {
1001
1455
  }
1002
1456
  }}
1003
1457
  />
1004
- <button type="button" onClick={addColumn} disabled={!draftField.trim()}>Add</button>
1458
+ <button type="button" onClick={addColumn} disabled={Boolean(dataModelTable) || !draftField.trim()}>Add</button>
1005
1459
  </div>
1006
1460
  </section>;
1007
1461
  }
1008
1462
 
1009
- function SortSubPanel({ widget, onChange, onBack }) {
1463
+ function SortSubPanel({ widget, dataModelTable, onChange, onBack }) {
1464
+ const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
1010
1465
  const sort = getSortClauses(widget);
1011
- const columns = getColumnList(widget);
1466
+ const columns = getColumnList(viewWidget);
1012
1467
  const updateSort = (next) => onChange({ ...widget.config, sort: next });
1013
1468
  const addClause = () => {
1014
1469
  const fieldId = columns[0] || "";
@@ -1052,24 +1507,86 @@ function SortSubPanel({ widget, onChange, onBack }) {
1052
1507
  </section>;
1053
1508
  }
1054
1509
 
1055
- function FilterSubPanel({ widget, onChange, onBack }) {
1510
+ function FilterSubPanel({ widget, integrations, dataModelTable, onChange, onBack }) {
1511
+ const viewWidget = dataModelTable ? resolveViewWidget(widget, [dataModelTable]) : widget;
1512
+ const binding = widget.config?.binding || {};
1056
1513
  const filter = getFilterConfig(widget);
1057
- const columns = getColumnList(widget);
1514
+ const [entities, setEntities] = useState([]);
1515
+ const [entitiesLoading, setEntitiesLoading] = useState(false);
1516
+ const fieldChoices = getFilterFieldChoices(viewWidget, entities);
1517
+ const columns = fieldChoices.map((field) => field.id);
1058
1518
  const setFilter = (next) => onChange({ ...widget.config, filter: next });
1059
1519
  const setOp = (op) => setFilter({ ...filter, op });
1520
+ const activeIntegration = useMemo(() => {
1521
+ if (binding.mode !== "integration" || !binding.integrationId) return null;
1522
+ const list = Array.isArray(integrations) ? integrations : [];
1523
+ return list.find((item) => item.id === binding.integrationId) || null;
1524
+ }, [binding.integrationId, binding.mode, integrations]);
1525
+
1526
+ useEffect(() => {
1527
+ if (!binding.integrationId || binding.mode !== "integration") {
1528
+ setEntities([]);
1529
+ return;
1530
+ }
1531
+ let cancelled = false;
1532
+ setEntitiesLoading(true);
1533
+ fetch(`/api/workspace/integration-entities?integrationId=${encodeURIComponent(binding.integrationId)}`, { cache: "no-store" })
1534
+ .then((res) => res.ok ? res.json() : { entities: [] })
1535
+ .then((data) => {
1536
+ if (!cancelled) {
1537
+ setEntities(Array.isArray(data.entities) ? data.entities : []);
1538
+ setEntitiesLoading(false);
1539
+ }
1540
+ })
1541
+ .catch(() => {
1542
+ if (!cancelled) {
1543
+ setEntities([]);
1544
+ setEntitiesLoading(false);
1545
+ }
1546
+ });
1547
+ return () => { cancelled = true; };
1548
+ }, [binding.integrationId, binding.mode]);
1549
+
1060
1550
  const addClause = () => {
1061
- const fieldId = columns[0] || "";
1551
+ const fieldId = binding.mode === "integration" && entities.length ? "id" : (columns[0] || "");
1062
1552
  if (!fieldId) return;
1063
- setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: DEFAULT_FILTER_OPERATOR, value: "" }] });
1553
+ setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: "eq", value: "" }] });
1554
+ };
1555
+ const updateField = (index, fieldId) => {
1556
+ setFilter({
1557
+ ...filter,
1558
+ clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, fieldId, value: "" } : clause)
1559
+ });
1064
1560
  };
1561
+ const selectEntity = useCallback((entity) => {
1562
+ onChange(updateWidgetEntityBinding(widget, entity));
1563
+ }, [onChange, widget]);
1065
1564
  const updateClause = (index, patch) => {
1066
1565
  setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
1067
1566
  };
1068
1567
  const removeClause = (index) => {
1069
1568
  setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
1070
1569
  };
1071
- return <section className="workspace-widget-subpanel">
1072
- <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1570
+ return <section className="workspace-widget-subpanel">
1571
+ <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1572
+ <UniversalSourceInfoCard />
1573
+ <div className="workspace-filter-source-state">
1574
+ <span>{summarizeSourceType(binding)}</span>
1575
+ <strong>{summarizeSource(widget)}</strong>
1576
+ </div>
1577
+ {binding.mode === "integration" && binding.integrationId ? <EntitySelector
1578
+ integration={activeIntegration}
1579
+ entities={entities}
1580
+ selectedEntityId={binding.entityId || null}
1581
+ selectedEntityLabel={binding.entityLabel || null}
1582
+ selectedEntityType={binding.entityType || null}
1583
+ onSelect={selectEntity}
1584
+ loading={entitiesLoading}
1585
+ /> : null}
1586
+ {binding.sourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-filter-source-state">
1587
+ <span>Custom endpoint</span>
1588
+ <code>{binding.endpointRef || "No endpoint reference set"}</code>
1589
+ </div> : null}
1073
1590
  <div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
1074
1591
  {KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
1075
1592
  key={op}
@@ -1084,14 +1601,15 @@ function FilterSubPanel({ widget, onChange, onBack }) {
1084
1601
  {filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
1085
1602
  {filter.clauses.map((clause, index) => {
1086
1603
  const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
1604
+ const valueChoices = binding.mode === "integration" ? getEntityValueChoices(entities, clause.fieldId) : [];
1087
1605
  return <div key={index} className="workspace-filter-clause">
1088
1606
  <select
1089
1607
  aria-label={`Filter ${index + 1} field`}
1090
1608
  value={clause.fieldId}
1091
- onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1609
+ onChange={(event) => updateField(index, event.target.value)}
1092
1610
  >
1093
1611
  {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1094
- {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1612
+ {fieldChoices.map((field) => <option key={field.id} value={field.id}>{field.label}</option>)}
1095
1613
  </select>
1096
1614
  <select
1097
1615
  aria-label={`Filter ${index + 1} operator`}
@@ -1100,7 +1618,20 @@ function FilterSubPanel({ widget, onChange, onBack }) {
1100
1618
  >
1101
1619
  {KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
1102
1620
  </select>
1103
- {!valueless ? <input
1621
+ {!valueless && binding.mode === "integration" && valueChoices.length ? <select
1622
+ aria-label={`Filter ${index + 1} value`}
1623
+ value={clause.value ?? ""}
1624
+ onChange={(event) => {
1625
+ const entity = findEntityByFieldValue(entities, clause.fieldId, event.target.value);
1626
+ updateClause(index, { value: event.target.value });
1627
+ if (entity && (clause.fieldId === "id" || clause.fieldId === "entityId")) {
1628
+ onChange(updateWidgetEntityBinding(widget, entity));
1629
+ }
1630
+ }}
1631
+ >
1632
+ <option value="">Select value</option>
1633
+ {valueChoices.map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
1634
+ </select> : !valueless ? <input
1104
1635
  aria-label={`Filter ${index + 1} value`}
1105
1636
  value={clause.value ?? ""}
1106
1637
  placeholder="value"
@@ -1119,11 +1650,12 @@ function FilterSubPanel({ widget, onChange, onBack }) {
1119
1650
  </section>;
1120
1651
  }
1121
1652
 
1122
- function ChartConfigPanel({ widget, onChange, onSubPage }) {
1653
+ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
1123
1654
  const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
1124
1655
  const xAxis = getChartAxis(widget, "xAxis");
1125
1656
  const yAxis = getChartAxis(widget, "yAxis");
1126
1657
  const style = getChartStyle(widget);
1658
+ const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
1127
1659
  const setChartType = (type) => onChange({ ...widget.config, chartType: type });
1128
1660
  const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
1129
1661
  const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
@@ -1148,11 +1680,17 @@ function ChartConfigPanel({ widget, onChange, onSubPage }) {
1148
1680
  })}
1149
1681
  </div>
1150
1682
  <button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
1151
- <span>Source</span><code>{summarizeSource(widget)}</code>
1683
+ <span>Source</span><code>{summarizeSourceType(widget.config?.binding)} · {summarizeSource(widget)}</code>
1152
1684
  </button>
1153
1685
  <button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
1154
1686
  <span>Filter</span><code>{summarizeFilter(widget)}</code>
1155
1687
  </button>
1688
+ {widget.config?.binding?.entityId ? <EntityBadge entity={{
1689
+ id: widget.config.binding.entityId,
1690
+ label: widget.config.binding.entityLabel || widget.config.binding.entityId,
1691
+ secondaryLabel: widget.config.binding.entityId,
1692
+ entityType: widget.config.binding.entityType
1693
+ }} /> : null}
1156
1694
  <p className="workspace-panel-label">X axis</p>
1157
1695
  <label>
1158
1696
  <span>Data on display</span>
@@ -1225,9 +1763,16 @@ function ChartConfigPanel({ widget, onChange, onSubPage }) {
1225
1763
  <select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
1226
1764
  <option value="auto">Auto</option>
1227
1765
  <option value="accent">Accent</option>
1766
+ <option value="brand-local">Local brand kit</option>
1767
+ <option value="brand-bridge">Bridge brand kit</option>
1228
1768
  <option value="manual">Manual</option>
1229
1769
  </select>
1230
1770
  </label>
1771
+ <div className="workspace-color-preview-row">
1772
+ <span>Active color</span>
1773
+ <em style={{ background: activeColor }} />
1774
+ <code>{activeColor}</code>
1775
+ </div>
1231
1776
  {style.colors === "manual" ? <div className="workspace-color-picker-row">
1232
1777
  <label>
1233
1778
  <span>Manual color</span>
@@ -1414,7 +1959,7 @@ function IframePreviewModal({ widget, onClose }) {
1414
1959
  </div>;
1415
1960
  }
1416
1961
 
1417
- function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1962
+ function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1418
1963
  const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
1419
1964
  const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
1420
1965
  const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
@@ -1423,7 +1968,11 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
1423
1968
  const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
1424
1969
  const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
1425
1970
  const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
1426
- const chartColor = chartStyle.colors === "manual" && chartStyle.manualColor ? chartStyle.manualColor : undefined;
1971
+ const chartColor = resolveChartColor(chartStyle, branding);
1972
+ const selectedSourceObject = widget.config?.binding?.entityId ? {
1973
+ id: widget.config.binding.entityId,
1974
+ label: widget.config.binding.entityLabel || widget.config.binding.entityId
1975
+ } : null;
1427
1976
  return <article
1428
1977
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
1429
1978
  onClick={onSelect}
@@ -1439,6 +1988,10 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
1439
1988
  onPointerDown={(event) => onMoveStart(event)}
1440
1989
  >::</span>
1441
1990
  <strong>{widget.title}</strong>
1991
+ {selectedSourceObject ? <span
1992
+ className="workspace-widget-source-chip"
1993
+ title={`${selectedSourceObject.label} · ${selectedSourceObject.id}`}
1994
+ >{selectedSourceObject.label}</span> : null}
1442
1995
  <button
1443
1996
  aria-label={`Remove ${widget.title}`}
1444
1997
  onClick={(event) => {
@@ -1532,7 +2085,7 @@ function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integratio
1532
2085
  Inspect-only. Sourced from <code>growthub.config.json</code> + <code>GET /api/workspace</code>.
1533
2086
  Edit branding by updating <code>growthub.config.json</code> inside your governed fork.
1534
2087
  The builder itself never holds tokens, never executes hosted workflows, and never bypasses the PATCH allowlist
1535
- (<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>).
2088
+ (<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>, <code>dataModel</code>).
1536
2089
  </p>
1537
2090
  <div className="workspace-readiness">
1538
2091
  <article className="workspace-readiness-section">
@@ -1613,7 +2166,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
1613
2166
  </article>
1614
2167
  <article className="workspace-readiness-section">
1615
2168
  <h3>API</h3>
1616
- <div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas</code></div>
2169
+ <div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas | dataModel</code></div>
1617
2170
  <div className="workspace-readiness-row"><span>Unknown field</span><code>400</code></div>
1618
2171
  <div className="workspace-readiness-row"><span>Read-only runtime</span><code>409 + guidance</code></div>
1619
2172
  <div className="workspace-readiness-row"><span>Can save now</span>
@@ -1651,7 +2204,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
1651
2204
  </div>;
1652
2205
  }
1653
2206
 
1654
- function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
2207
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
1655
2208
  const [config, setConfig] = useState(() => {
1656
2209
  const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
1657
2210
  ? initialConfig.dashboards.map((dashboard, index) =>
@@ -1706,6 +2259,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1706
2259
  const importInputRef = useRef(null);
1707
2260
  const addSlot = dragPreview || selectedPosition;
1708
2261
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
2262
+ const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
2263
+ const dataModelTables = useMemo(() => listWorkspaceDataModelTables(config), [config]);
2264
+ const selectedResolvedWidget = selectedWidget ? resolveViewWidget(selectedWidget, dataModelTables) : null;
2265
+ const branding = config.branding || {};
1709
2266
  const occupiedCells = useMemo(() => {
1710
2267
  const cells = new Set();
1711
2268
  for (const widget of activeWidgets) {
@@ -2585,6 +3142,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2585
3142
  </div>
2586
3143
  <nav className="workspace-nav">
2587
3144
  <button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
3145
+ <Link href="/data-model">Data Model</Link>
2588
3146
  <Link href="/settings/integrations">Integrations</Link>
2589
3147
  <button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
2590
3148
  <button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
@@ -2769,13 +3327,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2769
3327
  </button>
2770
3328
  {activeWidgets.map((widget) => <WidgetPreview
2771
3329
  key={widget.id}
3330
+ branding={branding}
2772
3331
  onMoveStart={(event) => beginMoveDrag(widget, event)}
2773
3332
  onRemove={() => removeSelectedWidget(widget.id)}
2774
3333
  onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
2775
3334
  onSelect={() => selectWidget(widget.id)}
2776
3335
  onExpandIframe={setExpandedIframeWidget}
2777
3336
  selected={widget.id === selectedWidgetId}
2778
- widget={widget}
3337
+ widget={resolveViewWidget(widget, dataModelTables)}
2779
3338
  />)}
2780
3339
  </div>
2781
3340
  </section> : null}
@@ -2820,22 +3379,27 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2820
3379
  </div> : null}
2821
3380
  {selectedWidget && inspectorPath === "source" ? <SourceSubPanel
2822
3381
  widget={selectedWidget}
2823
- integrations={governedWorkspaceIntegrationCatalog}
3382
+ integrations={availableIntegrations}
3383
+ dataModelTables={dataModelTables}
2824
3384
  onChange={replaceSelectedWidgetConfig}
2825
3385
  onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2826
3386
  /> : null}
2827
3387
  {selectedWidget && inspectorPath === "fields" ? <FieldsSubPanel
2828
3388
  widget={selectedWidget}
3389
+ dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
2829
3390
  onChange={replaceSelectedWidgetConfig}
2830
3391
  onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2831
3392
  /> : null}
2832
3393
  {selectedWidget && inspectorPath === "sort" ? <SortSubPanel
2833
3394
  widget={selectedWidget}
3395
+ dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
2834
3396
  onChange={replaceSelectedWidgetConfig}
2835
3397
  onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2836
3398
  /> : null}
2837
3399
  {selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
2838
3400
  widget={selectedWidget}
3401
+ integrations={availableIntegrations}
3402
+ dataModelTable={resolveDataModelTable(dataModelTables, selectedWidget.config?.binding)}
2839
3403
  onChange={replaceSelectedWidgetConfig}
2840
3404
  onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2841
3405
  /> : null}
@@ -2846,6 +3410,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2846
3410
  </label>
2847
3411
  {selectedWidget.kind === "chart" ? <ChartConfigPanel
2848
3412
  widget={selectedWidget}
3413
+ branding={branding}
2849
3414
  onChange={replaceSelectedWidgetConfig}
2850
3415
  onSubPage={(name) => setInspectorPath(name)}
2851
3416
  /> : null}
@@ -2897,7 +3462,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2897
3462
  </small>
2898
3463
  </label> : null}
2899
3464
  {selectedWidget.kind === "view" ? <section className="workspace-field-stack">
2900
- <label>
3465
+ {selectedWidget.config?.binding?.sourceType === DATA_MODEL_SOURCE_TYPE ? <div className="workspace-active-source-state">
3466
+ <span>Data Model object</span>
3467
+ <strong>{summarizeSource(selectedWidget)}</strong>
3468
+ <code>{selectedWidget.config?.binding?.objectId || "workspace-config"}</code>
3469
+ </div> : <label>
2901
3470
  <span>Manual Rows</span>
2902
3471
  <textarea
2903
3472
  value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
@@ -2909,7 +3478,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2909
3478
  });
2910
3479
  }}
2911
3480
  />
2912
- </label>
3481
+ </label>}
2913
3482
  <div className="workspace-settings-list" role="group" aria-label="View widget settings">
2914
3483
  <p className="workspace-panel-label">Settings</p>
2915
3484
  <button type="button" className="workspace-settings-row" disabled>
@@ -2919,13 +3488,13 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2919
3488
  <span>Source</span><code>{summarizeSource(selectedWidget)}</code>
2920
3489
  </button>
2921
3490
  <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
2922
- <span>Fields</span><code>{summarizeFields(selectedWidget)}</code>
3491
+ <span>Fields</span><code>{summarizeFields(selectedResolvedWidget || selectedWidget)}</code>
2923
3492
  </button>
2924
3493
  <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
2925
- <span>Filter</span><code>{summarizeFilter(selectedWidget)}</code>
3494
+ <span>Filter</span><code>{summarizeFilter(selectedResolvedWidget || selectedWidget)}</code>
2926
3495
  </button>
2927
3496
  <button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
2928
- <span>Sort</span><code>{summarizeSort(selectedWidget)}</code>
3497
+ <span>Sort</span><code>{summarizeSort(selectedResolvedWidget || selectedWidget)}</code>
2929
3498
  </button>
2930
3499
  </div>
2931
3500
  </section> : null}