@bygd/nc-report-ui 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -3132,6 +3172,16 @@ const SortableComputedChip = ({
3132
3172
  handleCancel();
3133
3173
  }
3134
3174
  };
3175
+
3176
+ // The value field is multiline, so Enter inserts a newline instead of
3177
+ // saving; only Escape (cancel) and Cmd/Ctrl+Enter (save) are handled here.
3178
+ const handleValueKeyDown = e => {
3179
+ if (e.key === "Escape") {
3180
+ handleCancel();
3181
+ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3182
+ handleSave();
3183
+ }
3184
+ };
3135
3185
  return /*#__PURE__*/React__namespace.default.createElement("div", _extends({
3136
3186
  ref: setNodeRef,
3137
3187
  style: style
@@ -3235,10 +3285,13 @@ const SortableComputedChip = ({
3235
3285
  }), /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3236
3286
  value: editValue,
3237
3287
  onChange: e => setEditValue(e.target.value),
3238
- onKeyDown: handleKeyDown,
3288
+ onKeyDown: handleValueKeyDown,
3239
3289
  size: "small",
3240
3290
  label: "Value",
3241
- helperText: " ",
3291
+ helperText: "Cmd/Ctrl+Enter to save",
3292
+ multiline: true,
3293
+ minRows: 2,
3294
+ maxRows: 10,
3242
3295
  sx: {
3243
3296
  flex: 1,
3244
3297
  minWidth: "200px",
@@ -3262,7 +3315,7 @@ const SortableComputedChip = ({
3262
3315
  onClick: handleSave,
3263
3316
  color: "primary",
3264
3317
  "aria-label": "save computed dimension",
3265
- disabled: !editName.trim() || !editValue.trim() || nameError
3318
+ disabled: !editName.trim() || nameError
3266
3319
  }, /*#__PURE__*/React__namespace.default.createElement(CheckIcon__default.default, {
3267
3320
  fontSize: "small"
3268
3321
  })))), /*#__PURE__*/React__namespace.default.createElement(material.Tooltip, {
@@ -3311,12 +3364,14 @@ const Dimensions = ({
3311
3364
  // Resolve the unified dimensionOrder into the actual dimension / computed
3312
3365
  // dimension objects, so dimensions and computed dimensions can be shown
3313
3366
  // (and dragged) as a single list while staying stored in their own arrays.
3314
- const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
3367
+ // Keyed by instance id rather than fullPath, so two instances of the same
3368
+ // dimension (added deliberately as a duplicate) stay distinct entries.
3369
+ const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
3315
3370
  const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
3316
3371
  const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
3317
3372
  kind: "dimension",
3318
3373
  key: entry.key,
3319
- data: dimensionByFullPath[entry.key]
3374
+ data: dimensionById[entry.key]
3320
3375
  } : {
3321
3376
  kind: "computed",
3322
3377
  key: entry.key,
@@ -3330,9 +3385,13 @@ const Dimensions = ({
3330
3385
  handleMoveDown
3331
3386
  } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3332
3387
 
3333
- // Handle sort order change (dimensions only - computed dimensions have no order_by)
3334
- const handleSortOrderChange = (fullPath, sortOrder) => {
3335
- onUpdateDimensionSortOrder(fullPath, sortOrder);
3388
+ // Handle sort order change (dimensions only - computed dimensions have no
3389
+ // order_by). Keyed by instance id but applied query-wide: SQL can only
3390
+ // sort by a given column once, so toggling sort on any duplicate instance
3391
+ // of the same field is propagated to all instances sharing its fullPath
3392
+ // (handled in ReportBuilder's onUpdateDimensionSortOrder).
3393
+ const handleSortOrderChange = (id, sortOrder) => {
3394
+ onUpdateDimensionSortOrder(id, sortOrder);
3336
3395
  };
3337
3396
 
3338
3397
  // Computed Dimensions: { name, value } pairs independent of any provider.
@@ -3353,7 +3412,7 @@ const Dimensions = ({
3353
3412
  const handleSaveComputed = () => {
3354
3413
  const trimmedName = computedName.trim();
3355
3414
  const trimmedValue = computedValue.trim();
3356
- if (!trimmedName || !trimmedValue) return;
3415
+ if (!trimmedName) return;
3357
3416
  if (isComputedNameTaken(trimmedName)) return;
3358
3417
  onSaveComputedDimension({
3359
3418
  name: trimmedName,
@@ -3395,7 +3454,10 @@ const Dimensions = ({
3395
3454
  currentProviderKey = selection.targetKey;
3396
3455
  });
3397
3456
 
3398
- // Create a Set of already selected dimension fullPaths for quick lookup
3457
+ // Create a Set of already selected dimension fullPaths for quick lookup.
3458
+ // Already-selected items stay selectable (so the same dimension can be
3459
+ // added again on purpose) - they're just flagged so the dropdown can
3460
+ // highlight them instead of blocking the click.
3399
3461
  const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
3400
3462
  const items = [];
3401
3463
  // Iterate through aliases (e.g., 'ba', 'pc')
@@ -3407,9 +3469,6 @@ const Dimensions = ({
3407
3469
 
3408
3470
  // Construct the fullPath for this dimension
3409
3471
  const fullPath = `${aliasPath.join("_")}.${dimKey}`;
3410
-
3411
- // Check if this dimension is already selected
3412
- const isAlreadySelected = selectedFullPaths.has(fullPath);
3413
3472
  items.push({
3414
3473
  // Include provider name to ensure uniqueness across different providers
3415
3474
  key: `${currentProvider}_${alias}.${dimKey}`,
@@ -3417,7 +3476,7 @@ const Dimensions = ({
3417
3476
  dimensionKey: dimKey,
3418
3477
  alias: alias,
3419
3478
  dimension: dimension,
3420
- disabled: isAlreadySelected
3479
+ alreadySelected: selectedFullPaths.has(fullPath)
3421
3480
  });
3422
3481
  });
3423
3482
  });
@@ -3479,7 +3538,10 @@ const Dimensions = ({
3479
3538
  dimensionKey: selectedItem.dimensionKey,
3480
3539
  dimensionTitle: selectedItem.value,
3481
3540
  // The constructed path for server
3482
- fullPath: fullPath
3541
+ fullPath: fullPath,
3542
+ // Unique per-instance identity, so this field can be added more than
3543
+ // once and each occurrence stays independently orderable/removable.
3544
+ id: nextInstanceId(fullPath, savedDimensions)
3483
3545
  };
3484
3546
  onSaveDimension(dimensionData);
3485
3547
  handleCancel();
@@ -3490,8 +3552,9 @@ const Dimensions = ({
3490
3552
  const handleAddAll = () => {
3491
3553
  const dimensionItems = getDimensionItems();
3492
3554
 
3493
- // Filter out already selected dimensions (disabled items)
3494
- const availableItems = dimensionItems.filter(item => !item.disabled);
3555
+ // Filter out already selected dimensions - "Add all" only fills in
3556
+ // missing ones; intentional duplicates are added one at a time above.
3557
+ const availableItems = dimensionItems.filter(item => !item.alreadySelected);
3495
3558
  if (availableItems.length === 0) return;
3496
3559
 
3497
3560
  // Build the complete relation objects array (same for all dimensions in this provider)
@@ -3536,7 +3599,10 @@ const Dimensions = ({
3536
3599
  dimensionKey: item.dimensionKey,
3537
3600
  dimensionTitle: item.value,
3538
3601
  // The constructed path for server
3539
- fullPath: fullPath
3602
+ fullPath: fullPath,
3603
+ // "Add all" only ever adds fields that aren't already selected, so
3604
+ // this is always the first (and only) occurrence of this fullPath.
3605
+ id: fullPath
3540
3606
  };
3541
3607
  onSaveDimension(dimensionData);
3542
3608
  });
@@ -3635,6 +3701,9 @@ const Dimensions = ({
3635
3701
  value: computedValue,
3636
3702
  onChange: e => setComputedValue(e.target.value),
3637
3703
  size: "small",
3704
+ multiline: true,
3705
+ minRows: 2,
3706
+ maxRows: 10,
3638
3707
  sx: {
3639
3708
  width: "400px",
3640
3709
  "& .MuiInputBase-input": {
@@ -3670,7 +3739,7 @@ const Dimensions = ({
3670
3739
  }, "Cancel"), /*#__PURE__*/React__namespace.default.createElement(material.Button, {
3671
3740
  variant: "contained",
3672
3741
  onClick: handleSaveComputed,
3673
- disabled: !computedName.trim() || !computedValue.trim() || computedNameError,
3742
+ disabled: !computedName.trim() || computedNameError,
3674
3743
  sx: {
3675
3744
  height: "40px",
3676
3745
  fontFamily: "system-ui",
@@ -3746,7 +3815,7 @@ const Dimensions = ({
3746
3815
  placement: "top"
3747
3816
  }, /*#__PURE__*/React__namespace.default.createElement("span", null, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3748
3817
  onClick: handleAddAll,
3749
- disabled: getDimensionItems().filter(item => !item.disabled).length === 0,
3818
+ disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
3750
3819
  "aria-label": "add all dimensions",
3751
3820
  sx: {
3752
3821
  color: "rgb(70, 134, 128)",
@@ -3837,9 +3906,9 @@ const Dimensions = ({
3837
3906
  isLast: index === orderedItems.length - 1,
3838
3907
  sortOrder: item.data.sortOrder || null,
3839
3908
  onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
3840
- fullPath: item.data.fullPath,
3909
+ instanceId: item.data.id,
3841
3910
  defaultTitle: item.data.dimensionTitle,
3842
- customTitle: titleOverrides[item.data.fullPath],
3911
+ customTitle: titleOverrides[item.data.id],
3843
3912
  onUpdateTitle: onUpdateTitle,
3844
3913
  onResetTitle: onResetTitle
3845
3914
  }) : /*#__PURE__*/React__namespace.default.createElement(SortableComputedChip, {
@@ -3912,7 +3981,7 @@ const SortableChip = ({
3912
3981
  onMoveDown,
3913
3982
  isFirst,
3914
3983
  isLast,
3915
- fullPath,
3984
+ instanceId,
3916
3985
  defaultTitle,
3917
3986
  customTitle,
3918
3987
  onUpdateTitle,
@@ -3965,7 +4034,7 @@ const SortableChip = ({
3965
4034
  handleCancel();
3966
4035
  return;
3967
4036
  }
3968
- onUpdateTitle(fullPath, trimmedValue);
4037
+ onUpdateTitle(instanceId, trimmedValue);
3969
4038
  setIsEditing(false);
3970
4039
  };
3971
4040
  const handleCancel = () => {
@@ -3973,7 +4042,7 @@ const SortableChip = ({
3973
4042
  setEditValue('');
3974
4043
  };
3975
4044
  const handleReset = () => {
3976
- onResetTitle(fullPath);
4045
+ onResetTitle(instanceId);
3977
4046
  setIsEditing(false);
3978
4047
  setEditValue('');
3979
4048
  };
@@ -4194,7 +4263,10 @@ const Metrics = ({
4194
4263
  currentProviderKey = selection.targetKey;
4195
4264
  });
4196
4265
 
4197
- // Create a Set of already selected metric fullPaths for quick lookup
4266
+ // Create a Set of already selected metric fullPaths for quick lookup.
4267
+ // Already-selected items stay selectable (so the same metric can be
4268
+ // added again on purpose) - they're just flagged so the dropdown can
4269
+ // highlight them instead of blocking the click.
4198
4270
  const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
4199
4271
 
4200
4272
  // Metrics are a direct array, not nested by alias
@@ -4202,15 +4274,12 @@ const Metrics = ({
4202
4274
  const items = provider.metrics.map((metric, index) => {
4203
4275
  // Construct the fullPath for this metric
4204
4276
  const fullPath = `${aliasPath.join('_')}.${metric.name}`;
4205
-
4206
- // Check if this metric is already selected
4207
- const isAlreadySelected = selectedFullPaths.has(fullPath);
4208
4277
  return {
4209
4278
  key: `${currentProvider}_${metric.name}_${index}`,
4210
4279
  value: metric.title || metric.name,
4211
4280
  metricName: metric.name,
4212
4281
  metric: metric,
4213
- disabled: isAlreadySelected,
4282
+ alreadySelected: selectedFullPaths.has(fullPath),
4214
4283
  icon: metric.source ? /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
4215
4284
  source: metric.source
4216
4285
  }) : undefined
@@ -4273,7 +4342,11 @@ const Metrics = ({
4273
4342
  metricName: selectedItem.metricName,
4274
4343
  metricTitle: selectedItem.value,
4275
4344
  // The constructed path for server
4276
- fullPath: fullPath
4345
+ fullPath: fullPath,
4346
+ // Unique per-instance identity, so this field can be added more
4347
+ // than once and each occurrence stays independently
4348
+ // orderable/removable/titleable.
4349
+ id: nextInstanceId(fullPath, savedMetrics)
4277
4350
  };
4278
4351
  onSaveMetric(metricData);
4279
4352
  handleCancel();
@@ -4284,8 +4357,9 @@ const Metrics = ({
4284
4357
  const handleAddAll = () => {
4285
4358
  const metricItems = getMetricItems();
4286
4359
 
4287
- // Filter out already selected metrics (disabled items)
4288
- const availableItems = metricItems.filter(item => !item.disabled);
4360
+ // Filter out already selected metrics - "Add all" only fills in
4361
+ // missing ones; intentional duplicates are added one at a time above.
4362
+ const availableItems = metricItems.filter(item => !item.alreadySelected);
4289
4363
  if (availableItems.length === 0) return;
4290
4364
 
4291
4365
  // Build the complete relation objects array (same for all metrics in this provider)
@@ -4330,7 +4404,10 @@ const Metrics = ({
4330
4404
  metricName: item.metricName,
4331
4405
  metricTitle: item.value,
4332
4406
  // The constructed path for server
4333
- fullPath: fullPath
4407
+ fullPath: fullPath,
4408
+ // "Add all" only ever adds fields that aren't already
4409
+ // selected, so this is always the first occurrence.
4410
+ id: fullPath
4334
4411
  };
4335
4412
  onSaveMetric(metricData);
4336
4413
  });
@@ -4429,7 +4506,7 @@ const Metrics = ({
4429
4506
  placement: "top"
4430
4507
  }, /*#__PURE__*/React__namespace.default.createElement("span", null, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4431
4508
  onClick: handleAddAll,
4432
- disabled: getMetricItems().filter(item => !item.disabled).length === 0,
4509
+ disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
4433
4510
  "aria-label": "add all metrics",
4434
4511
  sx: {
4435
4512
  color: "rgb(70, 134, 128)",
@@ -4507,20 +4584,64 @@ const Metrics = ({
4507
4584
  id: index,
4508
4585
  label: metric.metricTitle,
4509
4586
  fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
4510
- onDelete: () => onRemoveMetric(index),
4587
+ onDelete: () => onRemoveMetric(metric.id),
4511
4588
  onMoveUp: () => handleMoveUp(index),
4512
4589
  onMoveDown: () => handleMoveDown(index),
4513
4590
  isFirst: index === 0,
4514
4591
  isLast: index === savedMetrics.length - 1,
4515
- fullPath: metric.fullPath,
4592
+ instanceId: metric.id,
4516
4593
  defaultTitle: metric.metricTitle,
4517
- customTitle: titleOverrides[metric.fullPath],
4594
+ customTitle: titleOverrides[metric.id],
4518
4595
  onUpdateTitle: onUpdateTitle,
4519
4596
  onResetTitle: onResetTitle,
4520
4597
  source: metric.metric?.source
4521
4598
  })))));
4522
4599
  };
4523
4600
 
4601
+ // Small text input + button for adding a filter value that isn't present in
4602
+ // the "Select Filter Values" dropdown (e.g. because no existing row has that
4603
+ // value). Kept as an uncontrolled local field so it clears itself after add.
4604
+ const CustomFilterValueInput = ({
4605
+ onAdd
4606
+ }) => {
4607
+ const [value, setValue] = React.useState('');
4608
+ const handleAdd = () => {
4609
+ const trimmed = value.trim();
4610
+ if (!trimmed) return;
4611
+ onAdd(trimmed);
4612
+ setValue('');
4613
+ };
4614
+ return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4615
+ sx: {
4616
+ display: 'flex',
4617
+ gap: 1,
4618
+ mt: 1
4619
+ }
4620
+ }, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4621
+ size: "small",
4622
+ fullWidth: true,
4623
+ placeholder: "Add a value not in the list above",
4624
+ value: value,
4625
+ onChange: e => setValue(e.target.value),
4626
+ onKeyDown: e => {
4627
+ if (e.key === 'Enter') {
4628
+ e.preventDefault();
4629
+ handleAdd();
4630
+ }
4631
+ }
4632
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Button, {
4633
+ variant: "outlined",
4634
+ size: "small",
4635
+ onClick: handleAdd,
4636
+ disabled: !value.trim(),
4637
+ sx: {
4638
+ textTransform: 'none',
4639
+ whiteSpace: 'nowrap',
4640
+ borderColor: "rgb(70, 134, 128)",
4641
+ color: "rgb(70, 134, 128)"
4642
+ }
4643
+ }, "Add"));
4644
+ };
4524
4645
  const Filters = ({
4525
4646
  providersData,
4526
4647
  rootProvider,
@@ -4660,8 +4781,9 @@ const Filters = ({
4660
4781
  }
4661
4782
  } else {
4662
4783
  // For regular filters, set selected values and fetch available values
4663
- setSelectedFilterValues(filter.values || []);
4664
- await fetchFilterValues(fullPath);
4784
+ const currentValues = filter.values || [];
4785
+ setSelectedFilterValues(currentValues);
4786
+ await fetchFilterValues(fullPath, '', currentValues);
4665
4787
  }
4666
4788
  };
4667
4789
  const handleSaveEditedFilter = fullPath => {
@@ -4756,7 +4878,11 @@ const Filters = ({
4756
4878
  };
4757
4879
 
4758
4880
  // Fetch distinct values for the selected dimension
4759
- const fetchFilterValues = async (fullPath, searchText = '') => {
4881
+ // preserveValues lets a caller pass the values that must survive the merge
4882
+ // explicitly, instead of relying on the selectedFilterValues closure - a
4883
+ // setSelectedFilterValues() call right before fetchFilterValues() hasn't
4884
+ // committed yet by the time this function reads that state.
4885
+ const fetchFilterValues = async (fullPath, searchText = '', preserveValues = null) => {
4760
4886
  setLoadingFilterValues(true);
4761
4887
  try {
4762
4888
  // Get parameters from context if available, otherwise use default
@@ -4829,7 +4955,8 @@ const Filters = ({
4829
4955
  // Merge with currently selected values to ensure they're always available
4830
4956
  // This is important when editing filters with search text - we don't want to lose
4831
4957
  // previously selected values that don't match the current search
4832
- const selectedValueItems = selectedFilterValues.map(key => ({
4958
+ const valuesToPreserve = preserveValues !== null ? preserveValues : selectedFilterValues;
4959
+ const selectedValueItems = valuesToPreserve.map(key => ({
4833
4960
  key: String(key),
4834
4961
  value: String(key)
4835
4962
  }));
@@ -5058,6 +5185,21 @@ const Filters = ({
5058
5185
  }
5059
5186
  };
5060
5187
 
5188
+ // Add a value typed in the "custom value" box directly into the selected
5189
+ // filter values, and inject it into availableFilterValues too, since
5190
+ // CheckboxMultiAutocomplete only renders a chip for keys it can resolve
5191
+ // against its items list.
5192
+ const handleAddCustomFilterValue = rawValue => {
5193
+ const value = rawValue.trim();
5194
+ if (!value) return;
5195
+ if (selectedFilterValues.includes(value)) return;
5196
+ setAvailableFilterValues(prev => prev.some(item => item.key === value) ? prev : [{
5197
+ key: value,
5198
+ value
5199
+ }, ...prev]);
5200
+ setSelectedFilterValues(prev => [...prev, value]);
5201
+ };
5202
+
5061
5203
  // Handler for search text input in filter values dropdown (debounced - triggers API call)
5062
5204
  const handleFilterSearchChange = searchText => {
5063
5205
  // Determine the fullPath based on current mode
@@ -5277,6 +5419,8 @@ const Filters = ({
5277
5419
  loading: loadingFilterValues,
5278
5420
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5279
5421
  debounceMs: 1000
5422
+ }), /*#__PURE__*/React__namespace.default.createElement(CustomFilterValueInput, {
5423
+ onAdd: handleAddCustomFilterValue
5280
5424
  })));
5281
5425
  })(), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5282
5426
  sx: {
@@ -5351,7 +5495,12 @@ const Filters = ({
5351
5495
  mb: 3
5352
5496
  }
5353
5497
  }, /*#__PURE__*/React__namespace.default.createElement(SingleSelect, {
5354
- items: existingDimensions.map(dim => ({
5498
+ items: Array.from(
5499
+ // Dimensions can now be added more than once (same fullPath,
5500
+ // different instance), but a filter targets the underlying
5501
+ // field regardless of how many columns display it - so this
5502
+ // list is deduped by fullPath to avoid showing it twice.
5503
+ new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
5355
5504
  key: dim.fullPath,
5356
5505
  value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
5357
5506
  })).sort((a, b) => a.value.localeCompare(b.value)),
@@ -5423,6 +5572,8 @@ const Filters = ({
5423
5572
  loading: loadingFilterValues,
5424
5573
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5425
5574
  debounceMs: 1000
5575
+ }), /*#__PURE__*/React__namespace.default.createElement(CustomFilterValueInput, {
5576
+ onAdd: handleAddCustomFilterValue
5426
5577
  })));
5427
5578
  })(), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5428
5579
  sx: {
@@ -5712,6 +5863,8 @@ const Filters = ({
5712
5863
  loading: loadingFilterValues,
5713
5864
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5714
5865
  debounceMs: 1000
5866
+ }), /*#__PURE__*/React__namespace.default.createElement(CustomFilterValueInput, {
5867
+ onAdd: handleAddCustomFilterValue
5715
5868
  })), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5716
5869
  sx: {
5717
5870
  display: 'flex',
@@ -5934,7 +6087,7 @@ const ColumnOrder = ({
5934
6087
  key: `${item.kind}-${item.key}`,
5935
6088
  id: index,
5936
6089
  label: labelFor(item),
5937
- fullPath: item.kind === "computed" ? item.data.value : item.key,
6090
+ fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
5938
6091
  kind: item.kind,
5939
6092
  source: item.kind === "metric" ? item.data.metric?.source : undefined,
5940
6093
  onMoveUp: () => handleMoveUp(index),
@@ -5960,11 +6113,17 @@ const formatValue = (value, def) => {
5960
6113
  const isNumericType = type => type === 'integer' || type === 'currency';
5961
6114
 
5962
6115
  // Builds a single DataGrid column definition for one resolved column item
5963
- // (a dimension, computed dimension, or metric). Field naming logic:
6116
+ // (a dimension, computed dimension, or metric). The DataGrid `field` is the
6117
+ // item's instance id (so two duplicate instances of the same underlying
6118
+ // dimension/metric still get distinct columns); the actual cell value is
6119
+ // read via `valueGetter` from `dataFieldName`, the key the API response
6120
+ // actually uses - shared by every duplicate instance, since the backend
6121
+ // only ever returns one value per fullPath. dataFieldName rules:
5964
6122
  // - Dimensions from base provider: baseAlias.field -> baseAlias_field
5965
6123
  // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5966
6124
  // - Computed dimensions have no provider/relations chain, so the API returns
5967
- // each row keyed by the computed dimension's own `name`.
6125
+ // each row keyed by the computed dimension's own `name` (and can't be
6126
+ // duplicated - names must be unique - so field/dataFieldName coincide).
5968
6127
  // - Metrics: API returns metric keys in different forms depending on path depth
5969
6128
  // (see comment inline below), normalised to match the row-key normalisation
5970
6129
  // done when building `rows`.
@@ -5982,13 +6141,13 @@ const buildColumn = (item, titleOverrides, parameters) => {
5982
6141
  if (item.kind === 'metric') {
5983
6142
  const metric = item.data;
5984
6143
  const metricDef = metric.metric;
5985
- let fieldName;
6144
+ let dataFieldName;
5986
6145
  const dotCount = (metric.fullPath.match(/\./g) || []).length;
5987
6146
  if (dotCount >= 2) {
5988
6147
  // 3+-part namespaced path: API returns the full dotted path as the key.
5989
6148
  // Normalise dots to underscores to match the row key normalisation below.
5990
6149
  // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5991
- fieldName = metric.fullPath.replace(/\./g, '_');
6150
+ dataFieldName = metric.fullPath.replace(/\./g, '_');
5992
6151
  } else if (metric.relations && metric.relations.length > 0) {
5993
6152
  // 2-part path from a nested provider: API drops the base provider alias.
5994
6153
  // Example: "mc_fa.total_amount" -> "fa_total_amount"
@@ -5999,19 +6158,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
5999
6158
  const pathParts = pathWithoutField.split('_');
6000
6159
  pathParts.shift(); // remove base provider alias
6001
6160
  const pathWithoutBase = pathParts.join('_');
6002
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6161
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6003
6162
  } else {
6004
6163
  // 2-part path from the base provider: API returns just the metric name.
6005
6164
  // Example: "mc.record_count" -> "record_count"
6006
- fieldName = metric.metricName;
6165
+ dataFieldName = metric.metricName;
6007
6166
  }
6008
- const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
6167
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
6009
6168
  return {
6010
- field: fieldName,
6169
+ // `field` is the instance id (unique even for duplicate columns
6170
+ // pointing at the same underlying metric); the actual value is
6171
+ // read from the row via valueGetter using dataFieldName, which is
6172
+ // the key the API response actually uses (shared across duplicates).
6173
+ field: metric.id.replace(/[.#]/g, '_'),
6011
6174
  headerName: headerName,
6012
6175
  flex: 1,
6013
6176
  minWidth: 150,
6014
6177
  type: isNumericType(metricDef?.type) ? 'number' : 'string',
6178
+ valueGetter: (_value, row) => row[dataFieldName],
6015
6179
  renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6016
6180
  sx: {
6017
6181
  display: 'flex',
@@ -6027,7 +6191,7 @@ const buildColumn = (item, titleOverrides, parameters) => {
6027
6191
 
6028
6192
  // Dimension
6029
6193
  const dim = item.data;
6030
- let fieldName;
6194
+ let dataFieldName;
6031
6195
  if (dim.relations && dim.relations.length > 0) {
6032
6196
  // From nested provider: drop the base provider alias
6033
6197
  // Example: ft_fa_db.currency -> fa_db_currency
@@ -6039,20 +6203,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
6039
6203
  pathParts.shift(); // Remove base provider alias
6040
6204
  const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
6041
6205
 
6042
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6206
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6043
6207
  } else {
6044
6208
  // From base provider: keep the full path with underscore
6045
6209
  // Example: ba.created_at -> ba_created_at
6046
- fieldName = dim.fullPath.replace('.', '_');
6210
+ dataFieldName = dim.fullPath.replace('.', '_');
6047
6211
  }
6048
- const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
6212
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
6049
6213
  const dimDef = dim.dimension;
6050
6214
  return {
6051
- field: fieldName,
6215
+ // Same rationale as the metric branch above: `field` must be unique
6216
+ // per instance, while the value always comes from the one real
6217
+ // underlying row key (dataFieldName), shared by every duplicate.
6218
+ field: dim.id.replace(/[.#]/g, '_'),
6052
6219
  headerName: headerName,
6053
6220
  flex: 1,
6054
6221
  minWidth: 150,
6055
6222
  type: isNumericType(dimDef?.type) ? 'number' : 'string',
6223
+ valueGetter: (_value, row) => row[dataFieldName],
6056
6224
  valueFormatter: value => formatValue(value, dimDef)
6057
6225
  };
6058
6226
  };
@@ -6378,6 +6546,72 @@ const ReportBuilder = ({
6378
6546
  }
6379
6547
  };
6380
6548
 
6549
+ // Reconstructs saved dimensions as independent, individually-identified
6550
+ // instances so that deliberate duplicates (the same field added more than
6551
+ // once) survive a save/reload round trip. Prefers `ordered_dimensions`
6552
+ // (one ref per instance - the nth duplicate of a path is suffixed
6553
+ // `#n`) when present; falls back to one instance per entry in the
6554
+ // (necessarily duplicate-free) `dimensions` path list for reports saved
6555
+ // before this field existed.
6556
+ const reconstructDimensions = (reportDef, providersData) => {
6557
+ const orderByMap = {};
6558
+ if (reportDef.definition?.doc?.query?.order_by) {
6559
+ for (const orderItem of reportDef.definition.doc.query.order_by) {
6560
+ orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
6561
+ }
6562
+ }
6563
+ const reconstructed = [];
6564
+ const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
6565
+ if (orderedRefs.length > 0) {
6566
+ orderedRefs.forEach(ref => {
6567
+ const fullPath = ref.split("#")[0];
6568
+ const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
6569
+ if (dim) {
6570
+ dim.id = ref;
6571
+ dim.sortOrder = orderByMap[fullPath] || null;
6572
+ reconstructed.push(dim);
6573
+ }
6574
+ });
6575
+ } else if (reportDef.definition?.doc?.query?.dimensions) {
6576
+ for (const dimPath of reportDef.definition.doc.query.dimensions) {
6577
+ const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6578
+ if (dim) {
6579
+ dim.id = dimPath;
6580
+ dim.sortOrder = orderByMap[dimPath] || null;
6581
+ reconstructed.push(dim);
6582
+ }
6583
+ }
6584
+ }
6585
+ return reconstructed;
6586
+ };
6587
+
6588
+ // Mirrors reconstructDimensions for metrics, using the analogous
6589
+ // `ordered_metrics` field (metrics have no other unified-order concept to
6590
+ // fall back on, so this is always written whenever there are metrics).
6591
+ const reconstructMetrics = (reportDef, providersData) => {
6592
+ const reconstructed = [];
6593
+ const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
6594
+ if (orderedRefs.length > 0) {
6595
+ orderedRefs.forEach(ref => {
6596
+ const fullPath = ref.split("#")[0];
6597
+ const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
6598
+ if (metric) {
6599
+ metric.id = ref;
6600
+ reconstructed.push(metric);
6601
+ }
6602
+ });
6603
+ } else if (reportDef.definition?.doc?.query?.metrics) {
6604
+ for (const metricPath of reportDef.definition.doc.query.metrics) {
6605
+ const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6606
+ if (metric) {
6607
+ metric.id = metricPath;
6608
+ reconstructed.push(metric);
6609
+ }
6610
+ }
6611
+ }
6612
+ return reconstructed;
6613
+ };
6614
+
6381
6615
  // Build the unified display/execution order for dimensions + computed
6382
6616
  // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
6383
6617
  // to "dimensions first, then computed dimensions" for report definitions
@@ -6388,7 +6622,7 @@ const ReportBuilder = ({
6388
6622
  const seenComputedKeys = new Set();
6389
6623
  if (Array.isArray(rawOrderedDimensions)) {
6390
6624
  rawOrderedDimensions.forEach(entry => {
6391
- if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
6625
+ if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
6392
6626
  dimensionOrder.push({
6393
6627
  type: "dimension",
6394
6628
  key: entry.ref
@@ -6406,10 +6640,10 @@ const ReportBuilder = ({
6406
6640
 
6407
6641
  // Append anything not covered by ordered_dimensions (older reports, or drift)
6408
6642
  dimensions.forEach(d => {
6409
- if (!seenDimensionKeys.has(d.fullPath)) {
6643
+ if (!seenDimensionKeys.has(d.id)) {
6410
6644
  dimensionOrder.push({
6411
6645
  type: "dimension",
6412
- key: d.fullPath
6646
+ key: d.id
6413
6647
  });
6414
6648
  }
6415
6649
  });
@@ -6432,12 +6666,12 @@ const ReportBuilder = ({
6432
6666
  // "fall back to the default order".
6433
6667
  const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
6434
6668
  if (!Array.isArray(rawOrderedColumns)) return [];
6435
- const dimensionPaths = new Set(dimensions.map(d => d.fullPath));
6669
+ const dimensionIds = new Set(dimensions.map(d => d.id));
6436
6670
  const computedNames = new Set(computedDimensions.map(cd => cd.name));
6437
- const metricPaths = new Set(metrics.map(m => m.fullPath));
6671
+ const metricIds = new Set(metrics.map(m => m.id));
6438
6672
  const columnOrder = [];
6439
6673
  rawOrderedColumns.forEach(entry => {
6440
- if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
6674
+ if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
6441
6675
  columnOrder.push({
6442
6676
  type: "dimension",
6443
6677
  key: entry.ref
@@ -6447,7 +6681,7 @@ const ReportBuilder = ({
6447
6681
  type: "computed",
6448
6682
  key: entry.ref
6449
6683
  });
6450
- } else if (entry.type === "metric" && metricPaths.has(entry.ref)) {
6684
+ } else if (entry.type === "metric" && metricIds.has(entry.ref)) {
6451
6685
  columnOrder.push({
6452
6686
  type: "metric",
6453
6687
  key: entry.ref
@@ -6477,43 +6711,10 @@ const ReportBuilder = ({
6477
6711
  // Set the title
6478
6712
  setReportTitle(reportDef.title || "");
6479
6713
 
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
- }
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);
6517
6718
 
6518
6719
  // Load title overrides if they exist
6519
6720
  const loadedTitleOverrides = {
@@ -6597,43 +6798,10 @@ const ReportBuilder = ({
6597
6798
  // Set the title (already modified with " (Copy)" suffix)
6598
6799
  setReportTitle(reportDef.title || "");
6599
6800
 
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
- }
6801
+ // Reconstruct dimensions and metrics as independent instances
6802
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
6803
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
6804
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
6637
6805
 
6638
6806
  // Load title overrides if they exist
6639
6807
  const loadedTitleOverrides = {
@@ -6732,42 +6900,45 @@ const ReportBuilder = ({
6732
6900
  });
6733
6901
  console.log("Selected root provider:", event.target.value);
6734
6902
  };
6735
- const handleUpdateDimensionTitle = (fullPath, customTitle) => {
6903
+
6904
+ // Keyed by instance id, so two instances of the same dimension can carry
6905
+ // independent custom titles.
6906
+ const handleUpdateDimensionTitle = (id, customTitle) => {
6736
6907
  setTitleOverrides(prev => ({
6737
6908
  ...prev,
6738
6909
  dimensions: {
6739
6910
  ...prev.dimensions,
6740
- [fullPath]: customTitle
6911
+ [id]: customTitle
6741
6912
  }
6742
6913
  }));
6743
6914
  };
6744
- const handleResetDimensionTitle = fullPath => {
6915
+ const handleResetDimensionTitle = id => {
6745
6916
  setTitleOverrides(prev => {
6746
6917
  const newDimensions = {
6747
6918
  ...prev.dimensions
6748
6919
  };
6749
- delete newDimensions[fullPath];
6920
+ delete newDimensions[id];
6750
6921
  return {
6751
6922
  ...prev,
6752
6923
  dimensions: newDimensions
6753
6924
  };
6754
6925
  });
6755
6926
  };
6756
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
6927
+ const handleUpdateMetricTitle = (id, customTitle) => {
6757
6928
  setTitleOverrides(prev => ({
6758
6929
  ...prev,
6759
6930
  metrics: {
6760
6931
  ...prev.metrics,
6761
- [fullPath]: customTitle
6932
+ [id]: customTitle
6762
6933
  }
6763
6934
  }));
6764
6935
  };
6765
- const handleResetMetricTitle = fullPath => {
6936
+ const handleResetMetricTitle = id => {
6766
6937
  setTitleOverrides(prev => {
6767
6938
  const newMetrics = {
6768
6939
  ...prev.metrics
6769
6940
  };
6770
- delete newMetrics[fullPath];
6941
+ delete newMetrics[id];
6771
6942
  return {
6772
6943
  ...prev,
6773
6944
  metrics: newMetrics
@@ -6802,11 +6973,11 @@ const ReportBuilder = ({
6802
6973
  dimensions: [...prev.dimensions, dimensionData],
6803
6974
  dimensionOrder: [...prev.dimensionOrder, {
6804
6975
  type: "dimension",
6805
- key: dimensionData.fullPath
6976
+ key: dimensionData.id
6806
6977
  }],
6807
6978
  columnOrder: appendToColumnOrder(prev.columnOrder, {
6808
6979
  type: "dimension",
6809
- key: dimensionData.fullPath
6980
+ key: dimensionData.id
6810
6981
  })
6811
6982
  };
6812
6983
  console.log("Dimension saved:", dimensionData);
@@ -6814,22 +6985,30 @@ const ReportBuilder = ({
6814
6985
  return newReport;
6815
6986
  });
6816
6987
  };
6817
- const handleRemoveDimension = fullPath => {
6988
+ const handleRemoveDimension = id => {
6818
6989
  setReport(prev => ({
6819
6990
  ...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))
6991
+ dimensions: prev.dimensions.filter(d => d.id !== id),
6992
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
6993
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
6823
6994
  }));
6824
6995
  };
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
- }));
6996
+
6997
+ // sortOrder is fundamentally a per-field (query-level) setting - SQL can
6998
+ // only sort by a given column once - so toggling it on any instance of a
6999
+ // duplicated dimension applies to every instance sharing that fullPath.
7000
+ const handleUpdateDimensionSortOrder = (id, sortOrder) => {
7001
+ setReport(prev => {
7002
+ const target = prev.dimensions.find(d => d.id === id);
7003
+ if (!target) return prev;
7004
+ return {
7005
+ ...prev,
7006
+ dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
7007
+ ...d,
7008
+ sortOrder
7009
+ } : d)
7010
+ };
7011
+ });
6833
7012
  };
6834
7013
 
6835
7014
  // Reorders the single unified dimensions + computed-dimensions list;
@@ -6884,7 +7063,7 @@ const ReportBuilder = ({
6884
7063
  metrics: [...prev.metrics, metricData],
6885
7064
  columnOrder: appendToColumnOrder(prev.columnOrder, {
6886
7065
  type: "metric",
6887
- key: metricData.fullPath
7066
+ key: metricData.id
6888
7067
  })
6889
7068
  };
6890
7069
  console.log("Metric saved:", metricData);
@@ -6892,15 +7071,12 @@ const ReportBuilder = ({
6892
7071
  return newReport;
6893
7072
  });
6894
7073
  };
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
- });
7074
+ const handleRemoveMetric = id => {
7075
+ setReport(prev => ({
7076
+ ...prev,
7077
+ metrics: prev.metrics.filter(m => m.id !== id),
7078
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
7079
+ }));
6904
7080
  };
6905
7081
  const handleReorderMetrics = newOrder => {
6906
7082
  setReport(prev => ({
@@ -6955,13 +7131,30 @@ const ReportBuilder = ({
6955
7131
  // persisted `ordered_dimensions` field all stay consistent with whatever
6956
7132
  // order the user arranged on screen.
6957
7133
  const buildQueryObject = () => {
6958
- const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7134
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
6959
7135
  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);
7136
+
7137
+ // May contain more than one instance of the same fullPath (deliberate
7138
+ // duplicates), kept in dimensionOrder's display order.
7139
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
6961
7140
  const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
6962
7141
 
7142
+ // The API keys each result row by fullPath, so duplicate instances of
7143
+ // the same field can only ever carry one value/one sort direction at
7144
+ // the query level - dedupe by fullPath (keeping first-occurrence order)
7145
+ // before sending. The builder still shows/orders/titles each instance
7146
+ // independently; they just read from the same underlying query column.
7147
+ const uniqueDimensionsByPath = [];
7148
+ const seenDimensionPaths = new Set();
7149
+ orderedDimensionsOnly.forEach(dim => {
7150
+ if (!seenDimensionPaths.has(dim.fullPath)) {
7151
+ seenDimensionPaths.add(dim.fullPath);
7152
+ uniqueDimensionsByPath.push(dim);
7153
+ }
7154
+ });
7155
+
6963
7156
  // Build order_by array - include all dimensions, add desc property only when needed
6964
- const orderBy = orderedDimensionsOnly.map(dim => {
7157
+ const orderBy = uniqueDimensionsByPath.map(dim => {
6965
7158
  const orderItem = {
6966
7159
  name: dim.fullPath
6967
7160
  };
@@ -7016,9 +7209,20 @@ const ReportBuilder = ({
7016
7209
  if (Object.keys(titleOverrides.filters).length > 0) {
7017
7210
  titles.filters = titleOverrides.filters;
7018
7211
  }
7212
+
7213
+ // Same dedupe rationale as dimensions above: the query only needs each
7214
+ // metric fullPath once, however many display instances reference it.
7215
+ const uniqueMetricPaths = [];
7216
+ const seenMetricPaths = new Set();
7217
+ report.metrics.forEach(metric => {
7218
+ if (!seenMetricPaths.has(metric.fullPath)) {
7219
+ seenMetricPaths.add(metric.fullPath);
7220
+ uniqueMetricPaths.push(metric.fullPath);
7221
+ }
7222
+ });
7019
7223
  const queryObj = {
7020
- dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
7021
- metrics: report.metrics.map(metric => metric.fullPath),
7224
+ dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
7225
+ metrics: uniqueMetricPaths,
7022
7226
  order_by: orderBy
7023
7227
  };
7024
7228
 
@@ -7032,7 +7236,9 @@ const ReportBuilder = ({
7032
7236
 
7033
7237
  // Persist the unified dimension + computed-dimension order so it can be
7034
7238
  // reconstructed on reload and so executed reports render columns in the
7035
- // same order they were arranged in the builder.
7239
+ // same order they were arranged in the builder. Entries use each
7240
+ // dimension's instance id as `ref`, so deliberate duplicates (the nth
7241
+ // occurrence of a fullPath is suffixed `#n`) round-trip correctly.
7036
7242
  if (report.dimensionOrder.length > 0) {
7037
7243
  queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
7038
7244
  type: entry.type,
@@ -7040,14 +7246,30 @@ const ReportBuilder = ({
7040
7246
  }));
7041
7247
  }
7042
7248
 
7043
- // Persist the optional unified column order (Column Order tab) so the
7044
- // results grid can reproduce it on reload. Absent when no custom order
7045
- // has been defined, so old/untouched reports keep their current
7046
- // dimensions-then-metrics column order unchanged.
7047
- if (report.columnOrder.length > 0) {
7048
- queryObj.ordered_columns = report.columnOrder.map(entry => ({
7049
- type: entry.type,
7050
- ref: entry.key
7249
+ // Persist metric instance order/identity the same way (metrics have no
7250
+ // other unified-order field to piggyback on).
7251
+ if (report.metrics.length > 0) {
7252
+ queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
7253
+ }
7254
+
7255
+ // Persist the full resolved column order - the explicit Column Order tab
7256
+ // arrangement when defined, otherwise dimensions/computed (in their own
7257
+ // order) followed by metrics (in their own order), same as what the
7258
+ // results grid renders. Always emitted (not just when a custom order
7259
+ // exists) so the API/results grid get an explicit order even for
7260
+ // untouched reports, rather than having to fall back to guessing it.
7261
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
7262
+ kind: "dimension",
7263
+ data: dimensionById[entry.key]
7264
+ } : {
7265
+ kind: "computed",
7266
+ data: computedByName[entry.key]
7267
+ }).filter(item => item.data);
7268
+ const resolvedColumnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
7269
+ if (resolvedColumnItems.length > 0) {
7270
+ queryObj.ordered_columns = resolvedColumnItems.map(item => ({
7271
+ type: item.kind,
7272
+ ref: item.key
7051
7273
  }));
7052
7274
  }
7053
7275
 
@@ -7209,11 +7431,11 @@ const ReportBuilder = ({
7209
7431
 
7210
7432
  // Resolve the unified dimensionOrder into actual dimension/computed-dimension
7211
7433
  // objects so the results grid can render columns in that same order.
7212
- const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7434
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
7213
7435
  const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
7214
7436
  const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
7215
7437
  kind: "dimension",
7216
- data: dimensionByFullPath[entry.key]
7438
+ data: dimensionById[entry.key]
7217
7439
  } : {
7218
7440
  kind: "computed",
7219
7441
  data: computedDimensionByName[entry.key]