@bygd/nc-report-ui 0.1.42 → 0.1.43
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/dist/app/esm/index.js +311 -182
- package/dist/default/cjs/index.cjs +306 -181
- package/dist/default/esm/index.js +305 -181
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import { useInView } from 'react-intersection-observer';
|
|
|
17
17
|
import { FormControl, Autocomplete, TextField, CircularProgress as CircularProgress$1, Chip, Checkbox, FormHelperText, Snackbar, Alert, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, Box as Box$1, Typography as Typography$1, Tooltip as Tooltip$1, IconButton as IconButton$1, Paper as Paper$1, Tabs, Tab, Badge } from '@mui/material';
|
|
18
18
|
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
|
19
19
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
|
20
|
+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
20
21
|
import EventEmitter from 'eventemitter3';
|
|
21
22
|
import Grid from '@mui/material/Grid';
|
|
22
23
|
import IconButton from '@mui/material/IconButton';
|
|
@@ -1055,7 +1056,8 @@ function SingleSelect({
|
|
|
1055
1056
|
key,
|
|
1056
1057
|
value,
|
|
1057
1058
|
disabled,
|
|
1058
|
-
icon
|
|
1059
|
+
icon,
|
|
1060
|
+
alreadySelected
|
|
1059
1061
|
} = itm;
|
|
1060
1062
|
return /*#__PURE__*/React__default.createElement(MenuItem, {
|
|
1061
1063
|
key: key,
|
|
@@ -1066,7 +1068,12 @@ function SingleSelect({
|
|
|
1066
1068
|
minHeight: "36px",
|
|
1067
1069
|
display: "flex",
|
|
1068
1070
|
alignItems: "center",
|
|
1069
|
-
gap: "6px"
|
|
1071
|
+
gap: "6px",
|
|
1072
|
+
...(alreadySelected && {
|
|
1073
|
+
color: "rgb(70, 134, 128)",
|
|
1074
|
+
backgroundColor: "rgba(70, 134, 128, 0.08)",
|
|
1075
|
+
fontWeight: 600
|
|
1076
|
+
})
|
|
1070
1077
|
}
|
|
1071
1078
|
}, icon && /*#__PURE__*/React__default.createElement(Box, {
|
|
1072
1079
|
component: "span",
|
|
@@ -1074,7 +1081,13 @@ function SingleSelect({
|
|
|
1074
1081
|
display: "inline-flex",
|
|
1075
1082
|
alignItems: "center"
|
|
1076
1083
|
}
|
|
1077
|
-
}, icon),
|
|
1084
|
+
}, icon), alreadySelected && /*#__PURE__*/React__default.createElement(CheckCircleIcon, {
|
|
1085
|
+
fontSize: "small",
|
|
1086
|
+
sx: {
|
|
1087
|
+
color: "rgb(70, 134, 128)",
|
|
1088
|
+
fontSize: "16px"
|
|
1089
|
+
}
|
|
1090
|
+
}), formatLabel(value));
|
|
1078
1091
|
}))));
|
|
1079
1092
|
}
|
|
1080
1093
|
|
|
@@ -2663,6 +2676,29 @@ const MoveButtons = ({
|
|
|
2663
2676
|
fontSize: "small"
|
|
2664
2677
|
})));
|
|
2665
2678
|
|
|
2679
|
+
// Builds an identity string for a saved dimension/metric instance. Deliberate
|
|
2680
|
+
// duplicates (the same field added more than once) are allowed - the first
|
|
2681
|
+
// occurrence keeps the plain fullPath (so existing saved reports and their
|
|
2682
|
+
// title-override keys stay backward compatible), later occurrences get a
|
|
2683
|
+
// `#n` suffix.
|
|
2684
|
+
const makeInstanceId = (fullPath, occurrenceIndex) => occurrenceIndex === 0 ? fullPath : `${fullPath}#${occurrenceIndex}`;
|
|
2685
|
+
|
|
2686
|
+
// Picks the next free instance id for a newly-added fullPath, given the
|
|
2687
|
+
// items already saved. Scans occurrence numbers from 0 rather than just
|
|
2688
|
+
// counting existing instances with this fullPath, because counting alone
|
|
2689
|
+
// can collide: e.g. add "ft.currency" twice (ids "ft.currency",
|
|
2690
|
+
// "ft.currency#1"), remove the first, then add "ft.currency" again - a
|
|
2691
|
+
// plain count of 1 would regenerate "ft.currency#1", clashing with the
|
|
2692
|
+
// surviving duplicate.
|
|
2693
|
+
const nextInstanceId = (fullPath, existingItems) => {
|
|
2694
|
+
const existingIds = new Set(existingItems.map(item => item.id));
|
|
2695
|
+
let occurrenceIndex = 0;
|
|
2696
|
+
while (existingIds.has(makeInstanceId(fullPath, occurrenceIndex))) {
|
|
2697
|
+
occurrenceIndex += 1;
|
|
2698
|
+
}
|
|
2699
|
+
return makeInstanceId(fullPath, occurrenceIndex);
|
|
2700
|
+
};
|
|
2701
|
+
|
|
2666
2702
|
// Replace {{key}} placeholders in a title with values from the report
|
|
2667
2703
|
// parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
|
|
2668
2704
|
// Unknown keys are left as-is so missing parameters remain visible.
|
|
@@ -2689,14 +2725,17 @@ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
|
|
|
2689
2725
|
// Normalise every candidate item to a uniform { kind, key, data } shape
|
|
2690
2726
|
// up front, so callers (e.g. the Column Order tab) can always read
|
|
2691
2727
|
// `.key` regardless of where the item came from.
|
|
2728
|
+
// Keyed by instance id (not fullPath) so that two instances of the same
|
|
2729
|
+
// dimension/metric - added deliberately as duplicates - are treated as
|
|
2730
|
+
// distinct columns rather than collapsing into one.
|
|
2692
2731
|
const normalizedDimensionItems = orderedDimensionItems.map(item => ({
|
|
2693
2732
|
kind: item.kind,
|
|
2694
|
-
key: item.kind === 'computed' ? item.data.name : item.data.
|
|
2733
|
+
key: item.kind === 'computed' ? item.data.name : item.data.id,
|
|
2695
2734
|
data: item.data
|
|
2696
2735
|
}));
|
|
2697
2736
|
const normalizedMetricItems = metrics.map(m => ({
|
|
2698
2737
|
kind: 'metric',
|
|
2699
|
-
key: m.
|
|
2738
|
+
key: m.id,
|
|
2700
2739
|
data: m
|
|
2701
2740
|
}));
|
|
2702
2741
|
const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
|
|
@@ -2730,7 +2769,7 @@ const SortableChip$1 = ({
|
|
|
2730
2769
|
isLast,
|
|
2731
2770
|
sortOrder,
|
|
2732
2771
|
onSortOrderChange,
|
|
2733
|
-
|
|
2772
|
+
instanceId,
|
|
2734
2773
|
defaultTitle,
|
|
2735
2774
|
customTitle,
|
|
2736
2775
|
onUpdateTitle,
|
|
@@ -2813,7 +2852,7 @@ const SortableChip$1 = ({
|
|
|
2813
2852
|
handleCancel();
|
|
2814
2853
|
return;
|
|
2815
2854
|
}
|
|
2816
|
-
onUpdateTitle(
|
|
2855
|
+
onUpdateTitle(instanceId, trimmedValue);
|
|
2817
2856
|
setIsEditing(false);
|
|
2818
2857
|
};
|
|
2819
2858
|
const handleCancel = () => {
|
|
@@ -2821,7 +2860,7 @@ const SortableChip$1 = ({
|
|
|
2821
2860
|
setEditValue("");
|
|
2822
2861
|
};
|
|
2823
2862
|
const handleReset = () => {
|
|
2824
|
-
onResetTitle(
|
|
2863
|
+
onResetTitle(instanceId);
|
|
2825
2864
|
setIsEditing(false);
|
|
2826
2865
|
setEditValue("");
|
|
2827
2866
|
};
|
|
@@ -3032,7 +3071,7 @@ const SortableComputedChip = ({
|
|
|
3032
3071
|
const handleSave = () => {
|
|
3033
3072
|
const trimmedName = editName.trim();
|
|
3034
3073
|
const trimmedValue = editValue.trim();
|
|
3035
|
-
if (!trimmedName
|
|
3074
|
+
if (!trimmedName) return;
|
|
3036
3075
|
if (isNameTaken(trimmedName, name)) return;
|
|
3037
3076
|
onUpdate(name, {
|
|
3038
3077
|
name: trimmedName,
|
|
@@ -3182,7 +3221,7 @@ const SortableComputedChip = ({
|
|
|
3182
3221
|
onClick: handleSave,
|
|
3183
3222
|
color: "primary",
|
|
3184
3223
|
"aria-label": "save computed dimension",
|
|
3185
|
-
disabled: !editName.trim() ||
|
|
3224
|
+
disabled: !editName.trim() || nameError
|
|
3186
3225
|
}, /*#__PURE__*/React__default.createElement(CheckIcon, {
|
|
3187
3226
|
fontSize: "small"
|
|
3188
3227
|
})))), /*#__PURE__*/React__default.createElement(Tooltip$1, {
|
|
@@ -3231,12 +3270,14 @@ const Dimensions = ({
|
|
|
3231
3270
|
// Resolve the unified dimensionOrder into the actual dimension / computed
|
|
3232
3271
|
// dimension objects, so dimensions and computed dimensions can be shown
|
|
3233
3272
|
// (and dragged) as a single list while staying stored in their own arrays.
|
|
3234
|
-
|
|
3273
|
+
// Keyed by instance id rather than fullPath, so two instances of the same
|
|
3274
|
+
// dimension (added deliberately as a duplicate) stay distinct entries.
|
|
3275
|
+
const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
|
|
3235
3276
|
const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
|
|
3236
3277
|
const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
3237
3278
|
kind: "dimension",
|
|
3238
3279
|
key: entry.key,
|
|
3239
|
-
data:
|
|
3280
|
+
data: dimensionById[entry.key]
|
|
3240
3281
|
} : {
|
|
3241
3282
|
kind: "computed",
|
|
3242
3283
|
key: entry.key,
|
|
@@ -3250,9 +3291,13 @@ const Dimensions = ({
|
|
|
3250
3291
|
handleMoveDown
|
|
3251
3292
|
} = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
|
|
3252
3293
|
|
|
3253
|
-
// Handle sort order change (dimensions only - computed dimensions have no
|
|
3254
|
-
|
|
3255
|
-
|
|
3294
|
+
// Handle sort order change (dimensions only - computed dimensions have no
|
|
3295
|
+
// order_by). Keyed by instance id but applied query-wide: SQL can only
|
|
3296
|
+
// sort by a given column once, so toggling sort on any duplicate instance
|
|
3297
|
+
// of the same field is propagated to all instances sharing its fullPath
|
|
3298
|
+
// (handled in ReportBuilder's onUpdateDimensionSortOrder).
|
|
3299
|
+
const handleSortOrderChange = (id, sortOrder) => {
|
|
3300
|
+
onUpdateDimensionSortOrder(id, sortOrder);
|
|
3256
3301
|
};
|
|
3257
3302
|
|
|
3258
3303
|
// Computed Dimensions: { name, value } pairs independent of any provider.
|
|
@@ -3273,7 +3318,7 @@ const Dimensions = ({
|
|
|
3273
3318
|
const handleSaveComputed = () => {
|
|
3274
3319
|
const trimmedName = computedName.trim();
|
|
3275
3320
|
const trimmedValue = computedValue.trim();
|
|
3276
|
-
if (!trimmedName
|
|
3321
|
+
if (!trimmedName) return;
|
|
3277
3322
|
if (isComputedNameTaken(trimmedName)) return;
|
|
3278
3323
|
onSaveComputedDimension({
|
|
3279
3324
|
name: trimmedName,
|
|
@@ -3315,7 +3360,10 @@ const Dimensions = ({
|
|
|
3315
3360
|
currentProviderKey = selection.targetKey;
|
|
3316
3361
|
});
|
|
3317
3362
|
|
|
3318
|
-
// Create a Set of already selected dimension fullPaths for quick lookup
|
|
3363
|
+
// Create a Set of already selected dimension fullPaths for quick lookup.
|
|
3364
|
+
// Already-selected items stay selectable (so the same dimension can be
|
|
3365
|
+
// added again on purpose) - they're just flagged so the dropdown can
|
|
3366
|
+
// highlight them instead of blocking the click.
|
|
3319
3367
|
const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
|
|
3320
3368
|
const items = [];
|
|
3321
3369
|
// Iterate through aliases (e.g., 'ba', 'pc')
|
|
@@ -3327,9 +3375,6 @@ const Dimensions = ({
|
|
|
3327
3375
|
|
|
3328
3376
|
// Construct the fullPath for this dimension
|
|
3329
3377
|
const fullPath = `${aliasPath.join("_")}.${dimKey}`;
|
|
3330
|
-
|
|
3331
|
-
// Check if this dimension is already selected
|
|
3332
|
-
const isAlreadySelected = selectedFullPaths.has(fullPath);
|
|
3333
3378
|
items.push({
|
|
3334
3379
|
// Include provider name to ensure uniqueness across different providers
|
|
3335
3380
|
key: `${currentProvider}_${alias}.${dimKey}`,
|
|
@@ -3337,7 +3382,7 @@ const Dimensions = ({
|
|
|
3337
3382
|
dimensionKey: dimKey,
|
|
3338
3383
|
alias: alias,
|
|
3339
3384
|
dimension: dimension,
|
|
3340
|
-
|
|
3385
|
+
alreadySelected: selectedFullPaths.has(fullPath)
|
|
3341
3386
|
});
|
|
3342
3387
|
});
|
|
3343
3388
|
});
|
|
@@ -3399,7 +3444,10 @@ const Dimensions = ({
|
|
|
3399
3444
|
dimensionKey: selectedItem.dimensionKey,
|
|
3400
3445
|
dimensionTitle: selectedItem.value,
|
|
3401
3446
|
// The constructed path for server
|
|
3402
|
-
fullPath: fullPath
|
|
3447
|
+
fullPath: fullPath,
|
|
3448
|
+
// Unique per-instance identity, so this field can be added more than
|
|
3449
|
+
// once and each occurrence stays independently orderable/removable.
|
|
3450
|
+
id: nextInstanceId(fullPath, savedDimensions)
|
|
3403
3451
|
};
|
|
3404
3452
|
onSaveDimension(dimensionData);
|
|
3405
3453
|
handleCancel();
|
|
@@ -3410,8 +3458,9 @@ const Dimensions = ({
|
|
|
3410
3458
|
const handleAddAll = () => {
|
|
3411
3459
|
const dimensionItems = getDimensionItems();
|
|
3412
3460
|
|
|
3413
|
-
// Filter out already selected dimensions
|
|
3414
|
-
|
|
3461
|
+
// Filter out already selected dimensions - "Add all" only fills in
|
|
3462
|
+
// missing ones; intentional duplicates are added one at a time above.
|
|
3463
|
+
const availableItems = dimensionItems.filter(item => !item.alreadySelected);
|
|
3415
3464
|
if (availableItems.length === 0) return;
|
|
3416
3465
|
|
|
3417
3466
|
// Build the complete relation objects array (same for all dimensions in this provider)
|
|
@@ -3456,7 +3505,10 @@ const Dimensions = ({
|
|
|
3456
3505
|
dimensionKey: item.dimensionKey,
|
|
3457
3506
|
dimensionTitle: item.value,
|
|
3458
3507
|
// The constructed path for server
|
|
3459
|
-
fullPath: fullPath
|
|
3508
|
+
fullPath: fullPath,
|
|
3509
|
+
// "Add all" only ever adds fields that aren't already selected, so
|
|
3510
|
+
// this is always the first (and only) occurrence of this fullPath.
|
|
3511
|
+
id: fullPath
|
|
3460
3512
|
};
|
|
3461
3513
|
onSaveDimension(dimensionData);
|
|
3462
3514
|
});
|
|
@@ -3590,7 +3642,7 @@ const Dimensions = ({
|
|
|
3590
3642
|
}, "Cancel"), /*#__PURE__*/React__default.createElement(Button, {
|
|
3591
3643
|
variant: "contained",
|
|
3592
3644
|
onClick: handleSaveComputed,
|
|
3593
|
-
disabled: !computedName.trim() ||
|
|
3645
|
+
disabled: !computedName.trim() || computedNameError,
|
|
3594
3646
|
sx: {
|
|
3595
3647
|
height: "40px",
|
|
3596
3648
|
fontFamily: "system-ui",
|
|
@@ -3666,7 +3718,7 @@ const Dimensions = ({
|
|
|
3666
3718
|
placement: "top"
|
|
3667
3719
|
}, /*#__PURE__*/React__default.createElement("span", null, /*#__PURE__*/React__default.createElement(IconButton$1, {
|
|
3668
3720
|
onClick: handleAddAll,
|
|
3669
|
-
disabled: getDimensionItems().filter(item => !item.
|
|
3721
|
+
disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
|
|
3670
3722
|
"aria-label": "add all dimensions",
|
|
3671
3723
|
sx: {
|
|
3672
3724
|
color: "rgb(70, 134, 128)",
|
|
@@ -3757,9 +3809,9 @@ const Dimensions = ({
|
|
|
3757
3809
|
isLast: index === orderedItems.length - 1,
|
|
3758
3810
|
sortOrder: item.data.sortOrder || null,
|
|
3759
3811
|
onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
|
|
3760
|
-
|
|
3812
|
+
instanceId: item.data.id,
|
|
3761
3813
|
defaultTitle: item.data.dimensionTitle,
|
|
3762
|
-
customTitle: titleOverrides[item.data.
|
|
3814
|
+
customTitle: titleOverrides[item.data.id],
|
|
3763
3815
|
onUpdateTitle: onUpdateTitle,
|
|
3764
3816
|
onResetTitle: onResetTitle
|
|
3765
3817
|
}) : /*#__PURE__*/React__default.createElement(SortableComputedChip, {
|
|
@@ -3832,7 +3884,7 @@ const SortableChip = ({
|
|
|
3832
3884
|
onMoveDown,
|
|
3833
3885
|
isFirst,
|
|
3834
3886
|
isLast,
|
|
3835
|
-
|
|
3887
|
+
instanceId,
|
|
3836
3888
|
defaultTitle,
|
|
3837
3889
|
customTitle,
|
|
3838
3890
|
onUpdateTitle,
|
|
@@ -3885,7 +3937,7 @@ const SortableChip = ({
|
|
|
3885
3937
|
handleCancel();
|
|
3886
3938
|
return;
|
|
3887
3939
|
}
|
|
3888
|
-
onUpdateTitle(
|
|
3940
|
+
onUpdateTitle(instanceId, trimmedValue);
|
|
3889
3941
|
setIsEditing(false);
|
|
3890
3942
|
};
|
|
3891
3943
|
const handleCancel = () => {
|
|
@@ -3893,7 +3945,7 @@ const SortableChip = ({
|
|
|
3893
3945
|
setEditValue('');
|
|
3894
3946
|
};
|
|
3895
3947
|
const handleReset = () => {
|
|
3896
|
-
onResetTitle(
|
|
3948
|
+
onResetTitle(instanceId);
|
|
3897
3949
|
setIsEditing(false);
|
|
3898
3950
|
setEditValue('');
|
|
3899
3951
|
};
|
|
@@ -4114,7 +4166,10 @@ const Metrics = ({
|
|
|
4114
4166
|
currentProviderKey = selection.targetKey;
|
|
4115
4167
|
});
|
|
4116
4168
|
|
|
4117
|
-
// Create a Set of already selected metric fullPaths for quick lookup
|
|
4169
|
+
// Create a Set of already selected metric fullPaths for quick lookup.
|
|
4170
|
+
// Already-selected items stay selectable (so the same metric can be
|
|
4171
|
+
// added again on purpose) - they're just flagged so the dropdown can
|
|
4172
|
+
// highlight them instead of blocking the click.
|
|
4118
4173
|
const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
|
|
4119
4174
|
|
|
4120
4175
|
// Metrics are a direct array, not nested by alias
|
|
@@ -4122,15 +4177,12 @@ const Metrics = ({
|
|
|
4122
4177
|
const items = provider.metrics.map((metric, index) => {
|
|
4123
4178
|
// Construct the fullPath for this metric
|
|
4124
4179
|
const fullPath = `${aliasPath.join('_')}.${metric.name}`;
|
|
4125
|
-
|
|
4126
|
-
// Check if this metric is already selected
|
|
4127
|
-
const isAlreadySelected = selectedFullPaths.has(fullPath);
|
|
4128
4180
|
return {
|
|
4129
4181
|
key: `${currentProvider}_${metric.name}_${index}`,
|
|
4130
4182
|
value: metric.title || metric.name,
|
|
4131
4183
|
metricName: metric.name,
|
|
4132
4184
|
metric: metric,
|
|
4133
|
-
|
|
4185
|
+
alreadySelected: selectedFullPaths.has(fullPath),
|
|
4134
4186
|
icon: metric.source ? /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
|
|
4135
4187
|
source: metric.source
|
|
4136
4188
|
}) : undefined
|
|
@@ -4193,7 +4245,11 @@ const Metrics = ({
|
|
|
4193
4245
|
metricName: selectedItem.metricName,
|
|
4194
4246
|
metricTitle: selectedItem.value,
|
|
4195
4247
|
// The constructed path for server
|
|
4196
|
-
fullPath: fullPath
|
|
4248
|
+
fullPath: fullPath,
|
|
4249
|
+
// Unique per-instance identity, so this field can be added more
|
|
4250
|
+
// than once and each occurrence stays independently
|
|
4251
|
+
// orderable/removable/titleable.
|
|
4252
|
+
id: nextInstanceId(fullPath, savedMetrics)
|
|
4197
4253
|
};
|
|
4198
4254
|
onSaveMetric(metricData);
|
|
4199
4255
|
handleCancel();
|
|
@@ -4204,8 +4260,9 @@ const Metrics = ({
|
|
|
4204
4260
|
const handleAddAll = () => {
|
|
4205
4261
|
const metricItems = getMetricItems();
|
|
4206
4262
|
|
|
4207
|
-
// Filter out already selected metrics
|
|
4208
|
-
|
|
4263
|
+
// Filter out already selected metrics - "Add all" only fills in
|
|
4264
|
+
// missing ones; intentional duplicates are added one at a time above.
|
|
4265
|
+
const availableItems = metricItems.filter(item => !item.alreadySelected);
|
|
4209
4266
|
if (availableItems.length === 0) return;
|
|
4210
4267
|
|
|
4211
4268
|
// Build the complete relation objects array (same for all metrics in this provider)
|
|
@@ -4250,7 +4307,10 @@ const Metrics = ({
|
|
|
4250
4307
|
metricName: item.metricName,
|
|
4251
4308
|
metricTitle: item.value,
|
|
4252
4309
|
// The constructed path for server
|
|
4253
|
-
fullPath: fullPath
|
|
4310
|
+
fullPath: fullPath,
|
|
4311
|
+
// "Add all" only ever adds fields that aren't already
|
|
4312
|
+
// selected, so this is always the first occurrence.
|
|
4313
|
+
id: fullPath
|
|
4254
4314
|
};
|
|
4255
4315
|
onSaveMetric(metricData);
|
|
4256
4316
|
});
|
|
@@ -4349,7 +4409,7 @@ const Metrics = ({
|
|
|
4349
4409
|
placement: "top"
|
|
4350
4410
|
}, /*#__PURE__*/React__default.createElement("span", null, /*#__PURE__*/React__default.createElement(IconButton$1, {
|
|
4351
4411
|
onClick: handleAddAll,
|
|
4352
|
-
disabled: getMetricItems().filter(item => !item.
|
|
4412
|
+
disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
|
|
4353
4413
|
"aria-label": "add all metrics",
|
|
4354
4414
|
sx: {
|
|
4355
4415
|
color: "rgb(70, 134, 128)",
|
|
@@ -4427,14 +4487,14 @@ const Metrics = ({
|
|
|
4427
4487
|
id: index,
|
|
4428
4488
|
label: metric.metricTitle,
|
|
4429
4489
|
fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
|
|
4430
|
-
onDelete: () => onRemoveMetric(
|
|
4490
|
+
onDelete: () => onRemoveMetric(metric.id),
|
|
4431
4491
|
onMoveUp: () => handleMoveUp(index),
|
|
4432
4492
|
onMoveDown: () => handleMoveDown(index),
|
|
4433
4493
|
isFirst: index === 0,
|
|
4434
4494
|
isLast: index === savedMetrics.length - 1,
|
|
4435
|
-
|
|
4495
|
+
instanceId: metric.id,
|
|
4436
4496
|
defaultTitle: metric.metricTitle,
|
|
4437
|
-
customTitle: titleOverrides[metric.
|
|
4497
|
+
customTitle: titleOverrides[metric.id],
|
|
4438
4498
|
onUpdateTitle: onUpdateTitle,
|
|
4439
4499
|
onResetTitle: onResetTitle,
|
|
4440
4500
|
source: metric.metric?.source
|
|
@@ -5271,7 +5331,12 @@ const Filters = ({
|
|
|
5271
5331
|
mb: 3
|
|
5272
5332
|
}
|
|
5273
5333
|
}, /*#__PURE__*/React__default.createElement(SingleSelect, {
|
|
5274
|
-
items:
|
|
5334
|
+
items: Array.from(
|
|
5335
|
+
// Dimensions can now be added more than once (same fullPath,
|
|
5336
|
+
// different instance), but a filter targets the underlying
|
|
5337
|
+
// field regardless of how many columns display it - so this
|
|
5338
|
+
// list is deduped by fullPath to avoid showing it twice.
|
|
5339
|
+
new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
|
|
5275
5340
|
key: dim.fullPath,
|
|
5276
5341
|
value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
|
|
5277
5342
|
})).sort((a, b) => a.value.localeCompare(b.value)),
|
|
@@ -5854,7 +5919,7 @@ const ColumnOrder = ({
|
|
|
5854
5919
|
key: `${item.kind}-${item.key}`,
|
|
5855
5920
|
id: index,
|
|
5856
5921
|
label: labelFor(item),
|
|
5857
|
-
fullPath: item.kind === "computed" ? item.data.value : item.
|
|
5922
|
+
fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
|
|
5858
5923
|
kind: item.kind,
|
|
5859
5924
|
source: item.kind === "metric" ? item.data.metric?.source : undefined,
|
|
5860
5925
|
onMoveUp: () => handleMoveUp(index),
|
|
@@ -5880,11 +5945,17 @@ const formatValue = (value, def) => {
|
|
|
5880
5945
|
const isNumericType = type => type === 'integer' || type === 'currency';
|
|
5881
5946
|
|
|
5882
5947
|
// Builds a single DataGrid column definition for one resolved column item
|
|
5883
|
-
// (a dimension, computed dimension, or metric).
|
|
5948
|
+
// (a dimension, computed dimension, or metric). The DataGrid `field` is the
|
|
5949
|
+
// item's instance id (so two duplicate instances of the same underlying
|
|
5950
|
+
// dimension/metric still get distinct columns); the actual cell value is
|
|
5951
|
+
// read via `valueGetter` from `dataFieldName`, the key the API response
|
|
5952
|
+
// actually uses - shared by every duplicate instance, since the backend
|
|
5953
|
+
// only ever returns one value per fullPath. dataFieldName rules:
|
|
5884
5954
|
// - Dimensions from base provider: baseAlias.field -> baseAlias_field
|
|
5885
5955
|
// - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
|
|
5886
5956
|
// - Computed dimensions have no provider/relations chain, so the API returns
|
|
5887
|
-
// each row keyed by the computed dimension's own `name
|
|
5957
|
+
// each row keyed by the computed dimension's own `name` (and can't be
|
|
5958
|
+
// duplicated - names must be unique - so field/dataFieldName coincide).
|
|
5888
5959
|
// - Metrics: API returns metric keys in different forms depending on path depth
|
|
5889
5960
|
// (see comment inline below), normalised to match the row-key normalisation
|
|
5890
5961
|
// done when building `rows`.
|
|
@@ -5902,13 +5973,13 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5902
5973
|
if (item.kind === 'metric') {
|
|
5903
5974
|
const metric = item.data;
|
|
5904
5975
|
const metricDef = metric.metric;
|
|
5905
|
-
let
|
|
5976
|
+
let dataFieldName;
|
|
5906
5977
|
const dotCount = (metric.fullPath.match(/\./g) || []).length;
|
|
5907
5978
|
if (dotCount >= 2) {
|
|
5908
5979
|
// 3+-part namespaced path: API returns the full dotted path as the key.
|
|
5909
5980
|
// Normalise dots to underscores to match the row key normalisation below.
|
|
5910
5981
|
// Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
|
|
5911
|
-
|
|
5982
|
+
dataFieldName = metric.fullPath.replace(/\./g, '_');
|
|
5912
5983
|
} else if (metric.relations && metric.relations.length > 0) {
|
|
5913
5984
|
// 2-part path from a nested provider: API drops the base provider alias.
|
|
5914
5985
|
// Example: "mc_fa.total_amount" -> "fa_total_amount"
|
|
@@ -5919,19 +5990,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5919
5990
|
const pathParts = pathWithoutField.split('_');
|
|
5920
5991
|
pathParts.shift(); // remove base provider alias
|
|
5921
5992
|
const pathWithoutBase = pathParts.join('_');
|
|
5922
|
-
|
|
5993
|
+
dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
|
|
5923
5994
|
} else {
|
|
5924
5995
|
// 2-part path from the base provider: API returns just the metric name.
|
|
5925
5996
|
// Example: "mc.record_count" -> "record_count"
|
|
5926
|
-
|
|
5997
|
+
dataFieldName = metric.metricName;
|
|
5927
5998
|
}
|
|
5928
|
-
const headerName = interpolateTitle(titleOverrides.metrics[metric.
|
|
5999
|
+
const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
|
|
5929
6000
|
return {
|
|
5930
|
-
field
|
|
6001
|
+
// `field` is the instance id (unique even for duplicate columns
|
|
6002
|
+
// pointing at the same underlying metric); the actual value is
|
|
6003
|
+
// read from the row via valueGetter using dataFieldName, which is
|
|
6004
|
+
// the key the API response actually uses (shared across duplicates).
|
|
6005
|
+
field: metric.id.replace(/[.#]/g, '_'),
|
|
5931
6006
|
headerName: headerName,
|
|
5932
6007
|
flex: 1,
|
|
5933
6008
|
minWidth: 150,
|
|
5934
6009
|
type: isNumericType(metricDef?.type) ? 'number' : 'string',
|
|
6010
|
+
valueGetter: (_value, row) => row[dataFieldName],
|
|
5935
6011
|
renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
|
|
5936
6012
|
sx: {
|
|
5937
6013
|
display: 'flex',
|
|
@@ -5947,7 +6023,7 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5947
6023
|
|
|
5948
6024
|
// Dimension
|
|
5949
6025
|
const dim = item.data;
|
|
5950
|
-
let
|
|
6026
|
+
let dataFieldName;
|
|
5951
6027
|
if (dim.relations && dim.relations.length > 0) {
|
|
5952
6028
|
// From nested provider: drop the base provider alias
|
|
5953
6029
|
// Example: ft_fa_db.currency -> fa_db_currency
|
|
@@ -5959,20 +6035,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5959
6035
|
pathParts.shift(); // Remove base provider alias
|
|
5960
6036
|
const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
|
|
5961
6037
|
|
|
5962
|
-
|
|
6038
|
+
dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
|
|
5963
6039
|
} else {
|
|
5964
6040
|
// From base provider: keep the full path with underscore
|
|
5965
6041
|
// Example: ba.created_at -> ba_created_at
|
|
5966
|
-
|
|
6042
|
+
dataFieldName = dim.fullPath.replace('.', '_');
|
|
5967
6043
|
}
|
|
5968
|
-
const headerName = interpolateTitle(titleOverrides.dimensions[dim.
|
|
6044
|
+
const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
|
|
5969
6045
|
const dimDef = dim.dimension;
|
|
5970
6046
|
return {
|
|
5971
|
-
|
|
6047
|
+
// Same rationale as the metric branch above: `field` must be unique
|
|
6048
|
+
// per instance, while the value always comes from the one real
|
|
6049
|
+
// underlying row key (dataFieldName), shared by every duplicate.
|
|
6050
|
+
field: dim.id.replace(/[.#]/g, '_'),
|
|
5972
6051
|
headerName: headerName,
|
|
5973
6052
|
flex: 1,
|
|
5974
6053
|
minWidth: 150,
|
|
5975
6054
|
type: isNumericType(dimDef?.type) ? 'number' : 'string',
|
|
6055
|
+
valueGetter: (_value, row) => row[dataFieldName],
|
|
5976
6056
|
valueFormatter: value => formatValue(value, dimDef)
|
|
5977
6057
|
};
|
|
5978
6058
|
};
|
|
@@ -6298,6 +6378,72 @@ const ReportBuilder = ({
|
|
|
6298
6378
|
}
|
|
6299
6379
|
};
|
|
6300
6380
|
|
|
6381
|
+
// Reconstructs saved dimensions as independent, individually-identified
|
|
6382
|
+
// instances so that deliberate duplicates (the same field added more than
|
|
6383
|
+
// once) survive a save/reload round trip. Prefers `ordered_dimensions`
|
|
6384
|
+
// (one ref per instance - the nth duplicate of a path is suffixed
|
|
6385
|
+
// `#n`) when present; falls back to one instance per entry in the
|
|
6386
|
+
// (necessarily duplicate-free) `dimensions` path list for reports saved
|
|
6387
|
+
// before this field existed.
|
|
6388
|
+
const reconstructDimensions = (reportDef, providersData) => {
|
|
6389
|
+
const orderByMap = {};
|
|
6390
|
+
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6391
|
+
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6392
|
+
orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
|
|
6393
|
+
}
|
|
6394
|
+
}
|
|
6395
|
+
const reconstructed = [];
|
|
6396
|
+
const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
|
|
6397
|
+
if (orderedRefs.length > 0) {
|
|
6398
|
+
orderedRefs.forEach(ref => {
|
|
6399
|
+
const fullPath = ref.split("#")[0];
|
|
6400
|
+
const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
|
|
6401
|
+
if (dim) {
|
|
6402
|
+
dim.id = ref;
|
|
6403
|
+
dim.sortOrder = orderByMap[fullPath] || null;
|
|
6404
|
+
reconstructed.push(dim);
|
|
6405
|
+
}
|
|
6406
|
+
});
|
|
6407
|
+
} else if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6408
|
+
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6409
|
+
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6410
|
+
if (dim) {
|
|
6411
|
+
dim.id = dimPath;
|
|
6412
|
+
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6413
|
+
reconstructed.push(dim);
|
|
6414
|
+
}
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
return reconstructed;
|
|
6418
|
+
};
|
|
6419
|
+
|
|
6420
|
+
// Mirrors reconstructDimensions for metrics, using the analogous
|
|
6421
|
+
// `ordered_metrics` field (metrics have no other unified-order concept to
|
|
6422
|
+
// fall back on, so this is always written whenever there are metrics).
|
|
6423
|
+
const reconstructMetrics = (reportDef, providersData) => {
|
|
6424
|
+
const reconstructed = [];
|
|
6425
|
+
const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
|
|
6426
|
+
if (orderedRefs.length > 0) {
|
|
6427
|
+
orderedRefs.forEach(ref => {
|
|
6428
|
+
const fullPath = ref.split("#")[0];
|
|
6429
|
+
const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
|
|
6430
|
+
if (metric) {
|
|
6431
|
+
metric.id = ref;
|
|
6432
|
+
reconstructed.push(metric);
|
|
6433
|
+
}
|
|
6434
|
+
});
|
|
6435
|
+
} else if (reportDef.definition?.doc?.query?.metrics) {
|
|
6436
|
+
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6437
|
+
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6438
|
+
if (metric) {
|
|
6439
|
+
metric.id = metricPath;
|
|
6440
|
+
reconstructed.push(metric);
|
|
6441
|
+
}
|
|
6442
|
+
}
|
|
6443
|
+
}
|
|
6444
|
+
return reconstructed;
|
|
6445
|
+
};
|
|
6446
|
+
|
|
6301
6447
|
// Build the unified display/execution order for dimensions + computed
|
|
6302
6448
|
// dimensions. Prefers the persisted `ordered_dimensions` field; falls back
|
|
6303
6449
|
// to "dimensions first, then computed dimensions" for report definitions
|
|
@@ -6308,7 +6454,7 @@ const ReportBuilder = ({
|
|
|
6308
6454
|
const seenComputedKeys = new Set();
|
|
6309
6455
|
if (Array.isArray(rawOrderedDimensions)) {
|
|
6310
6456
|
rawOrderedDimensions.forEach(entry => {
|
|
6311
|
-
if (entry.type === "dimension" && dimensions.some(d => d.
|
|
6457
|
+
if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
|
|
6312
6458
|
dimensionOrder.push({
|
|
6313
6459
|
type: "dimension",
|
|
6314
6460
|
key: entry.ref
|
|
@@ -6326,10 +6472,10 @@ const ReportBuilder = ({
|
|
|
6326
6472
|
|
|
6327
6473
|
// Append anything not covered by ordered_dimensions (older reports, or drift)
|
|
6328
6474
|
dimensions.forEach(d => {
|
|
6329
|
-
if (!seenDimensionKeys.has(d.
|
|
6475
|
+
if (!seenDimensionKeys.has(d.id)) {
|
|
6330
6476
|
dimensionOrder.push({
|
|
6331
6477
|
type: "dimension",
|
|
6332
|
-
key: d.
|
|
6478
|
+
key: d.id
|
|
6333
6479
|
});
|
|
6334
6480
|
}
|
|
6335
6481
|
});
|
|
@@ -6352,12 +6498,12 @@ const ReportBuilder = ({
|
|
|
6352
6498
|
// "fall back to the default order".
|
|
6353
6499
|
const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
|
|
6354
6500
|
if (!Array.isArray(rawOrderedColumns)) return [];
|
|
6355
|
-
const
|
|
6501
|
+
const dimensionIds = new Set(dimensions.map(d => d.id));
|
|
6356
6502
|
const computedNames = new Set(computedDimensions.map(cd => cd.name));
|
|
6357
|
-
const
|
|
6503
|
+
const metricIds = new Set(metrics.map(m => m.id));
|
|
6358
6504
|
const columnOrder = [];
|
|
6359
6505
|
rawOrderedColumns.forEach(entry => {
|
|
6360
|
-
if (entry.type === "dimension" &&
|
|
6506
|
+
if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
|
|
6361
6507
|
columnOrder.push({
|
|
6362
6508
|
type: "dimension",
|
|
6363
6509
|
key: entry.ref
|
|
@@ -6367,7 +6513,7 @@ const ReportBuilder = ({
|
|
|
6367
6513
|
type: "computed",
|
|
6368
6514
|
key: entry.ref
|
|
6369
6515
|
});
|
|
6370
|
-
} else if (entry.type === "metric" &&
|
|
6516
|
+
} else if (entry.type === "metric" && metricIds.has(entry.ref)) {
|
|
6371
6517
|
columnOrder.push({
|
|
6372
6518
|
type: "metric",
|
|
6373
6519
|
key: entry.ref
|
|
@@ -6397,43 +6543,10 @@ const ReportBuilder = ({
|
|
|
6397
6543
|
// Set the title
|
|
6398
6544
|
setReportTitle(reportDef.title || "");
|
|
6399
6545
|
|
|
6400
|
-
// Reconstruct dimensions
|
|
6401
|
-
|
|
6402
|
-
const
|
|
6403
|
-
|
|
6404
|
-
// First, build a map of fullPath -> sortOrder from order_by
|
|
6405
|
-
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6406
|
-
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6407
|
-
if (orderItem.desc === true) {
|
|
6408
|
-
orderByMap[orderItem.name] = "desc";
|
|
6409
|
-
} else {
|
|
6410
|
-
orderByMap[orderItem.name] = "asc";
|
|
6411
|
-
}
|
|
6412
|
-
}
|
|
6413
|
-
}
|
|
6414
|
-
|
|
6415
|
-
// Now reconstruct dimensions and add sortOrder from the map
|
|
6416
|
-
if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6417
|
-
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6418
|
-
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6419
|
-
if (dim) {
|
|
6420
|
-
// Add sortOrder from order_by if it exists
|
|
6421
|
-
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6422
|
-
reconstructedDimensions.push(dim);
|
|
6423
|
-
}
|
|
6424
|
-
}
|
|
6425
|
-
}
|
|
6426
|
-
|
|
6427
|
-
// Reconstruct metrics
|
|
6428
|
-
const reconstructedMetrics = [];
|
|
6429
|
-
if (reportDef.definition?.doc?.query?.metrics) {
|
|
6430
|
-
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6431
|
-
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6432
|
-
if (metric) {
|
|
6433
|
-
reconstructedMetrics.push(metric);
|
|
6434
|
-
}
|
|
6435
|
-
}
|
|
6436
|
-
}
|
|
6546
|
+
// Reconstruct dimensions and metrics as independent instances
|
|
6547
|
+
// (preserving deliberate duplicates - see reconstructDimensions/Metrics)
|
|
6548
|
+
const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
|
|
6549
|
+
const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
|
|
6437
6550
|
|
|
6438
6551
|
// Load title overrides if they exist
|
|
6439
6552
|
const loadedTitleOverrides = {
|
|
@@ -6517,43 +6630,10 @@ const ReportBuilder = ({
|
|
|
6517
6630
|
// Set the title (already modified with " (Copy)" suffix)
|
|
6518
6631
|
setReportTitle(reportDef.title || "");
|
|
6519
6632
|
|
|
6520
|
-
// Reconstruct dimensions
|
|
6521
|
-
|
|
6522
|
-
const
|
|
6523
|
-
|
|
6524
|
-
// First, build a map of fullPath -> sortOrder from order_by
|
|
6525
|
-
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6526
|
-
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6527
|
-
if (orderItem.desc === true) {
|
|
6528
|
-
orderByMap[orderItem.name] = "desc";
|
|
6529
|
-
} else {
|
|
6530
|
-
orderByMap[orderItem.name] = "asc";
|
|
6531
|
-
}
|
|
6532
|
-
}
|
|
6533
|
-
}
|
|
6534
|
-
|
|
6535
|
-
// Now reconstruct dimensions and add sortOrder from the map
|
|
6536
|
-
if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6537
|
-
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6538
|
-
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6539
|
-
if (dim) {
|
|
6540
|
-
// Add sortOrder from order_by if it exists
|
|
6541
|
-
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6542
|
-
reconstructedDimensions.push(dim);
|
|
6543
|
-
}
|
|
6544
|
-
}
|
|
6545
|
-
}
|
|
6546
|
-
|
|
6547
|
-
// Reconstruct metrics
|
|
6548
|
-
const reconstructedMetrics = [];
|
|
6549
|
-
if (reportDef.definition?.doc?.query?.metrics) {
|
|
6550
|
-
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6551
|
-
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6552
|
-
if (metric) {
|
|
6553
|
-
reconstructedMetrics.push(metric);
|
|
6554
|
-
}
|
|
6555
|
-
}
|
|
6556
|
-
}
|
|
6633
|
+
// Reconstruct dimensions and metrics as independent instances
|
|
6634
|
+
// (preserving deliberate duplicates - see reconstructDimensions/Metrics)
|
|
6635
|
+
const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
|
|
6636
|
+
const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
|
|
6557
6637
|
|
|
6558
6638
|
// Load title overrides if they exist
|
|
6559
6639
|
const loadedTitleOverrides = {
|
|
@@ -6652,42 +6732,45 @@ const ReportBuilder = ({
|
|
|
6652
6732
|
});
|
|
6653
6733
|
console.log("Selected root provider:", event.target.value);
|
|
6654
6734
|
};
|
|
6655
|
-
|
|
6735
|
+
|
|
6736
|
+
// Keyed by instance id, so two instances of the same dimension can carry
|
|
6737
|
+
// independent custom titles.
|
|
6738
|
+
const handleUpdateDimensionTitle = (id, customTitle) => {
|
|
6656
6739
|
setTitleOverrides(prev => ({
|
|
6657
6740
|
...prev,
|
|
6658
6741
|
dimensions: {
|
|
6659
6742
|
...prev.dimensions,
|
|
6660
|
-
[
|
|
6743
|
+
[id]: customTitle
|
|
6661
6744
|
}
|
|
6662
6745
|
}));
|
|
6663
6746
|
};
|
|
6664
|
-
const handleResetDimensionTitle =
|
|
6747
|
+
const handleResetDimensionTitle = id => {
|
|
6665
6748
|
setTitleOverrides(prev => {
|
|
6666
6749
|
const newDimensions = {
|
|
6667
6750
|
...prev.dimensions
|
|
6668
6751
|
};
|
|
6669
|
-
delete newDimensions[
|
|
6752
|
+
delete newDimensions[id];
|
|
6670
6753
|
return {
|
|
6671
6754
|
...prev,
|
|
6672
6755
|
dimensions: newDimensions
|
|
6673
6756
|
};
|
|
6674
6757
|
});
|
|
6675
6758
|
};
|
|
6676
|
-
const handleUpdateMetricTitle = (
|
|
6759
|
+
const handleUpdateMetricTitle = (id, customTitle) => {
|
|
6677
6760
|
setTitleOverrides(prev => ({
|
|
6678
6761
|
...prev,
|
|
6679
6762
|
metrics: {
|
|
6680
6763
|
...prev.metrics,
|
|
6681
|
-
[
|
|
6764
|
+
[id]: customTitle
|
|
6682
6765
|
}
|
|
6683
6766
|
}));
|
|
6684
6767
|
};
|
|
6685
|
-
const handleResetMetricTitle =
|
|
6768
|
+
const handleResetMetricTitle = id => {
|
|
6686
6769
|
setTitleOverrides(prev => {
|
|
6687
6770
|
const newMetrics = {
|
|
6688
6771
|
...prev.metrics
|
|
6689
6772
|
};
|
|
6690
|
-
delete newMetrics[
|
|
6773
|
+
delete newMetrics[id];
|
|
6691
6774
|
return {
|
|
6692
6775
|
...prev,
|
|
6693
6776
|
metrics: newMetrics
|
|
@@ -6722,11 +6805,11 @@ const ReportBuilder = ({
|
|
|
6722
6805
|
dimensions: [...prev.dimensions, dimensionData],
|
|
6723
6806
|
dimensionOrder: [...prev.dimensionOrder, {
|
|
6724
6807
|
type: "dimension",
|
|
6725
|
-
key: dimensionData.
|
|
6808
|
+
key: dimensionData.id
|
|
6726
6809
|
}],
|
|
6727
6810
|
columnOrder: appendToColumnOrder(prev.columnOrder, {
|
|
6728
6811
|
type: "dimension",
|
|
6729
|
-
key: dimensionData.
|
|
6812
|
+
key: dimensionData.id
|
|
6730
6813
|
})
|
|
6731
6814
|
};
|
|
6732
6815
|
console.log("Dimension saved:", dimensionData);
|
|
@@ -6734,22 +6817,30 @@ const ReportBuilder = ({
|
|
|
6734
6817
|
return newReport;
|
|
6735
6818
|
});
|
|
6736
6819
|
};
|
|
6737
|
-
const handleRemoveDimension =
|
|
6820
|
+
const handleRemoveDimension = id => {
|
|
6738
6821
|
setReport(prev => ({
|
|
6739
6822
|
...prev,
|
|
6740
|
-
dimensions: prev.dimensions.filter(d => d.
|
|
6741
|
-
dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key ===
|
|
6742
|
-
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key ===
|
|
6823
|
+
dimensions: prev.dimensions.filter(d => d.id !== id),
|
|
6824
|
+
dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
|
|
6825
|
+
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
|
|
6743
6826
|
}));
|
|
6744
6827
|
};
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6828
|
+
|
|
6829
|
+
// sortOrder is fundamentally a per-field (query-level) setting - SQL can
|
|
6830
|
+
// only sort by a given column once - so toggling it on any instance of a
|
|
6831
|
+
// duplicated dimension applies to every instance sharing that fullPath.
|
|
6832
|
+
const handleUpdateDimensionSortOrder = (id, sortOrder) => {
|
|
6833
|
+
setReport(prev => {
|
|
6834
|
+
const target = prev.dimensions.find(d => d.id === id);
|
|
6835
|
+
if (!target) return prev;
|
|
6836
|
+
return {
|
|
6837
|
+
...prev,
|
|
6838
|
+
dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
|
|
6839
|
+
...d,
|
|
6840
|
+
sortOrder
|
|
6841
|
+
} : d)
|
|
6842
|
+
};
|
|
6843
|
+
});
|
|
6753
6844
|
};
|
|
6754
6845
|
|
|
6755
6846
|
// Reorders the single unified dimensions + computed-dimensions list;
|
|
@@ -6804,7 +6895,7 @@ const ReportBuilder = ({
|
|
|
6804
6895
|
metrics: [...prev.metrics, metricData],
|
|
6805
6896
|
columnOrder: appendToColumnOrder(prev.columnOrder, {
|
|
6806
6897
|
type: "metric",
|
|
6807
|
-
key: metricData.
|
|
6898
|
+
key: metricData.id
|
|
6808
6899
|
})
|
|
6809
6900
|
};
|
|
6810
6901
|
console.log("Metric saved:", metricData);
|
|
@@ -6812,15 +6903,12 @@ const ReportBuilder = ({
|
|
|
6812
6903
|
return newReport;
|
|
6813
6904
|
});
|
|
6814
6905
|
};
|
|
6815
|
-
const handleRemoveMetric =
|
|
6816
|
-
setReport(prev => {
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
|
|
6822
|
-
};
|
|
6823
|
-
});
|
|
6906
|
+
const handleRemoveMetric = id => {
|
|
6907
|
+
setReport(prev => ({
|
|
6908
|
+
...prev,
|
|
6909
|
+
metrics: prev.metrics.filter(m => m.id !== id),
|
|
6910
|
+
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
|
|
6911
|
+
}));
|
|
6824
6912
|
};
|
|
6825
6913
|
const handleReorderMetrics = newOrder => {
|
|
6826
6914
|
setReport(prev => ({
|
|
@@ -6875,13 +6963,30 @@ const ReportBuilder = ({
|
|
|
6875
6963
|
// persisted `ordered_dimensions` field all stay consistent with whatever
|
|
6876
6964
|
// order the user arranged on screen.
|
|
6877
6965
|
const buildQueryObject = () => {
|
|
6878
|
-
const
|
|
6966
|
+
const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
|
|
6879
6967
|
const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
|
|
6880
|
-
|
|
6968
|
+
|
|
6969
|
+
// May contain more than one instance of the same fullPath (deliberate
|
|
6970
|
+
// duplicates), kept in dimensionOrder's display order.
|
|
6971
|
+
const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
|
|
6881
6972
|
const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
|
|
6882
6973
|
|
|
6974
|
+
// The API keys each result row by fullPath, so duplicate instances of
|
|
6975
|
+
// the same field can only ever carry one value/one sort direction at
|
|
6976
|
+
// the query level - dedupe by fullPath (keeping first-occurrence order)
|
|
6977
|
+
// before sending. The builder still shows/orders/titles each instance
|
|
6978
|
+
// independently; they just read from the same underlying query column.
|
|
6979
|
+
const uniqueDimensionsByPath = [];
|
|
6980
|
+
const seenDimensionPaths = new Set();
|
|
6981
|
+
orderedDimensionsOnly.forEach(dim => {
|
|
6982
|
+
if (!seenDimensionPaths.has(dim.fullPath)) {
|
|
6983
|
+
seenDimensionPaths.add(dim.fullPath);
|
|
6984
|
+
uniqueDimensionsByPath.push(dim);
|
|
6985
|
+
}
|
|
6986
|
+
});
|
|
6987
|
+
|
|
6883
6988
|
// Build order_by array - include all dimensions, add desc property only when needed
|
|
6884
|
-
const orderBy =
|
|
6989
|
+
const orderBy = uniqueDimensionsByPath.map(dim => {
|
|
6885
6990
|
const orderItem = {
|
|
6886
6991
|
name: dim.fullPath
|
|
6887
6992
|
};
|
|
@@ -6936,9 +7041,20 @@ const ReportBuilder = ({
|
|
|
6936
7041
|
if (Object.keys(titleOverrides.filters).length > 0) {
|
|
6937
7042
|
titles.filters = titleOverrides.filters;
|
|
6938
7043
|
}
|
|
7044
|
+
|
|
7045
|
+
// Same dedupe rationale as dimensions above: the query only needs each
|
|
7046
|
+
// metric fullPath once, however many display instances reference it.
|
|
7047
|
+
const uniqueMetricPaths = [];
|
|
7048
|
+
const seenMetricPaths = new Set();
|
|
7049
|
+
report.metrics.forEach(metric => {
|
|
7050
|
+
if (!seenMetricPaths.has(metric.fullPath)) {
|
|
7051
|
+
seenMetricPaths.add(metric.fullPath);
|
|
7052
|
+
uniqueMetricPaths.push(metric.fullPath);
|
|
7053
|
+
}
|
|
7054
|
+
});
|
|
6939
7055
|
const queryObj = {
|
|
6940
|
-
dimensions:
|
|
6941
|
-
metrics:
|
|
7056
|
+
dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
|
|
7057
|
+
metrics: uniqueMetricPaths,
|
|
6942
7058
|
order_by: orderBy
|
|
6943
7059
|
};
|
|
6944
7060
|
|
|
@@ -6952,7 +7068,9 @@ const ReportBuilder = ({
|
|
|
6952
7068
|
|
|
6953
7069
|
// Persist the unified dimension + computed-dimension order so it can be
|
|
6954
7070
|
// reconstructed on reload and so executed reports render columns in the
|
|
6955
|
-
// same order they were arranged in the builder.
|
|
7071
|
+
// same order they were arranged in the builder. Entries use each
|
|
7072
|
+
// dimension's instance id as `ref`, so deliberate duplicates (the nth
|
|
7073
|
+
// occurrence of a fullPath is suffixed `#n`) round-trip correctly.
|
|
6956
7074
|
if (report.dimensionOrder.length > 0) {
|
|
6957
7075
|
queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
|
|
6958
7076
|
type: entry.type,
|
|
@@ -6960,6 +7078,12 @@ const ReportBuilder = ({
|
|
|
6960
7078
|
}));
|
|
6961
7079
|
}
|
|
6962
7080
|
|
|
7081
|
+
// Persist metric instance order/identity the same way (metrics have no
|
|
7082
|
+
// other unified-order field to piggyback on).
|
|
7083
|
+
if (report.metrics.length > 0) {
|
|
7084
|
+
queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
|
|
7085
|
+
}
|
|
7086
|
+
|
|
6963
7087
|
// Persist the optional unified column order (Column Order tab) so the
|
|
6964
7088
|
// results grid can reproduce it on reload. Absent when no custom order
|
|
6965
7089
|
// has been defined, so old/untouched reports keep their current
|
|
@@ -7129,11 +7253,11 @@ const ReportBuilder = ({
|
|
|
7129
7253
|
|
|
7130
7254
|
// Resolve the unified dimensionOrder into actual dimension/computed-dimension
|
|
7131
7255
|
// objects so the results grid can render columns in that same order.
|
|
7132
|
-
const
|
|
7256
|
+
const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
|
|
7133
7257
|
const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
|
|
7134
7258
|
const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
7135
7259
|
kind: "dimension",
|
|
7136
|
-
data:
|
|
7260
|
+
data: dimensionById[entry.key]
|
|
7137
7261
|
} : {
|
|
7138
7262
|
kind: "computed",
|
|
7139
7263
|
data: computedDimensionByName[entry.key]
|