@growthub/cli 0.13.1 → 0.13.4
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/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 +48 -3
- 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/OrchestrationNodeConfigPanel.jsx +136 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +713 -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/globals.css +514 -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 +72 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +778 -140
- 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-graph-runner.js +15 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +384 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-inputs.js +323 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +32 -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 +629 -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 +542 -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-schema.js +111 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
- package/package.json +1 -1
|
@@ -79,6 +79,10 @@ 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
|
+
} from "@/lib/workspace-chart-values";
|
|
82
86
|
import { HelperSidecar } from "./data-model/components/HelperSidecar.jsx";
|
|
83
87
|
import { WorkspaceRail } from "./workspace-rail.jsx";
|
|
84
88
|
|
|
@@ -92,6 +96,7 @@ const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
|
|
|
92
96
|
const DATA_MODEL_SOURCE_TYPE = "workspace-data-model";
|
|
93
97
|
const LIVE_SOURCE_TYPE = "workspace-source-records";
|
|
94
98
|
const TESTED_SOURCE_STATUSES = new Set(["connected", "approved", "ok", "success"]);
|
|
99
|
+
const HIDDEN_SANDBOX_OBJECT_IDS = new Set(["workspace-helper-sandbox"]);
|
|
95
100
|
|
|
96
101
|
const SOURCE_TYPE_OBJECTS = [
|
|
97
102
|
{
|
|
@@ -155,6 +160,23 @@ const CHART_TYPE_ICONS = {
|
|
|
155
160
|
|
|
156
161
|
const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
|
|
157
162
|
|
|
163
|
+
// User-facing labels for the Twenty-style Y-axis operation dropdown.
|
|
164
|
+
// Keys must stay in sync with `lib/workspace-chart-values.js#KNOWN_AGGREGATIONS`
|
|
165
|
+
// and the validator in `lib/workspace-schema.js`.
|
|
166
|
+
const AGGREGATION_LABELS = {
|
|
167
|
+
sum: "Sum",
|
|
168
|
+
avg: "Average",
|
|
169
|
+
count: "Count (all)",
|
|
170
|
+
countAll: "Count (all)",
|
|
171
|
+
countEmpty: "Count (empty)",
|
|
172
|
+
countNotEmpty: "Count (not empty)",
|
|
173
|
+
countUnique: "Count (unique)",
|
|
174
|
+
percentEmpty: "Percent empty",
|
|
175
|
+
percentNotEmpty: "Percent not empty",
|
|
176
|
+
min: "Min",
|
|
177
|
+
max: "Max"
|
|
178
|
+
};
|
|
179
|
+
|
|
158
180
|
const WIDGET_KIND_ICONS = {
|
|
159
181
|
chart: BarChart3,
|
|
160
182
|
view: Table2,
|
|
@@ -292,11 +314,11 @@ function textColorForAccent(accent) {
|
|
|
292
314
|
|
|
293
315
|
function defaultTitleFor(kind) {
|
|
294
316
|
switch (kind) {
|
|
295
|
-
case "chart": return "
|
|
296
|
-
case "view": return "
|
|
297
|
-
case "iframe": return "
|
|
298
|
-
case "rich-text": return "
|
|
299
|
-
default: return "
|
|
317
|
+
case "chart": return "Chart widget";
|
|
318
|
+
case "view": return "Data view";
|
|
319
|
+
case "iframe": return "Embedded page";
|
|
320
|
+
case "rich-text": return "Text note";
|
|
321
|
+
default: return "Workspace widget";
|
|
300
322
|
}
|
|
301
323
|
}
|
|
302
324
|
|
|
@@ -336,7 +358,7 @@ function commitTabs(canvas, tabs, activeTabId) {
|
|
|
336
358
|
return next;
|
|
337
359
|
}
|
|
338
360
|
|
|
339
|
-
function createDashboardRecord(name = "
|
|
361
|
+
function createDashboardRecord(name = "New Dashboard") {
|
|
340
362
|
const tab = createEmptyTab("Tab 1");
|
|
341
363
|
return {
|
|
342
364
|
id: generateId("dashboard"),
|
|
@@ -363,6 +385,15 @@ function getDataModelObject(config, objectId) {
|
|
|
363
385
|
return objects.find((object) => object?.id === objectId) || null;
|
|
364
386
|
}
|
|
365
387
|
|
|
388
|
+
function getWorkflowSandboxObject(config) {
|
|
389
|
+
const objects = Array.isArray(config?.dataModel?.objects) ? config.dataModel.objects : [];
|
|
390
|
+
return objects.find((object) => {
|
|
391
|
+
if (object?.objectType !== "sandbox-environment") return false;
|
|
392
|
+
const id = String(object?.id || "").trim();
|
|
393
|
+
return id && !HIDDEN_SANDBOX_OBJECT_IDS.has(id);
|
|
394
|
+
}) || null;
|
|
395
|
+
}
|
|
396
|
+
|
|
366
397
|
function listBuilderWorkflowItems(config) {
|
|
367
398
|
const navFolders = getDataModelObject(config, "nav-folders");
|
|
368
399
|
const rows = Array.isArray(navFolders?.rows) ? navFolders.rows : [];
|
|
@@ -624,7 +655,7 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
|
|
|
624
655
|
}
|
|
625
656
|
|
|
626
657
|
function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
|
|
627
|
-
const nextName = name.trim() || "
|
|
658
|
+
const nextName = name.trim() || "New Dashboard";
|
|
628
659
|
const prevDashboards = config.dashboards || [];
|
|
629
660
|
const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
|
|
630
661
|
if (index < 0) return config;
|
|
@@ -1008,6 +1039,62 @@ function resolveViewWidget(widget, dataModelTables) {
|
|
|
1008
1039
|
};
|
|
1009
1040
|
}
|
|
1010
1041
|
|
|
1042
|
+
/**
|
|
1043
|
+
* Recompute `config.values` for a chart widget config from its bound Data
|
|
1044
|
+
* Model rows. This is the only path that writes chart values from rows.
|
|
1045
|
+
* The chart renderer continues to read from `config.values` — it never
|
|
1046
|
+
* queries rows directly.
|
|
1047
|
+
*
|
|
1048
|
+
* Returns the next `config` object (with finite-number `values`) plus the
|
|
1049
|
+
* computation result (`rowCount`, `usedRowCount`, `warnings`).
|
|
1050
|
+
*
|
|
1051
|
+
* When the chart has no bound Data Model source, the input config is
|
|
1052
|
+
* returned unchanged and the result is `{ status: "unbound" }`.
|
|
1053
|
+
*/
|
|
1054
|
+
function recomputeChartConfig(chartConfig, dataModelTables) {
|
|
1055
|
+
const config = chartConfig && typeof chartConfig === "object" && !Array.isArray(chartConfig) ? chartConfig : {};
|
|
1056
|
+
const binding = config.binding;
|
|
1057
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE) {
|
|
1058
|
+
return { config, result: { status: "unbound" } };
|
|
1059
|
+
}
|
|
1060
|
+
const table = resolveDataModelTable(dataModelTables, binding);
|
|
1061
|
+
if (!table) {
|
|
1062
|
+
return { config, result: { status: "no-source", warnings: ["Selected source is unavailable."] } };
|
|
1063
|
+
}
|
|
1064
|
+
const computation = computeChartValuesFromRows({
|
|
1065
|
+
rows: Array.isArray(table.rows) ? table.rows : [],
|
|
1066
|
+
xAxis: config.xAxis,
|
|
1067
|
+
yAxis: config.yAxis,
|
|
1068
|
+
filter: config.filter,
|
|
1069
|
+
chartType: config.chartType
|
|
1070
|
+
});
|
|
1071
|
+
return {
|
|
1072
|
+
config: { ...config, values: computation.values },
|
|
1073
|
+
result: { status: "computed", ...computation }
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function findWidgetByIdInConfig(workspaceConfig, widgetId) {
|
|
1078
|
+
if (!widgetId) return null;
|
|
1079
|
+
for (const dashboard of workspaceConfig?.dashboards || []) {
|
|
1080
|
+
for (const tab of dashboard.tabs || []) {
|
|
1081
|
+
for (const widget of tab.widgets || []) {
|
|
1082
|
+
if (widget?.id === widgetId) return widget;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const canvas = workspaceConfig?.canvas;
|
|
1087
|
+
for (const tab of canvas?.tabs || []) {
|
|
1088
|
+
for (const widget of tab.widgets || []) {
|
|
1089
|
+
if (widget?.id === widgetId) return widget;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
for (const widget of canvas?.widgets || []) {
|
|
1093
|
+
if (widget?.id === widgetId) return widget;
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1011
1098
|
function summarizeFields(widget) {
|
|
1012
1099
|
const total = getColumnList(widget).length;
|
|
1013
1100
|
const hidden = getHiddenColumnSet(widget).size;
|
|
@@ -1032,6 +1119,37 @@ function summarizeFilter(widget) {
|
|
|
1032
1119
|
return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
|
|
1033
1120
|
}
|
|
1034
1121
|
|
|
1122
|
+
function WidgetPanelHeaderIcon({ kind }) {
|
|
1123
|
+
const Icon = WIDGET_KIND_ICONS[kind] || Box;
|
|
1124
|
+
return <span className="workspace-widget-panel-kind-icon"><IconGlyph icon={Icon} size={15} /></span>;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function WidgetSettingsRow({ icon: Icon, label, value, disabled, active, onClick }) {
|
|
1128
|
+
return <button
|
|
1129
|
+
type="button"
|
|
1130
|
+
className={`workspace-twenty-settings-row${active ? " is-active" : ""}`}
|
|
1131
|
+
disabled={disabled}
|
|
1132
|
+
onClick={onClick}
|
|
1133
|
+
>
|
|
1134
|
+
<span className="workspace-twenty-settings-row__main">
|
|
1135
|
+
<span className="workspace-twenty-settings-row__icon">{Icon ? <Icon size={15} /> : null}</span>
|
|
1136
|
+
<span>{label}</span>
|
|
1137
|
+
</span>
|
|
1138
|
+
<span className="workspace-twenty-settings-row__value">{value || ""}</span>
|
|
1139
|
+
{!disabled ? <ChevronDown className="workspace-twenty-settings-row__chevron" size={14} /> : null}
|
|
1140
|
+
</button>;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function WidgetSelectRow({ icon: Icon, label, value, children }) {
|
|
1144
|
+
return <label className="workspace-twenty-select-row">
|
|
1145
|
+
<span className="workspace-twenty-settings-row__main">
|
|
1146
|
+
<span className="workspace-twenty-settings-row__icon">{Icon ? <Icon size={15} /> : null}</span>
|
|
1147
|
+
<span>{label}</span>
|
|
1148
|
+
</span>
|
|
1149
|
+
<span className="workspace-twenty-select-row__control">{children}</span>
|
|
1150
|
+
</label>;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1035
1153
|
function describeIntegrationLane(integration) {
|
|
1036
1154
|
return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
|
|
1037
1155
|
}
|
|
@@ -1749,14 +1867,20 @@ function SourceDropdown({ widget, dataModelTables, onChange }) {
|
|
|
1749
1867
|
})();
|
|
1750
1868
|
|
|
1751
1869
|
function selectObject(table) {
|
|
1752
|
-
|
|
1870
|
+
const nextConfig = {
|
|
1753
1871
|
...widget.config,
|
|
1754
1872
|
source: table.source,
|
|
1755
1873
|
columns: table.columns,
|
|
1756
1874
|
rows: [],
|
|
1757
1875
|
binding: { mode: "manual", source: table.source, sourceType: DATA_MODEL_SOURCE_TYPE, sourceAuthority: "workspace-config", objectId: table.objectId },
|
|
1758
1876
|
fieldSettings: { hidden: [], order: table.columns }
|
|
1759
|
-
}
|
|
1877
|
+
};
|
|
1878
|
+
if (widget.kind === "chart") {
|
|
1879
|
+
const { config: recomputed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
1880
|
+
onChange(recomputed);
|
|
1881
|
+
} else {
|
|
1882
|
+
onChange(nextConfig);
|
|
1883
|
+
}
|
|
1760
1884
|
setOpen(false);
|
|
1761
1885
|
setQuery("");
|
|
1762
1886
|
}
|
|
@@ -2256,7 +2380,7 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2256
2380
|
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
2257
2381
|
if (!window.confirm(`Change source to "${table.label}"?`)) return;
|
|
2258
2382
|
}
|
|
2259
|
-
|
|
2383
|
+
const nextConfig = {
|
|
2260
2384
|
...widget.config,
|
|
2261
2385
|
source: table.source,
|
|
2262
2386
|
columns: table.columns,
|
|
@@ -2269,8 +2393,16 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2269
2393
|
objectId: table.objectId,
|
|
2270
2394
|
},
|
|
2271
2395
|
fieldSettings: { hidden: [], order: table.columns }
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2396
|
+
};
|
|
2397
|
+
// Chart widgets always project rows into `config.values`. Recompute on
|
|
2398
|
+
// source change so the preview reflects the new binding immediately.
|
|
2399
|
+
if (widget.kind === "chart") {
|
|
2400
|
+
const { config: recomputed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
2401
|
+
onChange(recomputed);
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
onChange(nextConfig);
|
|
2405
|
+
}, [binding, dataModelTables, onChange, widget.config, widget.kind]);
|
|
2274
2406
|
|
|
2275
2407
|
const activeObjectId = binding.sourceType === DATA_MODEL_SOURCE_TYPE ? binding.objectId : null;
|
|
2276
2408
|
|
|
@@ -2290,6 +2422,10 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2290
2422
|
{savedObjects.length > 0 ? savedObjects.map((table) => {
|
|
2291
2423
|
const isActive = activeObjectId === table.objectId;
|
|
2292
2424
|
const iconName = table.icon || OBJECT_TYPE_PRESETS[table.objectType]?.icon || "Database";
|
|
2425
|
+
// Only surface a badge when it communicates *real* runtime state
|
|
2426
|
+
// (live-backed = sidecar-hydrated). Manual is the default and would
|
|
2427
|
+
// be visual noise; api/webhook are reserved for future surfacing.
|
|
2428
|
+
const showLiveBadge = table.sourceBadge === "live";
|
|
2293
2429
|
return (
|
|
2294
2430
|
<button
|
|
2295
2431
|
key={table.id}
|
|
@@ -2304,6 +2440,7 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2304
2440
|
<strong>{table.label}</strong>
|
|
2305
2441
|
<em>{table.columns.length} field{table.columns.length !== 1 ? "s" : ""} · {table.rows.length} record{table.rows.length !== 1 ? "s" : ""}</em>
|
|
2306
2442
|
</span>
|
|
2443
|
+
{showLiveBadge ? <span className="workspace-source-badge badge-live" aria-label="Live source">Live</span> : null}
|
|
2307
2444
|
{isActive && <Check size={14} strokeWidth={2.5} aria-hidden="true" />}
|
|
2308
2445
|
</button>
|
|
2309
2446
|
);
|
|
@@ -2728,30 +2865,208 @@ function FilterSubPanel({ widget, integrations, dataModelTable, adapterConfig, o
|
|
|
2728
2865
|
</section>;
|
|
2729
2866
|
}
|
|
2730
2867
|
|
|
2731
|
-
|
|
2868
|
+
/**
|
|
2869
|
+
* ChartHydrationInspector — diagnostics overlay for chart value computation.
|
|
2870
|
+
*
|
|
2871
|
+
* Renders the same projection pipeline the renderer reads from (source rows
|
|
2872
|
+
* → filter → grouping → aggregation → values[]), so the user can audit why
|
|
2873
|
+
* `widget.config.values` looks the way it does. It is read-only with two
|
|
2874
|
+
* actions:
|
|
2875
|
+
* - "Recompute values" re-runs `recomputeChartConfig` against the latest
|
|
2876
|
+
* Data Model tables; useful after manual row edits.
|
|
2877
|
+
* - "Save computed values" routes through the existing PATCH /api/workspace
|
|
2878
|
+
* path; respects the read-only runtime adapter (Save is disabled with
|
|
2879
|
+
* guidance instead of crashing).
|
|
2880
|
+
*/
|
|
2881
|
+
function ChartHydrationInspector({
|
|
2882
|
+
widget,
|
|
2883
|
+
dataModelTables,
|
|
2884
|
+
unsaved,
|
|
2885
|
+
saving,
|
|
2886
|
+
canSave,
|
|
2887
|
+
saveGuidance,
|
|
2888
|
+
onChange,
|
|
2889
|
+
onSave,
|
|
2890
|
+
onBack
|
|
2891
|
+
}) {
|
|
2892
|
+
const binding = widget?.config?.binding;
|
|
2893
|
+
const table = useMemo(() => {
|
|
2894
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return null;
|
|
2895
|
+
return (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
2896
|
+
.find((t) => t.objectId === binding.objectId) || null;
|
|
2897
|
+
}, [binding, dataModelTables]);
|
|
2898
|
+
const debug = useMemo(() => computeChartProjectionDebug({
|
|
2899
|
+
rows: Array.isArray(table?.rows) ? table.rows : [],
|
|
2900
|
+
xAxis: widget?.config?.xAxis,
|
|
2901
|
+
yAxis: widget?.config?.yAxis,
|
|
2902
|
+
filter: widget?.config?.filter,
|
|
2903
|
+
chartType: widget?.config?.chartType
|
|
2904
|
+
}), [table, widget?.config?.xAxis, widget?.config?.yAxis, widget?.config?.filter, widget?.config?.chartType]);
|
|
2905
|
+
|
|
2906
|
+
const recompute = useCallback(() => {
|
|
2907
|
+
const { config: recomputed } = recomputeChartConfig(widget.config || {}, dataModelTables);
|
|
2908
|
+
onChange(recomputed);
|
|
2909
|
+
}, [widget.config, dataModelTables, onChange]);
|
|
2910
|
+
|
|
2911
|
+
const dropReasonCounts = useMemo(() => {
|
|
2912
|
+
const counts = {};
|
|
2913
|
+
for (const entry of debug.droppedRows || []) {
|
|
2914
|
+
counts[entry.reason] = (counts[entry.reason] || 0) + 1;
|
|
2915
|
+
}
|
|
2916
|
+
return counts;
|
|
2917
|
+
}, [debug.droppedRows]);
|
|
2918
|
+
|
|
2919
|
+
return (
|
|
2920
|
+
<section className="workspace-widget-subpanel workspace-chart-inspector">
|
|
2921
|
+
<SubPanelHeader title="Inspect computation" breadcrumb={widget?.title} onBack={onBack} />
|
|
2922
|
+
<div className="workspace-widget-actions workspace-chart-inspector-top-actions" role="group" aria-label="Chart inspector navigation">
|
|
2923
|
+
<button type="button" onClick={onBack}>Edit chart</button>
|
|
2924
|
+
<button type="button" onClick={recompute}><RefreshCw size={15} />Recompute values</button>
|
|
2925
|
+
</div>
|
|
2926
|
+
|
|
2927
|
+
<p className="workspace-panel-label">Source</p>
|
|
2928
|
+
{table ? (
|
|
2929
|
+
<div className="workspace-settings-list">
|
|
2930
|
+
<div><span>Object</span><code>{table.label}</code></div>
|
|
2931
|
+
<div><span>Storage</span><code>{table.liveSource ? "Live-backed sidecar" : "Manual Data Model"}</code></div>
|
|
2932
|
+
<div><span>Rows available</span><code>{table.rows?.length || 0}</code></div>
|
|
2933
|
+
{table.liveSource?.fetchedAt ? <div><span>Last fetched</span><code>{table.liveSource.fetchedAt}</code></div> : null}
|
|
2934
|
+
</div>
|
|
2935
|
+
) : (
|
|
2936
|
+
<p className="workspace-panel-hint">
|
|
2937
|
+
No source bound. Open <strong>Source</strong> to pick a Data Model object.
|
|
2938
|
+
</p>
|
|
2939
|
+
)}
|
|
2940
|
+
|
|
2941
|
+
<p className="workspace-panel-label">Source preview</p>
|
|
2942
|
+
{debug.samples?.length ? (
|
|
2943
|
+
<details className="workspace-chart-inspector-preview">
|
|
2944
|
+
<summary>{debug.samples.length} sample row{debug.samples.length === 1 ? "" : "s"}</summary>
|
|
2945
|
+
<pre className="workspace-chart-inspector-sample">
|
|
2946
|
+
{JSON.stringify(debug.samples, null, 2)}
|
|
2947
|
+
</pre>
|
|
2948
|
+
</details>
|
|
2949
|
+
) : (
|
|
2950
|
+
<p className="workspace-panel-hint">No source rows.</p>
|
|
2951
|
+
)}
|
|
2952
|
+
|
|
2953
|
+
<p className="workspace-panel-label">Filter</p>
|
|
2954
|
+
<div className="workspace-settings-list">
|
|
2955
|
+
<div><span>Before</span><code>{debug.rowCount}</code></div>
|
|
2956
|
+
<div><span>After</span><code>{debug.filteredCount ?? 0}</code></div>
|
|
2957
|
+
<div><span>Dropped by filter</span><code>{debug.droppedByFilter ?? 0}</code></div>
|
|
2958
|
+
</div>
|
|
2959
|
+
|
|
2960
|
+
<p className="workspace-panel-label">Buckets</p>
|
|
2961
|
+
{debug.buckets?.length ? (
|
|
2962
|
+
<div className="workspace-settings-list">
|
|
2963
|
+
{debug.buckets.map((bucket, index) => (
|
|
2964
|
+
<div key={`${bucket.key || "_"}_${index}`}>
|
|
2965
|
+
<span>{bucket.key === "" ? "(all rows)" : String(bucket.key)}</span>
|
|
2966
|
+
<code>
|
|
2967
|
+
{bucket.rowCount} row{bucket.rowCount === 1 ? "" : "s"}
|
|
2968
|
+
{" · "}
|
|
2969
|
+
{bucket.numericCount} numeric
|
|
2970
|
+
{" · "}
|
|
2971
|
+
{bucket.value === null || bucket.value === undefined ? "—" : String(bucket.value)}
|
|
2972
|
+
</code>
|
|
2973
|
+
</div>
|
|
2974
|
+
))}
|
|
2975
|
+
</div>
|
|
2976
|
+
) : (
|
|
2977
|
+
<p className="workspace-panel-hint">No buckets — choose an X axis field or group by.</p>
|
|
2978
|
+
)}
|
|
2979
|
+
|
|
2980
|
+
<p className="workspace-panel-label">Dropped rows</p>
|
|
2981
|
+
{Object.keys(dropReasonCounts).length ? (
|
|
2982
|
+
<div className="workspace-settings-list">
|
|
2983
|
+
{Object.entries(dropReasonCounts).map(([reason, count]) => (
|
|
2984
|
+
<div key={reason}><span>{reason}</span><code>{count}</code></div>
|
|
2985
|
+
))}
|
|
2986
|
+
</div>
|
|
2987
|
+
) : (
|
|
2988
|
+
<p className="workspace-panel-hint">No rows dropped.</p>
|
|
2989
|
+
)}
|
|
2990
|
+
|
|
2991
|
+
<p className="workspace-panel-label">Final values</p>
|
|
2992
|
+
<pre className="workspace-chart-inspector-sample">
|
|
2993
|
+
{JSON.stringify(debug.values, null, 2)}
|
|
2994
|
+
</pre>
|
|
2995
|
+
|
|
2996
|
+
{debug.warnings?.length ? (
|
|
2997
|
+
<div className="workspace-settings-list" role="alert" aria-label="Computation warnings">
|
|
2998
|
+
{debug.warnings.map((warning, index) => (
|
|
2999
|
+
<div key={index}><span>Warning</span><code>{warning}</code></div>
|
|
3000
|
+
))}
|
|
3001
|
+
</div>
|
|
3002
|
+
) : null}
|
|
3003
|
+
|
|
3004
|
+
<div className="workspace-widget-actions" role="group" aria-label="Inspector actions">
|
|
3005
|
+
<button type="button" onClick={recompute}><RefreshCw size={15} />Recompute values</button>
|
|
3006
|
+
<button
|
|
3007
|
+
type="button"
|
|
3008
|
+
onClick={onSave}
|
|
3009
|
+
disabled={!canSave || saving}
|
|
3010
|
+
title={!canSave ? saveGuidance || "Save is disabled in this runtime." : "Persist computed values to growthub.config.json"}
|
|
3011
|
+
>
|
|
3012
|
+
<Save size={15} />{saving ? "Saving…" : unsaved ? "Save computed values" : "Save"}
|
|
3013
|
+
</button>
|
|
3014
|
+
</div>
|
|
3015
|
+
{unsaved ? <p className="workspace-panel-hint">Unsaved computed values.</p> : null}
|
|
3016
|
+
{!canSave && saveGuidance ? <p className="workspace-panel-hint">{saveGuidance}</p> : null}
|
|
3017
|
+
</section>
|
|
3018
|
+
);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function ChartConfigPanel({ widget, branding, dataModelTables, unsaved, onChange, onSubPage }) {
|
|
2732
3022
|
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
2733
3023
|
const xAxis = getChartAxis(widget, "xAxis");
|
|
2734
3024
|
const yAxis = getChartAxis(widget, "yAxis");
|
|
2735
3025
|
const style = getChartStyle(widget);
|
|
2736
3026
|
const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
3027
|
+
|
|
3028
|
+
// Every axis/filter/aggregation/chartType edit funnels through this writer
|
|
3029
|
+
// so `widget.config.values` is recomputed from the bound Data Model rows
|
|
3030
|
+
// before persistence. Unbound charts (no Data Model source) keep the
|
|
3031
|
+
// existing static `values` untouched — this is what preserves the legacy
|
|
3032
|
+
// chart-with-static-values path.
|
|
3033
|
+
const commitConfig = useCallback((nextConfig) => {
|
|
3034
|
+
const { config: computed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
3035
|
+
onChange(computed);
|
|
3036
|
+
}, [dataModelTables, onChange]);
|
|
3037
|
+
|
|
3038
|
+
const setChartType = (type) => commitConfig({ ...widget.config, chartType: type });
|
|
3039
|
+
const setXAxis = (patch) => commitConfig({ ...widget.config, xAxis: { ...xAxis, ...patch } });
|
|
3040
|
+
const setYAxis = (patch) => commitConfig({ ...widget.config, yAxis: { ...yAxis, ...patch } });
|
|
3041
|
+
// Style is render-only — it doesn't change values, so skip recomputation.
|
|
2740
3042
|
const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
|
|
2741
3043
|
|
|
2742
3044
|
// Derive source fields from the bound data model object
|
|
2743
|
-
const
|
|
3045
|
+
const boundTable = useMemo(() => {
|
|
2744
3046
|
const binding = widget.config?.binding;
|
|
2745
|
-
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return
|
|
2746
|
-
|
|
2747
|
-
.find((t) => t.objectId === binding.objectId || t.source === binding.source);
|
|
2748
|
-
return table?.columns || [];
|
|
3047
|
+
if (binding?.sourceType !== DATA_MODEL_SOURCE_TYPE || !binding.objectId) return null;
|
|
3048
|
+
return (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
3049
|
+
.find((t) => t.objectId === binding.objectId || t.source === binding.source) || null;
|
|
2749
3050
|
}, [widget.config?.binding, dataModelTables]);
|
|
2750
3051
|
|
|
3052
|
+
const sourceFields = boundTable?.columns || [];
|
|
2751
3053
|
const hasSource = sourceFields.length > 0;
|
|
2752
3054
|
|
|
2753
|
-
|
|
2754
|
-
|
|
3055
|
+
// Compute the live preview status for the configured chart so the panel
|
|
3056
|
+
// can surface row counts, warnings, and last-fetched timestamps without
|
|
3057
|
+
// having to re-derive the projection elsewhere.
|
|
3058
|
+
const computeStatus = useMemo(() => {
|
|
3059
|
+
const { result } = recomputeChartConfig(widget.config || {}, dataModelTables);
|
|
3060
|
+
return result;
|
|
3061
|
+
}, [widget.config, dataModelTables]);
|
|
3062
|
+
|
|
3063
|
+
const recomputeValues = useCallback(() => {
|
|
3064
|
+
commitConfig({ ...widget.config });
|
|
3065
|
+
}, [commitConfig, widget.config]);
|
|
3066
|
+
|
|
3067
|
+
return <section className="workspace-chart-config workspace-twenty-config">
|
|
3068
|
+
<p className="workspace-panel-label">Settings</p>
|
|
3069
|
+
<WidgetSettingsRow icon={BarChart3} label="Layout" value={CHART_TYPE_LABELS[chartType]} disabled />
|
|
2755
3070
|
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
2756
3071
|
{VISIBLE_CHART_TYPES.map((type) => {
|
|
2757
3072
|
const TypeIcon = CHART_TYPE_ICONS[type];
|
|
@@ -2770,17 +3085,27 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2770
3085
|
})}
|
|
2771
3086
|
</div>
|
|
2772
3087
|
|
|
2773
|
-
<
|
|
2774
|
-
<
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
3088
|
+
<WidgetSettingsRow icon={Box} label="Source" value={summarizeSource(widget) || "None"} onClick={() => onSubPage("source")} />
|
|
3089
|
+
<WidgetSettingsRow icon={Filter} label="Filter" value={summarizeFilter(widget)} onClick={() => onSubPage("filter")} />
|
|
3090
|
+
{boundTable ? (
|
|
3091
|
+
<WidgetSettingsRow
|
|
3092
|
+
icon={Activity}
|
|
3093
|
+
label="Values"
|
|
3094
|
+
value={
|
|
3095
|
+
<>
|
|
3096
|
+
{boundTable.rows?.length || 0} row{(boundTable.rows?.length || 0) === 1 ? "" : "s"}
|
|
3097
|
+
{" · "}
|
|
3098
|
+
{Array.isArray(widget.config?.values) ? widget.config.values.length : 0} value{(widget.config?.values?.length || 0) === 1 ? "" : "s"}
|
|
3099
|
+
{unsaved ? " · unsaved" : ""}
|
|
3100
|
+
{Array.isArray(computeStatus?.warnings) && computeStatus.warnings.length ? " · warning" : ""}
|
|
3101
|
+
</>
|
|
3102
|
+
}
|
|
3103
|
+
onClick={() => onSubPage("hydration")}
|
|
3104
|
+
/>
|
|
3105
|
+
) : null}
|
|
2780
3106
|
|
|
2781
3107
|
<p className="workspace-panel-label">X axis</p>
|
|
2782
|
-
<
|
|
2783
|
-
<span>Data on display</span>
|
|
3108
|
+
<WidgetSelectRow icon={Columns3} label="Data">
|
|
2784
3109
|
<FieldDropdown
|
|
2785
3110
|
fields={sourceFields}
|
|
2786
3111
|
value={xAxis.field || ""}
|
|
@@ -2788,23 +3113,21 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2788
3113
|
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2789
3114
|
disabled={!hasSource}
|
|
2790
3115
|
/>
|
|
2791
|
-
</
|
|
2792
|
-
<
|
|
2793
|
-
<span>Sort by</span>
|
|
3116
|
+
</WidgetSelectRow>
|
|
3117
|
+
<WidgetSelectRow icon={SlidersHorizontal} label="Sort">
|
|
2794
3118
|
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
2795
3119
|
<option value="position">Position asc</option>
|
|
2796
3120
|
<option value="asc">Value asc</option>
|
|
2797
3121
|
<option value="desc">Value desc</option>
|
|
2798
3122
|
</select>
|
|
2799
|
-
</
|
|
2800
|
-
<label className="workspace-toggle-row">
|
|
3123
|
+
</WidgetSelectRow>
|
|
3124
|
+
<label className="workspace-twenty-toggle-row">
|
|
2801
3125
|
<span>Omit zero values</span>
|
|
2802
3126
|
<input type="checkbox" checked={Boolean(xAxis.omitZero)} onChange={(event) => setXAxis({ omitZero: event.target.checked })} />
|
|
2803
3127
|
</label>
|
|
2804
3128
|
|
|
2805
3129
|
<p className="workspace-panel-label">Y axis</p>
|
|
2806
|
-
<
|
|
2807
|
-
<span>Data on display</span>
|
|
3130
|
+
<WidgetSelectRow icon={Hash} label="Data">
|
|
2808
3131
|
<FieldDropdown
|
|
2809
3132
|
fields={sourceFields}
|
|
2810
3133
|
value={yAxis.field || ""}
|
|
@@ -2812,9 +3135,8 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2812
3135
|
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2813
3136
|
disabled={!hasSource}
|
|
2814
3137
|
/>
|
|
2815
|
-
</
|
|
2816
|
-
<
|
|
2817
|
-
<span>Group by</span>
|
|
3138
|
+
</WidgetSelectRow>
|
|
3139
|
+
<WidgetSelectRow icon={Layers} label="Group by">
|
|
2818
3140
|
<FieldDropdown
|
|
2819
3141
|
fields={sourceFields}
|
|
2820
3142
|
value={yAxis.groupBy || ""}
|
|
@@ -2822,13 +3144,12 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2822
3144
|
placeholder="None"
|
|
2823
3145
|
disabled={!hasSource}
|
|
2824
3146
|
/>
|
|
2825
|
-
</
|
|
2826
|
-
<
|
|
2827
|
-
<
|
|
2828
|
-
|
|
2829
|
-
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{agg}</option>)}
|
|
3147
|
+
</WidgetSelectRow>
|
|
3148
|
+
<WidgetSelectRow icon={Sigma} label="Operation">
|
|
3149
|
+
<select value={yAxis.operation || yAxis.aggregation || "sum"} onChange={(event) => setYAxis({ operation: event.target.value, aggregation: event.target.value })}>
|
|
3150
|
+
{KNOWN_AGGREGATIONS.map((agg) => <option key={agg} value={agg}>{AGGREGATION_LABELS[agg] || agg}</option>)}
|
|
2830
3151
|
</select>
|
|
2831
|
-
</
|
|
3152
|
+
</WidgetSelectRow>
|
|
2832
3153
|
<div className="workspace-axis-range">
|
|
2833
3154
|
<label>
|
|
2834
3155
|
<span>Min range</span>
|
|
@@ -2840,8 +3161,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2840
3161
|
</label>
|
|
2841
3162
|
</div>
|
|
2842
3163
|
<p className="workspace-panel-label">Style</p>
|
|
2843
|
-
<label>
|
|
2844
|
-
<span>Colors</span>
|
|
3164
|
+
<WidgetSelectRow icon={Star} label="Colors">
|
|
2845
3165
|
<select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
|
|
2846
3166
|
<option value="auto">Auto</option>
|
|
2847
3167
|
<option value="accent">Accent</option>
|
|
@@ -2849,7 +3169,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2849
3169
|
<option value="brand-bridge">Bridge brand kit</option>
|
|
2850
3170
|
<option value="manual">Manual</option>
|
|
2851
3171
|
</select>
|
|
2852
|
-
</
|
|
3172
|
+
</WidgetSelectRow>
|
|
2853
3173
|
<div className="workspace-color-preview-row">
|
|
2854
3174
|
<span>Active color</span>
|
|
2855
3175
|
<em style={{ background: activeColor }} />
|
|
@@ -2878,7 +3198,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2878
3198
|
onChange={(event) => setStyle({ axisName: event.target.value })}
|
|
2879
3199
|
/>
|
|
2880
3200
|
</label>
|
|
2881
|
-
<label className="workspace-toggle-row">
|
|
3201
|
+
<label className="workspace-twenty-toggle-row">
|
|
2882
3202
|
<span>Data labels</span>
|
|
2883
3203
|
<input
|
|
2884
3204
|
type="checkbox"
|
|
@@ -3482,14 +3802,14 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
3482
3802
|
</div>;
|
|
3483
3803
|
}
|
|
3484
3804
|
|
|
3485
|
-
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
3805
|
+
function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
3486
3806
|
const searchParams = useSearchParams();
|
|
3487
3807
|
const [config, setConfig] = useState(() => {
|
|
3488
3808
|
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
3489
3809
|
? initialConfig.dashboards.map((dashboard, index) =>
|
|
3490
3810
|
normalizeDashboard(dashboard, index === 0 ? initialConfig.canvas : undefined)
|
|
3491
3811
|
)
|
|
3492
|
-
: [createDashboardRecord("
|
|
3812
|
+
: [createDashboardRecord("New Dashboard")];
|
|
3493
3813
|
return {
|
|
3494
3814
|
...initialConfig,
|
|
3495
3815
|
dashboards,
|
|
@@ -3506,10 +3826,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3506
3826
|
const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
|
|
3507
3827
|
const [editingWorkflowId, setEditingWorkflowId] = useState(null);
|
|
3508
3828
|
const [editingWorkflowDraft, setEditingWorkflowDraft] = useState("");
|
|
3829
|
+
const [editingTabId, setEditingTabId] = useState(null);
|
|
3830
|
+
const [editingTabDraft, setEditingTabDraft] = useState("");
|
|
3509
3831
|
const [workspaceView, setWorkspaceView] = useState("dashboards");
|
|
3510
3832
|
const [builderListFilter, setBuilderListFilter] = useState({ type: "all", query: "" });
|
|
3511
3833
|
const [builderActionMenuId, setBuilderActionMenuId] = useState(null);
|
|
3512
3834
|
const [builderActionMenuPlacement, setBuilderActionMenuPlacement] = useState(null);
|
|
3835
|
+
const [dashboardDraftMode, setDashboardDraftMode] = useState(false);
|
|
3836
|
+
const [dashboardLiveSnapshot, setDashboardLiveSnapshot] = useState(null);
|
|
3513
3837
|
const [activeDashboardId, setActiveDashboardId] = useState(() =>
|
|
3514
3838
|
getActiveDashboardId(
|
|
3515
3839
|
Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
|
|
@@ -3555,8 +3879,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3555
3879
|
const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
|
|
3556
3880
|
const activeWidgets = activeTab.widgets || [];
|
|
3557
3881
|
const activeDashboard = dashboards[resolvedActiveDashboardIndex] || dashboards[0] || null;
|
|
3882
|
+
const dashboardHasSavedDraft = Boolean(activeDashboard?.dashboardDraftStatus && Array.isArray(activeDashboard?.dashboardDraftTabs));
|
|
3883
|
+
const dashboardDirty = dashboardDraftMode && dashboardLiveSnapshot
|
|
3884
|
+
? JSON.stringify(activeDashboard?.tabs || []) !== JSON.stringify(dashboardLiveSnapshot.tabs || [])
|
|
3885
|
+
|| String(activeDashboard?.name || "") !== String(dashboardLiveSnapshot.name || "")
|
|
3886
|
+
: false;
|
|
3887
|
+
const dashboardModeLabel = dashboardDraftMode || dashboardHasSavedDraft ? "draft" : "live";
|
|
3558
3888
|
const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
|
|
3559
3889
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null);
|
|
3890
|
+
const [pendingSelectedWidgetId, setPendingSelectedWidgetId] = useState(null);
|
|
3560
3891
|
const [dragStartCell, setDragStartCell] = useState(null);
|
|
3561
3892
|
const [dragPreview, setDragPreview] = useState(null);
|
|
3562
3893
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
@@ -3572,13 +3903,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3572
3903
|
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
3573
3904
|
const [refreshing, setRefreshing] = useState(false);
|
|
3574
3905
|
const [refreshResult, setRefreshResult] = useState(null);
|
|
3906
|
+
// Sidecar source records (`growthub.source-records.json`) hydrate live-backed
|
|
3907
|
+
// Data Model objects at runtime. They are NOT persisted into growthub.config.json
|
|
3908
|
+
// and NEVER flow through PATCH /api/workspace. Updates land here only after a
|
|
3909
|
+
// successful POST /api/workspace/refresh-sources cycle re-reads GET /api/workspace.
|
|
3910
|
+
const [workspaceSourceRecords, setWorkspaceSourceRecords] = useState(
|
|
3911
|
+
() => (initialSourceRecords && typeof initialSourceRecords === "object" && !Array.isArray(initialSourceRecords)
|
|
3912
|
+
? initialSourceRecords
|
|
3913
|
+
: {})
|
|
3914
|
+
);
|
|
3575
3915
|
const resizeDragRef = useRef(null);
|
|
3576
3916
|
const moveDragRef = useRef(null);
|
|
3577
3917
|
const importInputRef = useRef(null);
|
|
3578
3918
|
const addSlot = dragPreview || selectedPosition;
|
|
3579
|
-
const
|
|
3919
|
+
const selectedWidgetLookupId = selectedWidgetId || pendingSelectedWidgetId;
|
|
3920
|
+
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetLookupId) || null;
|
|
3580
3921
|
const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
|
|
3581
|
-
const dataModelTables = useMemo(
|
|
3922
|
+
const dataModelTables = useMemo(
|
|
3923
|
+
() => listWorkspaceDataModelTables(config, { sourceRecords: workspaceSourceRecords }),
|
|
3924
|
+
[config, workspaceSourceRecords]
|
|
3925
|
+
);
|
|
3582
3926
|
const selectedResolvedWidget = selectedWidget ? resolveViewWidget(selectedWidget, dataModelTables) : null;
|
|
3583
3927
|
const branding = config.branding || {};
|
|
3584
3928
|
const occupiedCells = useMemo(() => {
|
|
@@ -3594,20 +3938,57 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3594
3938
|
}, [activeWidgets]);
|
|
3595
3939
|
|
|
3596
3940
|
/**
|
|
3597
|
-
* Collect
|
|
3598
|
-
*
|
|
3599
|
-
*
|
|
3941
|
+
* Collect refreshable source IDs from BOTH direct live bindings (a widget
|
|
3942
|
+
* binding with `sourceStorage === "workspace-source-records"`) AND
|
|
3943
|
+
* Data Model-bound widgets whose bound table resolves to a live-backed
|
|
3944
|
+
* sidecar source. The second path is what makes charts that point at a
|
|
3945
|
+
* live-backed Data Model object refreshable from the Chart panel — the
|
|
3946
|
+
* live-source metadata lives on the Data Model object, not on the widget
|
|
3947
|
+
* binding itself.
|
|
3948
|
+
*
|
|
3949
|
+
* This is runtime discovery only — config is never mutated.
|
|
3600
3950
|
*/
|
|
3601
3951
|
const liveSourceIds = useMemo(() => {
|
|
3602
3952
|
const ids = new Set();
|
|
3953
|
+
const addCandidates = (...candidates) => {
|
|
3954
|
+
for (const candidate of candidates) {
|
|
3955
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
3956
|
+
ids.add(candidate.trim());
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
};
|
|
3603
3960
|
for (const widget of activeWidgets) {
|
|
3604
3961
|
const binding = widget?.config?.binding;
|
|
3605
|
-
if (binding
|
|
3606
|
-
|
|
3962
|
+
if (!binding) continue;
|
|
3963
|
+
// Direct live binding (legacy path).
|
|
3964
|
+
if (binding.sourceStorage === "workspace-source-records") {
|
|
3965
|
+
addCandidates(binding.sourceId);
|
|
3966
|
+
}
|
|
3967
|
+
// Data Model-bound widgets (chart / view) whose bound table is itself
|
|
3968
|
+
// backed by a sidecar source.
|
|
3969
|
+
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
3970
|
+
const table = (Array.isArray(dataModelTables) ? dataModelTables : [])
|
|
3971
|
+
.find((t) => t.objectId === binding.objectId);
|
|
3972
|
+
if (!table) continue;
|
|
3973
|
+
const tableBinding = table.binding || {};
|
|
3974
|
+
if (table.liveSource || tableBinding.sourceStorage === "workspace-source-records") {
|
|
3975
|
+
addCandidates(
|
|
3976
|
+
table.liveSource?.sourceRecordKey,
|
|
3977
|
+
table.objectId,
|
|
3978
|
+
tableBinding.sourceId
|
|
3979
|
+
);
|
|
3980
|
+
}
|
|
3607
3981
|
}
|
|
3608
3982
|
}
|
|
3609
3983
|
return Array.from(ids);
|
|
3610
|
-
}, [activeWidgets]);
|
|
3984
|
+
}, [activeWidgets, dataModelTables]);
|
|
3985
|
+
|
|
3986
|
+
// Track which chart widgets have recomputed values that have not yet been
|
|
3987
|
+
// persisted. After a refresh, recomputed values live in local React state
|
|
3988
|
+
// only — until the user saves, the on-disk `growthub.config.json` still
|
|
3989
|
+
// holds the previous projection. The Chart panel shows an `Unsaved` chip
|
|
3990
|
+
// and a `Save computed values` action when this set is non-empty.
|
|
3991
|
+
const [unsavedChartIds, setUnsavedChartIds] = useState(() => new Set());
|
|
3611
3992
|
|
|
3612
3993
|
const refreshSources = useCallback(async () => {
|
|
3613
3994
|
if (refreshing || liveSourceIds.length === 0) return;
|
|
@@ -3619,20 +4000,96 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3619
4000
|
headers: { "content-type": "application/json" },
|
|
3620
4001
|
body: JSON.stringify({ sourceIds: liveSourceIds })
|
|
3621
4002
|
});
|
|
3622
|
-
if (response.ok) {
|
|
3623
|
-
const data = await response.json();
|
|
3624
|
-
setRefreshResult({ refreshed: data.refreshed?.length || 0, skipped: data.skipped?.length || 0 });
|
|
3625
|
-
} else {
|
|
4003
|
+
if (!response.ok) {
|
|
3626
4004
|
setRefreshResult({ error: true });
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
const data = await response.json();
|
|
4008
|
+
const refreshedIds = new Set((data.refreshed || [])
|
|
4009
|
+
.map((entry) => String(entry?.sourceId || "").trim())
|
|
4010
|
+
.filter(Boolean));
|
|
4011
|
+
let nextSourceRecords = workspaceSourceRecords;
|
|
4012
|
+
try {
|
|
4013
|
+
// Re-read GET so the sidecar (`workspaceSourceRecords`) reflects
|
|
4014
|
+
// the new rows the resolver just persisted. This is what makes the
|
|
4015
|
+
// chart preview update without a page reload.
|
|
4016
|
+
const getResponse = await fetch("/api/workspace", { method: "GET" });
|
|
4017
|
+
if (getResponse.ok) {
|
|
4018
|
+
const getPayload = await getResponse.json();
|
|
4019
|
+
if (getPayload?.workspaceSourceRecords && typeof getPayload.workspaceSourceRecords === "object") {
|
|
4020
|
+
nextSourceRecords = getPayload.workspaceSourceRecords;
|
|
4021
|
+
setWorkspaceSourceRecords(nextSourceRecords);
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
} catch {
|
|
4025
|
+
// Non-fatal: refresh result still reports counts; UI will use stale records.
|
|
3627
4026
|
}
|
|
4027
|
+
// Recompute chart widgets bound to refreshed objects. We rebuild the
|
|
4028
|
+
// Data Model tables from the latest sidecar before recomputing so the
|
|
4029
|
+
// computation sees the freshly-fetched rows. Recomputed widgets are
|
|
4030
|
+
// marked as unsaved — persistence still requires the explicit Save
|
|
4031
|
+
// action so the user can audit the projection before committing it.
|
|
4032
|
+
const dirtyWidgetIds = new Set();
|
|
4033
|
+
if (refreshedIds.size > 0) {
|
|
4034
|
+
const nextTables = listWorkspaceDataModelTables(config, { sourceRecords: nextSourceRecords });
|
|
4035
|
+
const objectIdsForRefreshedSources = new Set();
|
|
4036
|
+
for (const sourceId of refreshedIds) {
|
|
4037
|
+
for (const table of nextTables) {
|
|
4038
|
+
if (!table.objectId) continue;
|
|
4039
|
+
const liveKey = table.liveSource?.sourceRecordKey;
|
|
4040
|
+
const tableBindingSourceId = table.binding?.sourceId;
|
|
4041
|
+
if (liveKey === sourceId || table.objectId === sourceId || tableBindingSourceId === sourceId) {
|
|
4042
|
+
objectIdsForRefreshedSources.add(table.objectId);
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
if (objectIdsForRefreshedSources.size > 0) {
|
|
4047
|
+
const recomputeWidgets = (widgets) => (widgets || []).map((widget) => {
|
|
4048
|
+
if (widget?.kind !== "chart") return widget;
|
|
4049
|
+
const objectId = widget.config?.binding?.objectId;
|
|
4050
|
+
if (!objectId || !objectIdsForRefreshedSources.has(objectId)) return widget;
|
|
4051
|
+
const { config: recomputed } = recomputeChartConfig(widget.config || {}, nextTables);
|
|
4052
|
+
const prevValues = Array.isArray(widget.config?.values) ? widget.config.values : [];
|
|
4053
|
+
const nextValues = Array.isArray(recomputed.values) ? recomputed.values : [];
|
|
4054
|
+
const changed = prevValues.length !== nextValues.length
|
|
4055
|
+
|| prevValues.some((value, index) => value !== nextValues[index]);
|
|
4056
|
+
if (changed) dirtyWidgetIds.add(widget.id);
|
|
4057
|
+
return { ...widget, config: recomputed };
|
|
4058
|
+
});
|
|
4059
|
+
setConfig((prev) => {
|
|
4060
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard) => ({
|
|
4061
|
+
...dashboard,
|
|
4062
|
+
tabs: (dashboard.tabs || []).map((tab) => ({ ...tab, widgets: recomputeWidgets(tab.widgets) }))
|
|
4063
|
+
}));
|
|
4064
|
+
let nextCanvas = prev.canvas ? { ...prev.canvas } : {};
|
|
4065
|
+
if (Array.isArray(nextCanvas.widgets)) nextCanvas = { ...nextCanvas, widgets: recomputeWidgets(nextCanvas.widgets) };
|
|
4066
|
+
if (Array.isArray(nextCanvas.tabs)) nextCanvas = { ...nextCanvas, tabs: nextCanvas.tabs.map((tab) => ({ ...tab, widgets: recomputeWidgets(tab.widgets) })) };
|
|
4067
|
+
return { ...prev, dashboards: nextDashboards, canvas: nextCanvas };
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
if (dirtyWidgetIds.size > 0) {
|
|
4072
|
+
setUnsavedChartIds((prev) => {
|
|
4073
|
+
const next = new Set(prev);
|
|
4074
|
+
for (const id of dirtyWidgetIds) next.add(id);
|
|
4075
|
+
return next;
|
|
4076
|
+
});
|
|
4077
|
+
}
|
|
4078
|
+
setRefreshResult({
|
|
4079
|
+
refreshed: data.refreshed?.length || 0,
|
|
4080
|
+
skipped: data.skipped?.length || 0,
|
|
4081
|
+
recomputed: dirtyWidgetIds.size,
|
|
4082
|
+
unsaved: dirtyWidgetIds.size > 0
|
|
4083
|
+
});
|
|
3628
4084
|
} catch {
|
|
3629
4085
|
setRefreshResult({ error: true });
|
|
3630
4086
|
} finally {
|
|
3631
4087
|
setRefreshing(false);
|
|
3632
4088
|
}
|
|
3633
|
-
}, [refreshing, liveSourceIds]);
|
|
4089
|
+
}, [refreshing, liveSourceIds, workspaceSourceRecords, config]);
|
|
3634
4090
|
|
|
3635
4091
|
const addWidget = useCallback((kind) => {
|
|
4092
|
+
if (!dashboardDraftMode) return;
|
|
3636
4093
|
setConfig((prev) => {
|
|
3637
4094
|
const prevTabs = getTabs(prev.canvas);
|
|
3638
4095
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -3652,11 +4109,20 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3652
4109
|
tab.id === prevActiveId ? { ...tab, widgets: [...(tab.widgets || []), widget] } : tab
|
|
3653
4110
|
);
|
|
3654
4111
|
setSelectedWidgetId(widget.id);
|
|
4112
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4113
|
+
setInspectorPath(SUB_PANEL_ROOT);
|
|
4114
|
+
setPanelOpen(true);
|
|
4115
|
+
window.setTimeout(() => {
|
|
4116
|
+
setSelectedWidgetId(widget.id);
|
|
4117
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4118
|
+
setInspectorPath(SUB_PANEL_ROOT);
|
|
4119
|
+
setPanelOpen(true);
|
|
4120
|
+
}, 0);
|
|
3655
4121
|
setSelectedPosition(findFreePosition([...existingWidgets, widget]));
|
|
3656
4122
|
setDragPreview(null);
|
|
3657
4123
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
3658
4124
|
});
|
|
3659
|
-
}, [activeDashboardId, addSlot]);
|
|
4125
|
+
}, [activeDashboardId, addSlot, dashboardDraftMode]);
|
|
3660
4126
|
|
|
3661
4127
|
const switchTab = useCallback((tabId) => {
|
|
3662
4128
|
setConfig((prev) => {
|
|
@@ -3665,13 +4131,38 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3665
4131
|
if (!prevTabs.some((tab) => tab.id === tabId)) return prev;
|
|
3666
4132
|
const nextTab = prevTabs.find((tab) => tab.id === tabId);
|
|
3667
4133
|
setSelectedWidgetId(null);
|
|
4134
|
+
setPendingSelectedWidgetId(null);
|
|
3668
4135
|
setSelectedPosition(findFreePosition(nextTab?.widgets || []));
|
|
3669
4136
|
setDragPreview(null);
|
|
4137
|
+
setPanelOpen(false);
|
|
4138
|
+
setEditingTabId(null);
|
|
4139
|
+
setEditingTabDraft("");
|
|
3670
4140
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, prevTabs, tabId));
|
|
3671
4141
|
});
|
|
3672
4142
|
}, [activeDashboardId]);
|
|
3673
4143
|
|
|
4144
|
+
const beginTabRename = useCallback((tab, event) => {
|
|
4145
|
+
if (!dashboardDraftMode || !tab) return;
|
|
4146
|
+
event?.stopPropagation?.();
|
|
4147
|
+
setEditingTabId(tab.id);
|
|
4148
|
+
setEditingTabDraft(tab.name || "Tab");
|
|
4149
|
+
}, [dashboardDraftMode]);
|
|
4150
|
+
|
|
4151
|
+
const commitTabRename = useCallback((tabId) => {
|
|
4152
|
+
if (!dashboardDraftMode || !tabId) return;
|
|
4153
|
+
const nextName = editingTabDraft.trim() || "Tab";
|
|
4154
|
+
setConfig((prev) => {
|
|
4155
|
+
const prevTabs = getTabs(prev.canvas);
|
|
4156
|
+
const activeId = getActiveTabId(prev.canvas);
|
|
4157
|
+
const nextTabs = prevTabs.map((tab) => tab.id === tabId ? { ...tab, name: nextName } : tab);
|
|
4158
|
+
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, activeId));
|
|
4159
|
+
});
|
|
4160
|
+
setEditingTabId(null);
|
|
4161
|
+
setEditingTabDraft("");
|
|
4162
|
+
}, [activeDashboardId, dashboardDraftMode, editingTabDraft]);
|
|
4163
|
+
|
|
3674
4164
|
const addTab = useCallback(() => {
|
|
4165
|
+
if (!dashboardDraftMode) return;
|
|
3675
4166
|
setConfig((prev) => {
|
|
3676
4167
|
const prevTabs = getTabs(prev.canvas);
|
|
3677
4168
|
const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
|
|
@@ -3690,7 +4181,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3690
4181
|
setDragPreview(null);
|
|
3691
4182
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, newTab.id));
|
|
3692
4183
|
});
|
|
3693
|
-
}, [activeDashboardId]);
|
|
4184
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3694
4185
|
|
|
3695
4186
|
const addDashboard = useCallback(() => {
|
|
3696
4187
|
setConfig((prev) => {
|
|
@@ -3716,13 +4207,13 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3716
4207
|
|
|
3717
4208
|
const createWorkflow = useCallback(async () => {
|
|
3718
4209
|
if (saving) return;
|
|
3719
|
-
const sandboxObjectId = "sandboxes-alignment-loop";
|
|
3720
4210
|
const nowIso = new Date().toISOString();
|
|
3721
|
-
const existing =
|
|
4211
|
+
const existing = getWorkflowSandboxObject(config);
|
|
3722
4212
|
if (!existing) {
|
|
3723
4213
|
setConfigMessage("Workflow sandbox object is missing.");
|
|
3724
4214
|
return;
|
|
3725
4215
|
}
|
|
4216
|
+
const sandboxObjectId = String(existing.id || "").trim();
|
|
3726
4217
|
const rows = Array.isArray(existing.rows) ? existing.rows : [];
|
|
3727
4218
|
const base = slugifyWorkflowName(`workflow-${rows.length + 1}`);
|
|
3728
4219
|
const existingIds = new Set(rows.map((row) => String(row?.Name || row?.name || row?.id || "").trim()));
|
|
@@ -3777,11 +4268,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3777
4268
|
setSelectedWidgetId(null);
|
|
3778
4269
|
setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
|
|
3779
4270
|
setDragPreview(null);
|
|
4271
|
+
setPanelOpen(false);
|
|
3780
4272
|
setEditingDashboardId(null);
|
|
3781
4273
|
setEditingDashboardDraft("");
|
|
4274
|
+
setDashboardDraftMode(false);
|
|
4275
|
+
setDashboardLiveSnapshot(null);
|
|
3782
4276
|
setActiveDashboardId(dashboard.id);
|
|
3783
4277
|
setWorkspaceView("builder");
|
|
3784
|
-
setConfigMessage(`
|
|
4278
|
+
setConfigMessage(`Viewing ${dashboard.name}`);
|
|
3785
4279
|
return {
|
|
3786
4280
|
...synced,
|
|
3787
4281
|
dashboards: prevDashboards.map((item) => item.id === dashboard.id ? normalized : item),
|
|
@@ -3853,7 +4347,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3853
4347
|
const prevDashboards = synced.dashboards || [];
|
|
3854
4348
|
if (!prevDashboards[index]) return prev;
|
|
3855
4349
|
if (prevDashboards.length <= 1) {
|
|
3856
|
-
const dashboard = createDashboardRecord("
|
|
4350
|
+
const dashboard = createDashboardRecord("New Dashboard");
|
|
3857
4351
|
setSelectedWidgetId(null);
|
|
3858
4352
|
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
3859
4353
|
setDragPreview(null);
|
|
@@ -3889,6 +4383,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3889
4383
|
}, [cloneDashboard, resolvedActiveDashboardIndex]);
|
|
3890
4384
|
|
|
3891
4385
|
const duplicateTab = useCallback(() => {
|
|
4386
|
+
if (!dashboardDraftMode) return;
|
|
3892
4387
|
setConfig((prev) => {
|
|
3893
4388
|
const prevTabs = getTabs(prev.canvas);
|
|
3894
4389
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -3911,9 +4406,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3911
4406
|
setDragPreview(null);
|
|
3912
4407
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, cloned.id));
|
|
3913
4408
|
});
|
|
3914
|
-
}, [activeDashboardId]);
|
|
4409
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3915
4410
|
|
|
3916
4411
|
const deleteTab = useCallback((tabId) => {
|
|
4412
|
+
if (!dashboardDraftMode) return;
|
|
3917
4413
|
setConfig((prev) => {
|
|
3918
4414
|
const prevTabs = getTabs(prev.canvas);
|
|
3919
4415
|
const tab = prevTabs.find((item) => item.id === tabId);
|
|
@@ -3935,9 +4431,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3935
4431
|
setConfigMessage(`Deleted ${tab.name}`);
|
|
3936
4432
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, nextActiveTab.id));
|
|
3937
4433
|
});
|
|
3938
|
-
}, [activeDashboardId]);
|
|
4434
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3939
4435
|
|
|
3940
4436
|
const applyTemplateToCurrentTab = useCallback((templateId) => {
|
|
4437
|
+
if (!dashboardDraftMode) return;
|
|
3941
4438
|
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
3942
4439
|
if (!template) return;
|
|
3943
4440
|
const clonedTab = cloneTemplateToTab(template, { tabName: template.name, idFactory: generateId });
|
|
@@ -3969,7 +4466,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3969
4466
|
setConfigMessage(`Applied ${template.name} to current tab`);
|
|
3970
4467
|
setTemplateGalleryOpen(false);
|
|
3971
4468
|
setPreviewTemplateId(null);
|
|
3972
|
-
}, [activeDashboardId]);
|
|
4469
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3973
4470
|
|
|
3974
4471
|
const cloneTemplateAsDashboard = useCallback((templateId) => {
|
|
3975
4472
|
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
@@ -4082,6 +4579,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4082
4579
|
dashboards: savedDashboards,
|
|
4083
4580
|
canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
|
|
4084
4581
|
});
|
|
4582
|
+
// Saved values are now on disk — clear the unsaved-chart tracking
|
|
4583
|
+
// so the Chart panel stops showing the `Unsaved` chip / CTA.
|
|
4584
|
+
setUnsavedChartIds(new Set());
|
|
4085
4585
|
} else {
|
|
4086
4586
|
setConfigMessage(payload.error || "Save failed");
|
|
4087
4587
|
}
|
|
@@ -4096,6 +4596,105 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4096
4596
|
await persistWorkspaceConfig(config, activeDashboardId);
|
|
4097
4597
|
}, [activeDashboardId, config, persistWorkspaceConfig]);
|
|
4098
4598
|
|
|
4599
|
+
const beginDashboardDraft = useCallback(() => {
|
|
4600
|
+
if (!activeDashboard) return;
|
|
4601
|
+
const snapshot = cloneConfig(activeDashboard);
|
|
4602
|
+
setDashboardLiveSnapshot(snapshot);
|
|
4603
|
+
setDashboardDraftMode(true);
|
|
4604
|
+
setPanelOpen(false);
|
|
4605
|
+
setConfigMessage(`Editing draft for ${activeDashboard.name}`);
|
|
4606
|
+
if (dashboardHasSavedDraft) {
|
|
4607
|
+
setConfig((prev) => {
|
|
4608
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard) => {
|
|
4609
|
+
if (dashboard.id !== activeDashboard.id) return dashboard;
|
|
4610
|
+
return {
|
|
4611
|
+
...dashboard,
|
|
4612
|
+
tabs: cloneConfig(dashboard.dashboardDraftTabs),
|
|
4613
|
+
activeTabId: dashboard.dashboardDraftActiveTabId || dashboard.activeTabId
|
|
4614
|
+
};
|
|
4615
|
+
});
|
|
4616
|
+
const nextActive = nextDashboards.find((dashboard) => dashboard.id === activeDashboard.id) || nextDashboards[0];
|
|
4617
|
+
return {
|
|
4618
|
+
...prev,
|
|
4619
|
+
dashboards: nextDashboards,
|
|
4620
|
+
canvas: dashboardCanvasFrom(nextActive, prev.canvas)
|
|
4621
|
+
};
|
|
4622
|
+
});
|
|
4623
|
+
}
|
|
4624
|
+
}, [activeDashboard, dashboardHasSavedDraft]);
|
|
4625
|
+
|
|
4626
|
+
const saveDashboardDraft = useCallback(async () => {
|
|
4627
|
+
if (!activeDashboard || saving) return;
|
|
4628
|
+
const now = new Date().toISOString();
|
|
4629
|
+
const synced = syncActiveDashboard(config, activeDashboardId);
|
|
4630
|
+
const nextDashboards = (synced.dashboards || []).map((dashboard) =>
|
|
4631
|
+
dashboard.id === activeDashboard.id
|
|
4632
|
+
? {
|
|
4633
|
+
...dashboard,
|
|
4634
|
+
dashboardDraftStatus: "draft",
|
|
4635
|
+
dashboardDraftUpdatedAt: now,
|
|
4636
|
+
dashboardDraftBaseVersion: String(dashboard.version || "1"),
|
|
4637
|
+
dashboardDraftTabs: cloneConfig(dashboard.tabs || []),
|
|
4638
|
+
dashboardDraftActiveTabId: dashboard.activeTabId || ""
|
|
4639
|
+
}
|
|
4640
|
+
: dashboard
|
|
4641
|
+
);
|
|
4642
|
+
await persistWorkspaceConfig({ ...synced, dashboards: nextDashboards }, activeDashboardId);
|
|
4643
|
+
setDashboardDraftMode(true);
|
|
4644
|
+
setDashboardLiveSnapshot(cloneConfig(nextDashboards.find((dashboard) => dashboard.id === activeDashboard.id) || activeDashboard));
|
|
4645
|
+
setConfigMessage("Saved dashboard draft. Publish to update the live dashboard.");
|
|
4646
|
+
}, [activeDashboard, activeDashboardId, config, persistWorkspaceConfig, saving]);
|
|
4647
|
+
|
|
4648
|
+
const publishDashboard = useCallback(async () => {
|
|
4649
|
+
if (!activeDashboard || saving) return;
|
|
4650
|
+
const now = new Date().toISOString();
|
|
4651
|
+
const synced = syncActiveDashboard(config, activeDashboardId);
|
|
4652
|
+
const nextDashboards = (synced.dashboards || []).map((dashboard) => {
|
|
4653
|
+
if (dashboard.id !== activeDashboard.id) return dashboard;
|
|
4654
|
+
const {
|
|
4655
|
+
dashboardDraftTabs,
|
|
4656
|
+
dashboardDraftActiveTabId,
|
|
4657
|
+
dashboardDraftStatus,
|
|
4658
|
+
dashboardDraftUpdatedAt,
|
|
4659
|
+
dashboardDraftBaseVersion,
|
|
4660
|
+
...rest
|
|
4661
|
+
} = dashboard;
|
|
4662
|
+
return {
|
|
4663
|
+
...rest,
|
|
4664
|
+
tabs: cloneConfig(dashboard.tabs || []),
|
|
4665
|
+
activeTabId: dashboard.activeTabId,
|
|
4666
|
+
status: "active",
|
|
4667
|
+
version: String(Number(dashboard.version || "1") + 1),
|
|
4668
|
+
dashboardPublishedAt: now,
|
|
4669
|
+
updatedAt: now
|
|
4670
|
+
};
|
|
4671
|
+
});
|
|
4672
|
+
await persistWorkspaceConfig({ ...synced, dashboards: nextDashboards }, activeDashboardId);
|
|
4673
|
+
setDashboardDraftMode(false);
|
|
4674
|
+
setDashboardLiveSnapshot(null);
|
|
4675
|
+
setConfigMessage("Published dashboard.");
|
|
4676
|
+
}, [activeDashboard, activeDashboardId, config, persistWorkspaceConfig, saving]);
|
|
4677
|
+
|
|
4678
|
+
const discardDashboardDraft = useCallback(() => {
|
|
4679
|
+
if (!activeDashboard) return;
|
|
4680
|
+
const snapshot = dashboardLiveSnapshot;
|
|
4681
|
+
setDashboardDraftMode(false);
|
|
4682
|
+
setDashboardLiveSnapshot(null);
|
|
4683
|
+
setPanelOpen(false);
|
|
4684
|
+
if (!snapshot) return;
|
|
4685
|
+
setConfig((prev) => {
|
|
4686
|
+
const nextDashboards = (prev.dashboards || []).map((dashboard) =>
|
|
4687
|
+
dashboard.id === activeDashboard.id ? snapshot : dashboard
|
|
4688
|
+
);
|
|
4689
|
+
return {
|
|
4690
|
+
...prev,
|
|
4691
|
+
dashboards: nextDashboards,
|
|
4692
|
+
canvas: dashboardCanvasFrom(snapshot, prev.canvas)
|
|
4693
|
+
};
|
|
4694
|
+
});
|
|
4695
|
+
setConfigMessage("Discarded dashboard draft.");
|
|
4696
|
+
}, [activeDashboard, dashboardLiveSnapshot]);
|
|
4697
|
+
|
|
4099
4698
|
const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
|
|
4100
4699
|
const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
|
|
4101
4700
|
setEditingDashboardId(null);
|
|
@@ -4209,18 +4808,24 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4209
4808
|
setEditingDashboardDraft("");
|
|
4210
4809
|
}, [editingDashboardDraft]);
|
|
4211
4810
|
|
|
4212
|
-
const closePanel = useCallback(() =>
|
|
4811
|
+
const closePanel = useCallback(() => {
|
|
4812
|
+
setPanelOpen(false);
|
|
4813
|
+
setSelectedWidgetId(null);
|
|
4814
|
+
setPendingSelectedWidgetId(null);
|
|
4815
|
+
}, []);
|
|
4213
4816
|
const beginCellDrag = useCallback((index, event) => {
|
|
4817
|
+
if (!dashboardDraftMode) return;
|
|
4214
4818
|
const x = index % GRID_COLUMNS;
|
|
4215
4819
|
const y = Math.floor(index / GRID_COLUMNS);
|
|
4216
4820
|
if (occupiedCells.has(`${x}:${y}`)) return;
|
|
4217
4821
|
event.preventDefault();
|
|
4218
4822
|
const position = normalizePosition(index, index);
|
|
4219
4823
|
setSelectedWidgetId(null);
|
|
4824
|
+
setPendingSelectedWidgetId(null);
|
|
4220
4825
|
setDragStartCell(index);
|
|
4221
4826
|
setDragPreview(position);
|
|
4222
4827
|
setPanelOpen(true);
|
|
4223
|
-
}, [occupiedCells]);
|
|
4828
|
+
}, [dashboardDraftMode, occupiedCells]);
|
|
4224
4829
|
const updateCellDrag = useCallback((index) => {
|
|
4225
4830
|
if (dragStartCell === null) return;
|
|
4226
4831
|
setDragPreview(normalizePosition(dragStartCell, index));
|
|
@@ -4244,14 +4849,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4244
4849
|
finishCellDrag(index ?? dragStartCell);
|
|
4245
4850
|
}, [dragStartCell, finishCellDrag]);
|
|
4246
4851
|
const beginResizeDrag = useCallback((widget, corner, event) => {
|
|
4852
|
+
if (!dashboardDraftMode) return;
|
|
4247
4853
|
event.preventDefault();
|
|
4248
4854
|
event.stopPropagation();
|
|
4249
4855
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
4250
4856
|
const nextResizeDrag = { widgetId: widget.id, corner, originalPosition: widget.position };
|
|
4251
4857
|
setSelectedWidgetId(widget.id);
|
|
4858
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4252
4859
|
resizeDragRef.current = nextResizeDrag;
|
|
4253
4860
|
setResizeDrag(nextResizeDrag);
|
|
4254
|
-
}, []);
|
|
4861
|
+
}, [dashboardDraftMode]);
|
|
4255
4862
|
const updateResizeDrag = useCallback((event) => {
|
|
4256
4863
|
const activeResizeDrag = resizeDragRef.current;
|
|
4257
4864
|
if (!activeResizeDrag) return;
|
|
@@ -4282,6 +4889,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4282
4889
|
setResizeDrag(null);
|
|
4283
4890
|
}, []);
|
|
4284
4891
|
const beginMoveDrag = useCallback((widget, event) => {
|
|
4892
|
+
if (!dashboardDraftMode) return;
|
|
4285
4893
|
event.preventDefault();
|
|
4286
4894
|
event.stopPropagation();
|
|
4287
4895
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
@@ -4293,10 +4901,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4293
4901
|
offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
|
|
4294
4902
|
};
|
|
4295
4903
|
setSelectedWidgetId(widget.id);
|
|
4904
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4296
4905
|
setPanelOpen(true);
|
|
4297
4906
|
moveDragRef.current = nextMoveDrag;
|
|
4298
4907
|
setMoveDrag(nextMoveDrag);
|
|
4299
|
-
}, []);
|
|
4908
|
+
}, [dashboardDraftMode]);
|
|
4300
4909
|
const updateMoveDrag = useCallback((event) => {
|
|
4301
4910
|
const activeMoveDrag = moveDragRef.current;
|
|
4302
4911
|
if (!activeMoveDrag) return;
|
|
@@ -4331,10 +4940,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4331
4940
|
setMoveDrag(null);
|
|
4332
4941
|
}, []);
|
|
4333
4942
|
const selectWidget = useCallback((widgetId) => {
|
|
4943
|
+
if (!dashboardDraftMode) return;
|
|
4334
4944
|
setSelectedWidgetId(widgetId);
|
|
4945
|
+
setPendingSelectedWidgetId(widgetId);
|
|
4335
4946
|
setInspectorPath(SUB_PANEL_ROOT);
|
|
4336
4947
|
setPanelOpen(true);
|
|
4337
|
-
}, []);
|
|
4948
|
+
}, [dashboardDraftMode]);
|
|
4338
4949
|
// Fetches all records from a resolver and persists them into the data model object,
|
|
4339
4950
|
// then syncs the updated dataModel into local React state.
|
|
4340
4951
|
const handleRefreshDataModelObject = useCallback(async (binding, objectId) => {
|
|
@@ -4355,7 +4966,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4355
4966
|
}, []);
|
|
4356
4967
|
|
|
4357
4968
|
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
4358
|
-
if (!
|
|
4969
|
+
if (!dashboardDraftMode) return;
|
|
4970
|
+
if (!selectedWidgetLookupId) return;
|
|
4359
4971
|
setConfig((prev) => {
|
|
4360
4972
|
const prevTabs = getTabs(prev.canvas);
|
|
4361
4973
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4364,15 +4976,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4364
4976
|
return {
|
|
4365
4977
|
...tab,
|
|
4366
4978
|
widgets: (tab.widgets || []).map((widget) =>
|
|
4367
|
-
widget.id ===
|
|
4979
|
+
widget.id === selectedWidgetLookupId ? { ...widget, config: nextConfig } : widget
|
|
4368
4980
|
)
|
|
4369
4981
|
};
|
|
4370
4982
|
});
|
|
4371
4983
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4372
4984
|
});
|
|
4373
|
-
}, [activeDashboardId,
|
|
4985
|
+
}, [activeDashboardId, dashboardDraftMode, selectedWidgetLookupId]);
|
|
4374
4986
|
const updateSelectedWidget = useCallback((updates) => {
|
|
4375
|
-
if (!
|
|
4987
|
+
if (!dashboardDraftMode) return;
|
|
4988
|
+
if (!selectedWidgetLookupId) return;
|
|
4376
4989
|
setConfig((prev) => {
|
|
4377
4990
|
const prevTabs = getTabs(prev.canvas);
|
|
4378
4991
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4381,18 +4994,19 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4381
4994
|
return {
|
|
4382
4995
|
...tab,
|
|
4383
4996
|
widgets: (tab.widgets || []).map((widget) =>
|
|
4384
|
-
widget.id ===
|
|
4997
|
+
widget.id === selectedWidgetLookupId ? { ...widget, ...updates } : widget
|
|
4385
4998
|
)
|
|
4386
4999
|
};
|
|
4387
5000
|
});
|
|
4388
5001
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4389
5002
|
});
|
|
4390
|
-
}, [activeDashboardId,
|
|
5003
|
+
}, [activeDashboardId, dashboardDraftMode, selectedWidgetLookupId]);
|
|
4391
5004
|
const updateSelectedWidgetConfig = useCallback((updates) => {
|
|
4392
5005
|
if (!selectedWidget) return;
|
|
4393
5006
|
updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
|
|
4394
5007
|
}, [selectedWidget, updateSelectedWidget]);
|
|
4395
5008
|
const removeSelectedWidget = useCallback((widgetId) => {
|
|
5009
|
+
if (!dashboardDraftMode) return;
|
|
4396
5010
|
setConfig((prev) => {
|
|
4397
5011
|
const prevTabs = getTabs(prev.canvas);
|
|
4398
5012
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4402,10 +5016,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4402
5016
|
});
|
|
4403
5017
|
const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
|
|
4404
5018
|
setSelectedWidgetId(null);
|
|
5019
|
+
setPendingSelectedWidgetId(null);
|
|
4405
5020
|
setSelectedPosition(findFreePosition(nextActiveWidgets));
|
|
4406
5021
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4407
5022
|
});
|
|
4408
|
-
}, [activeDashboardId]);
|
|
5023
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
4409
5024
|
|
|
4410
5025
|
const duplicateSelectedWidget = useCallback(() => {
|
|
4411
5026
|
if (!selectedWidget) return;
|
|
@@ -4428,6 +5043,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4428
5043
|
return { ...tab, widgets: [...(tab.widgets || []), cloned] };
|
|
4429
5044
|
});
|
|
4430
5045
|
setSelectedWidgetId(cloned.id);
|
|
5046
|
+
setPendingSelectedWidgetId(cloned.id);
|
|
4431
5047
|
setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
|
|
4432
5048
|
setConfigMessage(`Duplicated ${selectedWidget.title}`);
|
|
4433
5049
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
@@ -4442,6 +5058,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4442
5058
|
const closeManagement = useCallback(() => setManagementOpen(false), []);
|
|
4443
5059
|
const resetWidgetSelection = useCallback(() => {
|
|
4444
5060
|
setSelectedWidgetId(null);
|
|
5061
|
+
setPendingSelectedWidgetId(null);
|
|
4445
5062
|
setPanelOpen(true);
|
|
4446
5063
|
}, []);
|
|
4447
5064
|
const showDashboardHome = useCallback(() => {
|
|
@@ -4724,26 +5341,32 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4724
5341
|
)}
|
|
4725
5342
|
/>
|
|
4726
5343
|
|
|
4727
|
-
<section className="workspace-surface">
|
|
4728
|
-
<header className=
|
|
4729
|
-
<div>
|
|
5344
|
+
<section className={`workspace-surface${workspaceView === "builder" ? " dm-workflow-surface workspace-dashboard-surface" : ""}`}>
|
|
5345
|
+
<header className={`workspace-toolbar${workspaceView === "builder" ? " dm-workflow-toolbar" : ""}`}>
|
|
5346
|
+
<div className={workspaceView === "builder" ? "dm-workflow-titlebar" : undefined}>
|
|
4730
5347
|
{workspaceView === "builder" ? <>
|
|
4731
|
-
<
|
|
5348
|
+
<span className="dm-workflow-title-muted">Dashboards</span>
|
|
5349
|
+
<span className="dm-workflow-title-separator">/</span>
|
|
4732
5350
|
<h1>{activeDashboard?.name || "Untitled"}</h1>
|
|
5351
|
+
<span className="dm-workflow-count">({activeWidgets.length}) · v{activeDashboard?.version || "1"} · {dashboardModeLabel}</span>
|
|
4733
5352
|
</> : <>
|
|
4734
5353
|
<p>Workspace home</p>
|
|
4735
5354
|
<h1>Builder</h1>
|
|
4736
5355
|
</>}
|
|
4737
5356
|
</div>
|
|
4738
|
-
<div className="
|
|
4739
|
-
<button type="button" onClick={
|
|
5357
|
+
{workspaceView === "builder" ? <div className="dm-workflow-toolbar-actions">
|
|
5358
|
+
{dashboardDraftMode || dashboardHasSavedDraft ? <button type="button" className="dm-workflow-chip-btn" onClick={discardDashboardDraft} disabled={saving}>Discard Draft</button> : null}
|
|
5359
|
+
{dashboardDraftMode ? <button type="button" className="dm-workflow-chip-btn" onClick={saveDashboardDraft} disabled={saving || !dashboardDirty}><Save size={13} />{saving ? "Saving" : "Save draft"}</button> : null}
|
|
5360
|
+
{dashboardDraftMode || dashboardHasSavedDraft ? <button type="button" className="dm-workflow-chip-btn" onClick={publishDashboard} disabled={saving || (!dashboardDirty && !dashboardHasSavedDraft)}><Check size={13} />Publish</button> : null}
|
|
5361
|
+
{!dashboardDraftMode ? <button type="button" className="dm-workflow-chip-btn" onClick={beginDashboardDraft}><Pencil size={13} />Edit</button> : null}
|
|
5362
|
+
<button type="button" className="dm-workflow-chip-btn" onClick={() => setTemplateGalleryOpen(true)} disabled={!dashboardDraftMode}><Grid2X2 size={13} />Templates</button>
|
|
5363
|
+
<button type="button" className="dm-workflow-chip-btn" onClick={exportConfig}><Download size={13} />Export</button>
|
|
5364
|
+
<button type="button" className="dm-workflow-icon-btn" onClick={() => importInputRef.current?.click()} aria-label="Import"><Import size={14} /></button>
|
|
5365
|
+
</div> : <div className="workspace-toolbar-actions">
|
|
4740
5366
|
<button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
|
|
4741
5367
|
<button type="button" onClick={createWorkflow} disabled={saving}><GitBranch size={15} />New Workflow</button>
|
|
4742
|
-
<button type="button" onClick={duplicateDashboard}><Copy size={15} />Duplicate Dashboard</button>
|
|
4743
5368
|
<button type="button" onClick={() => importInputRef.current?.click()}><Import size={15} />Import</button>
|
|
4744
|
-
|
|
4745
|
-
<button type="button" onClick={save} disabled={saving}><Save size={15} />{saving ? "Saving..." : "Save"}</button>
|
|
4746
|
-
</div>
|
|
5369
|
+
</div>}
|
|
4747
5370
|
<input
|
|
4748
5371
|
ref={importInputRef}
|
|
4749
5372
|
type="file"
|
|
@@ -4821,7 +5444,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4821
5444
|
>✓</button>
|
|
4822
5445
|
</span> : <button
|
|
4823
5446
|
className={item.index === resolvedActiveDashboardIndex ? "active" : ""}
|
|
4824
|
-
onClick={() =>
|
|
5447
|
+
onClick={() => selectDashboard(item.index)}
|
|
4825
5448
|
type="button"
|
|
4826
5449
|
>{item.dashboard.name}</button>}
|
|
4827
5450
|
</span>
|
|
@@ -4929,13 +5552,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4929
5552
|
className={tab.id === activeTabId ? "active" : ""}
|
|
4930
5553
|
type="button"
|
|
4931
5554
|
onClick={() => switchTab(tab.id)}
|
|
5555
|
+
onDoubleClick={(event) => beginTabRename(tab, event)}
|
|
4932
5556
|
>
|
|
4933
|
-
|
|
5557
|
+
{editingTabId === tab.id ? <input
|
|
5558
|
+
className="workspace-tab-name-input"
|
|
5559
|
+
autoFocus
|
|
5560
|
+
value={editingTabDraft}
|
|
5561
|
+
onChange={(event) => setEditingTabDraft(event.target.value)}
|
|
5562
|
+
onBlur={() => commitTabRename(tab.id)}
|
|
5563
|
+
onClick={(event) => event.stopPropagation()}
|
|
5564
|
+
onKeyDown={(event) => {
|
|
5565
|
+
if (event.key === "Enter") {
|
|
5566
|
+
event.preventDefault();
|
|
5567
|
+
commitTabRename(tab.id);
|
|
5568
|
+
}
|
|
5569
|
+
if (event.key === "Escape") {
|
|
5570
|
+
event.preventDefault();
|
|
5571
|
+
setEditingTabId(null);
|
|
5572
|
+
setEditingTabDraft("");
|
|
5573
|
+
}
|
|
5574
|
+
}}
|
|
5575
|
+
/> : <span>{tab.name}</span>}
|
|
4934
5576
|
<span
|
|
4935
5577
|
aria-label={`Delete tab ${tab.name}`}
|
|
4936
5578
|
className="workspace-tab-delete"
|
|
4937
5579
|
onClick={(event) => {
|
|
4938
5580
|
event.stopPropagation();
|
|
5581
|
+
if (!dashboardDraftMode) return;
|
|
4939
5582
|
deleteTab(tab.id);
|
|
4940
5583
|
}}
|
|
4941
5584
|
onKeyDown={(event) => {
|
|
@@ -4949,18 +5592,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4949
5592
|
tabIndex={0}
|
|
4950
5593
|
>x</span>
|
|
4951
5594
|
</button>)}
|
|
4952
|
-
<button type="button" onClick={addTab}><Plus size={15} />New Tab</button>
|
|
4953
|
-
<button type="button" onClick={duplicateTab}><Copy size={15} />Duplicate Tab</button>
|
|
4954
|
-
<button
|
|
4955
|
-
type="button"
|
|
4956
|
-
className={`workspace-tab-refresh${liveSourceIds.length === 0 ? " inert" : ""}${refreshing ? " loading" : ""}`}
|
|
4957
|
-
disabled={liveSourceIds.length === 0 || refreshing}
|
|
4958
|
-
onClick={refreshSources}
|
|
4959
|
-
title={liveSourceIds.length === 0 ? "No live-backed sources on this tab" : `Refresh ${liveSourceIds.length} live source${liveSourceIds.length === 1 ? "" : "s"}`}
|
|
4960
|
-
>
|
|
4961
|
-
<RefreshCw size={15} className={refreshing ? "spinning" : ""} />
|
|
4962
|
-
{refreshing ? "Refreshing…" : refreshResult?.error ? "Refresh failed" : refreshResult ? `${refreshResult.refreshed} updated` : "Refresh"}
|
|
4963
|
-
</button>
|
|
5595
|
+
<button type="button" onClick={addTab} disabled={!dashboardDraftMode}><Plus size={15} />New Tab</button>
|
|
5596
|
+
<button type="button" onClick={duplicateTab} disabled={!dashboardDraftMode}><Copy size={15} />Duplicate Tab</button>
|
|
4964
5597
|
</div>
|
|
4965
5598
|
<div
|
|
4966
5599
|
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
@@ -5000,7 +5633,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5000
5633
|
type="button"
|
|
5001
5634
|
/>;
|
|
5002
5635
|
})}
|
|
5003
|
-
<button className={`workspace-add-widget${dragPreview ? " selecting" : ""}`} type="button" onClick={() => setPanelOpen(true)} style={{
|
|
5636
|
+
<button className={`workspace-add-widget${dragPreview ? " selecting" : ""}`} type="button" disabled={!dashboardDraftMode} onClick={() => dashboardDraftMode && setPanelOpen(true)} style={{
|
|
5004
5637
|
gridColumn: `${addSlot.x + 1} / span ${addSlot.w}`,
|
|
5005
5638
|
gridRow: `${addSlot.y + 1} / span ${addSlot.h}`
|
|
5006
5639
|
}}>
|
|
@@ -5095,12 +5728,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5095
5728
|
/>
|
|
5096
5729
|
|
|
5097
5730
|
{workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
5098
|
-
<div className="workspace-panel-title">
|
|
5731
|
+
{(inspectorPath === SUB_PANEL_ROOT || !selectedWidget) ? <div className="workspace-panel-title">
|
|
5099
5732
|
<button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
|
|
5100
|
-
<span aria-hidden="true">+</span>
|
|
5733
|
+
{selectedWidget ? <WidgetPanelHeaderIcon kind={selectedWidget.kind} /> : <span aria-hidden="true">+</span>}
|
|
5101
5734
|
<strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
|
|
5102
5735
|
{selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
|
|
5103
|
-
</div>
|
|
5736
|
+
</div> : null}
|
|
5104
5737
|
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
5105
5738
|
<button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
|
|
5106
5739
|
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
|
|
@@ -5132,6 +5765,17 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5132
5765
|
onChange={replaceSelectedWidgetConfig}
|
|
5133
5766
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
5134
5767
|
/> : null}
|
|
5768
|
+
{selectedWidget && selectedWidget.kind === "chart" && inspectorPath === "hydration" ? <ChartHydrationInspector
|
|
5769
|
+
widget={selectedWidget}
|
|
5770
|
+
dataModelTables={dataModelTables}
|
|
5771
|
+
unsaved={unsavedChartIds.has(selectedWidget.id)}
|
|
5772
|
+
saving={saving}
|
|
5773
|
+
canSave={Boolean(persistence?.canSave)}
|
|
5774
|
+
saveGuidance={persistence?.guidance || persistence?.saveLabel || ""}
|
|
5775
|
+
onChange={replaceSelectedWidgetConfig}
|
|
5776
|
+
onSave={() => persistWorkspaceConfig(config, activeDashboardId)}
|
|
5777
|
+
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
5778
|
+
/> : null}
|
|
5135
5779
|
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
|
|
5136
5780
|
<label>
|
|
5137
5781
|
<span>Title</span>
|
|
@@ -5141,6 +5785,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5141
5785
|
widget={selectedWidget}
|
|
5142
5786
|
branding={branding}
|
|
5143
5787
|
dataModelTables={dataModelTables}
|
|
5788
|
+
unsaved={unsavedChartIds.has(selectedWidget.id)}
|
|
5144
5789
|
onChange={replaceSelectedWidgetConfig}
|
|
5145
5790
|
onSubPage={(name) => setInspectorPath(name)}
|
|
5146
5791
|
/> : null}
|
|
@@ -5170,30 +5815,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5170
5815
|
</small>
|
|
5171
5816
|
</label> : null}
|
|
5172
5817
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
5173
|
-
<div className="workspace-
|
|
5818
|
+
<div className="workspace-twenty-config" role="group" aria-label="View widget settings">
|
|
5174
5819
|
<p className="workspace-panel-label">Settings</p>
|
|
5175
|
-
<
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
<
|
|
5179
|
-
|
|
5180
|
-
</button>
|
|
5181
|
-
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("fields")}>
|
|
5182
|
-
<span>Fields</span><code>{summarizeFields(selectedResolvedWidget || selectedWidget)}</code>
|
|
5183
|
-
</button>
|
|
5184
|
-
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("filter")}>
|
|
5185
|
-
<span>Filter</span><code>{summarizeFilter(selectedResolvedWidget || selectedWidget)}</code>
|
|
5186
|
-
</button>
|
|
5187
|
-
<button type="button" className="workspace-settings-row" onClick={() => setInspectorPath("sort")}>
|
|
5188
|
-
<span>Sort</span><code>{summarizeSort(selectedResolvedWidget || selectedWidget)}</code>
|
|
5189
|
-
</button>
|
|
5820
|
+
<WidgetSettingsRow icon={Table2} label="Layout" value={selectedWidget.config?.layout || "Table"} disabled />
|
|
5821
|
+
<WidgetSettingsRow icon={Box} label="Source" value={summarizeSource(selectedWidget)} onClick={() => setInspectorPath("source")} />
|
|
5822
|
+
<WidgetSettingsRow icon={List} label="Fields" value={summarizeFields(selectedResolvedWidget || selectedWidget)} onClick={() => setInspectorPath("fields")} />
|
|
5823
|
+
<WidgetSettingsRow icon={Filter} label="Filter" value={summarizeFilter(selectedResolvedWidget || selectedWidget)} onClick={() => setInspectorPath("filter")} />
|
|
5824
|
+
<WidgetSettingsRow icon={SlidersHorizontal} label="Sort" value={summarizeSort(selectedResolvedWidget || selectedWidget)} onClick={() => setInspectorPath("sort")} />
|
|
5190
5825
|
</div>
|
|
5191
5826
|
</section> : null}
|
|
5192
|
-
<div className="workspace-settings-list">
|
|
5193
|
-
<p className="workspace-panel-label">Placement</p>
|
|
5194
|
-
<div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
|
|
5195
|
-
<div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
|
|
5196
|
-
</div>
|
|
5197
5827
|
</section> : null}
|
|
5198
5828
|
{!selectedWidget ? <section>
|
|
5199
5829
|
<div className="workspace-widget-empty">
|
|
@@ -5217,8 +5847,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5217
5847
|
})}
|
|
5218
5848
|
</div>
|
|
5219
5849
|
</section> : null}
|
|
5220
|
-
{inspectorPath === SUB_PANEL_ROOT ? <
|
|
5221
|
-
<
|
|
5850
|
+
{inspectorPath === SUB_PANEL_ROOT ? <details className="workspace-bindings" id="bindings">
|
|
5851
|
+
<summary>Config bindings and data model sync</summary>
|
|
5222
5852
|
{Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
|
|
5223
5853
|
<span>{key}</span>
|
|
5224
5854
|
<code>{String(value)}</code>
|
|
@@ -5227,7 +5857,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5227
5857
|
<span>integrationAdapter</span>
|
|
5228
5858
|
<code>{adapterConfig.integrationAdapter}</code>
|
|
5229
5859
|
</div>
|
|
5230
|
-
|
|
5860
|
+
<div>
|
|
5861
|
+
<span>workspaceSourceRecords</span>
|
|
5862
|
+
<code>{Object.keys(workspaceSourceRecords || {}).length} sources</code>
|
|
5863
|
+
</div>
|
|
5864
|
+
<div>
|
|
5865
|
+
<span>dataModelObjects</span>
|
|
5866
|
+
<code>{dataModelTables.length} synced</code>
|
|
5867
|
+
</div>
|
|
5868
|
+
</details> : null}
|
|
5231
5869
|
</aside> : null}
|
|
5232
5870
|
{expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
|
|
5233
5871
|
{commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
|