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