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