@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
|
@@ -20,6 +20,7 @@ var reactIntersectionObserver = require('react-intersection-observer');
|
|
|
20
20
|
var material = require('@mui/material');
|
|
21
21
|
var CheckBoxOutlineBlankIcon = require('@mui/icons-material/CheckBoxOutlineBlank');
|
|
22
22
|
var CheckBoxIcon = require('@mui/icons-material/CheckBox');
|
|
23
|
+
var CheckCircleIcon = require('@mui/icons-material/CheckCircle');
|
|
23
24
|
var EventEmitter = require('eventemitter3');
|
|
24
25
|
var Grid = require('@mui/material/Grid');
|
|
25
26
|
var IconButton = require('@mui/material/IconButton');
|
|
@@ -105,6 +106,7 @@ var Select__default = /*#__PURE__*/_interopDefault(Select);
|
|
|
105
106
|
var MenuItem__default = /*#__PURE__*/_interopDefault(MenuItem);
|
|
106
107
|
var CheckBoxOutlineBlankIcon__default = /*#__PURE__*/_interopDefault(CheckBoxOutlineBlankIcon);
|
|
107
108
|
var CheckBoxIcon__default = /*#__PURE__*/_interopDefault(CheckBoxIcon);
|
|
109
|
+
var CheckCircleIcon__default = /*#__PURE__*/_interopDefault(CheckCircleIcon);
|
|
108
110
|
var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
|
|
109
111
|
var Grid__default = /*#__PURE__*/_interopDefault(Grid);
|
|
110
112
|
var IconButton__default = /*#__PURE__*/_interopDefault(IconButton);
|
|
@@ -1135,7 +1137,8 @@ function SingleSelect({
|
|
|
1135
1137
|
key,
|
|
1136
1138
|
value,
|
|
1137
1139
|
disabled,
|
|
1138
|
-
icon
|
|
1140
|
+
icon,
|
|
1141
|
+
alreadySelected
|
|
1139
1142
|
} = itm;
|
|
1140
1143
|
return /*#__PURE__*/React__namespace.default.createElement(MenuItem__default.default, {
|
|
1141
1144
|
key: key,
|
|
@@ -1146,7 +1149,12 @@ function SingleSelect({
|
|
|
1146
1149
|
minHeight: "36px",
|
|
1147
1150
|
display: "flex",
|
|
1148
1151
|
alignItems: "center",
|
|
1149
|
-
gap: "6px"
|
|
1152
|
+
gap: "6px",
|
|
1153
|
+
...(alreadySelected && {
|
|
1154
|
+
color: "rgb(70, 134, 128)",
|
|
1155
|
+
backgroundColor: "rgba(70, 134, 128, 0.08)",
|
|
1156
|
+
fontWeight: 600
|
|
1157
|
+
})
|
|
1150
1158
|
}
|
|
1151
1159
|
}, icon && /*#__PURE__*/React__namespace.default.createElement(Box__default.default, {
|
|
1152
1160
|
component: "span",
|
|
@@ -1154,7 +1162,13 @@ function SingleSelect({
|
|
|
1154
1162
|
display: "inline-flex",
|
|
1155
1163
|
alignItems: "center"
|
|
1156
1164
|
}
|
|
1157
|
-
}, icon),
|
|
1165
|
+
}, icon), alreadySelected && /*#__PURE__*/React__namespace.default.createElement(CheckCircleIcon__default.default, {
|
|
1166
|
+
fontSize: "small",
|
|
1167
|
+
sx: {
|
|
1168
|
+
color: "rgb(70, 134, 128)",
|
|
1169
|
+
fontSize: "16px"
|
|
1170
|
+
}
|
|
1171
|
+
}), formatLabel(value));
|
|
1158
1172
|
}))));
|
|
1159
1173
|
}
|
|
1160
1174
|
|
|
@@ -2743,6 +2757,29 @@ const MoveButtons = ({
|
|
|
2743
2757
|
fontSize: "small"
|
|
2744
2758
|
})));
|
|
2745
2759
|
|
|
2760
|
+
// Builds an identity string for a saved dimension/metric instance. Deliberate
|
|
2761
|
+
// duplicates (the same field added more than once) are allowed - the first
|
|
2762
|
+
// occurrence keeps the plain fullPath (so existing saved reports and their
|
|
2763
|
+
// title-override keys stay backward compatible), later occurrences get a
|
|
2764
|
+
// `#n` suffix.
|
|
2765
|
+
const makeInstanceId = (fullPath, occurrenceIndex) => occurrenceIndex === 0 ? fullPath : `${fullPath}#${occurrenceIndex}`;
|
|
2766
|
+
|
|
2767
|
+
// Picks the next free instance id for a newly-added fullPath, given the
|
|
2768
|
+
// items already saved. Scans occurrence numbers from 0 rather than just
|
|
2769
|
+
// counting existing instances with this fullPath, because counting alone
|
|
2770
|
+
// can collide: e.g. add "ft.currency" twice (ids "ft.currency",
|
|
2771
|
+
// "ft.currency#1"), remove the first, then add "ft.currency" again - a
|
|
2772
|
+
// plain count of 1 would regenerate "ft.currency#1", clashing with the
|
|
2773
|
+
// surviving duplicate.
|
|
2774
|
+
const nextInstanceId = (fullPath, existingItems) => {
|
|
2775
|
+
const existingIds = new Set(existingItems.map(item => item.id));
|
|
2776
|
+
let occurrenceIndex = 0;
|
|
2777
|
+
while (existingIds.has(makeInstanceId(fullPath, occurrenceIndex))) {
|
|
2778
|
+
occurrenceIndex += 1;
|
|
2779
|
+
}
|
|
2780
|
+
return makeInstanceId(fullPath, occurrenceIndex);
|
|
2781
|
+
};
|
|
2782
|
+
|
|
2746
2783
|
// Replace {{key}} placeholders in a title with values from the report
|
|
2747
2784
|
// parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
|
|
2748
2785
|
// Unknown keys are left as-is so missing parameters remain visible.
|
|
@@ -2769,14 +2806,17 @@ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
|
|
|
2769
2806
|
// Normalise every candidate item to a uniform { kind, key, data } shape
|
|
2770
2807
|
// up front, so callers (e.g. the Column Order tab) can always read
|
|
2771
2808
|
// `.key` regardless of where the item came from.
|
|
2809
|
+
// Keyed by instance id (not fullPath) so that two instances of the same
|
|
2810
|
+
// dimension/metric - added deliberately as duplicates - are treated as
|
|
2811
|
+
// distinct columns rather than collapsing into one.
|
|
2772
2812
|
const normalizedDimensionItems = orderedDimensionItems.map(item => ({
|
|
2773
2813
|
kind: item.kind,
|
|
2774
|
-
key: item.kind === 'computed' ? item.data.name : item.data.
|
|
2814
|
+
key: item.kind === 'computed' ? item.data.name : item.data.id,
|
|
2775
2815
|
data: item.data
|
|
2776
2816
|
}));
|
|
2777
2817
|
const normalizedMetricItems = metrics.map(m => ({
|
|
2778
2818
|
kind: 'metric',
|
|
2779
|
-
key: m.
|
|
2819
|
+
key: m.id,
|
|
2780
2820
|
data: m
|
|
2781
2821
|
}));
|
|
2782
2822
|
const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
|
|
@@ -2810,7 +2850,7 @@ const SortableChip$1 = ({
|
|
|
2810
2850
|
isLast,
|
|
2811
2851
|
sortOrder,
|
|
2812
2852
|
onSortOrderChange,
|
|
2813
|
-
|
|
2853
|
+
instanceId,
|
|
2814
2854
|
defaultTitle,
|
|
2815
2855
|
customTitle,
|
|
2816
2856
|
onUpdateTitle,
|
|
@@ -2893,7 +2933,7 @@ const SortableChip$1 = ({
|
|
|
2893
2933
|
handleCancel();
|
|
2894
2934
|
return;
|
|
2895
2935
|
}
|
|
2896
|
-
onUpdateTitle(
|
|
2936
|
+
onUpdateTitle(instanceId, trimmedValue);
|
|
2897
2937
|
setIsEditing(false);
|
|
2898
2938
|
};
|
|
2899
2939
|
const handleCancel = () => {
|
|
@@ -2901,7 +2941,7 @@ const SortableChip$1 = ({
|
|
|
2901
2941
|
setEditValue("");
|
|
2902
2942
|
};
|
|
2903
2943
|
const handleReset = () => {
|
|
2904
|
-
onResetTitle(
|
|
2944
|
+
onResetTitle(instanceId);
|
|
2905
2945
|
setIsEditing(false);
|
|
2906
2946
|
setEditValue("");
|
|
2907
2947
|
};
|
|
@@ -3112,7 +3152,7 @@ const SortableComputedChip = ({
|
|
|
3112
3152
|
const handleSave = () => {
|
|
3113
3153
|
const trimmedName = editName.trim();
|
|
3114
3154
|
const trimmedValue = editValue.trim();
|
|
3115
|
-
if (!trimmedName
|
|
3155
|
+
if (!trimmedName) return;
|
|
3116
3156
|
if (isNameTaken(trimmedName, name)) return;
|
|
3117
3157
|
onUpdate(name, {
|
|
3118
3158
|
name: trimmedName,
|
|
@@ -3262,7 +3302,7 @@ const SortableComputedChip = ({
|
|
|
3262
3302
|
onClick: handleSave,
|
|
3263
3303
|
color: "primary",
|
|
3264
3304
|
"aria-label": "save computed dimension",
|
|
3265
|
-
disabled: !editName.trim() ||
|
|
3305
|
+
disabled: !editName.trim() || nameError
|
|
3266
3306
|
}, /*#__PURE__*/React__namespace.default.createElement(CheckIcon__default.default, {
|
|
3267
3307
|
fontSize: "small"
|
|
3268
3308
|
})))), /*#__PURE__*/React__namespace.default.createElement(material.Tooltip, {
|
|
@@ -3311,12 +3351,14 @@ const Dimensions = ({
|
|
|
3311
3351
|
// Resolve the unified dimensionOrder into the actual dimension / computed
|
|
3312
3352
|
// dimension objects, so dimensions and computed dimensions can be shown
|
|
3313
3353
|
// (and dragged) as a single list while staying stored in their own arrays.
|
|
3314
|
-
|
|
3354
|
+
// Keyed by instance id rather than fullPath, so two instances of the same
|
|
3355
|
+
// dimension (added deliberately as a duplicate) stay distinct entries.
|
|
3356
|
+
const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
|
|
3315
3357
|
const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
|
|
3316
3358
|
const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
3317
3359
|
kind: "dimension",
|
|
3318
3360
|
key: entry.key,
|
|
3319
|
-
data:
|
|
3361
|
+
data: dimensionById[entry.key]
|
|
3320
3362
|
} : {
|
|
3321
3363
|
kind: "computed",
|
|
3322
3364
|
key: entry.key,
|
|
@@ -3330,9 +3372,13 @@ const Dimensions = ({
|
|
|
3330
3372
|
handleMoveDown
|
|
3331
3373
|
} = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
|
|
3332
3374
|
|
|
3333
|
-
// Handle sort order change (dimensions only - computed dimensions have no
|
|
3334
|
-
|
|
3335
|
-
|
|
3375
|
+
// Handle sort order change (dimensions only - computed dimensions have no
|
|
3376
|
+
// order_by). Keyed by instance id but applied query-wide: SQL can only
|
|
3377
|
+
// sort by a given column once, so toggling sort on any duplicate instance
|
|
3378
|
+
// of the same field is propagated to all instances sharing its fullPath
|
|
3379
|
+
// (handled in ReportBuilder's onUpdateDimensionSortOrder).
|
|
3380
|
+
const handleSortOrderChange = (id, sortOrder) => {
|
|
3381
|
+
onUpdateDimensionSortOrder(id, sortOrder);
|
|
3336
3382
|
};
|
|
3337
3383
|
|
|
3338
3384
|
// Computed Dimensions: { name, value } pairs independent of any provider.
|
|
@@ -3353,7 +3399,7 @@ const Dimensions = ({
|
|
|
3353
3399
|
const handleSaveComputed = () => {
|
|
3354
3400
|
const trimmedName = computedName.trim();
|
|
3355
3401
|
const trimmedValue = computedValue.trim();
|
|
3356
|
-
if (!trimmedName
|
|
3402
|
+
if (!trimmedName) return;
|
|
3357
3403
|
if (isComputedNameTaken(trimmedName)) return;
|
|
3358
3404
|
onSaveComputedDimension({
|
|
3359
3405
|
name: trimmedName,
|
|
@@ -3395,7 +3441,10 @@ const Dimensions = ({
|
|
|
3395
3441
|
currentProviderKey = selection.targetKey;
|
|
3396
3442
|
});
|
|
3397
3443
|
|
|
3398
|
-
// Create a Set of already selected dimension fullPaths for quick lookup
|
|
3444
|
+
// Create a Set of already selected dimension fullPaths for quick lookup.
|
|
3445
|
+
// Already-selected items stay selectable (so the same dimension can be
|
|
3446
|
+
// added again on purpose) - they're just flagged so the dropdown can
|
|
3447
|
+
// highlight them instead of blocking the click.
|
|
3399
3448
|
const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
|
|
3400
3449
|
const items = [];
|
|
3401
3450
|
// Iterate through aliases (e.g., 'ba', 'pc')
|
|
@@ -3407,9 +3456,6 @@ const Dimensions = ({
|
|
|
3407
3456
|
|
|
3408
3457
|
// Construct the fullPath for this dimension
|
|
3409
3458
|
const fullPath = `${aliasPath.join("_")}.${dimKey}`;
|
|
3410
|
-
|
|
3411
|
-
// Check if this dimension is already selected
|
|
3412
|
-
const isAlreadySelected = selectedFullPaths.has(fullPath);
|
|
3413
3459
|
items.push({
|
|
3414
3460
|
// Include provider name to ensure uniqueness across different providers
|
|
3415
3461
|
key: `${currentProvider}_${alias}.${dimKey}`,
|
|
@@ -3417,7 +3463,7 @@ const Dimensions = ({
|
|
|
3417
3463
|
dimensionKey: dimKey,
|
|
3418
3464
|
alias: alias,
|
|
3419
3465
|
dimension: dimension,
|
|
3420
|
-
|
|
3466
|
+
alreadySelected: selectedFullPaths.has(fullPath)
|
|
3421
3467
|
});
|
|
3422
3468
|
});
|
|
3423
3469
|
});
|
|
@@ -3479,7 +3525,10 @@ const Dimensions = ({
|
|
|
3479
3525
|
dimensionKey: selectedItem.dimensionKey,
|
|
3480
3526
|
dimensionTitle: selectedItem.value,
|
|
3481
3527
|
// The constructed path for server
|
|
3482
|
-
fullPath: fullPath
|
|
3528
|
+
fullPath: fullPath,
|
|
3529
|
+
// Unique per-instance identity, so this field can be added more than
|
|
3530
|
+
// once and each occurrence stays independently orderable/removable.
|
|
3531
|
+
id: nextInstanceId(fullPath, savedDimensions)
|
|
3483
3532
|
};
|
|
3484
3533
|
onSaveDimension(dimensionData);
|
|
3485
3534
|
handleCancel();
|
|
@@ -3490,8 +3539,9 @@ const Dimensions = ({
|
|
|
3490
3539
|
const handleAddAll = () => {
|
|
3491
3540
|
const dimensionItems = getDimensionItems();
|
|
3492
3541
|
|
|
3493
|
-
// Filter out already selected dimensions
|
|
3494
|
-
|
|
3542
|
+
// Filter out already selected dimensions - "Add all" only fills in
|
|
3543
|
+
// missing ones; intentional duplicates are added one at a time above.
|
|
3544
|
+
const availableItems = dimensionItems.filter(item => !item.alreadySelected);
|
|
3495
3545
|
if (availableItems.length === 0) return;
|
|
3496
3546
|
|
|
3497
3547
|
// Build the complete relation objects array (same for all dimensions in this provider)
|
|
@@ -3536,7 +3586,10 @@ const Dimensions = ({
|
|
|
3536
3586
|
dimensionKey: item.dimensionKey,
|
|
3537
3587
|
dimensionTitle: item.value,
|
|
3538
3588
|
// The constructed path for server
|
|
3539
|
-
fullPath: fullPath
|
|
3589
|
+
fullPath: fullPath,
|
|
3590
|
+
// "Add all" only ever adds fields that aren't already selected, so
|
|
3591
|
+
// this is always the first (and only) occurrence of this fullPath.
|
|
3592
|
+
id: fullPath
|
|
3540
3593
|
};
|
|
3541
3594
|
onSaveDimension(dimensionData);
|
|
3542
3595
|
});
|
|
@@ -3670,7 +3723,7 @@ const Dimensions = ({
|
|
|
3670
3723
|
}, "Cancel"), /*#__PURE__*/React__namespace.default.createElement(material.Button, {
|
|
3671
3724
|
variant: "contained",
|
|
3672
3725
|
onClick: handleSaveComputed,
|
|
3673
|
-
disabled: !computedName.trim() ||
|
|
3726
|
+
disabled: !computedName.trim() || computedNameError,
|
|
3674
3727
|
sx: {
|
|
3675
3728
|
height: "40px",
|
|
3676
3729
|
fontFamily: "system-ui",
|
|
@@ -3746,7 +3799,7 @@ const Dimensions = ({
|
|
|
3746
3799
|
placement: "top"
|
|
3747
3800
|
}, /*#__PURE__*/React__namespace.default.createElement("span", null, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
|
|
3748
3801
|
onClick: handleAddAll,
|
|
3749
|
-
disabled: getDimensionItems().filter(item => !item.
|
|
3802
|
+
disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
|
|
3750
3803
|
"aria-label": "add all dimensions",
|
|
3751
3804
|
sx: {
|
|
3752
3805
|
color: "rgb(70, 134, 128)",
|
|
@@ -3837,9 +3890,9 @@ const Dimensions = ({
|
|
|
3837
3890
|
isLast: index === orderedItems.length - 1,
|
|
3838
3891
|
sortOrder: item.data.sortOrder || null,
|
|
3839
3892
|
onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
|
|
3840
|
-
|
|
3893
|
+
instanceId: item.data.id,
|
|
3841
3894
|
defaultTitle: item.data.dimensionTitle,
|
|
3842
|
-
customTitle: titleOverrides[item.data.
|
|
3895
|
+
customTitle: titleOverrides[item.data.id],
|
|
3843
3896
|
onUpdateTitle: onUpdateTitle,
|
|
3844
3897
|
onResetTitle: onResetTitle
|
|
3845
3898
|
}) : /*#__PURE__*/React__namespace.default.createElement(SortableComputedChip, {
|
|
@@ -3912,7 +3965,7 @@ const SortableChip = ({
|
|
|
3912
3965
|
onMoveDown,
|
|
3913
3966
|
isFirst,
|
|
3914
3967
|
isLast,
|
|
3915
|
-
|
|
3968
|
+
instanceId,
|
|
3916
3969
|
defaultTitle,
|
|
3917
3970
|
customTitle,
|
|
3918
3971
|
onUpdateTitle,
|
|
@@ -3965,7 +4018,7 @@ const SortableChip = ({
|
|
|
3965
4018
|
handleCancel();
|
|
3966
4019
|
return;
|
|
3967
4020
|
}
|
|
3968
|
-
onUpdateTitle(
|
|
4021
|
+
onUpdateTitle(instanceId, trimmedValue);
|
|
3969
4022
|
setIsEditing(false);
|
|
3970
4023
|
};
|
|
3971
4024
|
const handleCancel = () => {
|
|
@@ -3973,7 +4026,7 @@ const SortableChip = ({
|
|
|
3973
4026
|
setEditValue('');
|
|
3974
4027
|
};
|
|
3975
4028
|
const handleReset = () => {
|
|
3976
|
-
onResetTitle(
|
|
4029
|
+
onResetTitle(instanceId);
|
|
3977
4030
|
setIsEditing(false);
|
|
3978
4031
|
setEditValue('');
|
|
3979
4032
|
};
|
|
@@ -4194,7 +4247,10 @@ const Metrics = ({
|
|
|
4194
4247
|
currentProviderKey = selection.targetKey;
|
|
4195
4248
|
});
|
|
4196
4249
|
|
|
4197
|
-
// Create a Set of already selected metric fullPaths for quick lookup
|
|
4250
|
+
// Create a Set of already selected metric fullPaths for quick lookup.
|
|
4251
|
+
// Already-selected items stay selectable (so the same metric can be
|
|
4252
|
+
// added again on purpose) - they're just flagged so the dropdown can
|
|
4253
|
+
// highlight them instead of blocking the click.
|
|
4198
4254
|
const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
|
|
4199
4255
|
|
|
4200
4256
|
// Metrics are a direct array, not nested by alias
|
|
@@ -4202,15 +4258,12 @@ const Metrics = ({
|
|
|
4202
4258
|
const items = provider.metrics.map((metric, index) => {
|
|
4203
4259
|
// Construct the fullPath for this metric
|
|
4204
4260
|
const fullPath = `${aliasPath.join('_')}.${metric.name}`;
|
|
4205
|
-
|
|
4206
|
-
// Check if this metric is already selected
|
|
4207
|
-
const isAlreadySelected = selectedFullPaths.has(fullPath);
|
|
4208
4261
|
return {
|
|
4209
4262
|
key: `${currentProvider}_${metric.name}_${index}`,
|
|
4210
4263
|
value: metric.title || metric.name,
|
|
4211
4264
|
metricName: metric.name,
|
|
4212
4265
|
metric: metric,
|
|
4213
|
-
|
|
4266
|
+
alreadySelected: selectedFullPaths.has(fullPath),
|
|
4214
4267
|
icon: metric.source ? /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
|
|
4215
4268
|
source: metric.source
|
|
4216
4269
|
}) : undefined
|
|
@@ -4273,7 +4326,11 @@ const Metrics = ({
|
|
|
4273
4326
|
metricName: selectedItem.metricName,
|
|
4274
4327
|
metricTitle: selectedItem.value,
|
|
4275
4328
|
// The constructed path for server
|
|
4276
|
-
fullPath: fullPath
|
|
4329
|
+
fullPath: fullPath,
|
|
4330
|
+
// Unique per-instance identity, so this field can be added more
|
|
4331
|
+
// than once and each occurrence stays independently
|
|
4332
|
+
// orderable/removable/titleable.
|
|
4333
|
+
id: nextInstanceId(fullPath, savedMetrics)
|
|
4277
4334
|
};
|
|
4278
4335
|
onSaveMetric(metricData);
|
|
4279
4336
|
handleCancel();
|
|
@@ -4284,8 +4341,9 @@ const Metrics = ({
|
|
|
4284
4341
|
const handleAddAll = () => {
|
|
4285
4342
|
const metricItems = getMetricItems();
|
|
4286
4343
|
|
|
4287
|
-
// Filter out already selected metrics
|
|
4288
|
-
|
|
4344
|
+
// Filter out already selected metrics - "Add all" only fills in
|
|
4345
|
+
// missing ones; intentional duplicates are added one at a time above.
|
|
4346
|
+
const availableItems = metricItems.filter(item => !item.alreadySelected);
|
|
4289
4347
|
if (availableItems.length === 0) return;
|
|
4290
4348
|
|
|
4291
4349
|
// Build the complete relation objects array (same for all metrics in this provider)
|
|
@@ -4330,7 +4388,10 @@ const Metrics = ({
|
|
|
4330
4388
|
metricName: item.metricName,
|
|
4331
4389
|
metricTitle: item.value,
|
|
4332
4390
|
// The constructed path for server
|
|
4333
|
-
fullPath: fullPath
|
|
4391
|
+
fullPath: fullPath,
|
|
4392
|
+
// "Add all" only ever adds fields that aren't already
|
|
4393
|
+
// selected, so this is always the first occurrence.
|
|
4394
|
+
id: fullPath
|
|
4334
4395
|
};
|
|
4335
4396
|
onSaveMetric(metricData);
|
|
4336
4397
|
});
|
|
@@ -4429,7 +4490,7 @@ const Metrics = ({
|
|
|
4429
4490
|
placement: "top"
|
|
4430
4491
|
}, /*#__PURE__*/React__namespace.default.createElement("span", null, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
|
|
4431
4492
|
onClick: handleAddAll,
|
|
4432
|
-
disabled: getMetricItems().filter(item => !item.
|
|
4493
|
+
disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
|
|
4433
4494
|
"aria-label": "add all metrics",
|
|
4434
4495
|
sx: {
|
|
4435
4496
|
color: "rgb(70, 134, 128)",
|
|
@@ -4507,14 +4568,14 @@ const Metrics = ({
|
|
|
4507
4568
|
id: index,
|
|
4508
4569
|
label: metric.metricTitle,
|
|
4509
4570
|
fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
|
|
4510
|
-
onDelete: () => onRemoveMetric(
|
|
4571
|
+
onDelete: () => onRemoveMetric(metric.id),
|
|
4511
4572
|
onMoveUp: () => handleMoveUp(index),
|
|
4512
4573
|
onMoveDown: () => handleMoveDown(index),
|
|
4513
4574
|
isFirst: index === 0,
|
|
4514
4575
|
isLast: index === savedMetrics.length - 1,
|
|
4515
|
-
|
|
4576
|
+
instanceId: metric.id,
|
|
4516
4577
|
defaultTitle: metric.metricTitle,
|
|
4517
|
-
customTitle: titleOverrides[metric.
|
|
4578
|
+
customTitle: titleOverrides[metric.id],
|
|
4518
4579
|
onUpdateTitle: onUpdateTitle,
|
|
4519
4580
|
onResetTitle: onResetTitle,
|
|
4520
4581
|
source: metric.metric?.source
|
|
@@ -5351,7 +5412,12 @@ const Filters = ({
|
|
|
5351
5412
|
mb: 3
|
|
5352
5413
|
}
|
|
5353
5414
|
}, /*#__PURE__*/React__namespace.default.createElement(SingleSelect, {
|
|
5354
|
-
items:
|
|
5415
|
+
items: Array.from(
|
|
5416
|
+
// Dimensions can now be added more than once (same fullPath,
|
|
5417
|
+
// different instance), but a filter targets the underlying
|
|
5418
|
+
// field regardless of how many columns display it - so this
|
|
5419
|
+
// list is deduped by fullPath to avoid showing it twice.
|
|
5420
|
+
new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
|
|
5355
5421
|
key: dim.fullPath,
|
|
5356
5422
|
value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
|
|
5357
5423
|
})).sort((a, b) => a.value.localeCompare(b.value)),
|
|
@@ -5934,7 +6000,7 @@ const ColumnOrder = ({
|
|
|
5934
6000
|
key: `${item.kind}-${item.key}`,
|
|
5935
6001
|
id: index,
|
|
5936
6002
|
label: labelFor(item),
|
|
5937
|
-
fullPath: item.kind === "computed" ? item.data.value : item.
|
|
6003
|
+
fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
|
|
5938
6004
|
kind: item.kind,
|
|
5939
6005
|
source: item.kind === "metric" ? item.data.metric?.source : undefined,
|
|
5940
6006
|
onMoveUp: () => handleMoveUp(index),
|
|
@@ -5960,11 +6026,17 @@ const formatValue = (value, def) => {
|
|
|
5960
6026
|
const isNumericType = type => type === 'integer' || type === 'currency';
|
|
5961
6027
|
|
|
5962
6028
|
// Builds a single DataGrid column definition for one resolved column item
|
|
5963
|
-
// (a dimension, computed dimension, or metric).
|
|
6029
|
+
// (a dimension, computed dimension, or metric). The DataGrid `field` is the
|
|
6030
|
+
// item's instance id (so two duplicate instances of the same underlying
|
|
6031
|
+
// dimension/metric still get distinct columns); the actual cell value is
|
|
6032
|
+
// read via `valueGetter` from `dataFieldName`, the key the API response
|
|
6033
|
+
// actually uses - shared by every duplicate instance, since the backend
|
|
6034
|
+
// only ever returns one value per fullPath. dataFieldName rules:
|
|
5964
6035
|
// - Dimensions from base provider: baseAlias.field -> baseAlias_field
|
|
5965
6036
|
// - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
|
|
5966
6037
|
// - Computed dimensions have no provider/relations chain, so the API returns
|
|
5967
|
-
// each row keyed by the computed dimension's own `name
|
|
6038
|
+
// each row keyed by the computed dimension's own `name` (and can't be
|
|
6039
|
+
// duplicated - names must be unique - so field/dataFieldName coincide).
|
|
5968
6040
|
// - Metrics: API returns metric keys in different forms depending on path depth
|
|
5969
6041
|
// (see comment inline below), normalised to match the row-key normalisation
|
|
5970
6042
|
// done when building `rows`.
|
|
@@ -5982,13 +6054,13 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5982
6054
|
if (item.kind === 'metric') {
|
|
5983
6055
|
const metric = item.data;
|
|
5984
6056
|
const metricDef = metric.metric;
|
|
5985
|
-
let
|
|
6057
|
+
let dataFieldName;
|
|
5986
6058
|
const dotCount = (metric.fullPath.match(/\./g) || []).length;
|
|
5987
6059
|
if (dotCount >= 2) {
|
|
5988
6060
|
// 3+-part namespaced path: API returns the full dotted path as the key.
|
|
5989
6061
|
// Normalise dots to underscores to match the row key normalisation below.
|
|
5990
6062
|
// Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
|
|
5991
|
-
|
|
6063
|
+
dataFieldName = metric.fullPath.replace(/\./g, '_');
|
|
5992
6064
|
} else if (metric.relations && metric.relations.length > 0) {
|
|
5993
6065
|
// 2-part path from a nested provider: API drops the base provider alias.
|
|
5994
6066
|
// Example: "mc_fa.total_amount" -> "fa_total_amount"
|
|
@@ -5999,19 +6071,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
5999
6071
|
const pathParts = pathWithoutField.split('_');
|
|
6000
6072
|
pathParts.shift(); // remove base provider alias
|
|
6001
6073
|
const pathWithoutBase = pathParts.join('_');
|
|
6002
|
-
|
|
6074
|
+
dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
|
|
6003
6075
|
} else {
|
|
6004
6076
|
// 2-part path from the base provider: API returns just the metric name.
|
|
6005
6077
|
// Example: "mc.record_count" -> "record_count"
|
|
6006
|
-
|
|
6078
|
+
dataFieldName = metric.metricName;
|
|
6007
6079
|
}
|
|
6008
|
-
const headerName = interpolateTitle(titleOverrides.metrics[metric.
|
|
6080
|
+
const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
|
|
6009
6081
|
return {
|
|
6010
|
-
field
|
|
6082
|
+
// `field` is the instance id (unique even for duplicate columns
|
|
6083
|
+
// pointing at the same underlying metric); the actual value is
|
|
6084
|
+
// read from the row via valueGetter using dataFieldName, which is
|
|
6085
|
+
// the key the API response actually uses (shared across duplicates).
|
|
6086
|
+
field: metric.id.replace(/[.#]/g, '_'),
|
|
6011
6087
|
headerName: headerName,
|
|
6012
6088
|
flex: 1,
|
|
6013
6089
|
minWidth: 150,
|
|
6014
6090
|
type: isNumericType(metricDef?.type) ? 'number' : 'string',
|
|
6091
|
+
valueGetter: (_value, row) => row[dataFieldName],
|
|
6015
6092
|
renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
|
|
6016
6093
|
sx: {
|
|
6017
6094
|
display: 'flex',
|
|
@@ -6027,7 +6104,7 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
6027
6104
|
|
|
6028
6105
|
// Dimension
|
|
6029
6106
|
const dim = item.data;
|
|
6030
|
-
let
|
|
6107
|
+
let dataFieldName;
|
|
6031
6108
|
if (dim.relations && dim.relations.length > 0) {
|
|
6032
6109
|
// From nested provider: drop the base provider alias
|
|
6033
6110
|
// Example: ft_fa_db.currency -> fa_db_currency
|
|
@@ -6039,20 +6116,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
|
|
|
6039
6116
|
pathParts.shift(); // Remove base provider alias
|
|
6040
6117
|
const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
|
|
6041
6118
|
|
|
6042
|
-
|
|
6119
|
+
dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
|
|
6043
6120
|
} else {
|
|
6044
6121
|
// From base provider: keep the full path with underscore
|
|
6045
6122
|
// Example: ba.created_at -> ba_created_at
|
|
6046
|
-
|
|
6123
|
+
dataFieldName = dim.fullPath.replace('.', '_');
|
|
6047
6124
|
}
|
|
6048
|
-
const headerName = interpolateTitle(titleOverrides.dimensions[dim.
|
|
6125
|
+
const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
|
|
6049
6126
|
const dimDef = dim.dimension;
|
|
6050
6127
|
return {
|
|
6051
|
-
|
|
6128
|
+
// Same rationale as the metric branch above: `field` must be unique
|
|
6129
|
+
// per instance, while the value always comes from the one real
|
|
6130
|
+
// underlying row key (dataFieldName), shared by every duplicate.
|
|
6131
|
+
field: dim.id.replace(/[.#]/g, '_'),
|
|
6052
6132
|
headerName: headerName,
|
|
6053
6133
|
flex: 1,
|
|
6054
6134
|
minWidth: 150,
|
|
6055
6135
|
type: isNumericType(dimDef?.type) ? 'number' : 'string',
|
|
6136
|
+
valueGetter: (_value, row) => row[dataFieldName],
|
|
6056
6137
|
valueFormatter: value => formatValue(value, dimDef)
|
|
6057
6138
|
};
|
|
6058
6139
|
};
|
|
@@ -6378,6 +6459,72 @@ const ReportBuilder = ({
|
|
|
6378
6459
|
}
|
|
6379
6460
|
};
|
|
6380
6461
|
|
|
6462
|
+
// Reconstructs saved dimensions as independent, individually-identified
|
|
6463
|
+
// instances so that deliberate duplicates (the same field added more than
|
|
6464
|
+
// once) survive a save/reload round trip. Prefers `ordered_dimensions`
|
|
6465
|
+
// (one ref per instance - the nth duplicate of a path is suffixed
|
|
6466
|
+
// `#n`) when present; falls back to one instance per entry in the
|
|
6467
|
+
// (necessarily duplicate-free) `dimensions` path list for reports saved
|
|
6468
|
+
// before this field existed.
|
|
6469
|
+
const reconstructDimensions = (reportDef, providersData) => {
|
|
6470
|
+
const orderByMap = {};
|
|
6471
|
+
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6472
|
+
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6473
|
+
orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
const reconstructed = [];
|
|
6477
|
+
const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
|
|
6478
|
+
if (orderedRefs.length > 0) {
|
|
6479
|
+
orderedRefs.forEach(ref => {
|
|
6480
|
+
const fullPath = ref.split("#")[0];
|
|
6481
|
+
const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
|
|
6482
|
+
if (dim) {
|
|
6483
|
+
dim.id = ref;
|
|
6484
|
+
dim.sortOrder = orderByMap[fullPath] || null;
|
|
6485
|
+
reconstructed.push(dim);
|
|
6486
|
+
}
|
|
6487
|
+
});
|
|
6488
|
+
} else if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6489
|
+
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6490
|
+
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6491
|
+
if (dim) {
|
|
6492
|
+
dim.id = dimPath;
|
|
6493
|
+
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6494
|
+
reconstructed.push(dim);
|
|
6495
|
+
}
|
|
6496
|
+
}
|
|
6497
|
+
}
|
|
6498
|
+
return reconstructed;
|
|
6499
|
+
};
|
|
6500
|
+
|
|
6501
|
+
// Mirrors reconstructDimensions for metrics, using the analogous
|
|
6502
|
+
// `ordered_metrics` field (metrics have no other unified-order concept to
|
|
6503
|
+
// fall back on, so this is always written whenever there are metrics).
|
|
6504
|
+
const reconstructMetrics = (reportDef, providersData) => {
|
|
6505
|
+
const reconstructed = [];
|
|
6506
|
+
const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
|
|
6507
|
+
if (orderedRefs.length > 0) {
|
|
6508
|
+
orderedRefs.forEach(ref => {
|
|
6509
|
+
const fullPath = ref.split("#")[0];
|
|
6510
|
+
const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
|
|
6511
|
+
if (metric) {
|
|
6512
|
+
metric.id = ref;
|
|
6513
|
+
reconstructed.push(metric);
|
|
6514
|
+
}
|
|
6515
|
+
});
|
|
6516
|
+
} else if (reportDef.definition?.doc?.query?.metrics) {
|
|
6517
|
+
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6518
|
+
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6519
|
+
if (metric) {
|
|
6520
|
+
metric.id = metricPath;
|
|
6521
|
+
reconstructed.push(metric);
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
}
|
|
6525
|
+
return reconstructed;
|
|
6526
|
+
};
|
|
6527
|
+
|
|
6381
6528
|
// Build the unified display/execution order for dimensions + computed
|
|
6382
6529
|
// dimensions. Prefers the persisted `ordered_dimensions` field; falls back
|
|
6383
6530
|
// to "dimensions first, then computed dimensions" for report definitions
|
|
@@ -6388,7 +6535,7 @@ const ReportBuilder = ({
|
|
|
6388
6535
|
const seenComputedKeys = new Set();
|
|
6389
6536
|
if (Array.isArray(rawOrderedDimensions)) {
|
|
6390
6537
|
rawOrderedDimensions.forEach(entry => {
|
|
6391
|
-
if (entry.type === "dimension" && dimensions.some(d => d.
|
|
6538
|
+
if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
|
|
6392
6539
|
dimensionOrder.push({
|
|
6393
6540
|
type: "dimension",
|
|
6394
6541
|
key: entry.ref
|
|
@@ -6406,10 +6553,10 @@ const ReportBuilder = ({
|
|
|
6406
6553
|
|
|
6407
6554
|
// Append anything not covered by ordered_dimensions (older reports, or drift)
|
|
6408
6555
|
dimensions.forEach(d => {
|
|
6409
|
-
if (!seenDimensionKeys.has(d.
|
|
6556
|
+
if (!seenDimensionKeys.has(d.id)) {
|
|
6410
6557
|
dimensionOrder.push({
|
|
6411
6558
|
type: "dimension",
|
|
6412
|
-
key: d.
|
|
6559
|
+
key: d.id
|
|
6413
6560
|
});
|
|
6414
6561
|
}
|
|
6415
6562
|
});
|
|
@@ -6432,12 +6579,12 @@ const ReportBuilder = ({
|
|
|
6432
6579
|
// "fall back to the default order".
|
|
6433
6580
|
const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
|
|
6434
6581
|
if (!Array.isArray(rawOrderedColumns)) return [];
|
|
6435
|
-
const
|
|
6582
|
+
const dimensionIds = new Set(dimensions.map(d => d.id));
|
|
6436
6583
|
const computedNames = new Set(computedDimensions.map(cd => cd.name));
|
|
6437
|
-
const
|
|
6584
|
+
const metricIds = new Set(metrics.map(m => m.id));
|
|
6438
6585
|
const columnOrder = [];
|
|
6439
6586
|
rawOrderedColumns.forEach(entry => {
|
|
6440
|
-
if (entry.type === "dimension" &&
|
|
6587
|
+
if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
|
|
6441
6588
|
columnOrder.push({
|
|
6442
6589
|
type: "dimension",
|
|
6443
6590
|
key: entry.ref
|
|
@@ -6447,7 +6594,7 @@ const ReportBuilder = ({
|
|
|
6447
6594
|
type: "computed",
|
|
6448
6595
|
key: entry.ref
|
|
6449
6596
|
});
|
|
6450
|
-
} else if (entry.type === "metric" &&
|
|
6597
|
+
} else if (entry.type === "metric" && metricIds.has(entry.ref)) {
|
|
6451
6598
|
columnOrder.push({
|
|
6452
6599
|
type: "metric",
|
|
6453
6600
|
key: entry.ref
|
|
@@ -6477,43 +6624,10 @@ const ReportBuilder = ({
|
|
|
6477
6624
|
// Set the title
|
|
6478
6625
|
setReportTitle(reportDef.title || "");
|
|
6479
6626
|
|
|
6480
|
-
// Reconstruct dimensions
|
|
6481
|
-
|
|
6482
|
-
const
|
|
6483
|
-
|
|
6484
|
-
// First, build a map of fullPath -> sortOrder from order_by
|
|
6485
|
-
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6486
|
-
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6487
|
-
if (orderItem.desc === true) {
|
|
6488
|
-
orderByMap[orderItem.name] = "desc";
|
|
6489
|
-
} else {
|
|
6490
|
-
orderByMap[orderItem.name] = "asc";
|
|
6491
|
-
}
|
|
6492
|
-
}
|
|
6493
|
-
}
|
|
6494
|
-
|
|
6495
|
-
// Now reconstruct dimensions and add sortOrder from the map
|
|
6496
|
-
if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6497
|
-
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6498
|
-
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6499
|
-
if (dim) {
|
|
6500
|
-
// Add sortOrder from order_by if it exists
|
|
6501
|
-
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6502
|
-
reconstructedDimensions.push(dim);
|
|
6503
|
-
}
|
|
6504
|
-
}
|
|
6505
|
-
}
|
|
6506
|
-
|
|
6507
|
-
// Reconstruct metrics
|
|
6508
|
-
const reconstructedMetrics = [];
|
|
6509
|
-
if (reportDef.definition?.doc?.query?.metrics) {
|
|
6510
|
-
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6511
|
-
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6512
|
-
if (metric) {
|
|
6513
|
-
reconstructedMetrics.push(metric);
|
|
6514
|
-
}
|
|
6515
|
-
}
|
|
6516
|
-
}
|
|
6627
|
+
// Reconstruct dimensions and metrics as independent instances
|
|
6628
|
+
// (preserving deliberate duplicates - see reconstructDimensions/Metrics)
|
|
6629
|
+
const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
|
|
6630
|
+
const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
|
|
6517
6631
|
|
|
6518
6632
|
// Load title overrides if they exist
|
|
6519
6633
|
const loadedTitleOverrides = {
|
|
@@ -6597,43 +6711,10 @@ const ReportBuilder = ({
|
|
|
6597
6711
|
// Set the title (already modified with " (Copy)" suffix)
|
|
6598
6712
|
setReportTitle(reportDef.title || "");
|
|
6599
6713
|
|
|
6600
|
-
// Reconstruct dimensions
|
|
6601
|
-
|
|
6602
|
-
const
|
|
6603
|
-
|
|
6604
|
-
// First, build a map of fullPath -> sortOrder from order_by
|
|
6605
|
-
if (reportDef.definition?.doc?.query?.order_by) {
|
|
6606
|
-
for (const orderItem of reportDef.definition.doc.query.order_by) {
|
|
6607
|
-
if (orderItem.desc === true) {
|
|
6608
|
-
orderByMap[orderItem.name] = "desc";
|
|
6609
|
-
} else {
|
|
6610
|
-
orderByMap[orderItem.name] = "asc";
|
|
6611
|
-
}
|
|
6612
|
-
}
|
|
6613
|
-
}
|
|
6614
|
-
|
|
6615
|
-
// Now reconstruct dimensions and add sortOrder from the map
|
|
6616
|
-
if (reportDef.definition?.doc?.query?.dimensions) {
|
|
6617
|
-
for (const dimPath of reportDef.definition.doc.query.dimensions) {
|
|
6618
|
-
const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
|
|
6619
|
-
if (dim) {
|
|
6620
|
-
// Add sortOrder from order_by if it exists
|
|
6621
|
-
dim.sortOrder = orderByMap[dimPath] || null;
|
|
6622
|
-
reconstructedDimensions.push(dim);
|
|
6623
|
-
}
|
|
6624
|
-
}
|
|
6625
|
-
}
|
|
6626
|
-
|
|
6627
|
-
// Reconstruct metrics
|
|
6628
|
-
const reconstructedMetrics = [];
|
|
6629
|
-
if (reportDef.definition?.doc?.query?.metrics) {
|
|
6630
|
-
for (const metricPath of reportDef.definition.doc.query.metrics) {
|
|
6631
|
-
const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
|
|
6632
|
-
if (metric) {
|
|
6633
|
-
reconstructedMetrics.push(metric);
|
|
6634
|
-
}
|
|
6635
|
-
}
|
|
6636
|
-
}
|
|
6714
|
+
// Reconstruct dimensions and metrics as independent instances
|
|
6715
|
+
// (preserving deliberate duplicates - see reconstructDimensions/Metrics)
|
|
6716
|
+
const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
|
|
6717
|
+
const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
|
|
6637
6718
|
|
|
6638
6719
|
// Load title overrides if they exist
|
|
6639
6720
|
const loadedTitleOverrides = {
|
|
@@ -6732,42 +6813,45 @@ const ReportBuilder = ({
|
|
|
6732
6813
|
});
|
|
6733
6814
|
console.log("Selected root provider:", event.target.value);
|
|
6734
6815
|
};
|
|
6735
|
-
|
|
6816
|
+
|
|
6817
|
+
// Keyed by instance id, so two instances of the same dimension can carry
|
|
6818
|
+
// independent custom titles.
|
|
6819
|
+
const handleUpdateDimensionTitle = (id, customTitle) => {
|
|
6736
6820
|
setTitleOverrides(prev => ({
|
|
6737
6821
|
...prev,
|
|
6738
6822
|
dimensions: {
|
|
6739
6823
|
...prev.dimensions,
|
|
6740
|
-
[
|
|
6824
|
+
[id]: customTitle
|
|
6741
6825
|
}
|
|
6742
6826
|
}));
|
|
6743
6827
|
};
|
|
6744
|
-
const handleResetDimensionTitle =
|
|
6828
|
+
const handleResetDimensionTitle = id => {
|
|
6745
6829
|
setTitleOverrides(prev => {
|
|
6746
6830
|
const newDimensions = {
|
|
6747
6831
|
...prev.dimensions
|
|
6748
6832
|
};
|
|
6749
|
-
delete newDimensions[
|
|
6833
|
+
delete newDimensions[id];
|
|
6750
6834
|
return {
|
|
6751
6835
|
...prev,
|
|
6752
6836
|
dimensions: newDimensions
|
|
6753
6837
|
};
|
|
6754
6838
|
});
|
|
6755
6839
|
};
|
|
6756
|
-
const handleUpdateMetricTitle = (
|
|
6840
|
+
const handleUpdateMetricTitle = (id, customTitle) => {
|
|
6757
6841
|
setTitleOverrides(prev => ({
|
|
6758
6842
|
...prev,
|
|
6759
6843
|
metrics: {
|
|
6760
6844
|
...prev.metrics,
|
|
6761
|
-
[
|
|
6845
|
+
[id]: customTitle
|
|
6762
6846
|
}
|
|
6763
6847
|
}));
|
|
6764
6848
|
};
|
|
6765
|
-
const handleResetMetricTitle =
|
|
6849
|
+
const handleResetMetricTitle = id => {
|
|
6766
6850
|
setTitleOverrides(prev => {
|
|
6767
6851
|
const newMetrics = {
|
|
6768
6852
|
...prev.metrics
|
|
6769
6853
|
};
|
|
6770
|
-
delete newMetrics[
|
|
6854
|
+
delete newMetrics[id];
|
|
6771
6855
|
return {
|
|
6772
6856
|
...prev,
|
|
6773
6857
|
metrics: newMetrics
|
|
@@ -6802,11 +6886,11 @@ const ReportBuilder = ({
|
|
|
6802
6886
|
dimensions: [...prev.dimensions, dimensionData],
|
|
6803
6887
|
dimensionOrder: [...prev.dimensionOrder, {
|
|
6804
6888
|
type: "dimension",
|
|
6805
|
-
key: dimensionData.
|
|
6889
|
+
key: dimensionData.id
|
|
6806
6890
|
}],
|
|
6807
6891
|
columnOrder: appendToColumnOrder(prev.columnOrder, {
|
|
6808
6892
|
type: "dimension",
|
|
6809
|
-
key: dimensionData.
|
|
6893
|
+
key: dimensionData.id
|
|
6810
6894
|
})
|
|
6811
6895
|
};
|
|
6812
6896
|
console.log("Dimension saved:", dimensionData);
|
|
@@ -6814,22 +6898,30 @@ const ReportBuilder = ({
|
|
|
6814
6898
|
return newReport;
|
|
6815
6899
|
});
|
|
6816
6900
|
};
|
|
6817
|
-
const handleRemoveDimension =
|
|
6901
|
+
const handleRemoveDimension = id => {
|
|
6818
6902
|
setReport(prev => ({
|
|
6819
6903
|
...prev,
|
|
6820
|
-
dimensions: prev.dimensions.filter(d => d.
|
|
6821
|
-
dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key ===
|
|
6822
|
-
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key ===
|
|
6904
|
+
dimensions: prev.dimensions.filter(d => d.id !== id),
|
|
6905
|
+
dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
|
|
6906
|
+
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
|
|
6823
6907
|
}));
|
|
6824
6908
|
};
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6909
|
+
|
|
6910
|
+
// sortOrder is fundamentally a per-field (query-level) setting - SQL can
|
|
6911
|
+
// only sort by a given column once - so toggling it on any instance of a
|
|
6912
|
+
// duplicated dimension applies to every instance sharing that fullPath.
|
|
6913
|
+
const handleUpdateDimensionSortOrder = (id, sortOrder) => {
|
|
6914
|
+
setReport(prev => {
|
|
6915
|
+
const target = prev.dimensions.find(d => d.id === id);
|
|
6916
|
+
if (!target) return prev;
|
|
6917
|
+
return {
|
|
6918
|
+
...prev,
|
|
6919
|
+
dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
|
|
6920
|
+
...d,
|
|
6921
|
+
sortOrder
|
|
6922
|
+
} : d)
|
|
6923
|
+
};
|
|
6924
|
+
});
|
|
6833
6925
|
};
|
|
6834
6926
|
|
|
6835
6927
|
// Reorders the single unified dimensions + computed-dimensions list;
|
|
@@ -6884,7 +6976,7 @@ const ReportBuilder = ({
|
|
|
6884
6976
|
metrics: [...prev.metrics, metricData],
|
|
6885
6977
|
columnOrder: appendToColumnOrder(prev.columnOrder, {
|
|
6886
6978
|
type: "metric",
|
|
6887
|
-
key: metricData.
|
|
6979
|
+
key: metricData.id
|
|
6888
6980
|
})
|
|
6889
6981
|
};
|
|
6890
6982
|
console.log("Metric saved:", metricData);
|
|
@@ -6892,15 +6984,12 @@ const ReportBuilder = ({
|
|
|
6892
6984
|
return newReport;
|
|
6893
6985
|
});
|
|
6894
6986
|
};
|
|
6895
|
-
const handleRemoveMetric =
|
|
6896
|
-
setReport(prev => {
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
|
|
6902
|
-
};
|
|
6903
|
-
});
|
|
6987
|
+
const handleRemoveMetric = id => {
|
|
6988
|
+
setReport(prev => ({
|
|
6989
|
+
...prev,
|
|
6990
|
+
metrics: prev.metrics.filter(m => m.id !== id),
|
|
6991
|
+
columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
|
|
6992
|
+
}));
|
|
6904
6993
|
};
|
|
6905
6994
|
const handleReorderMetrics = newOrder => {
|
|
6906
6995
|
setReport(prev => ({
|
|
@@ -6955,13 +7044,30 @@ const ReportBuilder = ({
|
|
|
6955
7044
|
// persisted `ordered_dimensions` field all stay consistent with whatever
|
|
6956
7045
|
// order the user arranged on screen.
|
|
6957
7046
|
const buildQueryObject = () => {
|
|
6958
|
-
const
|
|
7047
|
+
const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
|
|
6959
7048
|
const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
|
|
6960
|
-
|
|
7049
|
+
|
|
7050
|
+
// May contain more than one instance of the same fullPath (deliberate
|
|
7051
|
+
// duplicates), kept in dimensionOrder's display order.
|
|
7052
|
+
const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
|
|
6961
7053
|
const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
|
|
6962
7054
|
|
|
7055
|
+
// The API keys each result row by fullPath, so duplicate instances of
|
|
7056
|
+
// the same field can only ever carry one value/one sort direction at
|
|
7057
|
+
// the query level - dedupe by fullPath (keeping first-occurrence order)
|
|
7058
|
+
// before sending. The builder still shows/orders/titles each instance
|
|
7059
|
+
// independently; they just read from the same underlying query column.
|
|
7060
|
+
const uniqueDimensionsByPath = [];
|
|
7061
|
+
const seenDimensionPaths = new Set();
|
|
7062
|
+
orderedDimensionsOnly.forEach(dim => {
|
|
7063
|
+
if (!seenDimensionPaths.has(dim.fullPath)) {
|
|
7064
|
+
seenDimensionPaths.add(dim.fullPath);
|
|
7065
|
+
uniqueDimensionsByPath.push(dim);
|
|
7066
|
+
}
|
|
7067
|
+
});
|
|
7068
|
+
|
|
6963
7069
|
// Build order_by array - include all dimensions, add desc property only when needed
|
|
6964
|
-
const orderBy =
|
|
7070
|
+
const orderBy = uniqueDimensionsByPath.map(dim => {
|
|
6965
7071
|
const orderItem = {
|
|
6966
7072
|
name: dim.fullPath
|
|
6967
7073
|
};
|
|
@@ -7016,9 +7122,20 @@ const ReportBuilder = ({
|
|
|
7016
7122
|
if (Object.keys(titleOverrides.filters).length > 0) {
|
|
7017
7123
|
titles.filters = titleOverrides.filters;
|
|
7018
7124
|
}
|
|
7125
|
+
|
|
7126
|
+
// Same dedupe rationale as dimensions above: the query only needs each
|
|
7127
|
+
// metric fullPath once, however many display instances reference it.
|
|
7128
|
+
const uniqueMetricPaths = [];
|
|
7129
|
+
const seenMetricPaths = new Set();
|
|
7130
|
+
report.metrics.forEach(metric => {
|
|
7131
|
+
if (!seenMetricPaths.has(metric.fullPath)) {
|
|
7132
|
+
seenMetricPaths.add(metric.fullPath);
|
|
7133
|
+
uniqueMetricPaths.push(metric.fullPath);
|
|
7134
|
+
}
|
|
7135
|
+
});
|
|
7019
7136
|
const queryObj = {
|
|
7020
|
-
dimensions:
|
|
7021
|
-
metrics:
|
|
7137
|
+
dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
|
|
7138
|
+
metrics: uniqueMetricPaths,
|
|
7022
7139
|
order_by: orderBy
|
|
7023
7140
|
};
|
|
7024
7141
|
|
|
@@ -7032,7 +7149,9 @@ const ReportBuilder = ({
|
|
|
7032
7149
|
|
|
7033
7150
|
// Persist the unified dimension + computed-dimension order so it can be
|
|
7034
7151
|
// reconstructed on reload and so executed reports render columns in the
|
|
7035
|
-
// same order they were arranged in the builder.
|
|
7152
|
+
// same order they were arranged in the builder. Entries use each
|
|
7153
|
+
// dimension's instance id as `ref`, so deliberate duplicates (the nth
|
|
7154
|
+
// occurrence of a fullPath is suffixed `#n`) round-trip correctly.
|
|
7036
7155
|
if (report.dimensionOrder.length > 0) {
|
|
7037
7156
|
queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
|
|
7038
7157
|
type: entry.type,
|
|
@@ -7040,6 +7159,12 @@ const ReportBuilder = ({
|
|
|
7040
7159
|
}));
|
|
7041
7160
|
}
|
|
7042
7161
|
|
|
7162
|
+
// Persist metric instance order/identity the same way (metrics have no
|
|
7163
|
+
// other unified-order field to piggyback on).
|
|
7164
|
+
if (report.metrics.length > 0) {
|
|
7165
|
+
queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
|
|
7166
|
+
}
|
|
7167
|
+
|
|
7043
7168
|
// Persist the optional unified column order (Column Order tab) so the
|
|
7044
7169
|
// results grid can reproduce it on reload. Absent when no custom order
|
|
7045
7170
|
// has been defined, so old/untouched reports keep their current
|
|
@@ -7209,11 +7334,11 @@ const ReportBuilder = ({
|
|
|
7209
7334
|
|
|
7210
7335
|
// Resolve the unified dimensionOrder into actual dimension/computed-dimension
|
|
7211
7336
|
// objects so the results grid can render columns in that same order.
|
|
7212
|
-
const
|
|
7337
|
+
const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
|
|
7213
7338
|
const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
|
|
7214
7339
|
const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
|
|
7215
7340
|
kind: "dimension",
|
|
7216
|
-
data:
|
|
7341
|
+
data: dimensionById[entry.key]
|
|
7217
7342
|
} : {
|
|
7218
7343
|
kind: "computed",
|
|
7219
7344
|
data: computedDimensionByName[entry.key]
|