@growthub/cli 0.13.2 → 0.13.5
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/workspace/metadata-graph/route.js +184 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +24 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +14 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +74 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +77 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +72 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +326 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +123 -27
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +6 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +224 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +754 -92
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +224 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxRunPanel.jsx +32 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/WorkspaceGraphInspectorPanel.jsx +226 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +530 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +8 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +10 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/RunSetupPanel.jsx +261 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +119 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +779 -138
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +91 -14
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +35 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +923 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +28 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +216 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +412 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-inputs.js +366 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +34 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth-eligibility.js +50 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth-redaction.js +64 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +665 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-host-catalog.js +168 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-chart-values.js +595 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +164 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +11 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-graph.js +646 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-selectors.js +249 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +1186 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +111 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +14 -0
- package/package.json +1 -1
|
@@ -79,9 +79,26 @@ import {
|
|
|
79
79
|
} from "@/lib/workspace-schema";
|
|
80
80
|
import { governedWorkspaceIntegrationCatalog } from "@/lib/domain/integrations";
|
|
81
81
|
import { OBJECT_TYPE_PRESETS, listWorkspaceDataModelTables } from "@/lib/workspace-data-model";
|
|
82
|
+
import {
|
|
83
|
+
computeChartProjectionDebug,
|
|
84
|
+
computeChartValuesFromRows,
|
|
85
|
+
deriveWidgetDependencyContract
|
|
86
|
+
} from "@/lib/workspace-chart-values";
|
|
87
|
+
import { selectObjectFilterableFields, selectObjectSortableFields } from "@/lib/workspace-metadata-selectors";
|
|
82
88
|
import { HelperSidecar } from "./data-model/components/HelperSidecar.jsx";
|
|
83
89
|
import { WorkspaceRail } from "./workspace-rail.jsx";
|
|
84
90
|
|
|
91
|
+
// Workspace Metadata Graph V1 — typed dependency contracts.
|
|
92
|
+
// Used by sidecar dependency summaries; the existing chart hydration path
|
|
93
|
+
// continues to compute values via `computeChartValuesFromRows`. These
|
|
94
|
+
// selectors only describe the widget's typed contract — they never mutate
|
|
95
|
+
// config or trigger network calls.
|
|
96
|
+
const WORKSPACE_METADATA_SELECTORS = Object.freeze({
|
|
97
|
+
deriveWidgetDependencyContract,
|
|
98
|
+
selectObjectFilterableFields,
|
|
99
|
+
selectObjectSortableFields
|
|
100
|
+
});
|
|
101
|
+
|
|
85
102
|
const DEFAULT_CHART_TYPE = "bar-vertical";
|
|
86
103
|
const DEFAULT_FILTER_OP = "and";
|
|
87
104
|
const DEFAULT_FILTER_OPERATOR = "contains";
|
|
@@ -156,6 +173,23 @@ const CHART_TYPE_ICONS = {
|
|
|
156
173
|
|
|
157
174
|
const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
|
|
158
175
|
|
|
176
|
+
// User-facing labels for the Twenty-style Y-axis operation dropdown.
|
|
177
|
+
// Keys must stay in sync with `lib/workspace-chart-values.js#KNOWN_AGGREGATIONS`
|
|
178
|
+
// and the validator in `lib/workspace-schema.js`.
|
|
179
|
+
const AGGREGATION_LABELS = {
|
|
180
|
+
sum: "Sum",
|
|
181
|
+
avg: "Average",
|
|
182
|
+
count: "Count (all)",
|
|
183
|
+
countAll: "Count (all)",
|
|
184
|
+
countEmpty: "Count (empty)",
|
|
185
|
+
countNotEmpty: "Count (not empty)",
|
|
186
|
+
countUnique: "Count (unique)",
|
|
187
|
+
percentEmpty: "Percent empty",
|
|
188
|
+
percentNotEmpty: "Percent not empty",
|
|
189
|
+
min: "Min",
|
|
190
|
+
max: "Max"
|
|
191
|
+
};
|
|
192
|
+
|
|
159
193
|
const WIDGET_KIND_ICONS = {
|
|
160
194
|
chart: BarChart3,
|
|
161
195
|
view: Table2,
|
|
@@ -293,11 +327,11 @@ function textColorForAccent(accent) {
|
|
|
293
327
|
|
|
294
328
|
function defaultTitleFor(kind) {
|
|
295
329
|
switch (kind) {
|
|
296
|
-
case "chart": return "
|
|
297
|
-
case "view": return "
|
|
298
|
-
case "iframe": return "
|
|
299
|
-
case "rich-text": return "
|
|
300
|
-
default: return "
|
|
330
|
+
case "chart": return "Chart widget";
|
|
331
|
+
case "view": return "Data view";
|
|
332
|
+
case "iframe": return "Embedded page";
|
|
333
|
+
case "rich-text": return "Text note";
|
|
334
|
+
default: return "Workspace widget";
|
|
301
335
|
}
|
|
302
336
|
}
|
|
303
337
|
|
|
@@ -337,7 +371,7 @@ function commitTabs(canvas, tabs, activeTabId) {
|
|
|
337
371
|
return next;
|
|
338
372
|
}
|
|
339
373
|
|
|
340
|
-
function createDashboardRecord(name = "
|
|
374
|
+
function createDashboardRecord(name = "New Dashboard") {
|
|
341
375
|
const tab = createEmptyTab("Tab 1");
|
|
342
376
|
return {
|
|
343
377
|
id: generateId("dashboard"),
|
|
@@ -634,7 +668,7 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
|
|
|
634
668
|
}
|
|
635
669
|
|
|
636
670
|
function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
|
|
637
|
-
const nextName = name.trim() || "
|
|
671
|
+
const nextName = name.trim() || "New Dashboard";
|
|
638
672
|
const prevDashboards = config.dashboards || [];
|
|
639
673
|
const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
|
|
640
674
|
if (index < 0) return config;
|
|
@@ -1018,6 +1052,62 @@ function resolveViewWidget(widget, dataModelTables) {
|
|
|
1018
1052
|
};
|
|
1019
1053
|
}
|
|
1020
1054
|
|
|
1055
|
+
/**
|
|
1056
|
+
* Recompute `config.values` for a chart widget config from its bound Data
|
|
1057
|
+
* Model rows. This is the only path that writes chart values from rows.
|
|
1058
|
+
* The chart renderer continues to read from `config.values` — it never
|
|
1059
|
+
* queries rows directly.
|
|
1060
|
+
*
|
|
1061
|
+
* Returns the next `config` object (with finite-number `values`) plus the
|
|
1062
|
+
* computation result (`rowCount`, `usedRowCount`, `warnings`).
|
|
1063
|
+
*
|
|
1064
|
+
* When the chart has no bound Data Model source, the input config is
|
|
1065
|
+
* returned unchanged and the result is `{ status: "unbound" }`.
|
|
1066
|
+
*/
|
|
1067
|
+
function recomputeChartConfig(chartConfig, dataModelTables) {
|
|
1068
|
+
const config = chartConfig && typeof chartConfig === "object" && !Array.isArray(chartConfig) ? chartConfig : {};
|
|
1069
|
+
const binding = config.binding;
|
|
1070
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE) {
|
|
1071
|
+
return { config, result: { status: "unbound" } };
|
|
1072
|
+
}
|
|
1073
|
+
const table = resolveDataModelTable(dataModelTables, binding);
|
|
1074
|
+
if (!table) {
|
|
1075
|
+
return { config, result: { status: "no-source", warnings: ["Selected source is unavailable."] } };
|
|
1076
|
+
}
|
|
1077
|
+
const computation = computeChartValuesFromRows({
|
|
1078
|
+
rows: Array.isArray(table.rows) ? table.rows : [],
|
|
1079
|
+
xAxis: config.xAxis,
|
|
1080
|
+
yAxis: config.yAxis,
|
|
1081
|
+
filter: config.filter,
|
|
1082
|
+
chartType: config.chartType
|
|
1083
|
+
});
|
|
1084
|
+
return {
|
|
1085
|
+
config: { ...config, values: computation.values },
|
|
1086
|
+
result: { status: "computed", ...computation }
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function findWidgetByIdInConfig(workspaceConfig, widgetId) {
|
|
1091
|
+
if (!widgetId) return null;
|
|
1092
|
+
for (const dashboard of workspaceConfig?.dashboards || []) {
|
|
1093
|
+
for (const tab of dashboard.tabs || []) {
|
|
1094
|
+
for (const widget of tab.widgets || []) {
|
|
1095
|
+
if (widget?.id === widgetId) return widget;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const canvas = workspaceConfig?.canvas;
|
|
1100
|
+
for (const tab of canvas?.tabs || []) {
|
|
1101
|
+
for (const widget of tab.widgets || []) {
|
|
1102
|
+
if (widget?.id === widgetId) return widget;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
for (const widget of canvas?.widgets || []) {
|
|
1106
|
+
if (widget?.id === widgetId) return widget;
|
|
1107
|
+
}
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1021
1111
|
function summarizeFields(widget) {
|
|
1022
1112
|
const total = getColumnList(widget).length;
|
|
1023
1113
|
const hidden = getHiddenColumnSet(widget).size;
|
|
@@ -1042,6 +1132,37 @@ function summarizeFilter(widget) {
|
|
|
1042
1132
|
return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
|
|
1043
1133
|
}
|
|
1044
1134
|
|
|
1135
|
+
function WidgetPanelHeaderIcon({ kind }) {
|
|
1136
|
+
const Icon = WIDGET_KIND_ICONS[kind] || Box;
|
|
1137
|
+
return <span className="workspace-widget-panel-kind-icon"><IconGlyph icon={Icon} size={15} /></span>;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function WidgetSettingsRow({ icon: Icon, label, value, disabled, active, onClick }) {
|
|
1141
|
+
return <button
|
|
1142
|
+
type="button"
|
|
1143
|
+
className={`workspace-twenty-settings-row${active ? " is-active" : ""}`}
|
|
1144
|
+
disabled={disabled}
|
|
1145
|
+
onClick={onClick}
|
|
1146
|
+
>
|
|
1147
|
+
<span className="workspace-twenty-settings-row__main">
|
|
1148
|
+
<span className="workspace-twenty-settings-row__icon">{Icon ? <Icon size={15} /> : null}</span>
|
|
1149
|
+
<span>{label}</span>
|
|
1150
|
+
</span>
|
|
1151
|
+
<span className="workspace-twenty-settings-row__value">{value || ""}</span>
|
|
1152
|
+
{!disabled ? <ChevronDown className="workspace-twenty-settings-row__chevron" size={14} /> : null}
|
|
1153
|
+
</button>;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function WidgetSelectRow({ icon: Icon, label, value, children }) {
|
|
1157
|
+
return <label className="workspace-twenty-select-row">
|
|
1158
|
+
<span className="workspace-twenty-settings-row__main">
|
|
1159
|
+
<span className="workspace-twenty-settings-row__icon">{Icon ? <Icon size={15} /> : null}</span>
|
|
1160
|
+
<span>{label}</span>
|
|
1161
|
+
</span>
|
|
1162
|
+
<span className="workspace-twenty-select-row__control">{children}</span>
|
|
1163
|
+
</label>;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1045
1166
|
function describeIntegrationLane(integration) {
|
|
1046
1167
|
return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
|
|
1047
1168
|
}
|
|
@@ -1759,14 +1880,20 @@ function SourceDropdown({ widget, dataModelTables, onChange }) {
|
|
|
1759
1880
|
})();
|
|
1760
1881
|
|
|
1761
1882
|
function selectObject(table) {
|
|
1762
|
-
|
|
1883
|
+
const nextConfig = {
|
|
1763
1884
|
...widget.config,
|
|
1764
1885
|
source: table.source,
|
|
1765
1886
|
columns: table.columns,
|
|
1766
1887
|
rows: [],
|
|
1767
1888
|
binding: { mode: "manual", source: table.source, sourceType: DATA_MODEL_SOURCE_TYPE, sourceAuthority: "workspace-config", objectId: table.objectId },
|
|
1768
1889
|
fieldSettings: { hidden: [], order: table.columns }
|
|
1769
|
-
}
|
|
1890
|
+
};
|
|
1891
|
+
if (widget.kind === "chart") {
|
|
1892
|
+
const { config: recomputed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
1893
|
+
onChange(recomputed);
|
|
1894
|
+
} else {
|
|
1895
|
+
onChange(nextConfig);
|
|
1896
|
+
}
|
|
1770
1897
|
setOpen(false);
|
|
1771
1898
|
setQuery("");
|
|
1772
1899
|
}
|
|
@@ -2266,7 +2393,7 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2266
2393
|
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
2267
2394
|
if (!window.confirm(`Change source to "${table.label}"?`)) return;
|
|
2268
2395
|
}
|
|
2269
|
-
|
|
2396
|
+
const nextConfig = {
|
|
2270
2397
|
...widget.config,
|
|
2271
2398
|
source: table.source,
|
|
2272
2399
|
columns: table.columns,
|
|
@@ -2279,8 +2406,16 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2279
2406
|
objectId: table.objectId,
|
|
2280
2407
|
},
|
|
2281
2408
|
fieldSettings: { hidden: [], order: table.columns }
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2409
|
+
};
|
|
2410
|
+
// Chart widgets always project rows into `config.values`. Recompute on
|
|
2411
|
+
// source change so the preview reflects the new binding immediately.
|
|
2412
|
+
if (widget.kind === "chart") {
|
|
2413
|
+
const { config: recomputed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
2414
|
+
onChange(recomputed);
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
onChange(nextConfig);
|
|
2418
|
+
}, [binding, dataModelTables, onChange, widget.config, widget.kind]);
|
|
2284
2419
|
|
|
2285
2420
|
const activeObjectId = binding.sourceType === DATA_MODEL_SOURCE_TYPE ? binding.objectId : null;
|
|
2286
2421
|
|
|
@@ -2300,6 +2435,10 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2300
2435
|
{savedObjects.length > 0 ? savedObjects.map((table) => {
|
|
2301
2436
|
const isActive = activeObjectId === table.objectId;
|
|
2302
2437
|
const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
|
|
2438
|
+
// Only surface a badge when it communicates *real* runtime state
|
|
2439
|
+
// (live-backed = sidecar-hydrated). Manual is the default and would
|
|
2440
|
+
// be visual noise; api/webhook are reserved for future surfacing.
|
|
2441
|
+
const showLiveBadge = table.sourceBadge === "live";
|
|
2303
2442
|
return (
|
|
2304
2443
|
<button
|
|
2305
2444
|
key={table.id}
|
|
@@ -2314,6 +2453,7 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2314
2453
|
<strong>{table.label}</strong>
|
|
2315
2454
|
<em>{table.columns.length} field{table.columns.length !== 1 ? "s" : ""} · {table.rows.length} record{table.rows.length !== 1 ? "s" : ""}</em>
|
|
2316
2455
|
</span>
|
|
2456
|
+
{showLiveBadge ? <span className="workspace-source-badge badge-live" aria-label="Live source">Live</span> : null}
|
|
2317
2457
|
{isActive && <Check size={14} strokeWidth={2.5} aria-hidden="true" />}
|
|
2318
2458
|
</button>
|
|
2319
2459
|
);
|
|
@@ -2738,30 +2878,208 @@ function FilterSubPanel({ widget, integrations, dataModelTable, adapterConfig, o
|
|
|
2738
2878
|
</section>;
|
|
2739
2879
|
}
|
|
2740
2880
|
|
|
2741
|
-
|
|
2881
|
+
/**
|
|
2882
|
+
* ChartHydrationInspector — diagnostics overlay for chart value computation.
|
|
2883
|
+
*
|
|
2884
|
+
* Renders the same projection pipeline the renderer reads from (source rows
|
|
2885
|
+
* → filter → grouping → aggregation → values[]), so the user can audit why
|
|
2886
|
+
* `widget.config.values` looks the way it does. It is read-only with two
|
|
2887
|
+
* actions:
|
|
2888
|
+
* - "Recompute values" re-runs `recomputeChartConfig` against the latest
|
|
2889
|
+
* Data Model tables; useful after manual row edits.
|
|
2890
|
+
* - "Save computed values" routes through the existing PATCH /api/workspace
|
|
2891
|
+
* path; respects the read-only runtime adapter (Save is disabled with
|
|
2892
|
+
* guidance instead of crashing).
|
|
2893
|
+
*/
|
|
2894
|
+
function ChartHydrationInspector({
|
|
2895
|
+
widget,
|
|
2896
|
+
dataModelTables,
|
|
2897
|
+
unsaved,
|
|
2898
|
+
saving,
|
|
2899
|
+
canSave,
|
|
2900
|
+
saveGuidance,
|
|
2901
|
+
onChange,
|
|
2902
|
+
onSave,
|
|
2903
|
+
onBack
|
|
2904
|
+
}) {
|
|
2905
|
+
const binding = widget?.config?.binding;
|
|
2906
|
+
const table = useMemo(() => {
|
|
2907
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return null;
|
|
2908
|
+
return (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
2909
|
+
.find((t) => t.objectId === binding.objectId) || null;
|
|
2910
|
+
}, [binding, dataModelTables]);
|
|
2911
|
+
const debug = useMemo(() => computeChartProjectionDebug({
|
|
2912
|
+
rows: Array.isArray(table?.rows) ? table.rows : [],
|
|
2913
|
+
xAxis: widget?.config?.xAxis,
|
|
2914
|
+
yAxis: widget?.config?.yAxis,
|
|
2915
|
+
filter: widget?.config?.filter,
|
|
2916
|
+
chartType: widget?.config?.chartType
|
|
2917
|
+
}), [table, widget?.config?.xAxis, widget?.config?.yAxis, widget?.config?.filter, widget?.config?.chartType]);
|
|
2918
|
+
|
|
2919
|
+
const recompute = useCallback(() => {
|
|
2920
|
+
const { config: recomputed } = recomputeChartConfig(widget.config || {}, dataModelTables);
|
|
2921
|
+
onChange(recomputed);
|
|
2922
|
+
}, [widget.config, dataModelTables, onChange]);
|
|
2923
|
+
|
|
2924
|
+
const dropReasonCounts = useMemo(() => {
|
|
2925
|
+
const counts = {};
|
|
2926
|
+
for (const entry of debug.droppedRows || []) {
|
|
2927
|
+
counts[entry.reason] = (counts[entry.reason] || 0) + 1;
|
|
2928
|
+
}
|
|
2929
|
+
return counts;
|
|
2930
|
+
}, [debug.droppedRows]);
|
|
2931
|
+
|
|
2932
|
+
return (
|
|
2933
|
+
<section className="workspace-widget-subpanel workspace-chart-inspector">
|
|
2934
|
+
<SubPanelHeader title="Inspect computation" breadcrumb={widget?.title} onBack={onBack} />
|
|
2935
|
+
<div className="workspace-widget-actions workspace-chart-inspector-top-actions" role="group" aria-label="Chart inspector navigation">
|
|
2936
|
+
<button type="button" onClick={onBack}>Edit chart</button>
|
|
2937
|
+
<button type="button" onClick={recompute}><RefreshCw size={15} />Recompute values</button>
|
|
2938
|
+
</div>
|
|
2939
|
+
|
|
2940
|
+
<p className="workspace-panel-label">Source</p>
|
|
2941
|
+
{table ? (
|
|
2942
|
+
<div className="workspace-settings-list">
|
|
2943
|
+
<div><span>Object</span><code>{table.label}</code></div>
|
|
2944
|
+
<div><span>Storage</span><code>{table.liveSource ? "Live-backed sidecar" : "Manual Data Model"}</code></div>
|
|
2945
|
+
<div><span>Rows available</span><code>{table.rows?.length || 0}</code></div>
|
|
2946
|
+
{table.liveSource?.fetchedAt ? <div><span>Last fetched</span><code>{table.liveSource.fetchedAt}</code></div> : null}
|
|
2947
|
+
</div>
|
|
2948
|
+
) : (
|
|
2949
|
+
<p className="workspace-panel-hint">
|
|
2950
|
+
No source bound. Open <strong>Source</strong> to pick a Data Model object.
|
|
2951
|
+
</p>
|
|
2952
|
+
)}
|
|
2953
|
+
|
|
2954
|
+
<p className="workspace-panel-label">Source preview</p>
|
|
2955
|
+
{debug.samples?.length ? (
|
|
2956
|
+
<details className="workspace-chart-inspector-preview">
|
|
2957
|
+
<summary>{debug.samples.length} sample row{debug.samples.length === 1 ? "" : "s"}</summary>
|
|
2958
|
+
<pre className="workspace-chart-inspector-sample">
|
|
2959
|
+
{JSON.stringify(debug.samples, null, 2)}
|
|
2960
|
+
</pre>
|
|
2961
|
+
</details>
|
|
2962
|
+
) : (
|
|
2963
|
+
<p className="workspace-panel-hint">No source rows.</p>
|
|
2964
|
+
)}
|
|
2965
|
+
|
|
2966
|
+
<p className="workspace-panel-label">Filter</p>
|
|
2967
|
+
<div className="workspace-settings-list">
|
|
2968
|
+
<div><span>Before</span><code>{debug.rowCount}</code></div>
|
|
2969
|
+
<div><span>After</span><code>{debug.filteredCount ?? 0}</code></div>
|
|
2970
|
+
<div><span>Dropped by filter</span><code>{debug.droppedByFilter ?? 0}</code></div>
|
|
2971
|
+
</div>
|
|
2972
|
+
|
|
2973
|
+
<p className="workspace-panel-label">Buckets</p>
|
|
2974
|
+
{debug.buckets?.length ? (
|
|
2975
|
+
<div className="workspace-settings-list">
|
|
2976
|
+
{debug.buckets.map((bucket, index) => (
|
|
2977
|
+
<div key={`${bucket.key || "_"}_${index}`}>
|
|
2978
|
+
<span>{bucket.key === "" ? "(all rows)" : String(bucket.key)}</span>
|
|
2979
|
+
<code>
|
|
2980
|
+
{bucket.rowCount} row{bucket.rowCount === 1 ? "" : "s"}
|
|
2981
|
+
{" · "}
|
|
2982
|
+
{bucket.numericCount} numeric
|
|
2983
|
+
{" · "}
|
|
2984
|
+
{bucket.value === null || bucket.value === undefined ? "—" : String(bucket.value)}
|
|
2985
|
+
</code>
|
|
2986
|
+
</div>
|
|
2987
|
+
))}
|
|
2988
|
+
</div>
|
|
2989
|
+
) : (
|
|
2990
|
+
<p className="workspace-panel-hint">No buckets — choose an X axis field or group by.</p>
|
|
2991
|
+
)}
|
|
2992
|
+
|
|
2993
|
+
<p className="workspace-panel-label">Dropped rows</p>
|
|
2994
|
+
{Object.keys(dropReasonCounts).length ? (
|
|
2995
|
+
<div className="workspace-settings-list">
|
|
2996
|
+
{Object.entries(dropReasonCounts).map(([reason, count]) => (
|
|
2997
|
+
<div key={reason}><span>{reason}</span><code>{count}</code></div>
|
|
2998
|
+
))}
|
|
2999
|
+
</div>
|
|
3000
|
+
) : (
|
|
3001
|
+
<p className="workspace-panel-hint">No rows dropped.</p>
|
|
3002
|
+
)}
|
|
3003
|
+
|
|
3004
|
+
<p className="workspace-panel-label">Final values</p>
|
|
3005
|
+
<pre className="workspace-chart-inspector-sample">
|
|
3006
|
+
{JSON.stringify(debug.values, null, 2)}
|
|
3007
|
+
</pre>
|
|
3008
|
+
|
|
3009
|
+
{debug.warnings?.length ? (
|
|
3010
|
+
<div className="workspace-settings-list" role="alert" aria-label="Computation warnings">
|
|
3011
|
+
{debug.warnings.map((warning, index) => (
|
|
3012
|
+
<div key={index}><span>Warning</span><code>{warning}</code></div>
|
|
3013
|
+
))}
|
|
3014
|
+
</div>
|
|
3015
|
+
) : null}
|
|
3016
|
+
|
|
3017
|
+
<div className="workspace-widget-actions" role="group" aria-label="Inspector actions">
|
|
3018
|
+
<button type="button" onClick={recompute}><RefreshCw size={15} />Recompute values</button>
|
|
3019
|
+
<button
|
|
3020
|
+
type="button"
|
|
3021
|
+
onClick={onSave}
|
|
3022
|
+
disabled={!canSave || saving}
|
|
3023
|
+
title={!canSave ? saveGuidance || "Save is disabled in this runtime." : "Persist computed values to growthub.config.json"}
|
|
3024
|
+
>
|
|
3025
|
+
<Save size={15} />{saving ? "Saving…" : unsaved ? "Save computed values" : "Save"}
|
|
3026
|
+
</button>
|
|
3027
|
+
</div>
|
|
3028
|
+
{unsaved ? <p className="workspace-panel-hint">Unsaved computed values.</p> : null}
|
|
3029
|
+
{!canSave && saveGuidance ? <p className="workspace-panel-hint">{saveGuidance}</p> : null}
|
|
3030
|
+
</section>
|
|
3031
|
+
);
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function ChartConfigPanel({ widget, branding, dataModelTables, unsaved, onChange, onSubPage }) {
|
|
2742
3035
|
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
2743
3036
|
const xAxis = getChartAxis(widget, "xAxis");
|
|
2744
3037
|
const yAxis = getChartAxis(widget, "yAxis");
|
|
2745
3038
|
const style = getChartStyle(widget);
|
|
2746
3039
|
const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
3040
|
+
|
|
3041
|
+
// Every axis/filter/aggregation/chartType edit funnels through this writer
|
|
3042
|
+
// so `widget.config.values` is recomputed from the bound Data Model rows
|
|
3043
|
+
// before persistence. Unbound charts (no Data Model source) keep the
|
|
3044
|
+
// existing static `values` untouched — this is what preserves the legacy
|
|
3045
|
+
// chart-with-static-values path.
|
|
3046
|
+
const commitConfig = useCallback((nextConfig) => {
|
|
3047
|
+
const { config: computed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
3048
|
+
onChange(computed);
|
|
3049
|
+
}, [dataModelTables, onChange]);
|
|
3050
|
+
|
|
3051
|
+
const setChartType = (type) => commitConfig({ ...widget.config, chartType: type });
|
|
3052
|
+
const setXAxis = (patch) => commitConfig({ ...widget.config, xAxis: { ...xAxis, ...patch } });
|
|
3053
|
+
const setYAxis = (patch) => commitConfig({ ...widget.config, yAxis: { ...yAxis, ...patch } });
|
|
3054
|
+
// Style is render-only — it doesn't change values, so skip recomputation.
|
|
2750
3055
|
const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
|
|
2751
3056
|
|
|
2752
3057
|
// Derive source fields from the bound data model object
|
|
2753
|
-
const
|
|
3058
|
+
const boundTable = useMemo(() => {
|
|
2754
3059
|
const binding = widget.config?.binding;
|
|
2755
|
-
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return
|
|
2756
|
-
|
|
2757
|
-
.find((t) => t.objectId === binding.objectId || t.source === binding.source);
|
|
2758
|
-
return table?.columns || [];
|
|
3060
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return null;
|
|
3061
|
+
return (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
3062
|
+
.find((t) => t.objectId === binding.objectId || t.source === binding.source) || null;
|
|
2759
3063
|
}, [widget.config?.binding, dataModelTables]);
|
|
2760
3064
|
|
|
3065
|
+
const sourceFields = boundTable?.columns || [];
|
|
2761
3066
|
const hasSource = sourceFields.length > 0;
|
|
2762
3067
|
|
|
2763
|
-
|
|
2764
|
-
|
|
3068
|
+
// Compute the live preview status for the configured chart so the panel
|
|
3069
|
+
// can surface row counts, warnings, and last-fetched timestamps without
|
|
3070
|
+
// having to re-derive the projection elsewhere.
|
|
3071
|
+
const computeStatus = useMemo(() => {
|
|
3072
|
+
const { result } = recomputeChartConfig(widget.config || {}, dataModelTables);
|
|
3073
|
+
return result;
|
|
3074
|
+
}, [widget.config, dataModelTables]);
|
|
3075
|
+
|
|
3076
|
+
const recomputeValues = useCallback(() => {
|
|
3077
|
+
commitConfig({ ...widget.config });
|
|
3078
|
+
}, [commitConfig, widget.config]);
|
|
3079
|
+
|
|
3080
|
+
return <section className="workspace-chart-config workspace-twenty-config">
|
|
3081
|
+
<p className="workspace-panel-label">Settings</p>
|
|
3082
|
+
<WidgetSettingsRow icon={BarChart3} label="Layout" value={CHART_TYPE_LABELS[chartType]} disabled />
|
|
2765
3083
|
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
2766
3084
|
{VISIBLE_CHART_TYPES.map((type) => {
|
|
2767
3085
|
const TypeIcon = CHART_TYPE_ICONS[type];
|
|
@@ -2780,17 +3098,27 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2780
3098
|
})}
|
|
2781
3099
|
</div>
|
|
2782
3100
|
|
|
2783
|
-
<
|
|
2784
|
-
<
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
3101
|
+
<WidgetSettingsRow icon={Box} label="Source" value={summarizeSource(widget) || "None"} onClick={() => onSubPage("source")} />
|
|
3102
|
+
<WidgetSettingsRow icon={Filter} label="Filter" value={summarizeFilter(widget)} onClick={() => onSubPage("filter")} />
|
|
3103
|
+
{boundTable ? (
|
|
3104
|
+
<WidgetSettingsRow
|
|
3105
|
+
icon={Activity}
|
|
3106
|
+
label="Values"
|
|
3107
|
+
value={
|
|
3108
|
+
<>
|
|
3109
|
+
{boundTable.rows?.length || 0} row{(boundTable.rows?.length || 0) === 1 ? "" : "s"}
|
|
3110
|
+
{" · "}
|
|
3111
|
+
{Array.isArray(widget.config?.values) ? widget.config.values.length : 0} value{(widget.config?.values?.length || 0) === 1 ? "" : "s"}
|
|
3112
|
+
{unsaved ? " · unsaved" : ""}
|
|
3113
|
+
{Array.isArray(computeStatus?.warnings) && computeStatus.warnings.length ? " · warning" : ""}
|
|
3114
|
+
</>
|
|
3115
|
+
}
|
|
3116
|
+
onClick={() => onSubPage("hydration")}
|
|
3117
|
+
/>
|
|
3118
|
+
) : null}
|
|
2790
3119
|
|
|
2791
3120
|
<p className="workspace-panel-label">X axis</p>
|
|
2792
|
-
<
|
|
2793
|
-
<span>Data on display</span>
|
|
3121
|
+
<WidgetSelectRow icon={Columns3} label="Data">
|
|
2794
3122
|
<FieldDropdown
|
|
2795
3123
|
fields={sourceFields}
|
|
2796
3124
|
value={xAxis.field || ""}
|
|
@@ -2798,23 +3126,21 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2798
3126
|
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2799
3127
|
disabled={!hasSource}
|
|
2800
3128
|
/>
|
|
2801
|
-
</
|
|
2802
|
-
<
|
|
2803
|
-
<span>Sort by</span>
|
|
3129
|
+
</WidgetSelectRow>
|
|
3130
|
+
<WidgetSelectRow icon={SlidersHorizontal} label="Sort">
|
|
2804
3131
|
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
2805
3132
|
<option value="position">Position asc</option>
|
|
2806
3133
|
<option value="asc">Value asc</option>
|
|
2807
3134
|
<option value="desc">Value desc</option>
|
|
2808
3135
|
</select>
|
|
2809
|
-
</
|
|
2810
|
-
<label className="workspace-toggle-row">
|
|
3136
|
+
</WidgetSelectRow>
|
|
3137
|
+
<label className="workspace-twenty-toggle-row">
|
|
2811
3138
|
<span>Omit zero values</span>
|
|
2812
3139
|
<input type="checkbox" checked={Boolean(xAxis.omitZero)} onChange={(event) => setXAxis({ omitZero: event.target.checked })} />
|
|
2813
3140
|
</label>
|
|
2814
3141
|
|
|
2815
3142
|
<p className="workspace-panel-label">Y axis</p>
|
|
2816
|
-
<
|
|
2817
|
-
<span>Data on display</span>
|
|
3143
|
+
<WidgetSelectRow icon={Hash} label="Data">
|
|
2818
3144
|
<FieldDropdown
|
|
2819
3145
|
fields={sourceFields}
|
|
2820
3146
|
value={yAxis.field || ""}
|
|
@@ -2822,9 +3148,8 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2822
3148
|
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2823
3149
|
disabled={!hasSource}
|
|
2824
3150
|
/>
|
|
2825
|
-
</
|
|
2826
|
-
<
|
|
2827
|
-
<span>Group by</span>
|
|
3151
|
+
</WidgetSelectRow>
|
|
3152
|
+
<WidgetSelectRow icon={Layers} label="Group by">
|
|
2828
3153
|
<FieldDropdown
|
|
2829
3154
|
fields={sourceFields}
|
|
2830
3155
|
value={yAxis.groupBy || ""}
|
|
@@ -2832,13 +3157,12 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2832
3157
|
placeholder="None"
|
|
2833
3158
|
disabled={!hasSource}
|
|
2834
3159
|
/>
|
|
2835
|
-
</
|
|
2836
|
-
<
|
|
2837
|
-
<
|
|
2838
|
-
|
|
2839
|
-
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
3160
|
+
</WidgetSelectRow>
|
|
3161
|
+
<WidgetSelectRow icon={Sigma} label="Operation">
|
|
3162
|
+
<select value={yAxis.operation || yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ operation: event.target.value, aggregation: event.target.value })}>
|
|
3163
|
+
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{AGGREGATION_LABELS[agg] || agg}</option>)}
|
|
2840
3164
|
</select>
|
|
2841
|
-
</
|
|
3165
|
+
</WidgetSelectRow>
|
|
2842
3166
|
<div className="workspace-axis-range">
|
|
2843
3167
|
<label>
|
|
2844
3168
|
<span>Min range</span>
|
|
@@ -2850,8 +3174,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2850
3174
|
</label>
|
|
2851
3175
|
</div>
|
|
2852
3176
|
<p className="workspace-panel-label">Style</p>
|
|
2853
|
-
<label>
|
|
2854
|
-
<span>Colors</span>
|
|
3177
|
+
<WidgetSelectRow icon={Star} label="Colors">
|
|
2855
3178
|
<select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
|
|
2856
3179
|
<option value="auto">Auto</option>
|
|
2857
3180
|
<option value="accent">Accent</option>
|
|
@@ -2859,7 +3182,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2859
3182
|
<option value="brand-bridge">Bridge brand kit</option>
|
|
2860
3183
|
<option value="manual">Manual</option>
|
|
2861
3184
|
</select>
|
|
2862
|
-
</
|
|
3185
|
+
</WidgetSelectRow>
|
|
2863
3186
|
<div className="workspace-color-preview-row">
|
|
2864
3187
|
<span>Active color</span>
|
|
2865
3188
|
<em style={{ background: activeColor }} />
|
|
@@ -2888,7 +3211,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2888
3211
|
onChange={(event) => setStyle({ axisName: event.target.value })}
|
|
2889
3212
|
/>
|
|
2890
3213
|
</label>
|
|
2891
|
-
<label className="workspace-toggle-row">
|
|
3214
|
+
<label className="workspace-twenty-toggle-row">
|
|
2892
3215
|
<span>Data labels</span>
|
|
2893
3216
|
<input
|
|
2894
3217
|
type="checkbox"
|
|
@@ -3492,14 +3815,14 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
3492
3815
|
</div>;
|
|
3493
3816
|
}
|
|
3494
3817
|
|
|
3495
|
-
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
3818
|
+
function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
3496
3819
|
const searchParams = useSearchParams();
|
|
3497
3820
|
const [config, setConfig] = useState(() => {
|
|
3498
3821
|
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
3499
3822
|
? initialConfig.dashboards.map((dashboard, index) =>
|
|
3500
3823
|
normalizeDashboard(dashboard, index === 0 ? initialConfig.canvas : undefined)
|
|
3501
3824
|
)
|
|
3502
|
-
: [createDashboardRecord("
|
|
3825
|
+
: [createDashboardRecord("New Dashboard")];
|
|
3503
3826
|
return {
|
|
3504
3827
|
...initialConfig,
|
|
3505
3828
|
dashboards,
|
|
@@ -3516,10 +3839,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3516
3839
|
const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
|
|
3517
3840
|
const [editingWorkflowId, setEditingWorkflowId] = useState(null);
|
|
3518
3841
|
const [editingWorkflowDraft, setEditingWorkflowDraft] = useState("");
|
|
3842
|
+
const [editingTabId, setEditingTabId] = useState(null);
|
|
3843
|
+
const [editingTabDraft, setEditingTabDraft] = useState("");
|
|
3519
3844
|
const [workspaceView, setWorkspaceView] = useState("dashboards");
|
|
3520
3845
|
const [builderListFilter, setBuilderListFilter] = useState({ type: "all", query: "" });
|
|
3521
3846
|
const [builderActionMenuId, setBuilderActionMenuId] = useState(null);
|
|
3522
3847
|
const [builderActionMenuPlacement, setBuilderActionMenuPlacement] = useState(null);
|
|
3848
|
+
const [dashboardDraftMode, setDashboardDraftMode] = useState(false);
|
|
3849
|
+
const [dashboardLiveSnapshot, setDashboardLiveSnapshot] = useState(null);
|
|
3523
3850
|
const [activeDashboardId, setActiveDashboardId] = useState(() =>
|
|
3524
3851
|
getActiveDashboardId(
|
|
3525
3852
|
Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
|
|
@@ -3565,8 +3892,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3565
3892
|
const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
|
|
3566
3893
|
const activeWidgets = activeTab.widgets || [];
|
|
3567
3894
|
const activeDashboard = dashboards[resolvedActiveDashboardIndex] || dashboards[0] || null;
|
|
3895
|
+
const dashboardHasSavedDraft = Boolean(activeDashboard?.dashboardDraftStatus && Array.isArray(activeDashboard?.dashboardDraftTabs));
|
|
3896
|
+
const dashboardDirty = dashboardDraftMode && dashboardLiveSnapshot
|
|
3897
|
+
? JSON.stringify(activeDashboard?.tabs || []) !== JSON.stringify(dashboardLiveSnapshot.tabs || [])
|
|
3898
|
+
|| String(activeDashboard?.name || "") !== String(dashboardLiveSnapshot.name || "")
|
|
3899
|
+
: false;
|
|
3900
|
+
const dashboardModeLabel = dashboardDraftMode || dashboardHasSavedDraft ? "draft" : "live";
|
|
3568
3901
|
const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
|
|
3569
3902
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null);
|
|
3903
|
+
const [pendingSelectedWidgetId, setPendingSelectedWidgetId] = useState(null);
|
|
3570
3904
|
const [dragStartCell, setDragStartCell] = useState(null);
|
|
3571
3905
|
const [dragPreview, setDragPreview] = useState(null);
|
|
3572
3906
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
@@ -3582,13 +3916,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3582
3916
|
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
3583
3917
|
const [refreshing, setRefreshing] = useState(false);
|
|
3584
3918
|
const [refreshResult, setRefreshResult] = useState(null);
|
|
3919
|
+
// Sidecar source records (`growthub.source-records.json`) hydrate live-backed
|
|
3920
|
+
// Data Model objects at runtime. They are NOT persisted into growthub.config.json
|
|
3921
|
+
// and NEVER flow through PATCH /api/workspace. Updates land here only after a
|
|
3922
|
+
// successful POST /api/workspace/refresh-sources cycle re-reads GET /api/workspace.
|
|
3923
|
+
const [workspaceSourceRecords, setWorkspaceSourceRecords] = useState(
|
|
3924
|
+
() => (initialSourceRecords && typeof initialSourceRecords === "object" && !Array.isArray(initialSourceRecords)
|
|
3925
|
+
? initialSourceRecords
|
|
3926
|
+
: {})
|
|
3927
|
+
);
|
|
3585
3928
|
const resizeDragRef = useRef(null);
|
|
3586
3929
|
const moveDragRef = useRef(null);
|
|
3587
3930
|
const importInputRef = useRef(null);
|
|
3588
3931
|
const addSlot = dragPreview || selectedPosition;
|
|
3589
|
-
const
|
|
3932
|
+
const selectedWidgetLookupId = selectedWidgetId || pendingSelectedWidgetId;
|
|
3933
|
+
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetLookupId) || null;
|
|
3590
3934
|
const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
|
|
3591
|
-
const dataModelTables = useMemo(
|
|
3935
|
+
const dataModelTables = useMemo(
|
|
3936
|
+
() => listWorkspaceDataModelTables(config, { sourceRecords: workspaceSourceRecords }),
|
|
3937
|
+
[config, workspaceSourceRecords]
|
|
3938
|
+
);
|
|
3592
3939
|
const selectedResolvedWidget = selectedWidget ? resolveViewWidget(selectedWidget, dataModelTables) : null;
|
|
3593
3940
|
const branding = config.branding || {};
|
|
3594
3941
|
const occupiedCells = useMemo(() => {
|
|
@@ -3604,20 +3951,57 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3604
3951
|
}, [activeWidgets]);
|
|
3605
3952
|
|
|
3606
3953
|
/**
|
|
3607
|
-
* Collect
|
|
3608
|
-
*
|
|
3609
|
-
*
|
|
3954
|
+
* Collect refreshable source IDs from BOTH direct live bindings (a widget
|
|
3955
|
+
* binding with `sourceStorage === "workspace-source-records"`) AND
|
|
3956
|
+
* Data Model-bound widgets whose bound table resolves to a live-backed
|
|
3957
|
+
* sidecar source. The second path is what makes charts that point at a
|
|
3958
|
+
* live-backed Data Model object refreshable from the Chart panel — the
|
|
3959
|
+
* live-source metadata lives on the Data Model object, not on the widget
|
|
3960
|
+
* binding itself.
|
|
3961
|
+
*
|
|
3962
|
+
* This is runtime discovery only — config is never mutated.
|
|
3610
3963
|
*/
|
|
3611
3964
|
const liveSourceIds = useMemo(() => {
|
|
3612
3965
|
const ids = new Set();
|
|
3966
|
+
const addCandidates = (...candidates) => {
|
|
3967
|
+
for (const candidate of candidates) {
|
|
3968
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
3969
|
+
ids.add(candidate.trim());
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
};
|
|
3613
3973
|
for (const widget of activeWidgets) {
|
|
3614
3974
|
const binding = widget?.config?.binding;
|
|
3615
|
-
if (binding
|
|
3616
|
-
|
|
3975
|
+
if (!binding) continue;
|
|
3976
|
+
// Direct live binding (legacy path).
|
|
3977
|
+
if (binding.sourceStorage === "workspace-source-records") {
|
|
3978
|
+
addCandidates(binding.sourceId);
|
|
3979
|
+
}
|
|
3980
|
+
// Data Model-bound widgets (chart / view) whose bound table is itself
|
|
3981
|
+
// backed by a sidecar source.
|
|
3982
|
+
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
3983
|
+
const table = (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
3984
|
+
.find((t) => t.objectId === binding.objectId);
|
|
3985
|
+
if (!table) continue;
|
|
3986
|
+
const tableBinding = table.binding || {};
|
|
3987
|
+
if (table.liveSource || tableBinding.sourceStorage === "workspace-source-records") {
|
|
3988
|
+
addCandidates(
|
|
3989
|
+
table.liveSource?.sourceRecordKey,
|
|
3990
|
+
table.objectId,
|
|
3991
|
+
tableBinding.sourceId
|
|
3992
|
+
);
|
|
3993
|
+
}
|
|
3617
3994
|
}
|
|
3618
3995
|
}
|
|
3619
3996
|
return Array.from(ids);
|
|
3620
|
-
}, [activeWidgets]);
|
|
3997
|
+
}, [activeWidgets, dataModelTables]);
|
|
3998
|
+
|
|
3999
|
+
// Track which chart widgets have recomputed values that have not yet been
|
|
4000
|
+
// persisted. After a refresh, recomputed values live in local React state
|
|
4001
|
+
// only — until the user saves, the on-disk `growthub.config.json` still
|
|
4002
|
+
// holds the previous projection. The Chart panel shows an `Unsaved` chip
|
|
4003
|
+
// and a `Save computed values` action when this set is non-empty.
|
|
4004
|
+
const [unsavedChartIds, setUnsavedChartIds] = useState(() => new Set());
|
|
3621
4005
|
|
|
3622
4006
|
const refreshSources = useCallback(async () => {
|
|
3623
4007
|
if (refreshing || liveSourceIds.length === 0) return;
|
|
@@ -3629,20 +4013,96 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3629
4013
|
headers: { "content-type": "application/json" },
|
|
3630
4014
|
body: JSON.stringify({ sourceIds: liveSourceIds })
|
|
3631
4015
|
});
|
|
3632
|
-
if (response.ok) {
|
|
3633
|
-
const data = await response.json();
|
|
3634
|
-
setRefreshResult({ refreshed: data.refreshed?.length || 0, skipped: data.skipped?.length || 0 });
|
|
3635
|
-
} else {
|
|
4016
|
+
if (!response.ok) {
|
|
3636
4017
|
setRefreshResult({ error: true });
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
const data = await response.json();
|
|
4021
|
+
const refreshedIds = new Set((data.refreshed || [])
|
|
4022
|
+
.map((entry) => String(entry?.sourceId || "").trim())
|
|
4023
|
+
.filter(Boolean));
|
|
4024
|
+
let nextSourceRecords = workspaceSourceRecords;
|
|
4025
|
+
try {
|
|
4026
|
+
// Re-read GET so the sidecar (`workspaceSourceRecords`) reflects
|
|
4027
|
+
// the new rows the resolver just persisted. This is what makes the
|
|
4028
|
+
// chart preview update without a page reload.
|
|
4029
|
+
const getResponse = await fetch("/api/workspace", { method: "GET" });
|
|
4030
|
+
if (getResponse.ok) {
|
|
4031
|
+
const getPayload = await getResponse.json();
|
|
4032
|
+
if (getPayload?.workspaceSourceRecords && typeof getPayload.workspaceSourceRecords === "object") {
|
|
4033
|
+
nextSourceRecords = getPayload.workspaceSourceRecords;
|
|
4034
|
+
setWorkspaceSourceRecords(nextSourceRecords);
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
} catch {
|
|
4038
|
+
// Non-fatal: refresh result still reports counts; UI will use stale records.
|
|
4039
|
+
}
|
|
4040
|
+
// Recompute chart widgets bound to refreshed objects. We rebuild the
|
|
4041
|
+
// Data Model tables from the latest sidecar before recomputing so the
|
|
4042
|
+
// computation sees the freshly-fetched rows. Recomputed widgets are
|
|
4043
|
+
// marked as unsaved — persistence still requires the explicit Save
|
|
4044
|
+
// action so the user can audit the projection before committing it.
|
|
4045
|
+
const dirtyWidgetIds = new Set();
|
|
4046
|
+
if (refreshedIds.size > 0) {
|
|
4047
|
+
const nextTables = listWorkspaceDataModelTables(config, { sourceRecords: nextSourceRecords });
|
|
4048
|
+
const objectIdsForRefreshedSources = new Set();
|
|
4049
|
+
for (const sourceId of refreshedIds) {
|
|
4050
|
+
for (const table of nextTables) {
|
|
4051
|
+
if (!table.objectId) continue;
|
|
4052
|
+
const liveKey = table.liveSource?.sourceRecordKey;
|
|
4053
|
+
const tableBindingSourceId = table.binding?.sourceId;
|
|
4054
|
+
if (liveKey === sourceId || table.objectId === sourceId || tableBindingSourceId === sourceId) {
|
|
4055
|
+
objectIdsForRefreshedSources.add(table.objectId);
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
if (objectIdsForRefreshedSources.size > 0) {
|
|
4060
|
+
const recomputeWidgets = (widgets) => (widgets || []).map((widget) => {
|
|
4061
|
+
if (widget?.kind !== "chart") return widget;
|
|
4062
|
+
const objectId = widget.config?.binding?.objectId;
|
|
4063
|
+
if (!objectId || !objectIdsForRefreshedSources.has(objectId)) return widget;
|
|
4064
|
+
const { config: recomputed } = recomputeChartConfig(widget.config || {}, nextTables);
|
|
4065
|
+
const prevValues = Array.isArray(widget.config?.values) ? widget.config.values : [];
|
|
4066
|
+
const nextValues = Array.isArray(recomputed.values) ? recomputed.values : [];
|
|
4067
|
+
const changed = prevValues.length !== nextValues.length
|
|
4068
|
+
|| prevValues.some((value, index) => value !== nextValues[index]);
|
|
4069
|
+
if (changed) dirtyWidgetIds.add(widget.id);
|
|
4070
|
+
return { ...widget, config: recomputed };
|
|
4071
|
+
});
|
|
4072
|
+
setConfig((prev) => {
|
|
4073
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard) => ({
|
|
4074
|
+
...dashboard,
|
|
4075
|
+
tabs: (dashboard.tabs || []).map((tab) => ({ ...tab, widgets: recomputeWidgets(tab.widgets) }))
|
|
4076
|
+
}));
|
|
4077
|
+
let nextCanvas = prev.canvas ? { ...prev.canvas } : {};
|
|
4078
|
+
if (Array.isArray(nextCanvas.widgets)) nextCanvas = { ...nextCanvas, widgets: recomputeWidgets(nextCanvas.widgets) };
|
|
4079
|
+
if (Array.isArray(nextCanvas.tabs)) nextCanvas = { ...nextCanvas, tabs: nextCanvas.tabs.map((tab) => ({ ...tab, widgets: recomputeWidgets(tab.widgets) })) };
|
|
4080
|
+
return { ...prev, dashboards: nextDashboards, canvas: nextCanvas };
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
3637
4083
|
}
|
|
4084
|
+
if (dirtyWidgetIds.size > 0) {
|
|
4085
|
+
setUnsavedChartIds((prev) => {
|
|
4086
|
+
const next = new Set(prev);
|
|
4087
|
+
for (const id of dirtyWidgetIds) next.add(id);
|
|
4088
|
+
return next;
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
setRefreshResult({
|
|
4092
|
+
refreshed: data.refreshed?.length || 0,
|
|
4093
|
+
skipped: data.skipped?.length || 0,
|
|
4094
|
+
recomputed: dirtyWidgetIds.size,
|
|
4095
|
+
unsaved: dirtyWidgetIds.size > 0
|
|
4096
|
+
});
|
|
3638
4097
|
} catch {
|
|
3639
4098
|
setRefreshResult({ error: true });
|
|
3640
4099
|
} finally {
|
|
3641
4100
|
setRefreshing(false);
|
|
3642
4101
|
}
|
|
3643
|
-
}, [refreshing, liveSourceIds]);
|
|
4102
|
+
}, [refreshing, liveSourceIds, workspaceSourceRecords, config]);
|
|
3644
4103
|
|
|
3645
4104
|
const addWidget = useCallback((kind) => {
|
|
4105
|
+
if (!dashboardDraftMode) return;
|
|
3646
4106
|
setConfig((prev) => {
|
|
3647
4107
|
const prevTabs = getTabs(prev.canvas);
|
|
3648
4108
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -3662,11 +4122,20 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3662
4122
|
tab.id === prevActiveId ? { ...tab, widgets: [...(tab.widgets || []), widget] } : tab
|
|
3663
4123
|
);
|
|
3664
4124
|
setSelectedWidgetId(widget.id);
|
|
4125
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4126
|
+
setInspectorPath(SUB_PANEL_ROOT);
|
|
4127
|
+
setPanelOpen(true);
|
|
4128
|
+
window.setTimeout(() => {
|
|
4129
|
+
setSelectedWidgetId(widget.id);
|
|
4130
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4131
|
+
setInspectorPath(SUB_PANEL_ROOT);
|
|
4132
|
+
setPanelOpen(true);
|
|
4133
|
+
}, 0);
|
|
3665
4134
|
setSelectedPosition(findFreePosition([...existingWidgets, widget]));
|
|
3666
4135
|
setDragPreview(null);
|
|
3667
4136
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
3668
4137
|
});
|
|
3669
|
-
}, [activeDashboardId, addSlot]);
|
|
4138
|
+
}, [activeDashboardId, addSlot, dashboardDraftMode]);
|
|
3670
4139
|
|
|
3671
4140
|
const switchTab = useCallback((tabId) => {
|
|
3672
4141
|
setConfig((prev) => {
|
|
@@ -3675,13 +4144,38 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3675
4144
|
if (!prevTabs.some((tab) => tab.id === tabId)) return prev;
|
|
3676
4145
|
const nextTab = prevTabs.find((tab) => tab.id === tabId);
|
|
3677
4146
|
setSelectedWidgetId(null);
|
|
4147
|
+
setPendingSelectedWidgetId(null);
|
|
3678
4148
|
setSelectedPosition(findFreePosition(nextTab?.widgets || []));
|
|
3679
4149
|
setDragPreview(null);
|
|
4150
|
+
setPanelOpen(false);
|
|
4151
|
+
setEditingTabId(null);
|
|
4152
|
+
setEditingTabDraft("");
|
|
3680
4153
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, prevTabs, tabId));
|
|
3681
4154
|
});
|
|
3682
4155
|
}, [activeDashboardId]);
|
|
3683
4156
|
|
|
4157
|
+
const beginTabRename = useCallback((tab, event) => {
|
|
4158
|
+
if (!dashboardDraftMode || !tab) return;
|
|
4159
|
+
event?.stopPropagation?.();
|
|
4160
|
+
setEditingTabId(tab.id);
|
|
4161
|
+
setEditingTabDraft(tab.name || "Tab");
|
|
4162
|
+
}, [dashboardDraftMode]);
|
|
4163
|
+
|
|
4164
|
+
const commitTabRename = useCallback((tabId) => {
|
|
4165
|
+
if (!dashboardDraftMode || !tabId) return;
|
|
4166
|
+
const nextName = editingTabDraft.trim() || "Tab";
|
|
4167
|
+
setConfig((prev) => {
|
|
4168
|
+
const prevTabs = getTabs(prev.canvas);
|
|
4169
|
+
const activeId = getActiveTabId(prev.canvas);
|
|
4170
|
+
const nextTabs = prevTabs.map((tab) => tab.id === tabId ? { ...tab, name: nextName } : tab);
|
|
4171
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, activeId));
|
|
4172
|
+
});
|
|
4173
|
+
setEditingTabId(null);
|
|
4174
|
+
setEditingTabDraft("");
|
|
4175
|
+
}, [activeDashboardId, dashboardDraftMode, editingTabDraft]);
|
|
4176
|
+
|
|
3684
4177
|
const addTab = useCallback(() => {
|
|
4178
|
+
if (!dashboardDraftMode) return;
|
|
3685
4179
|
setConfig((prev) => {
|
|
3686
4180
|
const prevTabs = getTabs(prev.canvas);
|
|
3687
4181
|
const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
|
|
@@ -3700,7 +4194,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3700
4194
|
setDragPreview(null);
|
|
3701
4195
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, newTab.id));
|
|
3702
4196
|
});
|
|
3703
|
-
}, [activeDashboardId]);
|
|
4197
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3704
4198
|
|
|
3705
4199
|
const addDashboard = useCallback(() => {
|
|
3706
4200
|
setConfig((prev) => {
|
|
@@ -3787,11 +4281,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3787
4281
|
setSelectedWidgetId(null);
|
|
3788
4282
|
setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
|
|
3789
4283
|
setDragPreview(null);
|
|
4284
|
+
setPanelOpen(false);
|
|
3790
4285
|
setEditingDashboardId(null);
|
|
3791
4286
|
setEditingDashboardDraft("");
|
|
4287
|
+
setDashboardDraftMode(false);
|
|
4288
|
+
setDashboardLiveSnapshot(null);
|
|
3792
4289
|
setActiveDashboardId(dashboard.id);
|
|
3793
4290
|
setWorkspaceView("builder");
|
|
3794
|
-
setConfigMessage(`
|
|
4291
|
+
setConfigMessage(`Viewing ${dashboard.name}`);
|
|
3795
4292
|
return {
|
|
3796
4293
|
...synced,
|
|
3797
4294
|
dashboards: prevDashboards.map((item) => item.id === dashboard.id ? normalized : item),
|
|
@@ -3863,7 +4360,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3863
4360
|
const prevDashboards = synced.dashboards || [];
|
|
3864
4361
|
if (!prevDashboards[index]) return prev;
|
|
3865
4362
|
if (prevDashboards.length <= 1) {
|
|
3866
|
-
const dashboard = createDashboardRecord("
|
|
4363
|
+
const dashboard = createDashboardRecord("New Dashboard");
|
|
3867
4364
|
setSelectedWidgetId(null);
|
|
3868
4365
|
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
3869
4366
|
setDragPreview(null);
|
|
@@ -3899,6 +4396,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3899
4396
|
}, [cloneDashboard, resolvedActiveDashboardIndex]);
|
|
3900
4397
|
|
|
3901
4398
|
const duplicateTab = useCallback(() => {
|
|
4399
|
+
if (!dashboardDraftMode) return;
|
|
3902
4400
|
setConfig((prev) => {
|
|
3903
4401
|
const prevTabs = getTabs(prev.canvas);
|
|
3904
4402
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -3921,9 +4419,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3921
4419
|
setDragPreview(null);
|
|
3922
4420
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, cloned.id));
|
|
3923
4421
|
});
|
|
3924
|
-
}, [activeDashboardId]);
|
|
4422
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3925
4423
|
|
|
3926
4424
|
const deleteTab = useCallback((tabId) => {
|
|
4425
|
+
if (!dashboardDraftMode) return;
|
|
3927
4426
|
setConfig((prev) => {
|
|
3928
4427
|
const prevTabs = getTabs(prev.canvas);
|
|
3929
4428
|
const tab = prevTabs.find((item) => item.id === tabId);
|
|
@@ -3945,9 +4444,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3945
4444
|
setConfigMessage(`Deleted ${tab.name}`);
|
|
3946
4445
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, nextActiveTab.id));
|
|
3947
4446
|
});
|
|
3948
|
-
}, [activeDashboardId]);
|
|
4447
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3949
4448
|
|
|
3950
4449
|
const applyTemplateToCurrentTab = useCallback((templateId) => {
|
|
4450
|
+
if (!dashboardDraftMode) return;
|
|
3951
4451
|
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
3952
4452
|
if (!template) return;
|
|
3953
4453
|
const clonedTab = cloneTemplateToTab(template, { tabName: template.name, idFactory: generateId });
|
|
@@ -3979,7 +4479,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3979
4479
|
setConfigMessage(`Applied ${template.name} to current tab`);
|
|
3980
4480
|
setTemplateGalleryOpen(false);
|
|
3981
4481
|
setPreviewTemplateId(null);
|
|
3982
|
-
}, [activeDashboardId]);
|
|
4482
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3983
4483
|
|
|
3984
4484
|
const cloneTemplateAsDashboard = useCallback((templateId) => {
|
|
3985
4485
|
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
@@ -4092,6 +4592,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4092
4592
|
dashboards: savedDashboards,
|
|
4093
4593
|
canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
|
|
4094
4594
|
});
|
|
4595
|
+
// Saved values are now on disk — clear the unsaved-chart tracking
|
|
4596
|
+
// so the Chart panel stops showing the `Unsaved` chip / CTA.
|
|
4597
|
+
setUnsavedChartIds(new Set());
|
|
4095
4598
|
} else {
|
|
4096
4599
|
setConfigMessage(payload.error || "Save failed");
|
|
4097
4600
|
}
|
|
@@ -4106,6 +4609,105 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4106
4609
|
await persistWorkspaceConfig(config, activeDashboardId);
|
|
4107
4610
|
}, [activeDashboardId, config, persistWorkspaceConfig]);
|
|
4108
4611
|
|
|
4612
|
+
const beginDashboardDraft = useCallback(() => {
|
|
4613
|
+
if (!activeDashboard) return;
|
|
4614
|
+
const snapshot = cloneConfig(activeDashboard);
|
|
4615
|
+
setDashboardLiveSnapshot(snapshot);
|
|
4616
|
+
setDashboardDraftMode(true);
|
|
4617
|
+
setPanelOpen(false);
|
|
4618
|
+
setConfigMessage(`Editing draft for ${activeDashboard.name}`);
|
|
4619
|
+
if (dashboardHasSavedDraft) {
|
|
4620
|
+
setConfig((prev) => {
|
|
4621
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard) => {
|
|
4622
|
+
if (dashboard.id !== activeDashboard.id) return dashboard;
|
|
4623
|
+
return {
|
|
4624
|
+
...dashboard,
|
|
4625
|
+
tabs: cloneConfig(dashboard.dashboardDraftTabs),
|
|
4626
|
+
activeTabId: dashboard.dashboardDraftActiveTabId || dashboard.activeTabId
|
|
4627
|
+
};
|
|
4628
|
+
});
|
|
4629
|
+
const nextActive = nextDashboards.find((dashboard) => dashboard.id === activeDashboard.id) || nextDashboards[0];
|
|
4630
|
+
return {
|
|
4631
|
+
...prev,
|
|
4632
|
+
dashboards: nextDashboards,
|
|
4633
|
+
canvas: dashboardCanvasFrom(nextActive, prev.canvas)
|
|
4634
|
+
};
|
|
4635
|
+
});
|
|
4636
|
+
}
|
|
4637
|
+
}, [activeDashboard, dashboardHasSavedDraft]);
|
|
4638
|
+
|
|
4639
|
+
const saveDashboardDraft = useCallback(async () => {
|
|
4640
|
+
if (!activeDashboard || saving) return;
|
|
4641
|
+
const now = new Date().toISOString();
|
|
4642
|
+
const synced = syncActiveDashboard(config, activeDashboardId);
|
|
4643
|
+
const nextDashboards = (synced.dashboards || []).map((dashboard) =>
|
|
4644
|
+
dashboard.id === activeDashboard.id
|
|
4645
|
+
? {
|
|
4646
|
+
...dashboard,
|
|
4647
|
+
dashboardDraftStatus: "draft",
|
|
4648
|
+
dashboardDraftUpdatedAt: now,
|
|
4649
|
+
dashboardDraftBaseVersion: String(dashboard.version || "1"),
|
|
4650
|
+
dashboardDraftTabs: cloneConfig(dashboard.tabs || []),
|
|
4651
|
+
dashboardDraftActiveTabId: dashboard.activeTabId || ""
|
|
4652
|
+
}
|
|
4653
|
+
: dashboard
|
|
4654
|
+
);
|
|
4655
|
+
await persistWorkspaceConfig({ ...synced, dashboards: nextDashboards }, activeDashboardId);
|
|
4656
|
+
setDashboardDraftMode(true);
|
|
4657
|
+
setDashboardLiveSnapshot(cloneConfig(nextDashboards.find((dashboard) => dashboard.id === activeDashboard.id) || activeDashboard));
|
|
4658
|
+
setConfigMessage("Saved dashboard draft. Publish to update the live dashboard.");
|
|
4659
|
+
}, [activeDashboard, activeDashboardId, config, persistWorkspaceConfig, saving]);
|
|
4660
|
+
|
|
4661
|
+
const publishDashboard = useCallback(async () => {
|
|
4662
|
+
if (!activeDashboard || saving) return;
|
|
4663
|
+
const now = new Date().toISOString();
|
|
4664
|
+
const synced = syncActiveDashboard(config, activeDashboardId);
|
|
4665
|
+
const nextDashboards = (synced.dashboards || []).map((dashboard) => {
|
|
4666
|
+
if (dashboard.id !== activeDashboard.id) return dashboard;
|
|
4667
|
+
const {
|
|
4668
|
+
dashboardDraftTabs,
|
|
4669
|
+
dashboardDraftActiveTabId,
|
|
4670
|
+
dashboardDraftStatus,
|
|
4671
|
+
dashboardDraftUpdatedAt,
|
|
4672
|
+
dashboardDraftBaseVersion,
|
|
4673
|
+
...rest
|
|
4674
|
+
} = dashboard;
|
|
4675
|
+
return {
|
|
4676
|
+
...rest,
|
|
4677
|
+
tabs: cloneConfig(dashboard.tabs || []),
|
|
4678
|
+
activeTabId: dashboard.activeTabId,
|
|
4679
|
+
status: "active",
|
|
4680
|
+
version: String(Number(dashboard.version || "1") + 1),
|
|
4681
|
+
dashboardPublishedAt: now,
|
|
4682
|
+
updatedAt: now
|
|
4683
|
+
};
|
|
4684
|
+
});
|
|
4685
|
+
await persistWorkspaceConfig({ ...synced, dashboards: nextDashboards }, activeDashboardId);
|
|
4686
|
+
setDashboardDraftMode(false);
|
|
4687
|
+
setDashboardLiveSnapshot(null);
|
|
4688
|
+
setConfigMessage("Published dashboard.");
|
|
4689
|
+
}, [activeDashboard, activeDashboardId, config, persistWorkspaceConfig, saving]);
|
|
4690
|
+
|
|
4691
|
+
const discardDashboardDraft = useCallback(() => {
|
|
4692
|
+
if (!activeDashboard) return;
|
|
4693
|
+
const snapshot = dashboardLiveSnapshot;
|
|
4694
|
+
setDashboardDraftMode(false);
|
|
4695
|
+
setDashboardLiveSnapshot(null);
|
|
4696
|
+
setPanelOpen(false);
|
|
4697
|
+
if (!snapshot) return;
|
|
4698
|
+
setConfig((prev) => {
|
|
4699
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard) =>
|
|
4700
|
+
dashboard.id === activeDashboard.id ? snapshot : dashboard
|
|
4701
|
+
);
|
|
4702
|
+
return {
|
|
4703
|
+
...prev,
|
|
4704
|
+
dashboards: nextDashboards,
|
|
4705
|
+
canvas: dashboardCanvasFrom(snapshot, prev.canvas)
|
|
4706
|
+
};
|
|
4707
|
+
});
|
|
4708
|
+
setConfigMessage("Discarded dashboard draft.");
|
|
4709
|
+
}, [activeDashboard, dashboardLiveSnapshot]);
|
|
4710
|
+
|
|
4109
4711
|
const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
|
|
4110
4712
|
const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
|
|
4111
4713
|
setEditingDashboardId(null);
|
|
@@ -4219,18 +4821,24 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4219
4821
|
setEditingDashboardDraft("");
|
|
4220
4822
|
}, [editingDashboardDraft]);
|
|
4221
4823
|
|
|
4222
|
-
const closePanel = useCallback(() =>
|
|
4824
|
+
const closePanel = useCallback(() => {
|
|
4825
|
+
setPanelOpen(false);
|
|
4826
|
+
setSelectedWidgetId(null);
|
|
4827
|
+
setPendingSelectedWidgetId(null);
|
|
4828
|
+
}, []);
|
|
4223
4829
|
const beginCellDrag = useCallback((index, event) => {
|
|
4830
|
+
if (!dashboardDraftMode) return;
|
|
4224
4831
|
const x = index % GRID_COLUMNS;
|
|
4225
4832
|
const y = Math.floor(index / GRID_COLUMNS);
|
|
4226
4833
|
if (occupiedCells.has(`${x}:${y}`)) return;
|
|
4227
4834
|
event.preventDefault();
|
|
4228
4835
|
const position = normalizePosition(index, index);
|
|
4229
4836
|
setSelectedWidgetId(null);
|
|
4837
|
+
setPendingSelectedWidgetId(null);
|
|
4230
4838
|
setDragStartCell(index);
|
|
4231
4839
|
setDragPreview(position);
|
|
4232
4840
|
setPanelOpen(true);
|
|
4233
|
-
}, [occupiedCells]);
|
|
4841
|
+
}, [dashboardDraftMode, occupiedCells]);
|
|
4234
4842
|
const updateCellDrag = useCallback((index) => {
|
|
4235
4843
|
if (dragStartCell === null) return;
|
|
4236
4844
|
setDragPreview(normalizePosition(dragStartCell, index));
|
|
@@ -4254,14 +4862,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4254
4862
|
finishCellDrag(index ?? dragStartCell);
|
|
4255
4863
|
}, [dragStartCell, finishCellDrag]);
|
|
4256
4864
|
const beginResizeDrag = useCallback((widget, corner, event) => {
|
|
4865
|
+
if (!dashboardDraftMode) return;
|
|
4257
4866
|
event.preventDefault();
|
|
4258
4867
|
event.stopPropagation();
|
|
4259
4868
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
4260
4869
|
const nextResizeDrag = { widgetId: widget.id, corner, originalPosition: widget.position };
|
|
4261
4870
|
setSelectedWidgetId(widget.id);
|
|
4871
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4262
4872
|
resizeDragRef.current = nextResizeDrag;
|
|
4263
4873
|
setResizeDrag(nextResizeDrag);
|
|
4264
|
-
}, []);
|
|
4874
|
+
}, [dashboardDraftMode]);
|
|
4265
4875
|
const updateResizeDrag = useCallback((event) => {
|
|
4266
4876
|
const activeResizeDrag = resizeDragRef.current;
|
|
4267
4877
|
if (!activeResizeDrag) return;
|
|
@@ -4292,6 +4902,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4292
4902
|
setResizeDrag(null);
|
|
4293
4903
|
}, []);
|
|
4294
4904
|
const beginMoveDrag = useCallback((widget, event) => {
|
|
4905
|
+
if (!dashboardDraftMode) return;
|
|
4295
4906
|
event.preventDefault();
|
|
4296
4907
|
event.stopPropagation();
|
|
4297
4908
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
@@ -4303,10 +4914,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4303
4914
|
offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
|
|
4304
4915
|
};
|
|
4305
4916
|
setSelectedWidgetId(widget.id);
|
|
4917
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4306
4918
|
setPanelOpen(true);
|
|
4307
4919
|
moveDragRef.current = nextMoveDrag;
|
|
4308
4920
|
setMoveDrag(nextMoveDrag);
|
|
4309
|
-
}, []);
|
|
4921
|
+
}, [dashboardDraftMode]);
|
|
4310
4922
|
const updateMoveDrag = useCallback((event) => {
|
|
4311
4923
|
const activeMoveDrag = moveDragRef.current;
|
|
4312
4924
|
if (!activeMoveDrag) return;
|
|
@@ -4341,10 +4953,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4341
4953
|
setMoveDrag(null);
|
|
4342
4954
|
}, []);
|
|
4343
4955
|
const selectWidget = useCallback((widgetId) => {
|
|
4956
|
+
if (!dashboardDraftMode) return;
|
|
4344
4957
|
setSelectedWidgetId(widgetId);
|
|
4958
|
+
setPendingSelectedWidgetId(widgetId);
|
|
4345
4959
|
setInspectorPath(SUB_PANEL_ROOT);
|
|
4346
4960
|
setPanelOpen(true);
|
|
4347
|
-
}, []);
|
|
4961
|
+
}, [dashboardDraftMode]);
|
|
4348
4962
|
// Fetches all records from a resolver and persists them into the data model object,
|
|
4349
4963
|
// then syncs the updated dataModel into local React state.
|
|
4350
4964
|
const handleRefreshDataModelObject = useCallback(async (binding, objectId) => {
|
|
@@ -4365,7 +4979,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4365
4979
|
}, []);
|
|
4366
4980
|
|
|
4367
4981
|
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
4368
|
-
if (!
|
|
4982
|
+
if (!dashboardDraftMode) return;
|
|
4983
|
+
if (!selectedWidgetLookupId) return;
|
|
4369
4984
|
setConfig((prev) => {
|
|
4370
4985
|
const prevTabs = getTabs(prev.canvas);
|
|
4371
4986
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4374,15 +4989,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4374
4989
|
return {
|
|
4375
4990
|
...tab,
|
|
4376
4991
|
widgets: (tab.widgets || []).map((widget) =>
|
|
4377
|
-
widget.id ===
|
|
4992
|
+
widget.id === selectedWidgetLookupId ? { ...widget, config: nextConfig } : widget
|
|
4378
4993
|
)
|
|
4379
4994
|
};
|
|
4380
4995
|
});
|
|
4381
4996
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4382
4997
|
});
|
|
4383
|
-
}, [activeDashboardId,
|
|
4998
|
+
}, [activeDashboardId, dashboardDraftMode, selectedWidgetLookupId]);
|
|
4384
4999
|
const updateSelectedWidget = useCallback((updates) => {
|
|
4385
|
-
if (!
|
|
5000
|
+
if (!dashboardDraftMode) return;
|
|
5001
|
+
if (!selectedWidgetLookupId) return;
|
|
4386
5002
|
setConfig((prev) => {
|
|
4387
5003
|
const prevTabs = getTabs(prev.canvas);
|
|
4388
5004
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4391,18 +5007,19 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4391
5007
|
return {
|
|
4392
5008
|
...tab,
|
|
4393
5009
|
widgets: (tab.widgets || []).map((widget) =>
|
|
4394
|
-
widget.id ===
|
|
5010
|
+
widget.id === selectedWidgetLookupId ? { ...widget, ...updates } : widget
|
|
4395
5011
|
)
|
|
4396
5012
|
};
|
|
4397
5013
|
});
|
|
4398
5014
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4399
5015
|
});
|
|
4400
|
-
}, [activeDashboardId,
|
|
5016
|
+
}, [activeDashboardId, dashboardDraftMode, selectedWidgetLookupId]);
|
|
4401
5017
|
const updateSelectedWidgetConfig = useCallback((updates) => {
|
|
4402
5018
|
if (!selectedWidget) return;
|
|
4403
5019
|
updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
|
|
4404
5020
|
}, [selectedWidget, updateSelectedWidget]);
|
|
4405
5021
|
const removeSelectedWidget = useCallback((widgetId) => {
|
|
5022
|
+
if (!dashboardDraftMode) return;
|
|
4406
5023
|
setConfig((prev) => {
|
|
4407
5024
|
const prevTabs = getTabs(prev.canvas);
|
|
4408
5025
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4412,10 +5029,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4412
5029
|
});
|
|
4413
5030
|
const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
|
|
4414
5031
|
setSelectedWidgetId(null);
|
|
5032
|
+
setPendingSelectedWidgetId(null);
|
|
4415
5033
|
setSelectedPosition(findFreePosition(nextActiveWidgets));
|
|
4416
5034
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4417
5035
|
});
|
|
4418
|
-
}, [activeDashboardId]);
|
|
5036
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
4419
5037
|
|
|
4420
5038
|
const duplicateSelectedWidget = useCallback(() => {
|
|
4421
5039
|
if (!selectedWidget) return;
|
|
@@ -4438,6 +5056,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4438
5056
|
return { ...tab, widgets: [...(tab.widgets || []), cloned] };
|
|
4439
5057
|
});
|
|
4440
5058
|
setSelectedWidgetId(cloned.id);
|
|
5059
|
+
setPendingSelectedWidgetId(cloned.id);
|
|
4441
5060
|
setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
|
|
4442
5061
|
setConfigMessage(`Duplicated ${selectedWidget.title}`);
|
|
4443
5062
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
@@ -4452,6 +5071,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4452
5071
|
const closeManagement = useCallback(() => setManagementOpen(false), []);
|
|
4453
5072
|
const resetWidgetSelection = useCallback(() => {
|
|
4454
5073
|
setSelectedWidgetId(null);
|
|
5074
|
+
setPendingSelectedWidgetId(null);
|
|
4455
5075
|
setPanelOpen(true);
|
|
4456
5076
|
}, []);
|
|
4457
5077
|
const showDashboardHome = useCallback(() => {
|
|
@@ -4734,26 +5354,32 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4734
5354
|
)}
|
|
4735
5355
|
/>
|
|
4736
5356
|
|
|
4737
|
-
<section className="workspace-surface">
|
|
4738
|
-
<header className=
|
|
4739
|
-
<div>
|
|
5357
|
+
<section className={`workspace-surface${workspaceView === "builder" ? " dm-workflow-surface workspace-dashboard-surface" : ""}`}>
|
|
5358
|
+
<header className={`workspace-toolbar${workspaceView === "builder" ? " dm-workflow-toolbar" : ""}`}>
|
|
5359
|
+
<div className={workspaceView === "builder" ? "dm-workflow-titlebar" : undefined}>
|
|
4740
5360
|
{workspaceView === "builder" ? <>
|
|
4741
|
-
<
|
|
5361
|
+
<span className="dm-workflow-title-muted">Dashboards</span>
|
|
5362
|
+
<span className="dm-workflow-title-separator">/</span>
|
|
4742
5363
|
<h1>{activeDashboard?.name || "Untitled"}</h1>
|
|
5364
|
+
<span className="dm-workflow-count">({activeWidgets.length}) · v{activeDashboard?.version || "1"} · {dashboardModeLabel}</span>
|
|
4743
5365
|
</> : <>
|
|
4744
5366
|
<p>Workspace home</p>
|
|
4745
5367
|
<h1>Builder</h1>
|
|
4746
5368
|
</>}
|
|
4747
5369
|
</div>
|
|
4748
|
-
<div className="
|
|
4749
|
-
<button type="button" onClick={
|
|
5370
|
+
{workspaceView === "builder" ? <div className="dm-workflow-toolbar-actions">
|
|
5371
|
+
{dashboardDraftMode || dashboardHasSavedDraft ? <button type="button" className="dm-workflow-chip-btn" onClick={discardDashboardDraft} disabled={saving}>Discard Draft</button> : null}
|
|
5372
|
+
{dashboardDraftMode ? <button type="button" className="dm-workflow-chip-btn" onClick={saveDashboardDraft} disabled={saving || !dashboardDirty}><Save size={13} />{saving ? "Saving" : "Save draft"}</button> : null}
|
|
5373
|
+
{dashboardDraftMode || dashboardHasSavedDraft ? <button type="button" className="dm-workflow-chip-btn" onClick={publishDashboard} disabled={saving || (!dashboardDirty && !dashboardHasSavedDraft)}><Check size={13} />Publish</button> : null}
|
|
5374
|
+
{!dashboardDraftMode ? <button type="button" className="dm-workflow-chip-btn" onClick={beginDashboardDraft}><Pencil size={13} />Edit</button> : null}
|
|
5375
|
+
<button type="button" className="dm-workflow-chip-btn" onClick={() => setTemplateGalleryOpen(true)} disabled={!dashboardDraftMode}><Grid2X2 size={13} />Templates</button>
|
|
5376
|
+
<button type="button" className="dm-workflow-chip-btn" onClick={exportConfig}><Download size={13} />Export</button>
|
|
5377
|
+
<button type="button" className="dm-workflow-icon-btn" onClick={() => importInputRef.current?.click()} aria-label="Import"><Import size={14} /></button>
|
|
5378
|
+
</div> : <div className="workspace-toolbar-actions">
|
|
4750
5379
|
<button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
|
|
4751
5380
|
<button type="button" onClick={createWorkflow} disabled={saving}><GitBranch size={15} />New Workflow</button>
|
|
4752
|
-
<button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
|
|
4753
5381
|
<button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
|
|
4754
|
-
|
|
4755
|
-
<button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
|
|
4756
|
-
</div>
|
|
5382
|
+
</div>}
|
|
4757
5383
|
<input
|
|
4758
5384
|
ref={importInputRef}
|
|
4759
5385
|
type="file"
|
|
@@ -4831,7 +5457,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4831
5457
|
>✓</button>
|
|
4832
5458
|
</span> : <button
|
|
4833
5459
|
className={item.index === resolvedActiveDashboardIndex ? "active" : ""}
|
|
4834
|
-
onClick={() =>
|
|
5460
|
+
onClick={() => selectDashboard(item.index)}
|
|
4835
5461
|
type="button"
|
|
4836
5462
|
>{item.dashboard.name}</button>}
|
|
4837
5463
|
</span>
|
|
@@ -4939,13 +5565,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4939
5565
|
className={tab.id === activeTabId ? "active" : ""}
|
|
4940
5566
|
type="button"
|
|
4941
5567
|
onClick={() => switchTab(tab.id)}
|
|
5568
|
+
onDoubleClick={(event) => beginTabRename(tab, event)}
|
|
4942
5569
|
>
|
|
4943
|
-
|
|
5570
|
+
{editingTabId === tab.id ? <input
|
|
5571
|
+
className="workspace-tab-name-input"
|
|
5572
|
+
autoFocus
|
|
5573
|
+
value={editingTabDraft}
|
|
5574
|
+
onChange={(event) => setEditingTabDraft(event.target.value)}
|
|
5575
|
+
onBlur={() => commitTabRename(tab.id)}
|
|
5576
|
+
onClick={(event) => event.stopPropagation()}
|
|
5577
|
+
onKeyDown={(event) => {
|
|
5578
|
+
if (event.key === "Enter") {
|
|
5579
|
+
event.preventDefault();
|
|
5580
|
+
commitTabRename(tab.id);
|
|
5581
|
+
}
|
|
5582
|
+
if (event.key === "Escape") {
|
|
5583
|
+
event.preventDefault();
|
|
5584
|
+
setEditingTabId(null);
|
|
5585
|
+
setEditingTabDraft("");
|
|
5586
|
+
}
|
|
5587
|
+
}}
|
|
5588
|
+
/> : <span>{tab.name}</span>}
|
|
4944
5589
|
<span
|
|
4945
5590
|
aria-label={`Delete tab ${tab.name}`}
|
|
4946
5591
|
className="workspace-tab-delete"
|
|
4947
5592
|
onClick={(event) => {
|
|
4948
5593
|
event.stopPropagation();
|
|
5594
|
+
if (!dashboardDraftMode) return;
|
|
4949
5595
|
deleteTab(tab.id);
|
|
4950
5596
|
}}
|
|
4951
5597
|
onKeyDown={(event) => {
|
|
@@ -4959,18 +5605,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4959
5605
|
tabIndex={0}
|
|
4960
5606
|
>x</span>
|
|
4961
5607
|
</button>)}
|
|
4962
|
-
<button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
|
|
4963
|
-
<button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
|
|
4964
|
-
<button
|
|
4965
|
-
type="button"
|
|
4966
|
-
className={`workspace-tab-refresh${liveSourceIds.length === 0 ? " inert" : ""}${refreshing ? " loading" : ""}`}
|
|
4967
|
-
disabled={liveSourceIds.length === 0 || refreshing}
|
|
4968
|
-
onClick={refreshSources}
|
|
4969
|
-
title={liveSourceIds.length === 0 ? "No live-backed sources on this tab" : `Refresh ${liveSourceIds.length} live source${liveSourceIds.length === 1 ? "" : "s"}`}
|
|
4970
|
-
>
|
|
4971
|
-
<RefreshCw size={15} className={refreshing ? "spinning" : ""} />
|
|
4972
|
-
{refreshing ? "Refreshing…" : refreshResult?.error ? "Refresh failed" : refreshResult ? `${refreshResult.refreshed} updated` : "Refresh"}
|
|
4973
|
-
</button>
|
|
5608
|
+
<button type="button" onClick={addTab} disabled={!dashboardDraftMode}><Plus size={15} />New Tab</button>
|
|
5609
|
+
<button type="button" onClick={duplicateTab} disabled={!dashboardDraftMode}><Copy size={15} />Duplicate Tab</button>
|
|
4974
5610
|
</div>
|
|
4975
5611
|
<div
|
|
4976
5612
|
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
@@ -5010,7 +5646,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5010
5646
|
type="button"
|
|
5011
5647
|
/>;
|
|
5012
5648
|
})}
|
|
5013
|
-
<button className={`workspace-add-widget${dragPreview ? " selecting" : ""}`} type="button" onClick={() => setPanelOpen(true)} style={{
|
|
5649
|
+
<button className={`workspace-add-widget${dragPreview ? " selecting" : ""}`} type="button" disabled={!dashboardDraftMode} onClick={() => dashboardDraftMode && setPanelOpen(true)} style={{
|
|
5014
5650
|
gridColumn: `${addSlot.x + 1} / span ${addSlot.w}`,
|
|
5015
5651
|
gridRow: `${addSlot.y + 1} / span ${addSlot.h}`
|
|
5016
5652
|
}}>
|
|
@@ -5105,12 +5741,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5105
5741
|
/>
|
|
5106
5742
|
|
|
5107
5743
|
{workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
5108
|
-
<div className="workspace-panel-title">
|
|
5744
|
+
{(inspectorPath === SUB_PANEL_ROOT || !selectedWidget) ? <div className="workspace-panel-title">
|
|
5109
5745
|
<button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
|
|
5110
|
-
<span aria-hidden="true">+</span>
|
|
5746
|
+
{selectedWidget ? <WidgetPanelHeaderIcon kind={selectedWidget.kind} /> : <span aria-hidden="true">+</span>}
|
|
5111
5747
|
<strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
|
|
5112
5748
|
{selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
|
|
5113
|
-
</div>
|
|
5749
|
+
</div> : null}
|
|
5114
5750
|
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
5115
5751
|
<button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
|
|
5116
5752
|
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
|
|
@@ -5142,6 +5778,17 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5142
5778
|
onChange={replaceSelectedWidgetConfig}
|
|
5143
5779
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
5144
5780
|
/> : null}
|
|
5781
|
+
{selectedWidget && selectedWidget.kind === "chart" && inspectorPath === "hydration" ? <ChartHydrationInspector
|
|
5782
|
+
widget={selectedWidget}
|
|
5783
|
+
dataModelTables={dataModelTables}
|
|
5784
|
+
unsaved={unsavedChartIds.has(selectedWidget.id)}
|
|
5785
|
+
saving={saving}
|
|
5786
|
+
canSave={Boolean(persistence?.canSave)}
|
|
5787
|
+
saveGuidance={persistence?.guidance || persistence?.saveLabel || ""}
|
|
5788
|
+
onChange={replaceSelectedWidgetConfig}
|
|
5789
|
+
onSave={() => persistWorkspaceConfig(config, activeDashboardId)}
|
|
5790
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
5791
|
+
/> : null}
|
|
5145
5792
|
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
|
|
5146
5793
|
<label>
|
|
5147
5794
|
<span>Title</span>
|
|
@@ -5151,6 +5798,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5151
5798
|
widget={selectedWidget}
|
|
5152
5799
|
branding={branding}
|
|
5153
5800
|
dataModelTables={dataModelTables}
|
|
5801
|
+
unsaved={unsavedChartIds.has(selectedWidget.id)}
|
|
5154
5802
|
onChange={replaceSelectedWidgetConfig}
|
|
5155
5803
|
onSubPage={(name) => setInspectorPath(name)}
|
|
5156
5804
|
/> : null}
|
|
@@ -5180,30 +5828,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5180
5828
|
</small>
|
|
5181
5829
|
</label> : null}
|
|
5182
5830
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
5183
|
-
<div className="workspace-
|
|
5831
|
+
<div className="workspace-twenty-config" role="group" aria-label="View widget settings">
|
|
5184
5832
|
<p className="workspace-panel-label">Settings</p>
|
|
5185
|
-
<
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
<
|
|
5189
|
-
|
|
5190
|
-
</button>
|
|
5191
|
-
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
|
|
5192
|
-
<span>Fields</span><code>{summarizeFields(selectedResolvedWidget || selectedWidget)}</code>
|
|
5193
|
-
</button>
|
|
5194
|
-
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
|
|
5195
|
-
<span>Filter</span><code>{summarizeFilter(selectedResolvedWidget || selectedWidget)}</code>
|
|
5196
|
-
</button>
|
|
5197
|
-
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
|
|
5198
|
-
<span>Sort</span><code>{summarizeSort(selectedResolvedWidget || selectedWidget)}</code>
|
|
5199
|
-
</button>
|
|
5833
|
+
<WidgetSettingsRow icon={Table2} label="Layout" value={selectedWidget.config?.layout || "Table"} disabled />
|
|
5834
|
+
<WidgetSettingsRow icon={Box} label="Source" value={summarizeSource(selectedWidget)} onClick={() => setInspectorPath("source")} />
|
|
5835
|
+
<WidgetSettingsRow icon={List} label="Fields" value={summarizeFields(selectedResolvedWidget || selectedWidget)} onClick={() => setInspectorPath("fields")} />
|
|
5836
|
+
<WidgetSettingsRow icon={Filter} label="Filter" value={summarizeFilter(selectedResolvedWidget || selectedWidget)} onClick={() => setInspectorPath("filter")} />
|
|
5837
|
+
<WidgetSettingsRow icon={SlidersHorizontal} label="Sort" value={summarizeSort(selectedResolvedWidget || selectedWidget)} onClick={() => setInspectorPath("sort")} />
|
|
5200
5838
|
</div>
|
|
5201
5839
|
</section> : null}
|
|
5202
|
-
<div className="workspace-settings-list">
|
|
5203
|
-
<p className="workspace-panel-label">Placement</p>
|
|
5204
|
-
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|
|
5205
|
-
<div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
|
|
5206
|
-
</div>
|
|
5207
5840
|
</section> : null}
|
|
5208
5841
|
{!selectedWidget ? <section>
|
|
5209
5842
|
<div className="workspace-widget-empty">
|
|
@@ -5227,8 +5860,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5227
5860
|
})}
|
|
5228
5861
|
</div>
|
|
5229
5862
|
</section> : null}
|
|
5230
|
-
{inspectorPath === SUB_PANEL_ROOT ? <
|
|
5231
|
-
<
|
|
5863
|
+
{inspectorPath === SUB_PANEL_ROOT ? <details className="workspace-bindings" id="bindings">
|
|
5864
|
+
<summary>Config bindings and data model sync</summary>
|
|
5232
5865
|
{Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
|
|
5233
5866
|
<span>{key}</span>
|
|
5234
5867
|
<code>{String(value)}</code>
|
|
@@ -5237,7 +5870,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5237
5870
|
<span>integrationAdapter</span>
|
|
5238
5871
|
<code>{adapterConfig.integrationAdapter}</code>
|
|
5239
5872
|
</div>
|
|
5240
|
-
|
|
5873
|
+
<div>
|
|
5874
|
+
<span>workspaceSourceRecords</span>
|
|
5875
|
+
<code>{Object.keys(workspaceSourceRecords || {}).length} sources</code>
|
|
5876
|
+
</div>
|
|
5877
|
+
<div>
|
|
5878
|
+
<span>dataModelObjects</span>
|
|
5879
|
+
<code>{dataModelTables.length} synced</code>
|
|
5880
|
+
</div>
|
|
5881
|
+
</details> : null}
|
|
5241
5882
|
</aside> : null}
|
|
5242
5883
|
{expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
|
|
5243
5884
|
{commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
|