@bygd/nc-report-ui 0.1.42 → 0.1.44
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 +422 -196
- package/dist/default/cjs/index.cjs +417 -195
- package/dist/default/esm/index.js +416 -195
- 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,
|
|
@@ -3052,6 +3091,16 @@ const SortableComputedChip = ({
|
|
|
3052
3091
|
handleCancel();
|
|
3053
3092
|
}
|
|
3054
3093
|
};
|
|
3094
|
+
|
|
3095
|
+
// The value field is multiline, so Enter inserts a newline instead of
|
|
3096
|
+
// saving; only Escape (cancel) and Cmd/Ctrl+Enter (save) are handled here.
|
|
3097
|
+
const handleValueKeyDown = e => {
|
|
3098
|
+
if (e.key === "Escape") {
|
|
3099
|
+
handleCancel();
|
|
3100
|
+
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
3101
|
+
handleSave();
|
|
3102
|
+
}
|
|
3103
|
+
};
|
|
3055
3104
|
return /*#__PURE__*/React__default.createElement("div", _extends({
|
|
3056
3105
|
ref: setNodeRef,
|
|
3057
3106
|
style: style
|
|
@@ -3155,10 +3204,13 @@ const SortableComputedChip = ({
|
|
|
3155
3204
|
}), /*#__PURE__*/React__default.createElement(TextField, {
|
|
3156
3205
|
value: editValue,
|
|
3157
3206
|
onChange: e => setEditValue(e.target.value),
|
|
3158
|
-
onKeyDown:
|
|
3207
|
+
onKeyDown: handleValueKeyDown,
|
|
3159
3208
|
size: "small",
|
|
3160
3209
|
label: "Value",
|
|
3161
|
-
helperText: " ",
|
|
3210
|
+
helperText: "Cmd/Ctrl+Enter to save",
|
|
3211
|
+
multiline: true,
|
|
3212
|
+
minRows: 2,
|
|
3213
|
+
maxRows: 10,
|
|
3162
3214
|
sx: {
|
|
3163
3215
|
flex: 1,
|
|
3164
3216
|
minWidth: "200px",
|
|
@@ -3182,7 +3234,7 @@ const SortableComputedChip = ({
|
|
|
3182
3234
|
onClick: handleSave,
|
|
3183
3235
|
color: "primary",
|
|
3184
3236
|
"aria-label": "save computed dimension",
|
|
3185
|
-
disabled: !editName.trim() ||
|
|
3237
|
+
disabled: !editName.trim() || nameError
|
|
3186
3238
|
}, /*#__PURE__*/React__default.createElement(CheckIcon, {
|
|
3187
3239
|
fontSize: "small"
|
|
3188
3240
|
})))), /*#__PURE__*/React__default.createElement(Tooltip$1, {
|
|
@@ -3231,12 +3283,14 @@ const Dimensions = ({
|
|
|
3231
3283
|
// Resolve the unified dimensionOrder into the actual dimension / computed
|
|
3232
3284
|
// dimension objects, so dimensions and computed dimensions can be shown
|
|
3233
3285
|
// (and dragged) as a single list while staying stored in their own arrays.
|
|
3234
|
-
|
|
3286
|
+
// Keyed by instance id rather than fullPath, so two instances of the same
|
|
3287
|
+
// dimension (added deliberately as a duplicate) stay distinct entries.
|
|
3288
|
+
const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
|
|
3235
3289
|
const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
|
|
3236
3290
|
const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
3237
3291
|
kind: "dimension",
|
|
3238
3292
|
key: entry.key,
|
|
3239
|
-
data:
|
|
3293
|
+
data: dimensionById[entry.key]
|
|
3240
3294
|
} : {
|
|
3241
3295
|
kind: "computed",
|
|
3242
3296
|
key: entry.key,
|
|
@@ -3250,9 +3304,13 @@ const Dimensions = ({
|
|
|
3250
3304
|
handleMoveDown
|
|
3251
3305
|
} = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
|
|
3252
3306
|
|
|
3253
|
-
// Handle sort order change (dimensions only - computed dimensions have no
|
|
3254
|
-
|
|
3255
|
-
|
|
3307
|
+
// Handle sort order change (dimensions only - computed dimensions have no
|
|
3308
|
+
// order_by). Keyed by instance id but applied query-wide: SQL can only
|
|
3309
|
+
// sort by a given column once, so toggling sort on any duplicate instance
|
|
3310
|
+
// of the same field is propagated to all instances sharing its fullPath
|
|
3311
|
+
// (handled in ReportBuilder's onUpdateDimensionSortOrder).
|
|
3312
|
+
const handleSortOrderChange = (id, sortOrder) => {
|
|
3313
|
+
onUpdateDimensionSortOrder(id, sortOrder);
|
|
3256
3314
|
};
|
|
3257
3315
|
|
|
3258
3316
|
// Computed Dimensions: { name, value } pairs independent of any provider.
|
|
@@ -3273,7 +3331,7 @@ const Dimensions = ({
|
|
|
3273
3331
|
const handleSaveComputed = () => {
|
|
3274
3332
|
const trimmedName = computedName.trim();
|
|
3275
3333
|
const trimmedValue = computedValue.trim();
|
|
3276
|
-
if (!trimmedName
|
|
3334
|
+
if (!trimmedName) return;
|
|
3277
3335
|
if (isComputedNameTaken(trimmedName)) return;
|
|
3278
3336
|
onSaveComputedDimension({
|
|
3279
3337
|
name: trimmedName,
|
|
@@ -3315,7 +3373,10 @@ const Dimensions = ({
|
|
|
3315
3373
|
currentProviderKey = selection.targetKey;
|
|
3316
3374
|
});
|
|
3317
3375
|
|
|
3318
|
-
// Create a Set of already selected dimension fullPaths for quick lookup
|
|
3376
|
+
// Create a Set of already selected dimension fullPaths for quick lookup.
|
|
3377
|
+
// Already-selected items stay selectable (so the same dimension can be
|
|
3378
|
+
// added again on purpose) - they're just flagged so the dropdown can
|
|
3379
|
+
// highlight them instead of blocking the click.
|
|
3319
3380
|
const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
|
|
3320
3381
|
const items = [];
|
|
3321
3382
|
// Iterate through aliases (e.g., 'ba', 'pc')
|
|
@@ -3327,9 +3388,6 @@ const Dimensions = ({
|
|
|
3327
3388
|
|
|
3328
3389
|
// Construct the fullPath for this dimension
|
|
3329
3390
|
const fullPath = `${aliasPath.join("_")}.${dimKey}`;
|
|
3330
|
-
|
|
3331
|
-
// Check if this dimension is already selected
|
|
3332
|
-
const isAlreadySelected = selectedFullPaths.has(fullPath);
|
|
3333
3391
|
items.push({
|
|
3334
3392
|
// Include provider name to ensure uniqueness across different providers
|
|
3335
3393
|
key: `${currentProvider}_${alias}.${dimKey}`,
|
|
@@ -3337,7 +3395,7 @@ const Dimensions = ({
|
|
|
3337
3395
|
dimensionKey: dimKey,
|
|
3338
3396
|
alias: alias,
|
|
3339
3397
|
dimension: dimension,
|
|
3340
|
-
|
|
3398
|
+
alreadySelected: selectedFullPaths.has(fullPath)
|
|
3341
3399
|
});
|
|
3342
3400
|
});
|
|
3343
3401
|
});
|
|
@@ -3399,7 +3457,10 @@ const Dimensions = ({
|
|
|
3399
3457
|
dimensionKey: selectedItem.dimensionKey,
|
|
3400
3458
|
dimensionTitle: selectedItem.value,
|
|
3401
3459
|
// The constructed path for server
|
|
3402
|
-
fullPath: fullPath
|
|
3460
|
+
fullPath: fullPath,
|
|
3461
|
+
// Unique per-instance identity, so this field can be added more than
|
|
3462
|
+
// once and each occurrence stays independently orderable/removable.
|
|
3463
|
+
id: nextInstanceId(fullPath, savedDimensions)
|
|
3403
3464
|
};
|
|
3404
3465
|
onSaveDimension(dimensionData);
|
|
3405
3466
|
handleCancel();
|
|
@@ -3410,8 +3471,9 @@ const Dimensions = ({
|
|
|
3410
3471
|
const handleAddAll = () => {
|
|
3411
3472
|
const dimensionItems = getDimensionItems();
|
|
3412
3473
|
|
|
3413
|
-
// Filter out already selected dimensions
|
|
3414
|
-
|
|
3474
|
+
// Filter out already selected dimensions - "Add all" only fills in
|
|
3475
|
+
// missing ones; intentional duplicates are added one at a time above.
|
|
3476
|
+
const availableItems = dimensionItems.filter(item => !item.alreadySelected);
|
|
3415
3477
|
if (availableItems.length === 0) return;
|
|
3416
3478
|
|
|
3417
3479
|
// Build the complete relation objects array (same for all dimensions in this provider)
|
|
@@ -3456,7 +3518,10 @@ const Dimensions = ({
|
|
|
3456
3518
|
dimensionKey: item.dimensionKey,
|
|
3457
3519
|
dimensionTitle: item.value,
|
|
3458
3520
|
// The constructed path for server
|
|
3459
|
-
fullPath: fullPath
|
|
3521
|
+
fullPath: fullPath,
|
|
3522
|
+
// "Add all" only ever adds fields that aren't already selected, so
|
|
3523
|
+
// this is always the first (and only) occurrence of this fullPath.
|
|
3524
|
+
id: fullPath
|
|
3460
3525
|
};
|
|
3461
3526
|
onSaveDimension(dimensionData);
|
|
3462
3527
|
});
|
|
@@ -3555,6 +3620,9 @@ const Dimensions = ({
|
|
|
3555
3620
|
value: computedValue,
|
|
3556
3621
|
onChange: e => setComputedValue(e.target.value),
|
|
3557
3622
|
size: "small",
|
|
3623
|
+
multiline: true,
|
|
3624
|
+
minRows: 2,
|
|
3625
|
+
maxRows: 10,
|
|
3558
3626
|
sx: {
|
|
3559
3627
|
width: "400px",
|
|
3560
3628
|
"& .MuiInputBase-input": {
|
|
@@ -3590,7 +3658,7 @@ const Dimensions = ({
|
|
|
3590
3658
|
}, "Cancel"), /*#__PURE__*/React__default.createElement(Button, {
|
|
3591
3659
|
variant: "contained",
|
|
3592
3660
|
onClick: handleSaveComputed,
|
|
3593
|
-
disabled: !computedName.trim() ||
|
|
3661
|
+
disabled: !computedName.trim() || computedNameError,
|
|
3594
3662
|
sx: {
|
|
3595
3663
|
height: "40px",
|
|
3596
3664
|
fontFamily: "system-ui",
|
|
@@ -3666,7 +3734,7 @@ const Dimensions = ({
|
|
|
3666
3734
|
placement: "top"
|
|
3667
3735
|
}, /*#__PURE__*/React__default.createElement("span", null, /*#__PURE__*/React__default.createElement(IconButton$1, {
|
|
3668
3736
|
onClick: handleAddAll,
|
|
3669
|
-
disabled: getDimensionItems().filter(item => !item.
|
|
3737
|
+
disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
|
|
3670
3738
|
"aria-label": "add all dimensions",
|
|
3671
3739
|
sx: {
|
|
3672
3740
|
color: "rgb(70, 134, 128)",
|
|
@@ -3757,9 +3825,9 @@ const Dimensions = ({
|
|
|
3757
3825
|
isLast: index === orderedItems.length - 1,
|
|
3758
3826
|
sortOrder: item.data.sortOrder || null,
|
|
3759
3827
|
onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
|
|
3760
|
-
|
|
3828
|
+
instanceId: item.data.id,
|
|
3761
3829
|
defaultTitle: item.data.dimensionTitle,
|
|
3762
|
-
customTitle: titleOverrides[item.data.
|
|
3830
|
+
customTitle: titleOverrides[item.data.id],
|
|
3763
3831
|
onUpdateTitle: onUpdateTitle,
|
|
3764
3832
|
onResetTitle: onResetTitle
|
|
3765
3833
|
}) : /*#__PURE__*/React__default.createElement(SortableComputedChip, {
|
|
@@ -3832,7 +3900,7 @@ const SortableChip = ({
|
|
|
3832
3900
|
onMoveDown,
|
|
3833
3901
|
isFirst,
|
|
3834
3902
|
isLast,
|
|
3835
|
-
|
|
3903
|
+
instanceId,
|
|
3836
3904
|
defaultTitle,
|
|
3837
3905
|
customTitle,
|
|
3838
3906
|
onUpdateTitle,
|
|
@@ -3885,7 +3953,7 @@ const SortableChip = ({
|
|
|
3885
3953
|
handleCancel();
|
|
3886
3954
|
return;
|
|
3887
3955
|
}
|
|
3888
|
-
onUpdateTitle(
|
|
3956
|
+
onUpdateTitle(instanceId, trimmedValue);
|
|
3889
3957
|
setIsEditing(false);
|
|
3890
3958
|
};
|
|
3891
3959
|
const handleCancel = () => {
|
|
@@ -3893,7 +3961,7 @@ const SortableChip = ({
|
|
|
3893
3961
|
setEditValue('');
|
|
3894
3962
|
};
|
|
3895
3963
|
const handleReset = () => {
|
|
3896
|
-
onResetTitle(
|
|
3964
|
+
onResetTitle(instanceId);
|
|
3897
3965
|
setIsEditing(false);
|
|
3898
3966
|
setEditValue('');
|
|
3899
3967
|
};
|
|
@@ -4114,7 +4182,10 @@ const Metrics = ({
|
|
|
4114
4182
|
currentProviderKey = selection.targetKey;
|
|
4115
4183
|
});
|
|
4116
4184
|
|
|
4117
|
-
// Create a Set of already selected metric fullPaths for quick lookup
|
|
4185
|
+
// Create a Set of already selected metric fullPaths for quick lookup.
|
|
4186
|
+
// Already-selected items stay selectable (so the same metric can be
|
|
4187
|
+
// added again on purpose) - they're just flagged so the dropdown can
|
|
4188
|
+
// highlight them instead of blocking the click.
|
|
4118
4189
|
const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
|
|
4119
4190
|
|
|
4120
4191
|
// Metrics are a direct array, not nested by alias
|
|
@@ -4122,15 +4193,12 @@ const Metrics = ({
|
|
|
4122
4193
|
const items = provider.metrics.map((metric, index) => {
|
|
4123
4194
|
// Construct the fullPath for this metric
|
|
4124
4195
|
const fullPath = `${aliasPath.join('_')}.${metric.name}`;
|
|
4125
|
-
|
|
4126
|
-
// Check if this metric is already selected
|
|
4127
|
-
const isAlreadySelected = selectedFullPaths.has(fullPath);
|
|
4128
4196
|
return {
|
|
4129
4197
|
key: `${currentProvider}_${metric.name}_${index}`,
|
|
4130
4198
|
value: metric.title || metric.name,
|
|
4131
4199
|
metricName: metric.name,
|
|
4132
4200
|
metric: metric,
|
|
4133
|
-
|
|
4201
|
+
alreadySelected: selectedFullPaths.has(fullPath),
|
|
4134
4202
|
icon: metric.source ? /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
|
|
4135
4203
|
source: metric.source
|
|
4136
4204
|
}) : undefined
|
|
@@ -4193,7 +4261,11 @@ const Metrics = ({
|
|
|
4193
4261
|
metricName: selectedItem.metricName,
|
|
4194
4262
|
metricTitle: selectedItem.value,
|
|
4195
4263
|
// The constructed path for server
|
|
4196
|
-
fullPath: fullPath
|
|
4264
|
+
fullPath: fullPath,
|
|
4265
|
+
// Unique per-instance identity, so this field can be added more
|
|
4266
|
+
// than once and each occurrence stays independently
|
|
4267
|
+
// orderable/removable/titleable.
|
|
4268
|
+
id: nextInstanceId(fullPath, savedMetrics)
|
|
4197
4269
|
};
|
|
4198
4270
|
onSaveMetric(metricData);
|
|
4199
4271
|
handleCancel();
|
|
@@ -4204,8 +4276,9 @@ const Metrics = ({
|
|
|
4204
4276
|
const handleAddAll = () => {
|
|
4205
4277
|
const metricItems = getMetricItems();
|
|
4206
4278
|
|
|
4207
|
-
// Filter out already selected metrics
|
|
4208
|
-
|
|
4279
|
+
// Filter out already selected metrics - "Add all" only fills in
|
|
4280
|
+
// missing ones; intentional duplicates are added one at a time above.
|
|
4281
|
+
const availableItems = metricItems.filter(item => !item.alreadySelected);
|
|
4209
4282
|
if (availableItems.length === 0) return;
|
|
4210
4283
|
|
|
4211
4284
|
// Build the complete relation objects array (same for all metrics in this provider)
|
|
@@ -4250,7 +4323,10 @@ const Metrics = ({
|
|
|
4250
4323
|
metricName: item.metricName,
|
|
4251
4324
|
metricTitle: item.value,
|
|
4252
4325
|
// The constructed path for server
|
|
4253
|
-
fullPath: fullPath
|
|
4326
|
+
fullPath: fullPath,
|
|
4327
|
+
// "Add all" only ever adds fields that aren't already
|
|
4328
|
+
// selected, so this is always the first occurrence.
|
|
4329
|
+
id: fullPath
|
|
4254
4330
|
};
|
|
4255
4331
|
onSaveMetric(metricData);
|
|
4256
4332
|
});
|
|
@@ -4349,7 +4425,7 @@ const Metrics = ({
|
|
|
4349
4425
|
placement: "top"
|
|
4350
4426
|
}, /*#__PURE__*/React__default.createElement("span", null, /*#__PURE__*/React__default.createElement(IconButton$1, {
|
|
4351
4427
|
onClick: handleAddAll,
|
|
4352
|
-
disabled: getMetricItems().filter(item => !item.
|
|
4428
|
+
disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
|
|
4353
4429
|
"aria-label": "add all metrics",
|
|
4354
4430
|
sx: {
|
|
4355
4431
|
color: "rgb(70, 134, 128)",
|
|
@@ -4427,20 +4503,64 @@ const Metrics = ({
|
|
|
4427
4503
|
id: index,
|
|
4428
4504
|
label: metric.metricTitle,
|
|
4429
4505
|
fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
|
|
4430
|
-
onDelete: () => onRemoveMetric(
|
|
4506
|
+
onDelete: () => onRemoveMetric(metric.id),
|
|
4431
4507
|
onMoveUp: () => handleMoveUp(index),
|
|
4432
4508
|
onMoveDown: () => handleMoveDown(index),
|
|
4433
4509
|
isFirst: index === 0,
|
|
4434
4510
|
isLast: index === savedMetrics.length - 1,
|
|
4435
|
-
|
|
4511
|
+
instanceId: metric.id,
|
|
4436
4512
|
defaultTitle: metric.metricTitle,
|
|
4437
|
-
customTitle: titleOverrides[metric.
|
|
4513
|
+
customTitle: titleOverrides[metric.id],
|
|
4438
4514
|
onUpdateTitle: onUpdateTitle,
|
|
4439
4515
|
onResetTitle: onResetTitle,
|
|
4440
4516
|
source: metric.metric?.source
|
|
4441
4517
|
})))));
|
|
4442
4518
|
};
|
|
4443
4519
|
|
|
4520
|
+
// Small text input + button for adding a filter value that isn't present in
|
|
4521
|
+
// the "Select Filter Values" dropdown (e.g. because no existing row has that
|
|
4522
|
+
// value). Kept as an uncontrolled local field so it clears itself after add.
|
|
4523
|
+
const CustomFilterValueInput = ({
|
|
4524
|
+
onAdd
|
|
4525
|
+
}) => {
|
|
4526
|
+
const [value, setValue] = useState('');
|
|
4527
|
+
const handleAdd = () => {
|
|
4528
|
+
const trimmed = value.trim();
|
|
4529
|
+
if (!trimmed) return;
|
|
4530
|
+
onAdd(trimmed);
|
|
4531
|
+
setValue('');
|
|
4532
|
+
};
|
|
4533
|
+
return /*#__PURE__*/React__default.createElement(Box$1, {
|
|
4534
|
+
sx: {
|
|
4535
|
+
display: 'flex',
|
|
4536
|
+
gap: 1,
|
|
4537
|
+
mt: 1
|
|
4538
|
+
}
|
|
4539
|
+
}, /*#__PURE__*/React__default.createElement(TextField, {
|
|
4540
|
+
size: "small",
|
|
4541
|
+
fullWidth: true,
|
|
4542
|
+
placeholder: "Add a value not in the list above",
|
|
4543
|
+
value: value,
|
|
4544
|
+
onChange: e => setValue(e.target.value),
|
|
4545
|
+
onKeyDown: e => {
|
|
4546
|
+
if (e.key === 'Enter') {
|
|
4547
|
+
e.preventDefault();
|
|
4548
|
+
handleAdd();
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
}), /*#__PURE__*/React__default.createElement(Button, {
|
|
4552
|
+
variant: "outlined",
|
|
4553
|
+
size: "small",
|
|
4554
|
+
onClick: handleAdd,
|
|
4555
|
+
disabled: !value.trim(),
|
|
4556
|
+
sx: {
|
|
4557
|
+
textTransform: 'none',
|
|
4558
|
+
whiteSpace: 'nowrap',
|
|
4559
|
+
borderColor: "rgb(70, 134, 128)",
|
|
4560
|
+
color: "rgb(70, 134, 128)"
|
|
4561
|
+
}
|
|
4562
|
+
}, "Add"));
|
|
4563
|
+
};
|
|
4444
4564
|
const Filters = ({
|
|
4445
4565
|
providersData,
|
|
4446
4566
|
rootProvider,
|
|
@@ -4580,8 +4700,9 @@ const Filters = ({
|
|
|
4580
4700
|
}
|
|
4581
4701
|
} else {
|
|
4582
4702
|
// For regular filters, set selected values and fetch available values
|
|
4583
|
-
|
|
4584
|
-
|
|
4703
|
+
const currentValues = filter.values || [];
|
|
4704
|
+
setSelectedFilterValues(currentValues);
|
|
4705
|
+
await fetchFilterValues(fullPath, '', currentValues);
|
|
4585
4706
|
}
|
|
4586
4707
|
};
|
|
4587
4708
|
const handleSaveEditedFilter = fullPath => {
|
|
@@ -4676,7 +4797,11 @@ const Filters = ({
|
|
|
4676
4797
|
};
|
|
4677
4798
|
|
|
4678
4799
|
// Fetch distinct values for the selected dimension
|
|
4679
|
-
|
|
4800
|
+
// preserveValues lets a caller pass the values that must survive the merge
|
|
4801
|
+
// explicitly, instead of relying on the selectedFilterValues closure - a
|
|
4802
|
+
// setSelectedFilterValues() call right before fetchFilterValues() hasn't
|
|
4803
|
+
// committed yet by the time this function reads that state.
|
|
4804
|
+
const fetchFilterValues = async (fullPath, searchText = '', preserveValues = null) => {
|
|
4680
4805
|
setLoadingFilterValues(true);
|
|
4681
4806
|
try {
|
|
4682
4807
|
// Get parameters from context if available, otherwise use default
|
|
@@ -4749,7 +4874,8 @@ const Filters = ({
|
|
|
4749
4874
|
// Merge with currently selected values to ensure they're always available
|
|
4750
4875
|
// This is important when editing filters with search text - we don't want to lose
|
|
4751
4876
|
// previously selected values that don't match the current search
|
|
4752
|
-
const
|
|
4877
|
+
const valuesToPreserve = preserveValues !== null ? preserveValues : selectedFilterValues;
|
|
4878
|
+
const selectedValueItems = valuesToPreserve.map(key => ({
|
|
4753
4879
|
key: String(key),
|
|
4754
4880
|
value: String(key)
|
|
4755
4881
|
}));
|
|
@@ -4978,6 +5104,21 @@ const Filters = ({
|
|
|
4978
5104
|
}
|
|
4979
5105
|
};
|
|
4980
5106
|
|
|
5107
|
+
// Add a value typed in the "custom value" box directly into the selected
|
|
5108
|
+
// filter values, and inject it into availableFilterValues too, since
|
|
5109
|
+
// CheckboxMultiAutocomplete only renders a chip for keys it can resolve
|
|
5110
|
+
// against its items list.
|
|
5111
|
+
const handleAddCustomFilterValue = rawValue => {
|
|
5112
|
+
const value = rawValue.trim();
|
|
5113
|
+
if (!value) return;
|
|
5114
|
+
if (selectedFilterValues.includes(value)) return;
|
|
5115
|
+
setAvailableFilterValues(prev => prev.some(item => item.key === value) ? prev : [{
|
|
5116
|
+
key: value,
|
|
5117
|
+
value
|
|
5118
|
+
}, ...prev]);
|
|
5119
|
+
setSelectedFilterValues(prev => [...prev, value]);
|
|
5120
|
+
};
|
|
5121
|
+
|
|
4981
5122
|
// Handler for search text input in filter values dropdown (debounced - triggers API call)
|
|
4982
5123
|
const handleFilterSearchChange = searchText => {
|
|
4983
5124
|
// Determine the fullPath based on current mode
|
|
@@ -5197,6 +5338,8 @@ const Filters = ({
|
|
|
5197
5338
|
loading: loadingFilterValues,
|
|
5198
5339
|
helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
|
|
5199
5340
|
debounceMs: 1000
|
|
5341
|
+
}), /*#__PURE__*/React__default.createElement(CustomFilterValueInput, {
|
|
5342
|
+
onAdd: handleAddCustomFilterValue
|
|
5200
5343
|
})));
|
|
5201
5344
|
})(), /*#__PURE__*/React__default.createElement(Box$1, {
|
|
5202
5345
|
sx: {
|
|
@@ -5271,7 +5414,12 @@ const Filters = ({
|
|
|
5271
5414
|
mb: 3
|
|
5272
5415
|
}
|
|
5273
5416
|
}, /*#__PURE__*/React__default.createElement(SingleSelect, {
|
|
5274
|
-
items:
|
|
5417
|
+
items: Array.from(
|
|
5418
|
+
// Dimensions can now be added more than once (same fullPath,
|
|
5419
|
+
// different instance), but a filter targets the underlying
|
|
5420
|
+
// field regardless of how many columns display it - so this
|
|
5421
|
+
// list is deduped by fullPath to avoid showing it twice.
|
|
5422
|
+
new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
|
|
5275
5423
|
key: dim.fullPath,
|
|
5276
5424
|
value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
|
|
5277
5425
|
})).sort((a, b) => a.value.localeCompare(b.value)),
|
|
@@ -5343,6 +5491,8 @@ const Filters = ({
|
|
|
5343
5491
|
loading: loadingFilterValues,
|
|
5344
5492
|
helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
|
|
5345
5493
|
debounceMs: 1000
|
|
5494
|
+
}), /*#__PURE__*/React__default.createElement(CustomFilterValueInput, {
|
|
5495
|
+
onAdd: handleAddCustomFilterValue
|
|
5346
5496
|
})));
|
|
5347
5497
|
})(), /*#__PURE__*/React__default.createElement(Box$1, {
|
|
5348
5498
|
sx: {
|
|
@@ -5632,6 +5782,8 @@ const Filters = ({
|
|
|
5632
5782
|
loading: loadingFilterValues,
|
|
5633
5783
|
helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
|
|
5634
5784
|
debounceMs: 1000
|
|
5785
|
+
}), /*#__PURE__*/React__default.createElement(CustomFilterValueInput, {
|
|
5786
|
+
onAdd: handleAddCustomFilterValue
|
|
5635
5787
|
})), /*#__PURE__*/React__default.createElement(Box$1, {
|
|
5636
5788
|
sx: {
|
|
5637
5789
|
display: 'flex',
|
|
@@ -5854,7 +6006,7 @@ const ColumnOrder = ({
|
|
|
5854
6006
|
key: `${item.kind}-${item.key}`,
|
|
5855
6007
|
id: index,
|
|
5856
6008
|
label: labelFor(item),
|
|
5857
|
-
fullPath: item.kind === "computed" ? item.data.value : item.
|
|
6009
|
+
fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
|
|
5858
6010
|
kind: item.kind,
|
|
5859
6011
|
source: item.kind === "metric" ? item.data.metric?.source : undefined,
|
|
5860
6012
|
onMoveUp: () => handleMoveUp(index),
|
|
@@ -5880,11 +6032,17 @@ const formatValue = (value, def) => {
|
|
|
5880
6032
|
const isNumericType = type => type === 'integer' || type === 'currency';
|
|
5881
6033
|
|
|
5882
6034
|
// Builds a single DataGrid column definition for one resolved column item
|
|
5883
|
-
// (a dimension, computed dimension, or metric).
|
|
6035
|
+
// (a dimension, computed dimension, or metric). The DataGrid `field` is the
|
|
6036
|
+
// item's instance id (so two duplicate instances of the same underlying
|
|
6037
|
+
// dimension/metric still get distinct columns); the actual cell value is
|
|
6038
|
+
// read via `valueGetter` from `dataFieldName`, the key the API response
|
|
6039
|
+
// actually uses - shared by every duplicate instance, since the backend
|
|
6040
|
+
// only ever returns one value per fullPath. dataFieldName rules:
|
|
5884
6041
|
// - Dimensions from base provider: baseAlias.field -> baseAlias_field
|
|
5885
6042
|
// - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
|
|
5886
6043
|
// - Computed dimensions have no provider/relations chain, so the API returns
|
|
5887
|
-
// each row keyed by the computed dimension's own `name
|
|
6044
|
+
// each row keyed by the computed dimension's own `name` (and can't be
|
|
6045
|
+
// duplicated - names must be unique - so field/dataFieldName coincide).
|
|
5888
6046
|
// - Metrics: API returns metric keys in different forms depending on path depth
|
|
5889
6047
|
// (see comment inline below), normalised to match the row-key normalisation
|
|
5890
6048
|
// done when building `rows`.
|
|
@@ -5902,13 +6060,13 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5902
6060
|
if (item.kind === 'metric') {
|
|
5903
6061
|
const metric = item.data;
|
|
5904
6062
|
const metricDef = metric.metric;
|
|
5905
|
-
let
|
|
6063
|
+
let dataFieldName;
|
|
5906
6064
|
const dotCount = (metric.fullPath.match(/\./g) || []).length;
|
|
5907
6065
|
if (dotCount >= 2) {
|
|
5908
6066
|
// 3+-part namespaced path: API returns the full dotted path as the key.
|
|
5909
6067
|
// Normalise dots to underscores to match the row key normalisation below.
|
|
5910
6068
|
// Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
|
|
5911
|
-
|
|
6069
|
+
dataFieldName = metric.fullPath.replace(/\./g, '_');
|
|
5912
6070
|
} else if (metric.relations && metric.relations.length > 0) {
|
|
5913
6071
|
// 2-part path from a nested provider: API drops the base provider alias.
|
|
5914
6072
|
// Example: "mc_fa.total_amount" -> "fa_total_amount"
|
|
@@ -5919,19 +6077,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5919
6077
|
const pathParts = pathWithoutField.split('_');
|
|
5920
6078
|
pathParts.shift(); // remove base provider alias
|
|
5921
6079
|
const pathWithoutBase = pathParts.join('_');
|
|
5922
|
-
|
|
6080
|
+
dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
|
|
5923
6081
|
} else {
|
|
5924
6082
|
// 2-part path from the base provider: API returns just the metric name.
|
|
5925
6083
|
// Example: "mc.record_count" -> "record_count"
|
|
5926
|
-
|
|
6084
|
+
dataFieldName = metric.metricName;
|
|
5927
6085
|
}
|
|
5928
|
-
const headerName = interpolateTitle(titleOverrides.metrics[metric.
|
|
6086
|
+
const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
|
|
5929
6087
|
return {
|
|
5930
|
-
field
|
|
6088
|
+
// `field` is the instance id (unique even for duplicate columns
|
|
6089
|
+
// pointing at the same underlying metric); the actual value is
|
|
6090
|
+
// read from the row via valueGetter using dataFieldName, which is
|
|
6091
|
+
// the key the API response actually uses (shared across duplicates).
|
|
6092
|
+
field: metric.id.replace(/[.#]/g, '_'),
|
|
5931
6093
|
headerName: headerName,
|
|
5932
6094
|
flex: 1,
|
|
5933
6095
|
minWidth: 150,
|
|
5934
6096
|
type: isNumericType(metricDef?.type) ? 'number' : 'string',
|
|
6097
|
+
valueGetter: (_value, row) => row[dataFieldName],
|
|
5935
6098
|
renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
|
|
5936
6099
|
sx: {
|
|
5937
6100
|
display: 'flex',
|
|
@@ -5947,7 +6110,7 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5947
6110
|
|
|
5948
6111
|
// Dimension
|
|
5949
6112
|
const dim = item.data;
|
|
5950
|
-
let
|
|
6113
|
+
let dataFieldName;
|
|
5951
6114
|
if (dim.relations && dim.relations.length > 0) {
|
|
5952
6115
|
// From nested provider: drop the base provider alias
|
|
5953
6116
|
// Example: ft_fa_db.currency -> fa_db_currency
|
|
@@ -5959,20 +6122,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5959
6122
|
pathParts.shift(); // Remove base provider alias
|
|
5960
6123
|
const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
|
|
5961
6124
|
|
|
5962
|
-
|
|
6125
|
+
dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
|
|
5963
6126
|
} else {
|
|
5964
6127
|
// From base provider: keep the full path with underscore
|
|
5965
6128
|
// Example: ba.created_at -> ba_created_at
|
|
5966
|
-
|
|
6129
|
+
dataFieldName = dim.fullPath.replace('.', '_');
|
|
5967
6130
|
}
|
|
5968
|
-
const headerName = interpolateTitle(titleOverrides.dimensions[dim.
|
|
6131
|
+
const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
|
|
5969
6132
|
const dimDef = dim.dimension;
|
|
5970
6133
|
return {
|
|
5971
|
-
|
|
6134
|
+
// Same rationale as the metric branch above: `field` must be unique
|
|
6135
|
+
// per instance, while the value always comes from the one real
|
|
6136
|
+
// underlying row key (dataFieldName), shared by every duplicate.
|
|
6137
|
+
field: dim.id.replace(/[.#]/g, '_'),
|
|
5972
6138
|
headerName: headerName,
|
|
5973
6139
|
flex: 1,
|
|
5974
6140
|
minWidth: 150,
|
|
5975
6141
|
type: isNumericType(dimDef?.type) ? 'number' : 'string',
|
|
6142
|
+
valueGetter: (_value, row) => row[dataFieldName],
|
|
5976
6143
|
valueFormatter: value => formatValue(value, dimDef)
|
|
5977
6144
|
};
|
|
5978
6145
|
};
|
|
@@ -6298,6 +6465,72 @@ const ReportBuilder = ({
|
|
|
6298
6465
|
}
|
|
6299
6466
|
};
|
|
6300
6467
|
|
|
6468
|
+
// Reconstructs saved dimensions as independent, individually-identified
|
|
6469
|
+
// instances so that deliberate duplicates (the same field added more than
|
|
6470
|
+
// once) survive a save/reload round trip. Prefers `ordered_dimensions`
|
|
6471
|
+
// (one ref per instance - the nth duplicate of a path is suffixed
|
|
6472
|
+
// `#n`) when present; falls back to one instance per entry in the
|
|
6473
|
+
// (necessarily duplicate-free) `dimensions` path list for reports saved
|
|
6474
|
+
// before this field existed.
|
|
6475
|
+
const reconstructDimensions = (reportDef, providersData) => {
|
|
6476
|
+
const orderByMap = {};
|
|
6477
|
+
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6478
|
+
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6479
|
+
orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
|
|
6480
|
+
}
|
|
6481
|
+
}
|
|
6482
|
+
const reconstructed = [];
|
|
6483
|
+
const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
|
|
6484
|
+
if (orderedRefs.length > 0) {
|
|
6485
|
+
orderedRefs.forEach(ref => {
|
|
6486
|
+
const fullPath = ref.split("#")[0];
|
|
6487
|
+
const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
|
|
6488
|
+
if (dim) {
|
|
6489
|
+
dim.id = ref;
|
|
6490
|
+
dim.sortOrder = orderByMap[fullPath] || null;
|
|
6491
|
+
reconstructed.push(dim);
|
|
6492
|
+
}
|
|
6493
|
+
});
|
|
6494
|
+
} else if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6495
|
+
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6496
|
+
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6497
|
+
if (dim) {
|
|
6498
|
+
dim.id = dimPath;
|
|
6499
|
+
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6500
|
+
reconstructed.push(dim);
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
}
|
|
6504
|
+
return reconstructed;
|
|
6505
|
+
};
|
|
6506
|
+
|
|
6507
|
+
// Mirrors reconstructDimensions for metrics, using the analogous
|
|
6508
|
+
// `ordered_metrics` field (metrics have no other unified-order concept to
|
|
6509
|
+
// fall back on, so this is always written whenever there are metrics).
|
|
6510
|
+
const reconstructMetrics = (reportDef, providersData) => {
|
|
6511
|
+
const reconstructed = [];
|
|
6512
|
+
const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
|
|
6513
|
+
if (orderedRefs.length > 0) {
|
|
6514
|
+
orderedRefs.forEach(ref => {
|
|
6515
|
+
const fullPath = ref.split("#")[0];
|
|
6516
|
+
const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
|
|
6517
|
+
if (metric) {
|
|
6518
|
+
metric.id = ref;
|
|
6519
|
+
reconstructed.push(metric);
|
|
6520
|
+
}
|
|
6521
|
+
});
|
|
6522
|
+
} else if (reportDef.definition?.doc?.query?.metrics) {
|
|
6523
|
+
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6524
|
+
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6525
|
+
if (metric) {
|
|
6526
|
+
metric.id = metricPath;
|
|
6527
|
+
reconstructed.push(metric);
|
|
6528
|
+
}
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
return reconstructed;
|
|
6532
|
+
};
|
|
6533
|
+
|
|
6301
6534
|
// Build the unified display/execution order for dimensions + computed
|
|
6302
6535
|
// dimensions. Prefers the persisted `ordered_dimensions` field; falls back
|
|
6303
6536
|
// to "dimensions first, then computed dimensions" for report definitions
|
|
@@ -6308,7 +6541,7 @@ const ReportBuilder = ({
|
|
|
6308
6541
|
const seenComputedKeys = new Set();
|
|
6309
6542
|
if (Array.isArray(rawOrderedDimensions)) {
|
|
6310
6543
|
rawOrderedDimensions.forEach(entry => {
|
|
6311
|
-
if (entry.type === "dimension" && dimensions.some(d => d.
|
|
6544
|
+
if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
|
|
6312
6545
|
dimensionOrder.push({
|
|
6313
6546
|
type: "dimension",
|
|
6314
6547
|
key: entry.ref
|
|
@@ -6326,10 +6559,10 @@ const ReportBuilder = ({
|
|
|
6326
6559
|
|
|
6327
6560
|
// Append anything not covered by ordered_dimensions (older reports, or drift)
|
|
6328
6561
|
dimensions.forEach(d => {
|
|
6329
|
-
if (!seenDimensionKeys.has(d.
|
|
6562
|
+
if (!seenDimensionKeys.has(d.id)) {
|
|
6330
6563
|
dimensionOrder.push({
|
|
6331
6564
|
type: "dimension",
|
|
6332
|
-
key: d.
|
|
6565
|
+
key: d.id
|
|
6333
6566
|
});
|
|
6334
6567
|
}
|
|
6335
6568
|
});
|
|
@@ -6352,12 +6585,12 @@ const ReportBuilder = ({
|
|
|
6352
6585
|
// "fall back to the default order".
|
|
6353
6586
|
const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
|
|
6354
6587
|
if (!Array.isArray(rawOrderedColumns)) return [];
|
|
6355
|
-
const
|
|
6588
|
+
const dimensionIds = new Set(dimensions.map(d => d.id));
|
|
6356
6589
|
const computedNames = new Set(computedDimensions.map(cd => cd.name));
|
|
6357
|
-
const
|
|
6590
|
+
const metricIds = new Set(metrics.map(m => m.id));
|
|
6358
6591
|
const columnOrder = [];
|
|
6359
6592
|
rawOrderedColumns.forEach(entry => {
|
|
6360
|
-
if (entry.type === "dimension" &&
|
|
6593
|
+
if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
|
|
6361
6594
|
columnOrder.push({
|
|
6362
6595
|
type: "dimension",
|
|
6363
6596
|
key: entry.ref
|
|
@@ -6367,7 +6600,7 @@ const ReportBuilder = ({
|
|
|
6367
6600
|
type: "computed",
|
|
6368
6601
|
key: entry.ref
|
|
6369
6602
|
});
|
|
6370
|
-
} else if (entry.type === "metric" &&
|
|
6603
|
+
} else if (entry.type === "metric" && metricIds.has(entry.ref)) {
|
|
6371
6604
|
columnOrder.push({
|
|
6372
6605
|
type: "metric",
|
|
6373
6606
|
key: entry.ref
|
|
@@ -6397,43 +6630,10 @@ const ReportBuilder = ({
|
|
|
6397
6630
|
// Set the title
|
|
6398
6631
|
setReportTitle(reportDef.title || "");
|
|
6399
6632
|
|
|
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
|
-
}
|
|
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);
|
|
6437
6637
|
|
|
6438
6638
|
// Load title overrides if they exist
|
|
6439
6639
|
const loadedTitleOverrides = {
|
|
@@ -6517,43 +6717,10 @@ const ReportBuilder = ({
|
|
|
6517
6717
|
// Set the title (already modified with " (Copy)" suffix)
|
|
6518
6718
|
setReportTitle(reportDef.title || "");
|
|
6519
6719
|
|
|
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
|
-
}
|
|
6720
|
+
// Reconstruct dimensions and metrics as independent instances
|
|
6721
|
+
// (preserving deliberate duplicates - see reconstructDimensions/Metrics)
|
|
6722
|
+
const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
|
|
6723
|
+
const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
|
|
6557
6724
|
|
|
6558
6725
|
// Load title overrides if they exist
|
|
6559
6726
|
const loadedTitleOverrides = {
|
|
@@ -6652,42 +6819,45 @@ const ReportBuilder = ({
|
|
|
6652
6819
|
});
|
|
6653
6820
|
console.log("Selected root provider:", event.target.value);
|
|
6654
6821
|
};
|
|
6655
|
-
|
|
6822
|
+
|
|
6823
|
+
// Keyed by instance id, so two instances of the same dimension can carry
|
|
6824
|
+
// independent custom titles.
|
|
6825
|
+
const handleUpdateDimensionTitle = (id, customTitle) => {
|
|
6656
6826
|
setTitleOverrides(prev => ({
|
|
6657
6827
|
...prev,
|
|
6658
6828
|
dimensions: {
|
|
6659
6829
|
...prev.dimensions,
|
|
6660
|
-
[
|
|
6830
|
+
[id]: customTitle
|
|
6661
6831
|
}
|
|
6662
6832
|
}));
|
|
6663
6833
|
};
|
|
6664
|
-
const handleResetDimensionTitle =
|
|
6834
|
+
const handleResetDimensionTitle = id => {
|
|
6665
6835
|
setTitleOverrides(prev => {
|
|
6666
6836
|
const newDimensions = {
|
|
6667
6837
|
...prev.dimensions
|
|
6668
6838
|
};
|
|
6669
|
-
delete newDimensions[
|
|
6839
|
+
delete newDimensions[id];
|
|
6670
6840
|
return {
|
|
6671
6841
|
...prev,
|
|
6672
6842
|
dimensions: newDimensions
|
|
6673
6843
|
};
|
|
6674
6844
|
});
|
|
6675
6845
|
};
|
|
6676
|
-
const handleUpdateMetricTitle = (
|
|
6846
|
+
const handleUpdateMetricTitle = (id, customTitle) => {
|
|
6677
6847
|
setTitleOverrides(prev => ({
|
|
6678
6848
|
...prev,
|
|
6679
6849
|
metrics: {
|
|
6680
6850
|
...prev.metrics,
|
|
6681
|
-
[
|
|
6851
|
+
[id]: customTitle
|
|
6682
6852
|
}
|
|
6683
6853
|
}));
|
|
6684
6854
|
};
|
|
6685
|
-
const handleResetMetricTitle =
|
|
6855
|
+
const handleResetMetricTitle = id => {
|
|
6686
6856
|
setTitleOverrides(prev => {
|
|
6687
6857
|
const newMetrics = {
|
|
6688
6858
|
...prev.metrics
|
|
6689
6859
|
};
|
|
6690
|
-
delete newMetrics[
|
|
6860
|
+
delete newMetrics[id];
|
|
6691
6861
|
return {
|
|
6692
6862
|
...prev,
|
|
6693
6863
|
metrics: newMetrics
|
|
@@ -6722,11 +6892,11 @@ const ReportBuilder = ({
|
|
|
6722
6892
|
dimensions: [...prev.dimensions, dimensionData],
|
|
6723
6893
|
dimensionOrder: [...prev.dimensionOrder, {
|
|
6724
6894
|
type: "dimension",
|
|
6725
|
-
key: dimensionData.
|
|
6895
|
+
key: dimensionData.id
|
|
6726
6896
|
}],
|
|
6727
6897
|
columnOrder: appendToColumnOrder(prev.columnOrder, {
|
|
6728
6898
|
type: "dimension",
|
|
6729
|
-
key: dimensionData.
|
|
6899
|
+
key: dimensionData.id
|
|
6730
6900
|
})
|
|
6731
6901
|
};
|
|
6732
6902
|
console.log("Dimension saved:", dimensionData);
|
|
@@ -6734,22 +6904,30 @@ const ReportBuilder = ({
|
|
|
6734
6904
|
return newReport;
|
|
6735
6905
|
});
|
|
6736
6906
|
};
|
|
6737
|
-
const handleRemoveDimension =
|
|
6907
|
+
const handleRemoveDimension = id => {
|
|
6738
6908
|
setReport(prev => ({
|
|
6739
6909
|
...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 ===
|
|
6910
|
+
dimensions: prev.dimensions.filter(d => d.id !== id),
|
|
6911
|
+
dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
|
|
6912
|
+
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
|
|
6743
6913
|
}));
|
|
6744
6914
|
};
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6915
|
+
|
|
6916
|
+
// sortOrder is fundamentally a per-field (query-level) setting - SQL can
|
|
6917
|
+
// only sort by a given column once - so toggling it on any instance of a
|
|
6918
|
+
// duplicated dimension applies to every instance sharing that fullPath.
|
|
6919
|
+
const handleUpdateDimensionSortOrder = (id, sortOrder) => {
|
|
6920
|
+
setReport(prev => {
|
|
6921
|
+
const target = prev.dimensions.find(d => d.id === id);
|
|
6922
|
+
if (!target) return prev;
|
|
6923
|
+
return {
|
|
6924
|
+
...prev,
|
|
6925
|
+
dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
|
|
6926
|
+
...d,
|
|
6927
|
+
sortOrder
|
|
6928
|
+
} : d)
|
|
6929
|
+
};
|
|
6930
|
+
});
|
|
6753
6931
|
};
|
|
6754
6932
|
|
|
6755
6933
|
// Reorders the single unified dimensions + computed-dimensions list;
|
|
@@ -6804,7 +6982,7 @@ const ReportBuilder = ({
|
|
|
6804
6982
|
metrics: [...prev.metrics, metricData],
|
|
6805
6983
|
columnOrder: appendToColumnOrder(prev.columnOrder, {
|
|
6806
6984
|
type: "metric",
|
|
6807
|
-
key: metricData.
|
|
6985
|
+
key: metricData.id
|
|
6808
6986
|
})
|
|
6809
6987
|
};
|
|
6810
6988
|
console.log("Metric saved:", metricData);
|
|
@@ -6812,15 +6990,12 @@ const ReportBuilder = ({
|
|
|
6812
6990
|
return newReport;
|
|
6813
6991
|
});
|
|
6814
6992
|
};
|
|
6815
|
-
const handleRemoveMetric =
|
|
6816
|
-
setReport(prev => {
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
|
|
6822
|
-
};
|
|
6823
|
-
});
|
|
6993
|
+
const handleRemoveMetric = id => {
|
|
6994
|
+
setReport(prev => ({
|
|
6995
|
+
...prev,
|
|
6996
|
+
metrics: prev.metrics.filter(m => m.id !== id),
|
|
6997
|
+
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
|
|
6998
|
+
}));
|
|
6824
6999
|
};
|
|
6825
7000
|
const handleReorderMetrics = newOrder => {
|
|
6826
7001
|
setReport(prev => ({
|
|
@@ -6875,13 +7050,30 @@ const ReportBuilder = ({
|
|
|
6875
7050
|
// persisted `ordered_dimensions` field all stay consistent with whatever
|
|
6876
7051
|
// order the user arranged on screen.
|
|
6877
7052
|
const buildQueryObject = () => {
|
|
6878
|
-
const
|
|
7053
|
+
const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
|
|
6879
7054
|
const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
|
|
6880
|
-
|
|
7055
|
+
|
|
7056
|
+
// May contain more than one instance of the same fullPath (deliberate
|
|
7057
|
+
// duplicates), kept in dimensionOrder's display order.
|
|
7058
|
+
const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
|
|
6881
7059
|
const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
|
|
6882
7060
|
|
|
7061
|
+
// The API keys each result row by fullPath, so duplicate instances of
|
|
7062
|
+
// the same field can only ever carry one value/one sort direction at
|
|
7063
|
+
// the query level - dedupe by fullPath (keeping first-occurrence order)
|
|
7064
|
+
// before sending. The builder still shows/orders/titles each instance
|
|
7065
|
+
// independently; they just read from the same underlying query column.
|
|
7066
|
+
const uniqueDimensionsByPath = [];
|
|
7067
|
+
const seenDimensionPaths = new Set();
|
|
7068
|
+
orderedDimensionsOnly.forEach(dim => {
|
|
7069
|
+
if (!seenDimensionPaths.has(dim.fullPath)) {
|
|
7070
|
+
seenDimensionPaths.add(dim.fullPath);
|
|
7071
|
+
uniqueDimensionsByPath.push(dim);
|
|
7072
|
+
}
|
|
7073
|
+
});
|
|
7074
|
+
|
|
6883
7075
|
// Build order_by array - include all dimensions, add desc property only when needed
|
|
6884
|
-
const orderBy =
|
|
7076
|
+
const orderBy = uniqueDimensionsByPath.map(dim => {
|
|
6885
7077
|
const orderItem = {
|
|
6886
7078
|
name: dim.fullPath
|
|
6887
7079
|
};
|
|
@@ -6936,9 +7128,20 @@ const ReportBuilder = ({
|
|
|
6936
7128
|
if (Object.keys(titleOverrides.filters).length > 0) {
|
|
6937
7129
|
titles.filters = titleOverrides.filters;
|
|
6938
7130
|
}
|
|
7131
|
+
|
|
7132
|
+
// Same dedupe rationale as dimensions above: the query only needs each
|
|
7133
|
+
// metric fullPath once, however many display instances reference it.
|
|
7134
|
+
const uniqueMetricPaths = [];
|
|
7135
|
+
const seenMetricPaths = new Set();
|
|
7136
|
+
report.metrics.forEach(metric => {
|
|
7137
|
+
if (!seenMetricPaths.has(metric.fullPath)) {
|
|
7138
|
+
seenMetricPaths.add(metric.fullPath);
|
|
7139
|
+
uniqueMetricPaths.push(metric.fullPath);
|
|
7140
|
+
}
|
|
7141
|
+
});
|
|
6939
7142
|
const queryObj = {
|
|
6940
|
-
dimensions:
|
|
6941
|
-
metrics:
|
|
7143
|
+
dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
|
|
7144
|
+
metrics: uniqueMetricPaths,
|
|
6942
7145
|
order_by: orderBy
|
|
6943
7146
|
};
|
|
6944
7147
|
|
|
@@ -6952,7 +7155,9 @@ const ReportBuilder = ({
|
|
|
6952
7155
|
|
|
6953
7156
|
// Persist the unified dimension + computed-dimension order so it can be
|
|
6954
7157
|
// reconstructed on reload and so executed reports render columns in the
|
|
6955
|
-
// same order they were arranged in the builder.
|
|
7158
|
+
// same order they were arranged in the builder. Entries use each
|
|
7159
|
+
// dimension's instance id as `ref`, so deliberate duplicates (the nth
|
|
7160
|
+
// occurrence of a fullPath is suffixed `#n`) round-trip correctly.
|
|
6956
7161
|
if (report.dimensionOrder.length > 0) {
|
|
6957
7162
|
queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
|
|
6958
7163
|
type: entry.type,
|
|
@@ -6960,14 +7165,30 @@ const ReportBuilder = ({
|
|
|
6960
7165
|
}));
|
|
6961
7166
|
}
|
|
6962
7167
|
|
|
6963
|
-
// Persist
|
|
6964
|
-
//
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
7168
|
+
// Persist metric instance order/identity the same way (metrics have no
|
|
7169
|
+
// other unified-order field to piggyback on).
|
|
7170
|
+
if (report.metrics.length > 0) {
|
|
7171
|
+
queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
|
|
7172
|
+
}
|
|
7173
|
+
|
|
7174
|
+
// Persist the full resolved column order - the explicit Column Order tab
|
|
7175
|
+
// arrangement when defined, otherwise dimensions/computed (in their own
|
|
7176
|
+
// order) followed by metrics (in their own order), same as what the
|
|
7177
|
+
// results grid renders. Always emitted (not just when a custom order
|
|
7178
|
+
// exists) so the API/results grid get an explicit order even for
|
|
7179
|
+
// untouched reports, rather than having to fall back to guessing it.
|
|
7180
|
+
const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
7181
|
+
kind: "dimension",
|
|
7182
|
+
data: dimensionById[entry.key]
|
|
7183
|
+
} : {
|
|
7184
|
+
kind: "computed",
|
|
7185
|
+
data: computedByName[entry.key]
|
|
7186
|
+
}).filter(item => item.data);
|
|
7187
|
+
const resolvedColumnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
|
|
7188
|
+
if (resolvedColumnItems.length > 0) {
|
|
7189
|
+
queryObj.ordered_columns = resolvedColumnItems.map(item => ({
|
|
7190
|
+
type: item.kind,
|
|
7191
|
+
ref: item.key
|
|
6971
7192
|
}));
|
|
6972
7193
|
}
|
|
6973
7194
|
|
|
@@ -7129,11 +7350,11 @@ const ReportBuilder = ({
|
|
|
7129
7350
|
|
|
7130
7351
|
// Resolve the unified dimensionOrder into actual dimension/computed-dimension
|
|
7131
7352
|
// objects so the results grid can render columns in that same order.
|
|
7132
|
-
const
|
|
7353
|
+
const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
|
|
7133
7354
|
const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
|
|
7134
7355
|
const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
7135
7356
|
kind: "dimension",
|
|
7136
|
-
data:
|
|
7357
|
+
data: dimensionById[entry.key]
|
|
7137
7358
|
} : {
|
|
7138
7359
|
kind: "computed",
|
|
7139
7360
|
data: computedDimensionByName[entry.key]
|