@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.
@@ -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), formatLabel(value));
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.fullPath,
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.fullPath,
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
- fullPath,
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(fullPath, trimmedValue);
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(fullPath);
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 || !trimmedValue) return;
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() || !editValue.trim() || nameError
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
- const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
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: dimensionByFullPath[entry.key]
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 order_by)
3334
- const handleSortOrderChange = (fullPath, sortOrder) => {
3335
- onUpdateDimensionSortOrder(fullPath, sortOrder);
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 || !trimmedValue) return;
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
- disabled: isAlreadySelected
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 (disabled items)
3494
- const availableItems = dimensionItems.filter(item => !item.disabled);
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() || !computedValue.trim() || computedNameError,
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.disabled).length === 0,
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
- fullPath: item.data.fullPath,
3893
+ instanceId: item.data.id,
3841
3894
  defaultTitle: item.data.dimensionTitle,
3842
- customTitle: titleOverrides[item.data.fullPath],
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
- fullPath,
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(fullPath, trimmedValue);
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(fullPath);
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
- disabled: isAlreadySelected,
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 (disabled items)
4288
- const availableItems = metricItems.filter(item => !item.disabled);
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.disabled).length === 0,
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(index),
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
- fullPath: metric.fullPath,
4576
+ instanceId: metric.id,
4516
4577
  defaultTitle: metric.metricTitle,
4517
- customTitle: titleOverrides[metric.fullPath],
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: existingDimensions.map(dim => ({
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.key,
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). Field naming logic:
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 fieldName;
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
- fieldName = metric.fullPath.replace(/\./g, '_');
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
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
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
- fieldName = metric.metricName;
6078
+ dataFieldName = metric.metricName;
6007
6079
  }
6008
- const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
6080
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
6009
6081
  return {
6010
- field: fieldName,
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 fieldName;
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
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
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
- fieldName = dim.fullPath.replace('.', '_');
6123
+ dataFieldName = dim.fullPath.replace('.', '_');
6047
6124
  }
6048
- const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
6125
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
6049
6126
  const dimDef = dim.dimension;
6050
6127
  return {
6051
- field: fieldName,
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.fullPath === entry.ref)) {
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.fullPath)) {
6556
+ if (!seenDimensionKeys.has(d.id)) {
6410
6557
  dimensionOrder.push({
6411
6558
  type: "dimension",
6412
- key: d.fullPath
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 dimensionPaths = new Set(dimensions.map(d => d.fullPath));
6582
+ const dimensionIds = new Set(dimensions.map(d => d.id));
6436
6583
  const computedNames = new Set(computedDimensions.map(cd => cd.name));
6437
- const metricPaths = new Set(metrics.map(m => m.fullPath));
6584
+ const metricIds = new Set(metrics.map(m => m.id));
6438
6585
  const columnOrder = [];
6439
6586
  rawOrderedColumns.forEach(entry => {
6440
- if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
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" && metricPaths.has(entry.ref)) {
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
- const reconstructedDimensions = [];
6482
- const orderByMap = {}; // Map to store sort order from order_by array
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
- const reconstructedDimensions = [];
6602
- const orderByMap = {}; // Map to store sort order from order_by array
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
- const handleUpdateDimensionTitle = (fullPath, customTitle) => {
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
- [fullPath]: customTitle
6824
+ [id]: customTitle
6741
6825
  }
6742
6826
  }));
6743
6827
  };
6744
- const handleResetDimensionTitle = fullPath => {
6828
+ const handleResetDimensionTitle = id => {
6745
6829
  setTitleOverrides(prev => {
6746
6830
  const newDimensions = {
6747
6831
  ...prev.dimensions
6748
6832
  };
6749
- delete newDimensions[fullPath];
6833
+ delete newDimensions[id];
6750
6834
  return {
6751
6835
  ...prev,
6752
6836
  dimensions: newDimensions
6753
6837
  };
6754
6838
  });
6755
6839
  };
6756
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
6840
+ const handleUpdateMetricTitle = (id, customTitle) => {
6757
6841
  setTitleOverrides(prev => ({
6758
6842
  ...prev,
6759
6843
  metrics: {
6760
6844
  ...prev.metrics,
6761
- [fullPath]: customTitle
6845
+ [id]: customTitle
6762
6846
  }
6763
6847
  }));
6764
6848
  };
6765
- const handleResetMetricTitle = fullPath => {
6849
+ const handleResetMetricTitle = id => {
6766
6850
  setTitleOverrides(prev => {
6767
6851
  const newMetrics = {
6768
6852
  ...prev.metrics
6769
6853
  };
6770
- delete newMetrics[fullPath];
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.fullPath
6889
+ key: dimensionData.id
6806
6890
  }],
6807
6891
  columnOrder: appendToColumnOrder(prev.columnOrder, {
6808
6892
  type: "dimension",
6809
- key: dimensionData.fullPath
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 = fullPath => {
6901
+ const handleRemoveDimension = id => {
6818
6902
  setReport(prev => ({
6819
6903
  ...prev,
6820
- dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6821
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath)),
6822
- columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
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
- const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
6826
- setReport(prev => ({
6827
- ...prev,
6828
- dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
6829
- ...d,
6830
- sortOrder
6831
- } : d)
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.fullPath
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 = index => {
6896
- setReport(prev => {
6897
- const fullPath = prev.metrics[index]?.fullPath;
6898
- return {
6899
- ...prev,
6900
- metrics: prev.metrics.filter((_, i) => i !== index),
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 dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
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
- const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionByPath[entry.key]).filter(Boolean);
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 = orderedDimensionsOnly.map(dim => {
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: orderedDimensionsOnly.map(dim => dim.fullPath),
7021
- metrics: report.metrics.map(metric => metric.fullPath),
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 dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
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: dimensionByFullPath[entry.key]
7341
+ data: dimensionById[entry.key]
7217
7342
  } : {
7218
7343
  kind: "computed",
7219
7344
  data: computedDimensionByName[entry.key]