@growthub/cli 0.13.2 → 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 +766 -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-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
|
|
|
@@ -156,6 +160,23 @@ const CHART_TYPE_ICONS = {
|
|
|
156
160
|
|
|
157
161
|
const VISIBLE_CHART_TYPES = KNOWN_CHART_TYPES.filter((type) => type !== "line");
|
|
158
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
|
+
|
|
159
180
|
const WIDGET_KIND_ICONS = {
|
|
160
181
|
chart: BarChart3,
|
|
161
182
|
view: Table2,
|
|
@@ -293,11 +314,11 @@ function textColorForAccent(accent) {
|
|
|
293
314
|
|
|
294
315
|
function defaultTitleFor(kind) {
|
|
295
316
|
switch (kind) {
|
|
296
|
-
case "chart": return "
|
|
297
|
-
case "view": return "
|
|
298
|
-
case "iframe": return "
|
|
299
|
-
case "rich-text": return "
|
|
300
|
-
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";
|
|
301
322
|
}
|
|
302
323
|
}
|
|
303
324
|
|
|
@@ -337,7 +358,7 @@ function commitTabs(canvas, tabs, activeTabId) {
|
|
|
337
358
|
return next;
|
|
338
359
|
}
|
|
339
360
|
|
|
340
|
-
function createDashboardRecord(name = "
|
|
361
|
+
function createDashboardRecord(name = "New Dashboard") {
|
|
341
362
|
const tab = createEmptyTab("Tab 1");
|
|
342
363
|
return {
|
|
343
364
|
id: generateId("dashboard"),
|
|
@@ -634,7 +655,7 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
|
|
|
634
655
|
}
|
|
635
656
|
|
|
636
657
|
function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
|
|
637
|
-
const nextName = name.trim() || "
|
|
658
|
+
const nextName = name.trim() || "New Dashboard";
|
|
638
659
|
const prevDashboards = config.dashboards || [];
|
|
639
660
|
const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
|
|
640
661
|
if (index < 0) return config;
|
|
@@ -1018,6 +1039,62 @@ function resolveViewWidget(widget, dataModelTables) {
|
|
|
1018
1039
|
};
|
|
1019
1040
|
}
|
|
1020
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
|
+
|
|
1021
1098
|
function summarizeFields(widget) {
|
|
1022
1099
|
const total = getColumnList(widget).length;
|
|
1023
1100
|
const hidden = getHiddenColumnSet(widget).size;
|
|
@@ -1042,6 +1119,37 @@ function summarizeFilter(widget) {
|
|
|
1042
1119
|
return `${count} clause${count === 1 ? "" : "s"} (${filter.op})`;
|
|
1043
1120
|
}
|
|
1044
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
|
+
|
|
1045
1153
|
function describeIntegrationLane(integration) {
|
|
1046
1154
|
return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
|
|
1047
1155
|
}
|
|
@@ -1759,14 +1867,20 @@ function SourceDropdown({ widget, dataModelTables, onChange }) {
|
|
|
1759
1867
|
})();
|
|
1760
1868
|
|
|
1761
1869
|
function selectObject(table) {
|
|
1762
|
-
|
|
1870
|
+
const nextConfig = {
|
|
1763
1871
|
...widget.config,
|
|
1764
1872
|
source: table.source,
|
|
1765
1873
|
columns: table.columns,
|
|
1766
1874
|
rows: [],
|
|
1767
1875
|
binding: { mode: "manual", source: table.source, sourceType: DATA_MODEL_SOURCE_TYPE, sourceAuthority: "workspace-config", objectId: table.objectId },
|
|
1768
1876
|
fieldSettings: { hidden: [], order: table.columns }
|
|
1769
|
-
}
|
|
1877
|
+
};
|
|
1878
|
+
if (widget.kind === "chart") {
|
|
1879
|
+
const { config: recomputed } = recomputeChartConfig(nextConfig, dataModelTables);
|
|
1880
|
+
onChange(recomputed);
|
|
1881
|
+
} else {
|
|
1882
|
+
onChange(nextConfig);
|
|
1883
|
+
}
|
|
1770
1884
|
setOpen(false);
|
|
1771
1885
|
setQuery("");
|
|
1772
1886
|
}
|
|
@@ -2266,7 +2380,7 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2266
2380
|
if (binding.sourceType === DATA_MODEL_SOURCE_TYPE && binding.objectId) {
|
|
2267
2381
|
if (!window.confirm(`Change source to "${table.label}"?`)) return;
|
|
2268
2382
|
}
|
|
2269
|
-
|
|
2383
|
+
const nextConfig = {
|
|
2270
2384
|
...widget.config,
|
|
2271
2385
|
source: table.source,
|
|
2272
2386
|
columns: table.columns,
|
|
@@ -2279,8 +2393,16 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2279
2393
|
objectId: table.objectId,
|
|
2280
2394
|
},
|
|
2281
2395
|
fieldSettings: { hidden: [], order: table.columns }
|
|
2282
|
-
}
|
|
2283
|
-
|
|
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]);
|
|
2284
2406
|
|
|
2285
2407
|
const activeObjectId = binding.sourceType === DATA_MODEL_SOURCE_TYPE ? binding.objectId : null;
|
|
2286
2408
|
|
|
@@ -2300,6 +2422,10 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2300
2422
|
{savedObjects.length > 0 ? savedObjects.map((table) => {
|
|
2301
2423
|
const isActive = activeObjectId === table.objectId;
|
|
2302
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";
|
|
2303
2429
|
return (
|
|
2304
2430
|
<button
|
|
2305
2431
|
key={table.id}
|
|
@@ -2314,6 +2440,7 @@ function SourceSubPanel({ widget, dataModelTables, onChange, onBack }) {
|
|
|
2314
2440
|
<strong>{table.label}</strong>
|
|
2315
2441
|
<em>{table.columns.length} field{table.columns.length !== 1 ? "s" : ""} · {table.rows.length} record{table.rows.length !== 1 ? "s" : ""}</em>
|
|
2316
2442
|
</span>
|
|
2443
|
+
{showLiveBadge ? <span className="workspace-source-badge badge-live" aria-label="Live source">Live</span> : null}
|
|
2317
2444
|
{isActive && <Check size={14} strokeWidth={2.5} aria-hidden="true" />}
|
|
2318
2445
|
</button>
|
|
2319
2446
|
);
|
|
@@ -2738,30 +2865,208 @@ function FilterSubPanel({ widget, integrations, dataModelTable, adapterConfig, o
|
|
|
2738
2865
|
</section>;
|
|
2739
2866
|
}
|
|
2740
2867
|
|
|
2741
|
-
|
|
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 }) {
|
|
2742
3022
|
const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
|
|
2743
3023
|
const xAxis = getChartAxis(widget, "xAxis");
|
|
2744
3024
|
const yAxis = getChartAxis(widget, "yAxis");
|
|
2745
3025
|
const style = getChartStyle(widget);
|
|
2746
3026
|
const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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.
|
|
2750
3042
|
const setStyle = (patch) => onChange({ ...widget.config, style: { ...style, ...patch } });
|
|
2751
3043
|
|
|
2752
3044
|
// Derive source fields from the bound data model object
|
|
2753
|
-
const
|
|
3045
|
+
const boundTable = useMemo(() => {
|
|
2754
3046
|
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 || [];
|
|
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;
|
|
2759
3050
|
}, [widget.config?.binding, dataModelTables]);
|
|
2760
3051
|
|
|
3052
|
+
const sourceFields = boundTable?.columns || [];
|
|
2761
3053
|
const hasSource = sourceFields.length > 0;
|
|
2762
3054
|
|
|
2763
|
-
|
|
2764
|
-
|
|
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 />
|
|
2765
3070
|
<div className="workspace-chart-type-tabs" role="tablist" aria-label="Chart type">
|
|
2766
3071
|
{VISIBLE_CHART_TYPES.map((type) => {
|
|
2767
3072
|
const TypeIcon = CHART_TYPE_ICONS[type];
|
|
@@ -2780,17 +3085,27 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2780
3085
|
})}
|
|
2781
3086
|
</div>
|
|
2782
3087
|
|
|
2783
|
-
<
|
|
2784
|
-
<
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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}
|
|
2790
3106
|
|
|
2791
3107
|
<p className="workspace-panel-label">X axis</p>
|
|
2792
|
-
<
|
|
2793
|
-
<span>Data on display</span>
|
|
3108
|
+
<WidgetSelectRow icon={Columns3} label="Data">
|
|
2794
3109
|
<FieldDropdown
|
|
2795
3110
|
fields={sourceFields}
|
|
2796
3111
|
value={xAxis.field || ""}
|
|
@@ -2798,23 +3113,21 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2798
3113
|
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2799
3114
|
disabled={!hasSource}
|
|
2800
3115
|
/>
|
|
2801
|
-
</
|
|
2802
|
-
<
|
|
2803
|
-
<span>Sort by</span>
|
|
3116
|
+
</WidgetSelectRow>
|
|
3117
|
+
<WidgetSelectRow icon={SlidersHorizontal} label="Sort">
|
|
2804
3118
|
<select value={xAxis.sort || "position"} onChange={(event) => setXAxis({ sort: event.target.value })}>
|
|
2805
3119
|
<option value="position">Position asc</option>
|
|
2806
3120
|
<option value="asc">Value asc</option>
|
|
2807
3121
|
<option value="desc">Value desc</option>
|
|
2808
3122
|
</select>
|
|
2809
|
-
</
|
|
2810
|
-
<label className="workspace-toggle-row">
|
|
3123
|
+
</WidgetSelectRow>
|
|
3124
|
+
<label className="workspace-twenty-toggle-row">
|
|
2811
3125
|
<span>Omit zero values</span>
|
|
2812
3126
|
<input type="checkbox" checked={Boolean(xAxis.omitZero)} onChange={(event) => setXAxis({ omitZero: event.target.checked })} />
|
|
2813
3127
|
</label>
|
|
2814
3128
|
|
|
2815
3129
|
<p className="workspace-panel-label">Y axis</p>
|
|
2816
|
-
<
|
|
2817
|
-
<span>Data on display</span>
|
|
3130
|
+
<WidgetSelectRow icon={Hash} label="Data">
|
|
2818
3131
|
<FieldDropdown
|
|
2819
3132
|
fields={sourceFields}
|
|
2820
3133
|
value={yAxis.field || ""}
|
|
@@ -2822,9 +3135,8 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2822
3135
|
placeholder={hasSource ? "Select field…" : "Select source first"}
|
|
2823
3136
|
disabled={!hasSource}
|
|
2824
3137
|
/>
|
|
2825
|
-
</
|
|
2826
|
-
<
|
|
2827
|
-
<span>Group by</span>
|
|
3138
|
+
</WidgetSelectRow>
|
|
3139
|
+
<WidgetSelectRow icon={Layers} label="Group by">
|
|
2828
3140
|
<FieldDropdown
|
|
2829
3141
|
fields={sourceFields}
|
|
2830
3142
|
value={yAxis.groupBy || ""}
|
|
@@ -2832,13 +3144,12 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2832
3144
|
placeholder="None"
|
|
2833
3145
|
disabled={!hasSource}
|
|
2834
3146
|
/>
|
|
2835
|
-
</
|
|
2836
|
-
<
|
|
2837
|
-
<
|
|
2838
|
-
|
|
2839
|
-
{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>)}
|
|
2840
3151
|
</select>
|
|
2841
|
-
</
|
|
3152
|
+
</WidgetSelectRow>
|
|
2842
3153
|
<div className="workspace-axis-range">
|
|
2843
3154
|
<label>
|
|
2844
3155
|
<span>Min range</span>
|
|
@@ -2850,8 +3161,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2850
3161
|
</label>
|
|
2851
3162
|
</div>
|
|
2852
3163
|
<p className="workspace-panel-label">Style</p>
|
|
2853
|
-
<label>
|
|
2854
|
-
<span>Colors</span>
|
|
3164
|
+
<WidgetSelectRow icon={Star} label="Colors">
|
|
2855
3165
|
<select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
|
|
2856
3166
|
<option value="auto">Auto</option>
|
|
2857
3167
|
<option value="accent">Accent</option>
|
|
@@ -2859,7 +3169,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2859
3169
|
<option value="brand-bridge">Bridge brand kit</option>
|
|
2860
3170
|
<option value="manual">Manual</option>
|
|
2861
3171
|
</select>
|
|
2862
|
-
</
|
|
3172
|
+
</WidgetSelectRow>
|
|
2863
3173
|
<div className="workspace-color-preview-row">
|
|
2864
3174
|
<span>Active color</span>
|
|
2865
3175
|
<em style={{ background: activeColor }} />
|
|
@@ -2888,7 +3198,7 @@ function ChartConfigPanel({ widget, branding, dataModelTables, onChange, onSubPa
|
|
|
2888
3198
|
onChange={(event) => setStyle({ axisName: event.target.value })}
|
|
2889
3199
|
/>
|
|
2890
3200
|
</label>
|
|
2891
|
-
<label className="workspace-toggle-row">
|
|
3201
|
+
<label className="workspace-twenty-toggle-row">
|
|
2892
3202
|
<span>Data labels</span>
|
|
2893
3203
|
<input
|
|
2894
3204
|
type="checkbox"
|
|
@@ -3492,14 +3802,14 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
|
|
|
3492
3802
|
</div>;
|
|
3493
3803
|
}
|
|
3494
3804
|
|
|
3495
|
-
function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
3805
|
+
function WorkspaceBuilder({ initialConfig, initialSourceRecords, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
|
|
3496
3806
|
const searchParams = useSearchParams();
|
|
3497
3807
|
const [config, setConfig] = useState(() => {
|
|
3498
3808
|
const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
|
|
3499
3809
|
? initialConfig.dashboards.map((dashboard, index) =>
|
|
3500
3810
|
normalizeDashboard(dashboard, index === 0 ? initialConfig.canvas : undefined)
|
|
3501
3811
|
)
|
|
3502
|
-
: [createDashboardRecord("
|
|
3812
|
+
: [createDashboardRecord("New Dashboard")];
|
|
3503
3813
|
return {
|
|
3504
3814
|
...initialConfig,
|
|
3505
3815
|
dashboards,
|
|
@@ -3516,10 +3826,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3516
3826
|
const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
|
|
3517
3827
|
const [editingWorkflowId, setEditingWorkflowId] = useState(null);
|
|
3518
3828
|
const [editingWorkflowDraft, setEditingWorkflowDraft] = useState("");
|
|
3829
|
+
const [editingTabId, setEditingTabId] = useState(null);
|
|
3830
|
+
const [editingTabDraft, setEditingTabDraft] = useState("");
|
|
3519
3831
|
const [workspaceView, setWorkspaceView] = useState("dashboards");
|
|
3520
3832
|
const [builderListFilter, setBuilderListFilter] = useState({ type: "all", query: "" });
|
|
3521
3833
|
const [builderActionMenuId, setBuilderActionMenuId] = useState(null);
|
|
3522
3834
|
const [builderActionMenuPlacement, setBuilderActionMenuPlacement] = useState(null);
|
|
3835
|
+
const [dashboardDraftMode, setDashboardDraftMode] = useState(false);
|
|
3836
|
+
const [dashboardLiveSnapshot, setDashboardLiveSnapshot] = useState(null);
|
|
3523
3837
|
const [activeDashboardId, setActiveDashboardId] = useState(() =>
|
|
3524
3838
|
getActiveDashboardId(
|
|
3525
3839
|
Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
|
|
@@ -3565,8 +3879,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3565
3879
|
const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
|
|
3566
3880
|
const activeWidgets = activeTab.widgets || [];
|
|
3567
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";
|
|
3568
3888
|
const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
|
|
3569
3889
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null);
|
|
3890
|
+
const [pendingSelectedWidgetId, setPendingSelectedWidgetId] = useState(null);
|
|
3570
3891
|
const [dragStartCell, setDragStartCell] = useState(null);
|
|
3571
3892
|
const [dragPreview, setDragPreview] = useState(null);
|
|
3572
3893
|
const [resizeDrag, setResizeDrag] = useState(null);
|
|
@@ -3582,13 +3903,26 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3582
3903
|
const [expandedIframeWidget, setExpandedIframeWidget] = useState(null);
|
|
3583
3904
|
const [refreshing, setRefreshing] = useState(false);
|
|
3584
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
|
+
);
|
|
3585
3915
|
const resizeDragRef = useRef(null);
|
|
3586
3916
|
const moveDragRef = useRef(null);
|
|
3587
3917
|
const importInputRef = useRef(null);
|
|
3588
3918
|
const addSlot = dragPreview || selectedPosition;
|
|
3589
|
-
const
|
|
3919
|
+
const selectedWidgetLookupId = selectedWidgetId || pendingSelectedWidgetId;
|
|
3920
|
+
const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetLookupId) || null;
|
|
3590
3921
|
const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
|
|
3591
|
-
const dataModelTables = useMemo(
|
|
3922
|
+
const dataModelTables = useMemo(
|
|
3923
|
+
() => listWorkspaceDataModelTables(config, { sourceRecords: workspaceSourceRecords }),
|
|
3924
|
+
[config, workspaceSourceRecords]
|
|
3925
|
+
);
|
|
3592
3926
|
const selectedResolvedWidget = selectedWidget ? resolveViewWidget(selectedWidget, dataModelTables) : null;
|
|
3593
3927
|
const branding = config.branding || {};
|
|
3594
3928
|
const occupiedCells = useMemo(() => {
|
|
@@ -3604,20 +3938,57 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3604
3938
|
}, [activeWidgets]);
|
|
3605
3939
|
|
|
3606
3940
|
/**
|
|
3607
|
-
* Collect
|
|
3608
|
-
*
|
|
3609
|
-
*
|
|
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.
|
|
3610
3950
|
*/
|
|
3611
3951
|
const liveSourceIds = useMemo(() => {
|
|
3612
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
|
+
};
|
|
3613
3960
|
for (const widget of activeWidgets) {
|
|
3614
3961
|
const binding = widget?.config?.binding;
|
|
3615
|
-
if (binding
|
|
3616
|
-
|
|
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
|
+
}
|
|
3617
3981
|
}
|
|
3618
3982
|
}
|
|
3619
3983
|
return Array.from(ids);
|
|
3620
|
-
}, [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());
|
|
3621
3992
|
|
|
3622
3993
|
const refreshSources = useCallback(async () => {
|
|
3623
3994
|
if (refreshing || liveSourceIds.length === 0) return;
|
|
@@ -3629,20 +4000,96 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3629
4000
|
headers: { "content-type": "application/json" },
|
|
3630
4001
|
body: JSON.stringify({ sourceIds: liveSourceIds })
|
|
3631
4002
|
});
|
|
3632
|
-
if (response.ok) {
|
|
3633
|
-
const data = await response.json();
|
|
3634
|
-
setRefreshResult({ refreshed: data.refreshed?.length || 0, skipped: data.skipped?.length || 0 });
|
|
3635
|
-
} else {
|
|
4003
|
+
if (!response.ok) {
|
|
3636
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.
|
|
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
|
+
}
|
|
3637
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
|
+
});
|
|
3638
4084
|
} catch {
|
|
3639
4085
|
setRefreshResult({ error: true });
|
|
3640
4086
|
} finally {
|
|
3641
4087
|
setRefreshing(false);
|
|
3642
4088
|
}
|
|
3643
|
-
}, [refreshing, liveSourceIds]);
|
|
4089
|
+
}, [refreshing, liveSourceIds, workspaceSourceRecords, config]);
|
|
3644
4090
|
|
|
3645
4091
|
const addWidget = useCallback((kind) => {
|
|
4092
|
+
if (!dashboardDraftMode) return;
|
|
3646
4093
|
setConfig((prev) => {
|
|
3647
4094
|
const prevTabs = getTabs(prev.canvas);
|
|
3648
4095
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -3662,11 +4109,20 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3662
4109
|
tab.id === prevActiveId ? { ...tab, widgets: [...(tab.widgets || []), widget] } : tab
|
|
3663
4110
|
);
|
|
3664
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);
|
|
3665
4121
|
setSelectedPosition(findFreePosition([...existingWidgets, widget]));
|
|
3666
4122
|
setDragPreview(null);
|
|
3667
4123
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
3668
4124
|
});
|
|
3669
|
-
}, [activeDashboardId, addSlot]);
|
|
4125
|
+
}, [activeDashboardId, addSlot, dashboardDraftMode]);
|
|
3670
4126
|
|
|
3671
4127
|
const switchTab = useCallback((tabId) => {
|
|
3672
4128
|
setConfig((prev) => {
|
|
@@ -3675,13 +4131,38 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3675
4131
|
if (!prevTabs.some((tab) => tab.id === tabId)) return prev;
|
|
3676
4132
|
const nextTab = prevTabs.find((tab) => tab.id === tabId);
|
|
3677
4133
|
setSelectedWidgetId(null);
|
|
4134
|
+
setPendingSelectedWidgetId(null);
|
|
3678
4135
|
setSelectedPosition(findFreePosition(nextTab?.widgets || []));
|
|
3679
4136
|
setDragPreview(null);
|
|
4137
|
+
setPanelOpen(false);
|
|
4138
|
+
setEditingTabId(null);
|
|
4139
|
+
setEditingTabDraft("");
|
|
3680
4140
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, prevTabs, tabId));
|
|
3681
4141
|
});
|
|
3682
4142
|
}, [activeDashboardId]);
|
|
3683
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
|
+
|
|
3684
4164
|
const addTab = useCallback(() => {
|
|
4165
|
+
if (!dashboardDraftMode) return;
|
|
3685
4166
|
setConfig((prev) => {
|
|
3686
4167
|
const prevTabs = getTabs(prev.canvas);
|
|
3687
4168
|
const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
|
|
@@ -3700,7 +4181,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3700
4181
|
setDragPreview(null);
|
|
3701
4182
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, newTab.id));
|
|
3702
4183
|
});
|
|
3703
|
-
}, [activeDashboardId]);
|
|
4184
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3704
4185
|
|
|
3705
4186
|
const addDashboard = useCallback(() => {
|
|
3706
4187
|
setConfig((prev) => {
|
|
@@ -3787,11 +4268,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3787
4268
|
setSelectedWidgetId(null);
|
|
3788
4269
|
setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
|
|
3789
4270
|
setDragPreview(null);
|
|
4271
|
+
setPanelOpen(false);
|
|
3790
4272
|
setEditingDashboardId(null);
|
|
3791
4273
|
setEditingDashboardDraft("");
|
|
4274
|
+
setDashboardDraftMode(false);
|
|
4275
|
+
setDashboardLiveSnapshot(null);
|
|
3792
4276
|
setActiveDashboardId(dashboard.id);
|
|
3793
4277
|
setWorkspaceView("builder");
|
|
3794
|
-
setConfigMessage(`
|
|
4278
|
+
setConfigMessage(`Viewing ${dashboard.name}`);
|
|
3795
4279
|
return {
|
|
3796
4280
|
...synced,
|
|
3797
4281
|
dashboards: prevDashboards.map((item) => item.id === dashboard.id ? normalized : item),
|
|
@@ -3863,7 +4347,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3863
4347
|
const prevDashboards = synced.dashboards || [];
|
|
3864
4348
|
if (!prevDashboards[index]) return prev;
|
|
3865
4349
|
if (prevDashboards.length <= 1) {
|
|
3866
|
-
const dashboard = createDashboardRecord("
|
|
4350
|
+
const dashboard = createDashboardRecord("New Dashboard");
|
|
3867
4351
|
setSelectedWidgetId(null);
|
|
3868
4352
|
setSelectedPosition({ ...DEFAULT_POSITION });
|
|
3869
4353
|
setDragPreview(null);
|
|
@@ -3899,6 +4383,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3899
4383
|
}, [cloneDashboard, resolvedActiveDashboardIndex]);
|
|
3900
4384
|
|
|
3901
4385
|
const duplicateTab = useCallback(() => {
|
|
4386
|
+
if (!dashboardDraftMode) return;
|
|
3902
4387
|
setConfig((prev) => {
|
|
3903
4388
|
const prevTabs = getTabs(prev.canvas);
|
|
3904
4389
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -3921,9 +4406,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3921
4406
|
setDragPreview(null);
|
|
3922
4407
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, cloned.id));
|
|
3923
4408
|
});
|
|
3924
|
-
}, [activeDashboardId]);
|
|
4409
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3925
4410
|
|
|
3926
4411
|
const deleteTab = useCallback((tabId) => {
|
|
4412
|
+
if (!dashboardDraftMode) return;
|
|
3927
4413
|
setConfig((prev) => {
|
|
3928
4414
|
const prevTabs = getTabs(prev.canvas);
|
|
3929
4415
|
const tab = prevTabs.find((item) => item.id === tabId);
|
|
@@ -3945,9 +4431,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3945
4431
|
setConfigMessage(`Deleted ${tab.name}`);
|
|
3946
4432
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, nextActiveTab.id));
|
|
3947
4433
|
});
|
|
3948
|
-
}, [activeDashboardId]);
|
|
4434
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3949
4435
|
|
|
3950
4436
|
const applyTemplateToCurrentTab = useCallback((templateId) => {
|
|
4437
|
+
if (!dashboardDraftMode) return;
|
|
3951
4438
|
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
3952
4439
|
if (!template) return;
|
|
3953
4440
|
const clonedTab = cloneTemplateToTab(template, { tabName: template.name, idFactory: generateId });
|
|
@@ -3979,7 +4466,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
3979
4466
|
setConfigMessage(`Applied ${template.name} to current tab`);
|
|
3980
4467
|
setTemplateGalleryOpen(false);
|
|
3981
4468
|
setPreviewTemplateId(null);
|
|
3982
|
-
}, [activeDashboardId]);
|
|
4469
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
3983
4470
|
|
|
3984
4471
|
const cloneTemplateAsDashboard = useCallback((templateId) => {
|
|
3985
4472
|
const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
|
|
@@ -4092,6 +4579,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4092
4579
|
dashboards: savedDashboards,
|
|
4093
4580
|
canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
|
|
4094
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());
|
|
4095
4585
|
} else {
|
|
4096
4586
|
setConfigMessage(payload.error || "Save failed");
|
|
4097
4587
|
}
|
|
@@ -4106,6 +4596,105 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4106
4596
|
await persistWorkspaceConfig(config, activeDashboardId);
|
|
4107
4597
|
}, [activeDashboardId, config, persistWorkspaceConfig]);
|
|
4108
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
|
+
|
|
4109
4698
|
const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
|
|
4110
4699
|
const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
|
|
4111
4700
|
setEditingDashboardId(null);
|
|
@@ -4219,18 +4808,24 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4219
4808
|
setEditingDashboardDraft("");
|
|
4220
4809
|
}, [editingDashboardDraft]);
|
|
4221
4810
|
|
|
4222
|
-
const closePanel = useCallback(() =>
|
|
4811
|
+
const closePanel = useCallback(() => {
|
|
4812
|
+
setPanelOpen(false);
|
|
4813
|
+
setSelectedWidgetId(null);
|
|
4814
|
+
setPendingSelectedWidgetId(null);
|
|
4815
|
+
}, []);
|
|
4223
4816
|
const beginCellDrag = useCallback((index, event) => {
|
|
4817
|
+
if (!dashboardDraftMode) return;
|
|
4224
4818
|
const x = index % GRID_COLUMNS;
|
|
4225
4819
|
const y = Math.floor(index / GRID_COLUMNS);
|
|
4226
4820
|
if (occupiedCells.has(`${x}:${y}`)) return;
|
|
4227
4821
|
event.preventDefault();
|
|
4228
4822
|
const position = normalizePosition(index, index);
|
|
4229
4823
|
setSelectedWidgetId(null);
|
|
4824
|
+
setPendingSelectedWidgetId(null);
|
|
4230
4825
|
setDragStartCell(index);
|
|
4231
4826
|
setDragPreview(position);
|
|
4232
4827
|
setPanelOpen(true);
|
|
4233
|
-
}, [occupiedCells]);
|
|
4828
|
+
}, [dashboardDraftMode, occupiedCells]);
|
|
4234
4829
|
const updateCellDrag = useCallback((index) => {
|
|
4235
4830
|
if (dragStartCell === null) return;
|
|
4236
4831
|
setDragPreview(normalizePosition(dragStartCell, index));
|
|
@@ -4254,14 +4849,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4254
4849
|
finishCellDrag(index ?? dragStartCell);
|
|
4255
4850
|
}, [dragStartCell, finishCellDrag]);
|
|
4256
4851
|
const beginResizeDrag = useCallback((widget, corner, event) => {
|
|
4852
|
+
if (!dashboardDraftMode) return;
|
|
4257
4853
|
event.preventDefault();
|
|
4258
4854
|
event.stopPropagation();
|
|
4259
4855
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
4260
4856
|
const nextResizeDrag = { widgetId: widget.id, corner, originalPosition: widget.position };
|
|
4261
4857
|
setSelectedWidgetId(widget.id);
|
|
4858
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4262
4859
|
resizeDragRef.current = nextResizeDrag;
|
|
4263
4860
|
setResizeDrag(nextResizeDrag);
|
|
4264
|
-
}, []);
|
|
4861
|
+
}, [dashboardDraftMode]);
|
|
4265
4862
|
const updateResizeDrag = useCallback((event) => {
|
|
4266
4863
|
const activeResizeDrag = resizeDragRef.current;
|
|
4267
4864
|
if (!activeResizeDrag) return;
|
|
@@ -4292,6 +4889,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4292
4889
|
setResizeDrag(null);
|
|
4293
4890
|
}, []);
|
|
4294
4891
|
const beginMoveDrag = useCallback((widget, event) => {
|
|
4892
|
+
if (!dashboardDraftMode) return;
|
|
4295
4893
|
event.preventDefault();
|
|
4296
4894
|
event.stopPropagation();
|
|
4297
4895
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
@@ -4303,10 +4901,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4303
4901
|
offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
|
|
4304
4902
|
};
|
|
4305
4903
|
setSelectedWidgetId(widget.id);
|
|
4904
|
+
setPendingSelectedWidgetId(widget.id);
|
|
4306
4905
|
setPanelOpen(true);
|
|
4307
4906
|
moveDragRef.current = nextMoveDrag;
|
|
4308
4907
|
setMoveDrag(nextMoveDrag);
|
|
4309
|
-
}, []);
|
|
4908
|
+
}, [dashboardDraftMode]);
|
|
4310
4909
|
const updateMoveDrag = useCallback((event) => {
|
|
4311
4910
|
const activeMoveDrag = moveDragRef.current;
|
|
4312
4911
|
if (!activeMoveDrag) return;
|
|
@@ -4341,10 +4940,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4341
4940
|
setMoveDrag(null);
|
|
4342
4941
|
}, []);
|
|
4343
4942
|
const selectWidget = useCallback((widgetId) => {
|
|
4943
|
+
if (!dashboardDraftMode) return;
|
|
4344
4944
|
setSelectedWidgetId(widgetId);
|
|
4945
|
+
setPendingSelectedWidgetId(widgetId);
|
|
4345
4946
|
setInspectorPath(SUB_PANEL_ROOT);
|
|
4346
4947
|
setPanelOpen(true);
|
|
4347
|
-
}, []);
|
|
4948
|
+
}, [dashboardDraftMode]);
|
|
4348
4949
|
// Fetches all records from a resolver and persists them into the data model object,
|
|
4349
4950
|
// then syncs the updated dataModel into local React state.
|
|
4350
4951
|
const handleRefreshDataModelObject = useCallback(async (binding, objectId) => {
|
|
@@ -4365,7 +4966,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4365
4966
|
}, []);
|
|
4366
4967
|
|
|
4367
4968
|
const replaceSelectedWidgetConfig = useCallback((nextConfig) => {
|
|
4368
|
-
if (!
|
|
4969
|
+
if (!dashboardDraftMode) return;
|
|
4970
|
+
if (!selectedWidgetLookupId) return;
|
|
4369
4971
|
setConfig((prev) => {
|
|
4370
4972
|
const prevTabs = getTabs(prev.canvas);
|
|
4371
4973
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4374,15 +4976,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4374
4976
|
return {
|
|
4375
4977
|
...tab,
|
|
4376
4978
|
widgets: (tab.widgets || []).map((widget) =>
|
|
4377
|
-
widget.id ===
|
|
4979
|
+
widget.id === selectedWidgetLookupId ? { ...widget, config: nextConfig } : widget
|
|
4378
4980
|
)
|
|
4379
4981
|
};
|
|
4380
4982
|
});
|
|
4381
4983
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4382
4984
|
});
|
|
4383
|
-
}, [activeDashboardId,
|
|
4985
|
+
}, [activeDashboardId, dashboardDraftMode, selectedWidgetLookupId]);
|
|
4384
4986
|
const updateSelectedWidget = useCallback((updates) => {
|
|
4385
|
-
if (!
|
|
4987
|
+
if (!dashboardDraftMode) return;
|
|
4988
|
+
if (!selectedWidgetLookupId) return;
|
|
4386
4989
|
setConfig((prev) => {
|
|
4387
4990
|
const prevTabs = getTabs(prev.canvas);
|
|
4388
4991
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4391,18 +4994,19 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4391
4994
|
return {
|
|
4392
4995
|
...tab,
|
|
4393
4996
|
widgets: (tab.widgets || []).map((widget) =>
|
|
4394
|
-
widget.id ===
|
|
4997
|
+
widget.id === selectedWidgetLookupId ? { ...widget, ...updates } : widget
|
|
4395
4998
|
)
|
|
4396
4999
|
};
|
|
4397
5000
|
});
|
|
4398
5001
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4399
5002
|
});
|
|
4400
|
-
}, [activeDashboardId,
|
|
5003
|
+
}, [activeDashboardId, dashboardDraftMode, selectedWidgetLookupId]);
|
|
4401
5004
|
const updateSelectedWidgetConfig = useCallback((updates) => {
|
|
4402
5005
|
if (!selectedWidget) return;
|
|
4403
5006
|
updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
|
|
4404
5007
|
}, [selectedWidget, updateSelectedWidget]);
|
|
4405
5008
|
const removeSelectedWidget = useCallback((widgetId) => {
|
|
5009
|
+
if (!dashboardDraftMode) return;
|
|
4406
5010
|
setConfig((prev) => {
|
|
4407
5011
|
const prevTabs = getTabs(prev.canvas);
|
|
4408
5012
|
const prevActiveId = getActiveTabId(prev.canvas);
|
|
@@ -4412,10 +5016,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4412
5016
|
});
|
|
4413
5017
|
const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
|
|
4414
5018
|
setSelectedWidgetId(null);
|
|
5019
|
+
setPendingSelectedWidgetId(null);
|
|
4415
5020
|
setSelectedPosition(findFreePosition(nextActiveWidgets));
|
|
4416
5021
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
4417
5022
|
});
|
|
4418
|
-
}, [activeDashboardId]);
|
|
5023
|
+
}, [activeDashboardId, dashboardDraftMode]);
|
|
4419
5024
|
|
|
4420
5025
|
const duplicateSelectedWidget = useCallback(() => {
|
|
4421
5026
|
if (!selectedWidget) return;
|
|
@@ -4438,6 +5043,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4438
5043
|
return { ...tab, widgets: [...(tab.widgets || []), cloned] };
|
|
4439
5044
|
});
|
|
4440
5045
|
setSelectedWidgetId(cloned.id);
|
|
5046
|
+
setPendingSelectedWidgetId(cloned.id);
|
|
4441
5047
|
setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
|
|
4442
5048
|
setConfigMessage(`Duplicated ${selectedWidget.title}`);
|
|
4443
5049
|
return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
|
|
@@ -4452,6 +5058,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4452
5058
|
const closeManagement = useCallback(() => setManagementOpen(false), []);
|
|
4453
5059
|
const resetWidgetSelection = useCallback(() => {
|
|
4454
5060
|
setSelectedWidgetId(null);
|
|
5061
|
+
setPendingSelectedWidgetId(null);
|
|
4455
5062
|
setPanelOpen(true);
|
|
4456
5063
|
}, []);
|
|
4457
5064
|
const showDashboardHome = useCallback(() => {
|
|
@@ -4734,26 +5341,32 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4734
5341
|
)}
|
|
4735
5342
|
/>
|
|
4736
5343
|
|
|
4737
|
-
<section className="workspace-surface">
|
|
4738
|
-
<header className=
|
|
4739
|
-
<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}>
|
|
4740
5347
|
{workspaceView === "builder" ? <>
|
|
4741
|
-
<
|
|
5348
|
+
<span className="dm-workflow-title-muted">Dashboards</span>
|
|
5349
|
+
<span className="dm-workflow-title-separator">/</span>
|
|
4742
5350
|
<h1>{activeDashboard?.name || "Untitled"}</h1>
|
|
5351
|
+
<span className="dm-workflow-count">({activeWidgets.length}) · v{activeDashboard?.version || "1"} · {dashboardModeLabel}</span>
|
|
4743
5352
|
</> : <>
|
|
4744
5353
|
<p>Workspace home</p>
|
|
4745
5354
|
<h1>Builder</h1>
|
|
4746
5355
|
</>}
|
|
4747
5356
|
</div>
|
|
4748
|
-
<div className="
|
|
4749
|
-
<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">
|
|
4750
5366
|
<button type="button" onClick={addDashboard}><Plus size={15} />New Dashboard</button>
|
|
4751
5367
|
<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
5368
|
<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>
|
|
5369
|
+
</div>}
|
|
4757
5370
|
<input
|
|
4758
5371
|
ref={importInputRef}
|
|
4759
5372
|
type="file"
|
|
@@ -4831,7 +5444,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4831
5444
|
>✓</button>
|
|
4832
5445
|
</span> : <button
|
|
4833
5446
|
className={item.index === resolvedActiveDashboardIndex ? "active" : ""}
|
|
4834
|
-
onClick={() =>
|
|
5447
|
+
onClick={() => selectDashboard(item.index)}
|
|
4835
5448
|
type="button"
|
|
4836
5449
|
>{item.dashboard.name}</button>}
|
|
4837
5450
|
</span>
|
|
@@ -4939,13 +5552,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4939
5552
|
className={tab.id === activeTabId ? "active" : ""}
|
|
4940
5553
|
type="button"
|
|
4941
5554
|
onClick={() => switchTab(tab.id)}
|
|
5555
|
+
onDoubleClick={(event) => beginTabRename(tab, event)}
|
|
4942
5556
|
>
|
|
4943
|
-
|
|
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>}
|
|
4944
5576
|
<span
|
|
4945
5577
|
aria-label={`Delete tab ${tab.name}`}
|
|
4946
5578
|
className="workspace-tab-delete"
|
|
4947
5579
|
onClick={(event) => {
|
|
4948
5580
|
event.stopPropagation();
|
|
5581
|
+
if (!dashboardDraftMode) return;
|
|
4949
5582
|
deleteTab(tab.id);
|
|
4950
5583
|
}}
|
|
4951
5584
|
onKeyDown={(event) => {
|
|
@@ -4959,18 +5592,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
4959
5592
|
tabIndex={0}
|
|
4960
5593
|
>x</span>
|
|
4961
5594
|
</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>
|
|
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>
|
|
4974
5597
|
</div>
|
|
4975
5598
|
<div
|
|
4976
5599
|
className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
|
|
@@ -5010,7 +5633,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5010
5633
|
type="button"
|
|
5011
5634
|
/>;
|
|
5012
5635
|
})}
|
|
5013
|
-
<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={{
|
|
5014
5637
|
gridColumn: `${addSlot.x + 1} / span ${addSlot.w}`,
|
|
5015
5638
|
gridRow: `${addSlot.y + 1} / span ${addSlot.h}`
|
|
5016
5639
|
}}>
|
|
@@ -5105,12 +5728,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5105
5728
|
/>
|
|
5106
5729
|
|
|
5107
5730
|
{workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
|
|
5108
|
-
<div className="workspace-panel-title">
|
|
5731
|
+
{(inspectorPath === SUB_PANEL_ROOT || !selectedWidget) ? <div className="workspace-panel-title">
|
|
5109
5732
|
<button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
|
|
5110
|
-
<span aria-hidden="true">+</span>
|
|
5733
|
+
{selectedWidget ? <WidgetPanelHeaderIcon kind={selectedWidget.kind} /> : <span aria-hidden="true">+</span>}
|
|
5111
5734
|
<strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
|
|
5112
5735
|
{selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
|
|
5113
|
-
</div>
|
|
5736
|
+
</div> : null}
|
|
5114
5737
|
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
|
|
5115
5738
|
<button type="button" onClick={duplicateSelectedWidget}><Copy size={15} />Duplicate</button>
|
|
5116
5739
|
<button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}><Trash2 size={15} />Remove</button>
|
|
@@ -5142,6 +5765,17 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5142
5765
|
onChange={replaceSelectedWidgetConfig}
|
|
5143
5766
|
onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
|
|
5144
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}
|
|
5145
5779
|
{selectedWidget && inspectorPath === SUB_PANEL_ROOT ? <section className="workspace-widget-settings">
|
|
5146
5780
|
<label>
|
|
5147
5781
|
<span>Title</span>
|
|
@@ -5151,6 +5785,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5151
5785
|
widget={selectedWidget}
|
|
5152
5786
|
branding={branding}
|
|
5153
5787
|
dataModelTables={dataModelTables}
|
|
5788
|
+
unsaved={unsavedChartIds.has(selectedWidget.id)}
|
|
5154
5789
|
onChange={replaceSelectedWidgetConfig}
|
|
5155
5790
|
onSubPage={(name) => setInspectorPath(name)}
|
|
5156
5791
|
/> : null}
|
|
@@ -5180,30 +5815,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5180
5815
|
</small>
|
|
5181
5816
|
</label> : null}
|
|
5182
5817
|
{selectedWidget.kind === "view" ? <section className="workspace-field-stack">
|
|
5183
|
-
<div className="workspace-
|
|
5818
|
+
<div className="workspace-twenty-config" role="group" aria-label="View widget settings">
|
|
5184
5819
|
<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>
|
|
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")} />
|
|
5200
5825
|
</div>
|
|
5201
5826
|
</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
5827
|
</section> : null}
|
|
5208
5828
|
{!selectedWidget ? <section>
|
|
5209
5829
|
<div className="workspace-widget-empty">
|
|
@@ -5227,8 +5847,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5227
5847
|
})}
|
|
5228
5848
|
</div>
|
|
5229
5849
|
</section> : null}
|
|
5230
|
-
{inspectorPath === SUB_PANEL_ROOT ? <
|
|
5231
|
-
<
|
|
5850
|
+
{inspectorPath === SUB_PANEL_ROOT ? <details className="workspace-bindings" id="bindings">
|
|
5851
|
+
<summary>Config bindings and data model sync</summary>
|
|
5232
5852
|
{Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
|
|
5233
5853
|
<span>{key}</span>
|
|
5234
5854
|
<code>{String(value)}</code>
|
|
@@ -5237,7 +5857,15 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, in
|
|
|
5237
5857
|
<span>integrationAdapter</span>
|
|
5238
5858
|
<code>{adapterConfig.integrationAdapter}</code>
|
|
5239
5859
|
</div>
|
|
5240
|
-
|
|
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}
|
|
5241
5869
|
</aside> : null}
|
|
5242
5870
|
{expandedIframeWidget ? <IframePreviewModal widget={expandedIframeWidget} onClose={() => setExpandedIframeWidget(null)} /> : null}
|
|
5243
5871
|
{commandPaletteOpen ? <CommandPalette commands={paletteCommands} onClose={closeCommandPalette} /> : null}
|