@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
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure chart value computation.
|
|
3
|
+
*
|
|
4
|
+
* Responsibility:
|
|
5
|
+
* Convert normalized rows (Data Model object rows or hydrated sidecar
|
|
6
|
+
* source records) into the `number[]` projection persisted on
|
|
7
|
+
* `widget.config.values`. The chart renderer remains dumb — it reads
|
|
8
|
+
* `widget.config.values` and renders. This module is the only path that
|
|
9
|
+
* produces those values from row data.
|
|
10
|
+
*
|
|
11
|
+
* Authority boundary:
|
|
12
|
+
* - No browser fetch.
|
|
13
|
+
* - No provider logic.
|
|
14
|
+
* - No schema mutation.
|
|
15
|
+
* - No secrets read or written.
|
|
16
|
+
* - Never throws on user-supplied axis/filter combinations — invalid
|
|
17
|
+
* inputs return `values: []` plus structured `warnings[]`.
|
|
18
|
+
*
|
|
19
|
+
* Contract:
|
|
20
|
+
* computeChartValuesFromRows({ rows, xAxis, yAxis, filter, chartType })
|
|
21
|
+
* → { values: number[], rowCount: number, usedRowCount: number, warnings: string[] }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Aggregation vocabulary — extends the V1 `sum | avg | count | min | max`
|
|
26
|
+
* set with Twenty-style row-presence operations. Every operation here is
|
|
27
|
+
* a pure function over filtered rows (or per-bucket subsets after group-by);
|
|
28
|
+
* none of them require the Y field to be numeric.
|
|
29
|
+
*
|
|
30
|
+
* countAll — total rows in the bucket
|
|
31
|
+
* countEmpty — rows where Y is null/undefined/""
|
|
32
|
+
* countNotEmpty — rows where Y is non-empty
|
|
33
|
+
* countUnique — distinct non-empty Y values
|
|
34
|
+
* percentEmpty — countEmpty / countAll × 100
|
|
35
|
+
* percentNotEmpty — countNotEmpty / countAll × 100
|
|
36
|
+
* sum, avg, min, max — numeric operations (require Y to coerce to number)
|
|
37
|
+
*
|
|
38
|
+
* `count` is kept as an alias for `countAll` for backward compatibility with
|
|
39
|
+
* V1 charts whose configs still set `aggregation: "count"`.
|
|
40
|
+
*/
|
|
41
|
+
const KNOWN_AGGREGATIONS = [
|
|
42
|
+
"sum",
|
|
43
|
+
"avg",
|
|
44
|
+
"count",
|
|
45
|
+
"countAll",
|
|
46
|
+
"countEmpty",
|
|
47
|
+
"countNotEmpty",
|
|
48
|
+
"countUnique",
|
|
49
|
+
"percentEmpty",
|
|
50
|
+
"percentNotEmpty",
|
|
51
|
+
"min",
|
|
52
|
+
"max"
|
|
53
|
+
];
|
|
54
|
+
const COUNT_AGGREGATIONS = new Set(["count", "countAll", "countEmpty", "countNotEmpty", "countUnique"]);
|
|
55
|
+
const PERCENT_AGGREGATIONS = new Set(["percentEmpty", "percentNotEmpty"]);
|
|
56
|
+
// Aggregations that DO NOT require Y values to be numeric — they operate on
|
|
57
|
+
// row presence or emptiness, not row magnitude.
|
|
58
|
+
const NON_NUMERIC_AGGREGATIONS = new Set([
|
|
59
|
+
"count",
|
|
60
|
+
"countAll",
|
|
61
|
+
"countEmpty",
|
|
62
|
+
"countNotEmpty",
|
|
63
|
+
"countUnique",
|
|
64
|
+
"percentEmpty",
|
|
65
|
+
"percentNotEmpty"
|
|
66
|
+
]);
|
|
67
|
+
const DEFAULT_AGGREGATION = "sum";
|
|
68
|
+
|
|
69
|
+
function isEmptyValue(value) {
|
|
70
|
+
return value === undefined || value === null || value === "" || (typeof value === "string" && value.trim() === "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPlainObject(value) {
|
|
74
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function coerceNumber(value) {
|
|
78
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
79
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
80
|
+
if (typeof value === "string") {
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
if (!trimmed) return null;
|
|
83
|
+
const parsed = Number(trimmed.replace(/,/g, ""));
|
|
84
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fieldHasNumericValue(rows, field) {
|
|
90
|
+
if (!field) return false;
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
if (coerceNumber(row?.[field]) !== null) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function compareFilterValue(left, right, operator) {
|
|
98
|
+
const op = String(operator || "eq");
|
|
99
|
+
if (op === "isEmpty") return left === undefined || left === null || left === "";
|
|
100
|
+
if (op === "isNotEmpty") return !(left === undefined || left === null || left === "");
|
|
101
|
+
const ln = coerceNumber(left);
|
|
102
|
+
const rn = coerceNumber(right);
|
|
103
|
+
if (op === "gt") return ln !== null && rn !== null && ln > rn;
|
|
104
|
+
if (op === "lt") return ln !== null && rn !== null && ln < rn;
|
|
105
|
+
const ls = left === undefined || left === null ? "" : String(left);
|
|
106
|
+
const rs = right === undefined || right === null ? "" : String(right);
|
|
107
|
+
if (op === "eq") return ls === rs;
|
|
108
|
+
if (op === "ne") return ls !== rs;
|
|
109
|
+
if (op === "contains") return ls.toLowerCase().includes(rs.toLowerCase());
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function applyFilter(rows, filter) {
|
|
114
|
+
if (!isPlainObject(filter) || !Array.isArray(filter.clauses) || filter.clauses.length === 0) {
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
const op = filter.op === "or" ? "or" : "and";
|
|
118
|
+
return rows.filter((row) => {
|
|
119
|
+
const decisions = filter.clauses.map((clause) => {
|
|
120
|
+
if (!isPlainObject(clause)) return true;
|
|
121
|
+
const field = String(clause.fieldId || "").trim();
|
|
122
|
+
if (!field) return true;
|
|
123
|
+
const left = row?.[field];
|
|
124
|
+
return compareFilterValue(left, clause.value, clause.operator);
|
|
125
|
+
});
|
|
126
|
+
return op === "or" ? decisions.some(Boolean) : decisions.every(Boolean);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Aggregate a list of numeric values. Kept for backward compatibility and
|
|
132
|
+
* the V1 public surface — callers that already have a `number[]` (e.g. count
|
|
133
|
+
* aggregations) can hand them in directly.
|
|
134
|
+
*
|
|
135
|
+
* For Twenty-style row-presence operations (countEmpty, countNotEmpty,
|
|
136
|
+
* countUnique, percentEmpty, percentNotEmpty), use `aggregateRows` instead —
|
|
137
|
+
* those operations need the raw row set, not just numeric values.
|
|
138
|
+
*/
|
|
139
|
+
function aggregateValues(numbers, aggregation) {
|
|
140
|
+
if (!numbers.length) return null;
|
|
141
|
+
const agg = KNOWN_AGGREGATIONS.includes(aggregation) ? aggregation : DEFAULT_AGGREGATION;
|
|
142
|
+
if (agg === "count" || agg === "countAll") return numbers.length;
|
|
143
|
+
if (agg === "min") return numbers.reduce((acc, value) => (value < acc ? value : acc), numbers[0]);
|
|
144
|
+
if (agg === "max") return numbers.reduce((acc, value) => (value > acc ? value : acc), numbers[0]);
|
|
145
|
+
const sum = numbers.reduce((acc, value) => acc + value, 0);
|
|
146
|
+
if (agg === "avg") return sum / numbers.length;
|
|
147
|
+
return sum;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Aggregate rows under a Twenty-style operation. Returns a finite number or
|
|
152
|
+
* null (for empty buckets where the operation cannot be defined).
|
|
153
|
+
*
|
|
154
|
+
* - count* operations ignore the Y field entirely — they count row presence.
|
|
155
|
+
* - percent* operations return a 0-100 percentage of countEmpty/countNotEmpty
|
|
156
|
+
* over countAll for the bucket.
|
|
157
|
+
* - sum/avg/min/max coerce row[yField] to number and discard non-numeric.
|
|
158
|
+
*/
|
|
159
|
+
function aggregateRows(rowsForBucket, yField, aggregation) {
|
|
160
|
+
if (!Array.isArray(rowsForBucket) || rowsForBucket.length === 0) return null;
|
|
161
|
+
const agg = KNOWN_AGGREGATIONS.includes(aggregation) ? aggregation : DEFAULT_AGGREGATION;
|
|
162
|
+
const total = rowsForBucket.length;
|
|
163
|
+
|
|
164
|
+
if (agg === "count" || agg === "countAll") return total;
|
|
165
|
+
if (agg === "countEmpty") return rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 1 : 0), 0);
|
|
166
|
+
if (agg === "countNotEmpty") return rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 0 : 1), 0);
|
|
167
|
+
if (agg === "countUnique") {
|
|
168
|
+
const set = new Set();
|
|
169
|
+
for (const row of rowsForBucket) {
|
|
170
|
+
const value = row?.[yField];
|
|
171
|
+
if (isEmptyValue(value)) continue;
|
|
172
|
+
set.add(String(value));
|
|
173
|
+
}
|
|
174
|
+
return set.size;
|
|
175
|
+
}
|
|
176
|
+
if (agg === "percentEmpty") {
|
|
177
|
+
if (!total) return null;
|
|
178
|
+
const empty = rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 1 : 0), 0);
|
|
179
|
+
return (empty / total) * 100;
|
|
180
|
+
}
|
|
181
|
+
if (agg === "percentNotEmpty") {
|
|
182
|
+
if (!total) return null;
|
|
183
|
+
const notEmpty = rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 0 : 1), 0);
|
|
184
|
+
return (notEmpty / total) * 100;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Numeric operations: coerce row[yField] to number and discard nulls.
|
|
188
|
+
const numbers = [];
|
|
189
|
+
for (const row of rowsForBucket) {
|
|
190
|
+
const coerced = coerceNumber(row?.[yField]);
|
|
191
|
+
if (coerced !== null) numbers.push(coerced);
|
|
192
|
+
}
|
|
193
|
+
return aggregateValues(numbers, agg);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function groupRows(rows, field) {
|
|
197
|
+
if (!field) return null;
|
|
198
|
+
const groups = new Map();
|
|
199
|
+
const order = [];
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const rawKey = row?.[field];
|
|
202
|
+
const key = rawKey === undefined || rawKey === null ? "" : String(rawKey);
|
|
203
|
+
if (!groups.has(key)) {
|
|
204
|
+
groups.set(key, []);
|
|
205
|
+
order.push(key);
|
|
206
|
+
}
|
|
207
|
+
groups.get(key).push(row);
|
|
208
|
+
}
|
|
209
|
+
return { order, groups };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sortGroups(order, valuesByKey, direction) {
|
|
213
|
+
const dir = direction === "desc" ? "desc" : direction === "asc" ? "asc" : "position";
|
|
214
|
+
if (dir === "position") return order;
|
|
215
|
+
const sorted = [...order];
|
|
216
|
+
sorted.sort((a, b) => {
|
|
217
|
+
const va = valuesByKey.get(a);
|
|
218
|
+
const vb = valuesByKey.get(b);
|
|
219
|
+
if (va === null && vb === null) return 0;
|
|
220
|
+
if (va === null) return 1;
|
|
221
|
+
if (vb === null) return -1;
|
|
222
|
+
return dir === "desc" ? vb - va : va - vb;
|
|
223
|
+
});
|
|
224
|
+
return sorted;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function omitZeroIfRequested(pairs, omitZero) {
|
|
228
|
+
if (!omitZero) return pairs;
|
|
229
|
+
return pairs.filter(([, value]) => value !== 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Compute the chart `values: number[]` projection from rows.
|
|
234
|
+
*
|
|
235
|
+
* - When `chartType === "count"` or `yAxis.aggregation === "count"`, every row
|
|
236
|
+
* in a bucket contributes 1 — the Y field is ignored entirely because
|
|
237
|
+
* counting is about row presence, not row values. A non-numeric Y field is
|
|
238
|
+
* therefore valid under count.
|
|
239
|
+
* - When the Y field is non-numeric and the aggregation needs numbers,
|
|
240
|
+
* the result is `values: []` plus a warning — never a throw.
|
|
241
|
+
* - When `yAxis.groupBy` is set, rows group on that key first; otherwise
|
|
242
|
+
* `xAxis.field` (if present) acts as the bucket key; otherwise every
|
|
243
|
+
* row contributes a single bucket.
|
|
244
|
+
*/
|
|
245
|
+
function computeChartValuesFromRows({
|
|
246
|
+
rows,
|
|
247
|
+
xAxis,
|
|
248
|
+
yAxis,
|
|
249
|
+
filter,
|
|
250
|
+
chartType
|
|
251
|
+
} = {}) {
|
|
252
|
+
const debug = computeChartProjectionDebug({ rows, xAxis, yAxis, filter, chartType });
|
|
253
|
+
return {
|
|
254
|
+
values: debug.values,
|
|
255
|
+
rowCount: debug.rowCount,
|
|
256
|
+
usedRowCount: debug.usedRowCount,
|
|
257
|
+
warnings: debug.warnings
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Same computation as `computeChartValuesFromRows`, but also returns the
|
|
263
|
+
* intermediate steps so the Chart Hydration Inspector can show why each
|
|
264
|
+
* value exists.
|
|
265
|
+
*
|
|
266
|
+
* The returned shape includes:
|
|
267
|
+
* - `samples`: first N source rows (preview only)
|
|
268
|
+
* - `filteredCount` and `droppedByFilter`
|
|
269
|
+
* - `buckets`: per-group breakdown ({ key, rowCount, numericCount, value, reason })
|
|
270
|
+
* - `droppedRows`: per-row drop reasons after filtering ("non-numeric-y",
|
|
271
|
+
* "missing-y", "filter-removed", "zero-omitted")
|
|
272
|
+
* - `values`: final number[] persisted on widget.config.values
|
|
273
|
+
*
|
|
274
|
+
* Pure: no fetch, no provider logic, no schema mutation.
|
|
275
|
+
*/
|
|
276
|
+
function computeChartProjectionDebug({
|
|
277
|
+
rows,
|
|
278
|
+
xAxis,
|
|
279
|
+
yAxis,
|
|
280
|
+
filter,
|
|
281
|
+
chartType
|
|
282
|
+
} = {}) {
|
|
283
|
+
const warnings = [];
|
|
284
|
+
const safeRows = Array.isArray(rows) ? rows.filter(isPlainObject) : [];
|
|
285
|
+
const rowCount = safeRows.length;
|
|
286
|
+
const samples = safeRows.slice(0, 5);
|
|
287
|
+
if (!rowCount) {
|
|
288
|
+
if (rows !== undefined && rows !== null) {
|
|
289
|
+
warnings.push("No rows available from the selected source.");
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
values: [],
|
|
293
|
+
rowCount: 0,
|
|
294
|
+
usedRowCount: 0,
|
|
295
|
+
filteredCount: 0,
|
|
296
|
+
droppedByFilter: 0,
|
|
297
|
+
buckets: [],
|
|
298
|
+
droppedRows: [],
|
|
299
|
+
samples,
|
|
300
|
+
warnings
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const filtered = applyFilter(safeRows, filter);
|
|
305
|
+
const droppedByFilter = rowCount - filtered.length;
|
|
306
|
+
if (!filtered.length) {
|
|
307
|
+
warnings.push("Filter clauses removed every row.");
|
|
308
|
+
return {
|
|
309
|
+
values: [],
|
|
310
|
+
rowCount,
|
|
311
|
+
usedRowCount: 0,
|
|
312
|
+
filteredCount: 0,
|
|
313
|
+
droppedByFilter,
|
|
314
|
+
buckets: [],
|
|
315
|
+
droppedRows: [],
|
|
316
|
+
samples,
|
|
317
|
+
warnings
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const xField = isPlainObject(xAxis) && typeof xAxis.field === "string" ? xAxis.field.trim() : "";
|
|
322
|
+
const yField = isPlainObject(yAxis) && typeof yAxis.field === "string" ? yAxis.field.trim() : "";
|
|
323
|
+
const groupField = isPlainObject(yAxis) && typeof yAxis.groupBy === "string" ? yAxis.groupBy.trim() : "";
|
|
324
|
+
const aggregationRaw = isPlainObject(yAxis) && typeof yAxis.operation === "string"
|
|
325
|
+
? yAxis.operation.trim()
|
|
326
|
+
: (isPlainObject(yAxis) && typeof yAxis.aggregation === "string" ? yAxis.aggregation.trim() : DEFAULT_AGGREGATION);
|
|
327
|
+
const aggregation = KNOWN_AGGREGATIONS.includes(aggregationRaw) ? aggregationRaw : DEFAULT_AGGREGATION;
|
|
328
|
+
const omitZero = isPlainObject(xAxis) ? Boolean(xAxis.omitZero) : false;
|
|
329
|
+
const sortDirection = isPlainObject(xAxis) && typeof xAxis.sort === "string" ? xAxis.sort : "position";
|
|
330
|
+
const chartKind = typeof chartType === "string" ? chartType : "";
|
|
331
|
+
const isCountAggregation = COUNT_AGGREGATIONS.has(aggregation) || chartKind === "count";
|
|
332
|
+
const isPercentAggregation = PERCENT_AGGREGATIONS.has(aggregation);
|
|
333
|
+
// Aggregation tolerates non-numeric Y when it operates on row presence or
|
|
334
|
+
// emptiness (count*, percent*) — every other operation needs at least one
|
|
335
|
+
// numeric Y value somewhere in the filtered set.
|
|
336
|
+
const tolerantOfNonNumericY = NON_NUMERIC_AGGREGATIONS.has(aggregation) || chartKind === "count";
|
|
337
|
+
|
|
338
|
+
if (!yField && !tolerantOfNonNumericY) {
|
|
339
|
+
warnings.push("Choose a Y axis field or switch aggregation to count.");
|
|
340
|
+
return {
|
|
341
|
+
values: [],
|
|
342
|
+
rowCount,
|
|
343
|
+
usedRowCount: 0,
|
|
344
|
+
filteredCount: filtered.length,
|
|
345
|
+
droppedByFilter,
|
|
346
|
+
buckets: [],
|
|
347
|
+
droppedRows: [],
|
|
348
|
+
samples,
|
|
349
|
+
warnings
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (yField && !tolerantOfNonNumericY && !fieldHasNumericValue(filtered, yField)) {
|
|
354
|
+
warnings.push(`Y field "${yField}" has no numeric values.`);
|
|
355
|
+
return {
|
|
356
|
+
values: [],
|
|
357
|
+
rowCount,
|
|
358
|
+
usedRowCount: 0,
|
|
359
|
+
filteredCount: filtered.length,
|
|
360
|
+
droppedByFilter,
|
|
361
|
+
buckets: [],
|
|
362
|
+
droppedRows: filtered.slice(0, 5).map((row) => ({ row, reason: "non-numeric-y" })),
|
|
363
|
+
samples,
|
|
364
|
+
warnings
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const bucketField = groupField || xField || "";
|
|
369
|
+
const grouped = bucketField ? groupRows(filtered, bucketField) : null;
|
|
370
|
+
const droppedRows = [];
|
|
371
|
+
|
|
372
|
+
// For numeric operations we still surface dropped-row diagnostics; for
|
|
373
|
+
// count/percent operations the Y field is ignored, so nothing is "dropped".
|
|
374
|
+
const trackNumericDrops = !tolerantOfNonNumericY;
|
|
375
|
+
const trackNumericDropsForBucket = (rowsForBucket) => {
|
|
376
|
+
if (!trackNumericDrops || !yField) return;
|
|
377
|
+
for (const row of rowsForBucket) {
|
|
378
|
+
const coerced = coerceNumber(row?.[yField]);
|
|
379
|
+
if (coerced === null) {
|
|
380
|
+
droppedRows.push({
|
|
381
|
+
row,
|
|
382
|
+
reason: isEmptyValue(row?.[yField]) ? "missing-y" : "non-numeric-y"
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const numericCountFor = (rowsForBucket) => {
|
|
389
|
+
if (!yField) return 0;
|
|
390
|
+
let count = 0;
|
|
391
|
+
for (const row of rowsForBucket) {
|
|
392
|
+
if (coerceNumber(row?.[yField]) !== null) count += 1;
|
|
393
|
+
}
|
|
394
|
+
return count;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const buckets = [];
|
|
398
|
+
let pairs;
|
|
399
|
+
if (!grouped) {
|
|
400
|
+
trackNumericDropsForBucket(filtered);
|
|
401
|
+
const value = aggregateRows(filtered, yField, aggregation);
|
|
402
|
+
if (value === null && !tolerantOfNonNumericY) {
|
|
403
|
+
warnings.push("No numeric values found.");
|
|
404
|
+
return {
|
|
405
|
+
values: [],
|
|
406
|
+
rowCount,
|
|
407
|
+
usedRowCount: filtered.length,
|
|
408
|
+
filteredCount: filtered.length,
|
|
409
|
+
droppedByFilter,
|
|
410
|
+
buckets: [],
|
|
411
|
+
droppedRows,
|
|
412
|
+
samples,
|
|
413
|
+
warnings
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
buckets.push({
|
|
417
|
+
key: "",
|
|
418
|
+
rowCount: filtered.length,
|
|
419
|
+
numericCount: numericCountFor(filtered),
|
|
420
|
+
value,
|
|
421
|
+
operation: aggregation
|
|
422
|
+
});
|
|
423
|
+
pairs = value === null ? [] : [["", value]];
|
|
424
|
+
} else {
|
|
425
|
+
const computed = new Map();
|
|
426
|
+
for (const key of grouped.order) {
|
|
427
|
+
const bucketRows = grouped.groups.get(key) || [];
|
|
428
|
+
trackNumericDropsForBucket(bucketRows);
|
|
429
|
+
const value = aggregateRows(bucketRows, yField, aggregation);
|
|
430
|
+
computed.set(key, value);
|
|
431
|
+
buckets.push({
|
|
432
|
+
key,
|
|
433
|
+
rowCount: bucketRows.length,
|
|
434
|
+
numericCount: numericCountFor(bucketRows),
|
|
435
|
+
value,
|
|
436
|
+
operation: aggregation
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const ordered = sortGroups(grouped.order, computed, sortDirection);
|
|
440
|
+
pairs = ordered
|
|
441
|
+
.map((key) => [key, computed.get(key)])
|
|
442
|
+
.filter(([, value]) => value !== null && Number.isFinite(value));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Cumulative running-total transform (Twenty-style yAxis.cumulative). Applied
|
|
446
|
+
// after sort/group and before omitZero so the displayed curve matches the
|
|
447
|
+
// sort direction the user picked.
|
|
448
|
+
const cumulative = isPlainObject(yAxis) ? Boolean(yAxis.cumulative) : false;
|
|
449
|
+
if (cumulative && pairs.length) {
|
|
450
|
+
let running = 0;
|
|
451
|
+
pairs = pairs.map(([key, value]) => {
|
|
452
|
+
running += value;
|
|
453
|
+
return [key, running];
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (isPercentAggregation) {
|
|
458
|
+
// Percent operations are already 0-100; nothing to clamp here.
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (omitZero) {
|
|
462
|
+
for (const [key, value] of pairs) {
|
|
463
|
+
if (value === 0) droppedRows.push({ key, reason: "zero-omitted" });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
pairs = omitZeroIfRequested(pairs, omitZero);
|
|
467
|
+
const values = pairs.map(([, value]) => value).filter((value) => Number.isFinite(value));
|
|
468
|
+
|
|
469
|
+
if (!values.length) {
|
|
470
|
+
warnings.push("Computation produced no finite values.");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
values,
|
|
475
|
+
rowCount,
|
|
476
|
+
usedRowCount: filtered.length,
|
|
477
|
+
filteredCount: filtered.length,
|
|
478
|
+
droppedByFilter,
|
|
479
|
+
buckets,
|
|
480
|
+
droppedRows,
|
|
481
|
+
samples,
|
|
482
|
+
warnings
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Derive a single status code the UI can render as a chip.
|
|
488
|
+
*
|
|
489
|
+
* Inputs:
|
|
490
|
+
* - widget: the chart widget
|
|
491
|
+
* - table: the bound Data Model table (or null)
|
|
492
|
+
* - computation: a `computeChartProjectionDebug` result
|
|
493
|
+
* - lastSavedValues: the values that were last persisted to disk
|
|
494
|
+
* - sourceFetchedAt: ISO timestamp of the last refresh, if any
|
|
495
|
+
* - readOnlySave: true when the runtime persistence is read-only
|
|
496
|
+
*
|
|
497
|
+
* Returns one of:
|
|
498
|
+
* "static" | "unbound" | "needs-source" | "needs-axis" | "warnings"
|
|
499
|
+
* "unsaved" | "stale-source" | "read-only-save-blocked" | "computed"
|
|
500
|
+
*/
|
|
501
|
+
function deriveChartHydrationState({
|
|
502
|
+
widget,
|
|
503
|
+
table,
|
|
504
|
+
computation,
|
|
505
|
+
lastSavedValues,
|
|
506
|
+
sourceFetchedAt,
|
|
507
|
+
readOnlySave
|
|
508
|
+
} = {}) {
|
|
509
|
+
const binding = widget?.config?.binding;
|
|
510
|
+
if (binding?.sourceType !== "workspace-data-model" || !binding.objectId) {
|
|
511
|
+
return Array.isArray(widget?.config?.values) && widget.config.values.length ? "static" : "unbound";
|
|
512
|
+
}
|
|
513
|
+
if (!table) return "needs-source";
|
|
514
|
+
const yField = isPlainObject(widget?.config?.yAxis) ? widget.config.yAxis.field : "";
|
|
515
|
+
const xField = isPlainObject(widget?.config?.xAxis) ? widget.config.xAxis.field : "";
|
|
516
|
+
const aggregation = isPlainObject(widget?.config?.yAxis) ? widget.config.yAxis.aggregation : "";
|
|
517
|
+
const isCount = aggregation === "count" || widget?.config?.chartType === "count";
|
|
518
|
+
if (!isCount && !yField) return "needs-axis";
|
|
519
|
+
if (!isCount && !xField) return "needs-axis";
|
|
520
|
+
if (computation?.warnings?.length) return "warnings";
|
|
521
|
+
const current = Array.isArray(widget?.config?.values) ? widget.config.values : [];
|
|
522
|
+
const saved = Array.isArray(lastSavedValues) ? lastSavedValues : current;
|
|
523
|
+
const valuesDiffer = current.length !== saved.length || current.some((value, index) => value !== saved[index]);
|
|
524
|
+
if (valuesDiffer && readOnlySave) return "read-only-save-blocked";
|
|
525
|
+
if (valuesDiffer) return "unsaved";
|
|
526
|
+
if (sourceFetchedAt && table.liveSource?.fetchedAt && sourceFetchedAt !== table.liveSource.fetchedAt) {
|
|
527
|
+
return "stale-source";
|
|
528
|
+
}
|
|
529
|
+
return "computed";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export {
|
|
533
|
+
KNOWN_AGGREGATIONS,
|
|
534
|
+
applyFilter,
|
|
535
|
+
aggregateRows,
|
|
536
|
+
aggregateValues,
|
|
537
|
+
coerceNumber,
|
|
538
|
+
computeChartProjectionDebug,
|
|
539
|
+
computeChartValuesFromRows,
|
|
540
|
+
deriveChartHydrationState,
|
|
541
|
+
groupRows
|
|
542
|
+
};
|