@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.
@@ -30273,6 +30273,7 @@ function useInView({
30273
30273
  delay,
30274
30274
  trackVisibility,
30275
30275
  rootMargin,
30276
+ scrollMargin,
30276
30277
  root,
30277
30278
  triggerOnce,
30278
30279
  skip,
@@ -30317,8 +30318,8 @@ function useInView({
30317
30318
  {
30318
30319
  root,
30319
30320
  rootMargin,
30321
+ scrollMargin,
30320
30322
  threshold,
30321
- // @ts-expect-error
30322
30323
  trackVisibility,
30323
30324
  delay
30324
30325
  },
@@ -30338,6 +30339,7 @@ function useInView({
30338
30339
  ref,
30339
30340
  root,
30340
30341
  rootMargin,
30342
+ scrollMargin,
30341
30343
  triggerOnce,
30342
30344
  skip,
30343
30345
  trackVisibility,
@@ -43266,6 +43268,10 @@ const ChartFilterMultiSelect = ({
43266
43268
  })));
43267
43269
  };
43268
43270
 
43271
+ var CheckCircleIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
43272
+ d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8z"
43273
+ }));
43274
+
43269
43275
  const formatLabel = key => {
43270
43276
  return key?.replace(/([a-z])([A-Z])/g, "$1 $2") // insert space before capital letters
43271
43277
  ?.replace(/_/g, " ")?.replace(/-/g, " ") // replace underscores with spaces (optional)
@@ -43329,7 +43335,8 @@ function SingleSelect({
43329
43335
  key,
43330
43336
  value,
43331
43337
  disabled,
43332
- icon
43338
+ icon,
43339
+ alreadySelected
43333
43340
  } = itm;
43334
43341
  return /*#__PURE__*/gn.createElement(MenuItem, {
43335
43342
  key: key,
@@ -43340,7 +43347,12 @@ function SingleSelect({
43340
43347
  minHeight: "36px",
43341
43348
  display: "flex",
43342
43349
  alignItems: "center",
43343
- gap: "6px"
43350
+ gap: "6px",
43351
+ ...(alreadySelected && {
43352
+ color: "rgb(70, 134, 128)",
43353
+ backgroundColor: "rgba(70, 134, 128, 0.08)",
43354
+ fontWeight: 600
43355
+ })
43344
43356
  }
43345
43357
  }, icon && /*#__PURE__*/gn.createElement(Box, {
43346
43358
  component: "span",
@@ -43348,7 +43360,13 @@ function SingleSelect({
43348
43360
  display: "inline-flex",
43349
43361
  alignItems: "center"
43350
43362
  }
43351
- }, icon), formatLabel(value));
43363
+ }, icon), alreadySelected && /*#__PURE__*/gn.createElement(CheckCircleIcon, {
43364
+ fontSize: "small",
43365
+ sx: {
43366
+ color: "rgb(70, 134, 128)",
43367
+ fontSize: "16px"
43368
+ }
43369
+ }), formatLabel(value));
43352
43370
  }))));
43353
43371
  }
43354
43372
 
@@ -77326,6 +77344,29 @@ var FunctionsIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
77326
77344
  d: "M18 4H6v2l6.5 6L6 18v2h12v-3h-7l5-5-5-5h7z"
77327
77345
  }));
77328
77346
 
77347
+ // Builds an identity string for a saved dimension/metric instance. Deliberate
77348
+ // duplicates (the same field added more than once) are allowed - the first
77349
+ // occurrence keeps the plain fullPath (so existing saved reports and their
77350
+ // title-override keys stay backward compatible), later occurrences get a
77351
+ // `#n` suffix.
77352
+ const makeInstanceId = (fullPath, occurrenceIndex) => occurrenceIndex === 0 ? fullPath : `${fullPath}#${occurrenceIndex}`;
77353
+
77354
+ // Picks the next free instance id for a newly-added fullPath, given the
77355
+ // items already saved. Scans occurrence numbers from 0 rather than just
77356
+ // counting existing instances with this fullPath, because counting alone
77357
+ // can collide: e.g. add "ft.currency" twice (ids "ft.currency",
77358
+ // "ft.currency#1"), remove the first, then add "ft.currency" again - a
77359
+ // plain count of 1 would regenerate "ft.currency#1", clashing with the
77360
+ // surviving duplicate.
77361
+ const nextInstanceId = (fullPath, existingItems) => {
77362
+ const existingIds = new Set(existingItems.map(item => item.id));
77363
+ let occurrenceIndex = 0;
77364
+ while (existingIds.has(makeInstanceId(fullPath, occurrenceIndex))) {
77365
+ occurrenceIndex += 1;
77366
+ }
77367
+ return makeInstanceId(fullPath, occurrenceIndex);
77368
+ };
77369
+
77329
77370
  // Replace {{key}} placeholders in a title with values from the report
77330
77371
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
77331
77372
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -77352,14 +77393,17 @@ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
77352
77393
  // Normalise every candidate item to a uniform { kind, key, data } shape
77353
77394
  // up front, so callers (e.g. the Column Order tab) can always read
77354
77395
  // `.key` regardless of where the item came from.
77396
+ // Keyed by instance id (not fullPath) so that two instances of the same
77397
+ // dimension/metric - added deliberately as duplicates - are treated as
77398
+ // distinct columns rather than collapsing into one.
77355
77399
  const normalizedDimensionItems = orderedDimensionItems.map(item => ({
77356
77400
  kind: item.kind,
77357
- key: item.kind === 'computed' ? item.data.name : item.data.fullPath,
77401
+ key: item.kind === 'computed' ? item.data.name : item.data.id,
77358
77402
  data: item.data
77359
77403
  }));
77360
77404
  const normalizedMetricItems = metrics.map(m => ({
77361
77405
  kind: 'metric',
77362
- key: m.fullPath,
77406
+ key: m.id,
77363
77407
  data: m
77364
77408
  }));
77365
77409
  const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
@@ -77393,7 +77437,7 @@ const SortableChip$1 = ({
77393
77437
  isLast,
77394
77438
  sortOrder,
77395
77439
  onSortOrderChange,
77396
- fullPath,
77440
+ instanceId,
77397
77441
  defaultTitle,
77398
77442
  customTitle,
77399
77443
  onUpdateTitle,
@@ -77476,7 +77520,7 @@ const SortableChip$1 = ({
77476
77520
  handleCancel();
77477
77521
  return;
77478
77522
  }
77479
- onUpdateTitle(fullPath, trimmedValue);
77523
+ onUpdateTitle(instanceId, trimmedValue);
77480
77524
  setIsEditing(false);
77481
77525
  };
77482
77526
  const handleCancel = () => {
@@ -77484,7 +77528,7 @@ const SortableChip$1 = ({
77484
77528
  setEditValue("");
77485
77529
  };
77486
77530
  const handleReset = () => {
77487
- onResetTitle(fullPath);
77531
+ onResetTitle(instanceId);
77488
77532
  setIsEditing(false);
77489
77533
  setEditValue("");
77490
77534
  };
@@ -77695,7 +77739,7 @@ const SortableComputedChip = ({
77695
77739
  const handleSave = () => {
77696
77740
  const trimmedName = editName.trim();
77697
77741
  const trimmedValue = editValue.trim();
77698
- if (!trimmedName || !trimmedValue) return;
77742
+ if (!trimmedName) return;
77699
77743
  if (isNameTaken(trimmedName, name)) return;
77700
77744
  onUpdate(name, {
77701
77745
  name: trimmedName,
@@ -77715,6 +77759,16 @@ const SortableComputedChip = ({
77715
77759
  handleCancel();
77716
77760
  }
77717
77761
  };
77762
+
77763
+ // The value field is multiline, so Enter inserts a newline instead of
77764
+ // saving; only Escape (cancel) and Cmd/Ctrl+Enter (save) are handled here.
77765
+ const handleValueKeyDown = e => {
77766
+ if (e.key === "Escape") {
77767
+ handleCancel();
77768
+ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
77769
+ handleSave();
77770
+ }
77771
+ };
77718
77772
  return /*#__PURE__*/gn.createElement("div", _extends({
77719
77773
  ref: setNodeRef,
77720
77774
  style: style
@@ -77818,10 +77872,13 @@ const SortableComputedChip = ({
77818
77872
  }), /*#__PURE__*/gn.createElement(TextField, {
77819
77873
  value: editValue,
77820
77874
  onChange: e => setEditValue(e.target.value),
77821
- onKeyDown: handleKeyDown,
77875
+ onKeyDown: handleValueKeyDown,
77822
77876
  size: "small",
77823
77877
  label: "Value",
77824
- helperText: " ",
77878
+ helperText: "Cmd/Ctrl+Enter to save",
77879
+ multiline: true,
77880
+ minRows: 2,
77881
+ maxRows: 10,
77825
77882
  sx: {
77826
77883
  flex: 1,
77827
77884
  minWidth: "200px",
@@ -77845,7 +77902,7 @@ const SortableComputedChip = ({
77845
77902
  onClick: handleSave,
77846
77903
  color: "primary",
77847
77904
  "aria-label": "save computed dimension",
77848
- disabled: !editName.trim() || !editValue.trim() || nameError
77905
+ disabled: !editName.trim() || nameError
77849
77906
  }, /*#__PURE__*/gn.createElement(CheckIcon, {
77850
77907
  fontSize: "small"
77851
77908
  })))), /*#__PURE__*/gn.createElement(Tooltip, {
@@ -77894,12 +77951,14 @@ const Dimensions = ({
77894
77951
  // Resolve the unified dimensionOrder into the actual dimension / computed
77895
77952
  // dimension objects, so dimensions and computed dimensions can be shown
77896
77953
  // (and dragged) as a single list while staying stored in their own arrays.
77897
- const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
77954
+ // Keyed by instance id rather than fullPath, so two instances of the same
77955
+ // dimension (added deliberately as a duplicate) stay distinct entries.
77956
+ const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
77898
77957
  const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
77899
77958
  const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
77900
77959
  kind: "dimension",
77901
77960
  key: entry.key,
77902
- data: dimensionByFullPath[entry.key]
77961
+ data: dimensionById[entry.key]
77903
77962
  } : {
77904
77963
  kind: "computed",
77905
77964
  key: entry.key,
@@ -77913,9 +77972,13 @@ const Dimensions = ({
77913
77972
  handleMoveDown
77914
77973
  } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
77915
77974
 
77916
- // Handle sort order change (dimensions only - computed dimensions have no order_by)
77917
- const handleSortOrderChange = (fullPath, sortOrder) => {
77918
- onUpdateDimensionSortOrder(fullPath, sortOrder);
77975
+ // Handle sort order change (dimensions only - computed dimensions have no
77976
+ // order_by). Keyed by instance id but applied query-wide: SQL can only
77977
+ // sort by a given column once, so toggling sort on any duplicate instance
77978
+ // of the same field is propagated to all instances sharing its fullPath
77979
+ // (handled in ReportBuilder's onUpdateDimensionSortOrder).
77980
+ const handleSortOrderChange = (id, sortOrder) => {
77981
+ onUpdateDimensionSortOrder(id, sortOrder);
77919
77982
  };
77920
77983
 
77921
77984
  // Computed Dimensions: { name, value } pairs independent of any provider.
@@ -77936,7 +77999,7 @@ const Dimensions = ({
77936
77999
  const handleSaveComputed = () => {
77937
78000
  const trimmedName = computedName.trim();
77938
78001
  const trimmedValue = computedValue.trim();
77939
- if (!trimmedName || !trimmedValue) return;
78002
+ if (!trimmedName) return;
77940
78003
  if (isComputedNameTaken(trimmedName)) return;
77941
78004
  onSaveComputedDimension({
77942
78005
  name: trimmedName,
@@ -77978,7 +78041,10 @@ const Dimensions = ({
77978
78041
  currentProviderKey = selection.targetKey;
77979
78042
  });
77980
78043
 
77981
- // Create a Set of already selected dimension fullPaths for quick lookup
78044
+ // Create a Set of already selected dimension fullPaths for quick lookup.
78045
+ // Already-selected items stay selectable (so the same dimension can be
78046
+ // added again on purpose) - they're just flagged so the dropdown can
78047
+ // highlight them instead of blocking the click.
77982
78048
  const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
77983
78049
  const items = [];
77984
78050
  // Iterate through aliases (e.g., 'ba', 'pc')
@@ -77990,9 +78056,6 @@ const Dimensions = ({
77990
78056
 
77991
78057
  // Construct the fullPath for this dimension
77992
78058
  const fullPath = `${aliasPath.join("_")}.${dimKey}`;
77993
-
77994
- // Check if this dimension is already selected
77995
- const isAlreadySelected = selectedFullPaths.has(fullPath);
77996
78059
  items.push({
77997
78060
  // Include provider name to ensure uniqueness across different providers
77998
78061
  key: `${currentProvider}_${alias}.${dimKey}`,
@@ -78000,7 +78063,7 @@ const Dimensions = ({
78000
78063
  dimensionKey: dimKey,
78001
78064
  alias: alias,
78002
78065
  dimension: dimension,
78003
- disabled: isAlreadySelected
78066
+ alreadySelected: selectedFullPaths.has(fullPath)
78004
78067
  });
78005
78068
  });
78006
78069
  });
@@ -78062,7 +78125,10 @@ const Dimensions = ({
78062
78125
  dimensionKey: selectedItem.dimensionKey,
78063
78126
  dimensionTitle: selectedItem.value,
78064
78127
  // The constructed path for server
78065
- fullPath: fullPath
78128
+ fullPath: fullPath,
78129
+ // Unique per-instance identity, so this field can be added more than
78130
+ // once and each occurrence stays independently orderable/removable.
78131
+ id: nextInstanceId(fullPath, savedDimensions)
78066
78132
  };
78067
78133
  onSaveDimension(dimensionData);
78068
78134
  handleCancel();
@@ -78073,8 +78139,9 @@ const Dimensions = ({
78073
78139
  const handleAddAll = () => {
78074
78140
  const dimensionItems = getDimensionItems();
78075
78141
 
78076
- // Filter out already selected dimensions (disabled items)
78077
- const availableItems = dimensionItems.filter(item => !item.disabled);
78142
+ // Filter out already selected dimensions - "Add all" only fills in
78143
+ // missing ones; intentional duplicates are added one at a time above.
78144
+ const availableItems = dimensionItems.filter(item => !item.alreadySelected);
78078
78145
  if (availableItems.length === 0) return;
78079
78146
 
78080
78147
  // Build the complete relation objects array (same for all dimensions in this provider)
@@ -78119,7 +78186,10 @@ const Dimensions = ({
78119
78186
  dimensionKey: item.dimensionKey,
78120
78187
  dimensionTitle: item.value,
78121
78188
  // The constructed path for server
78122
- fullPath: fullPath
78189
+ fullPath: fullPath,
78190
+ // "Add all" only ever adds fields that aren't already selected, so
78191
+ // this is always the first (and only) occurrence of this fullPath.
78192
+ id: fullPath
78123
78193
  };
78124
78194
  onSaveDimension(dimensionData);
78125
78195
  });
@@ -78218,6 +78288,9 @@ const Dimensions = ({
78218
78288
  value: computedValue,
78219
78289
  onChange: e => setComputedValue(e.target.value),
78220
78290
  size: "small",
78291
+ multiline: true,
78292
+ minRows: 2,
78293
+ maxRows: 10,
78221
78294
  sx: {
78222
78295
  width: "400px",
78223
78296
  "& .MuiInputBase-input": {
@@ -78253,7 +78326,7 @@ const Dimensions = ({
78253
78326
  }, "Cancel"), /*#__PURE__*/gn.createElement(Button, {
78254
78327
  variant: "contained",
78255
78328
  onClick: handleSaveComputed,
78256
- disabled: !computedName.trim() || !computedValue.trim() || computedNameError,
78329
+ disabled: !computedName.trim() || computedNameError,
78257
78330
  sx: {
78258
78331
  height: "40px",
78259
78332
  fontFamily: "system-ui",
@@ -78329,7 +78402,7 @@ const Dimensions = ({
78329
78402
  placement: "top"
78330
78403
  }, /*#__PURE__*/gn.createElement("span", null, /*#__PURE__*/gn.createElement(IconButton, {
78331
78404
  onClick: handleAddAll,
78332
- disabled: getDimensionItems().filter(item => !item.disabled).length === 0,
78405
+ disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
78333
78406
  "aria-label": "add all dimensions",
78334
78407
  sx: {
78335
78408
  color: "rgb(70, 134, 128)",
@@ -78420,9 +78493,9 @@ const Dimensions = ({
78420
78493
  isLast: index === orderedItems.length - 1,
78421
78494
  sortOrder: item.data.sortOrder || null,
78422
78495
  onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
78423
- fullPath: item.data.fullPath,
78496
+ instanceId: item.data.id,
78424
78497
  defaultTitle: item.data.dimensionTitle,
78425
- customTitle: titleOverrides[item.data.fullPath],
78498
+ customTitle: titleOverrides[item.data.id],
78426
78499
  onUpdateTitle: onUpdateTitle,
78427
78500
  onResetTitle: onResetTitle
78428
78501
  }) : /*#__PURE__*/gn.createElement(SortableComputedChip, {
@@ -78507,7 +78580,7 @@ const SortableChip = ({
78507
78580
  onMoveDown,
78508
78581
  isFirst,
78509
78582
  isLast,
78510
- fullPath,
78583
+ instanceId,
78511
78584
  defaultTitle,
78512
78585
  customTitle,
78513
78586
  onUpdateTitle,
@@ -78560,7 +78633,7 @@ const SortableChip = ({
78560
78633
  handleCancel();
78561
78634
  return;
78562
78635
  }
78563
- onUpdateTitle(fullPath, trimmedValue);
78636
+ onUpdateTitle(instanceId, trimmedValue);
78564
78637
  setIsEditing(false);
78565
78638
  };
78566
78639
  const handleCancel = () => {
@@ -78568,7 +78641,7 @@ const SortableChip = ({
78568
78641
  setEditValue('');
78569
78642
  };
78570
78643
  const handleReset = () => {
78571
- onResetTitle(fullPath);
78644
+ onResetTitle(instanceId);
78572
78645
  setIsEditing(false);
78573
78646
  setEditValue('');
78574
78647
  };
@@ -78789,7 +78862,10 @@ const Metrics = ({
78789
78862
  currentProviderKey = selection.targetKey;
78790
78863
  });
78791
78864
 
78792
- // Create a Set of already selected metric fullPaths for quick lookup
78865
+ // Create a Set of already selected metric fullPaths for quick lookup.
78866
+ // Already-selected items stay selectable (so the same metric can be
78867
+ // added again on purpose) - they're just flagged so the dropdown can
78868
+ // highlight them instead of blocking the click.
78793
78869
  const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
78794
78870
 
78795
78871
  // Metrics are a direct array, not nested by alias
@@ -78797,15 +78873,12 @@ const Metrics = ({
78797
78873
  const items = provider.metrics.map((metric, index) => {
78798
78874
  // Construct the fullPath for this metric
78799
78875
  const fullPath = `${aliasPath.join('_')}.${metric.name}`;
78800
-
78801
- // Check if this metric is already selected
78802
- const isAlreadySelected = selectedFullPaths.has(fullPath);
78803
78876
  return {
78804
78877
  key: `${currentProvider}_${metric.name}_${index}`,
78805
78878
  value: metric.title || metric.name,
78806
78879
  metricName: metric.name,
78807
78880
  metric: metric,
78808
- disabled: isAlreadySelected,
78881
+ alreadySelected: selectedFullPaths.has(fullPath),
78809
78882
  icon: metric.source ? /*#__PURE__*/gn.createElement(MetricSourceIcon, {
78810
78883
  source: metric.source
78811
78884
  }) : undefined
@@ -78868,7 +78941,11 @@ const Metrics = ({
78868
78941
  metricName: selectedItem.metricName,
78869
78942
  metricTitle: selectedItem.value,
78870
78943
  // The constructed path for server
78871
- fullPath: fullPath
78944
+ fullPath: fullPath,
78945
+ // Unique per-instance identity, so this field can be added more
78946
+ // than once and each occurrence stays independently
78947
+ // orderable/removable/titleable.
78948
+ id: nextInstanceId(fullPath, savedMetrics)
78872
78949
  };
78873
78950
  onSaveMetric(metricData);
78874
78951
  handleCancel();
@@ -78879,8 +78956,9 @@ const Metrics = ({
78879
78956
  const handleAddAll = () => {
78880
78957
  const metricItems = getMetricItems();
78881
78958
 
78882
- // Filter out already selected metrics (disabled items)
78883
- const availableItems = metricItems.filter(item => !item.disabled);
78959
+ // Filter out already selected metrics - "Add all" only fills in
78960
+ // missing ones; intentional duplicates are added one at a time above.
78961
+ const availableItems = metricItems.filter(item => !item.alreadySelected);
78884
78962
  if (availableItems.length === 0) return;
78885
78963
 
78886
78964
  // Build the complete relation objects array (same for all metrics in this provider)
@@ -78925,7 +79003,10 @@ const Metrics = ({
78925
79003
  metricName: item.metricName,
78926
79004
  metricTitle: item.value,
78927
79005
  // The constructed path for server
78928
- fullPath: fullPath
79006
+ fullPath: fullPath,
79007
+ // "Add all" only ever adds fields that aren't already
79008
+ // selected, so this is always the first occurrence.
79009
+ id: fullPath
78929
79010
  };
78930
79011
  onSaveMetric(metricData);
78931
79012
  });
@@ -79024,7 +79105,7 @@ const Metrics = ({
79024
79105
  placement: "top"
79025
79106
  }, /*#__PURE__*/gn.createElement("span", null, /*#__PURE__*/gn.createElement(IconButton, {
79026
79107
  onClick: handleAddAll,
79027
- disabled: getMetricItems().filter(item => !item.disabled).length === 0,
79108
+ disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
79028
79109
  "aria-label": "add all metrics",
79029
79110
  sx: {
79030
79111
  color: "rgb(70, 134, 128)",
@@ -79102,14 +79183,14 @@ const Metrics = ({
79102
79183
  id: index,
79103
79184
  label: metric.metricTitle,
79104
79185
  fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
79105
- onDelete: () => onRemoveMetric(index),
79186
+ onDelete: () => onRemoveMetric(metric.id),
79106
79187
  onMoveUp: () => handleMoveUp(index),
79107
79188
  onMoveDown: () => handleMoveDown(index),
79108
79189
  isFirst: index === 0,
79109
79190
  isLast: index === savedMetrics.length - 1,
79110
- fullPath: metric.fullPath,
79191
+ instanceId: metric.id,
79111
79192
  defaultTitle: metric.metricTitle,
79112
- customTitle: titleOverrides[metric.fullPath],
79193
+ customTitle: titleOverrides[metric.id],
79113
79194
  onUpdateTitle: onUpdateTitle,
79114
79195
  onResetTitle: onResetTitle,
79115
79196
  source: metric.metric?.source
@@ -90452,6 +90533,50 @@ class AdapterDayjs {
90452
90533
  };
90453
90534
  }
90454
90535
 
90536
+ // Small text input + button for adding a filter value that isn't present in
90537
+ // the "Select Filter Values" dropdown (e.g. because no existing row has that
90538
+ // value). Kept as an uncontrolled local field so it clears itself after add.
90539
+ const CustomFilterValueInput = ({
90540
+ onAdd
90541
+ }) => {
90542
+ const [value, setValue] = d('');
90543
+ const handleAdd = () => {
90544
+ const trimmed = value.trim();
90545
+ if (!trimmed) return;
90546
+ onAdd(trimmed);
90547
+ setValue('');
90548
+ };
90549
+ return /*#__PURE__*/gn.createElement(Box, {
90550
+ sx: {
90551
+ display: 'flex',
90552
+ gap: 1,
90553
+ mt: 1
90554
+ }
90555
+ }, /*#__PURE__*/gn.createElement(TextField, {
90556
+ size: "small",
90557
+ fullWidth: true,
90558
+ placeholder: "Add a value not in the list above",
90559
+ value: value,
90560
+ onChange: e => setValue(e.target.value),
90561
+ onKeyDown: e => {
90562
+ if (e.key === 'Enter') {
90563
+ e.preventDefault();
90564
+ handleAdd();
90565
+ }
90566
+ }
90567
+ }), /*#__PURE__*/gn.createElement(Button, {
90568
+ variant: "outlined",
90569
+ size: "small",
90570
+ onClick: handleAdd,
90571
+ disabled: !value.trim(),
90572
+ sx: {
90573
+ textTransform: 'none',
90574
+ whiteSpace: 'nowrap',
90575
+ borderColor: "rgb(70, 134, 128)",
90576
+ color: "rgb(70, 134, 128)"
90577
+ }
90578
+ }, "Add"));
90579
+ };
90455
90580
  const Filters = ({
90456
90581
  providersData,
90457
90582
  rootProvider,
@@ -90591,8 +90716,9 @@ const Filters = ({
90591
90716
  }
90592
90717
  } else {
90593
90718
  // For regular filters, set selected values and fetch available values
90594
- setSelectedFilterValues(filter.values || []);
90595
- await fetchFilterValues(fullPath);
90719
+ const currentValues = filter.values || [];
90720
+ setSelectedFilterValues(currentValues);
90721
+ await fetchFilterValues(fullPath, '', currentValues);
90596
90722
  }
90597
90723
  };
90598
90724
  const handleSaveEditedFilter = fullPath => {
@@ -90687,7 +90813,11 @@ const Filters = ({
90687
90813
  };
90688
90814
 
90689
90815
  // Fetch distinct values for the selected dimension
90690
- const fetchFilterValues = async (fullPath, searchText = '') => {
90816
+ // preserveValues lets a caller pass the values that must survive the merge
90817
+ // explicitly, instead of relying on the selectedFilterValues closure - a
90818
+ // setSelectedFilterValues() call right before fetchFilterValues() hasn't
90819
+ // committed yet by the time this function reads that state.
90820
+ const fetchFilterValues = async (fullPath, searchText = '', preserveValues = null) => {
90691
90821
  setLoadingFilterValues(true);
90692
90822
  try {
90693
90823
  // Get parameters from context if available, otherwise use default
@@ -90760,7 +90890,8 @@ const Filters = ({
90760
90890
  // Merge with currently selected values to ensure they're always available
90761
90891
  // This is important when editing filters with search text - we don't want to lose
90762
90892
  // previously selected values that don't match the current search
90763
- const selectedValueItems = selectedFilterValues.map(key => ({
90893
+ const valuesToPreserve = preserveValues !== null ? preserveValues : selectedFilterValues;
90894
+ const selectedValueItems = valuesToPreserve.map(key => ({
90764
90895
  key: String(key),
90765
90896
  value: String(key)
90766
90897
  }));
@@ -90989,6 +91120,21 @@ const Filters = ({
90989
91120
  }
90990
91121
  };
90991
91122
 
91123
+ // Add a value typed in the "custom value" box directly into the selected
91124
+ // filter values, and inject it into availableFilterValues too, since
91125
+ // CheckboxMultiAutocomplete only renders a chip for keys it can resolve
91126
+ // against its items list.
91127
+ const handleAddCustomFilterValue = rawValue => {
91128
+ const value = rawValue.trim();
91129
+ if (!value) return;
91130
+ if (selectedFilterValues.includes(value)) return;
91131
+ setAvailableFilterValues(prev => prev.some(item => item.key === value) ? prev : [{
91132
+ key: value,
91133
+ value
91134
+ }, ...prev]);
91135
+ setSelectedFilterValues(prev => [...prev, value]);
91136
+ };
91137
+
90992
91138
  // Handler for search text input in filter values dropdown (debounced - triggers API call)
90993
91139
  const handleFilterSearchChange = searchText => {
90994
91140
  // Determine the fullPath based on current mode
@@ -91208,6 +91354,8 @@ const Filters = ({
91208
91354
  loading: loadingFilterValues,
91209
91355
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
91210
91356
  debounceMs: 1000
91357
+ }), /*#__PURE__*/gn.createElement(CustomFilterValueInput, {
91358
+ onAdd: handleAddCustomFilterValue
91211
91359
  })));
91212
91360
  })(), /*#__PURE__*/gn.createElement(Box, {
91213
91361
  sx: {
@@ -91282,7 +91430,12 @@ const Filters = ({
91282
91430
  mb: 3
91283
91431
  }
91284
91432
  }, /*#__PURE__*/gn.createElement(SingleSelect, {
91285
- items: existingDimensions.map(dim => ({
91433
+ items: Array.from(
91434
+ // Dimensions can now be added more than once (same fullPath,
91435
+ // different instance), but a filter targets the underlying
91436
+ // field regardless of how many columns display it - so this
91437
+ // list is deduped by fullPath to avoid showing it twice.
91438
+ new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
91286
91439
  key: dim.fullPath,
91287
91440
  value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
91288
91441
  })).sort((a, b) => a.value.localeCompare(b.value)),
@@ -91354,6 +91507,8 @@ const Filters = ({
91354
91507
  loading: loadingFilterValues,
91355
91508
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
91356
91509
  debounceMs: 1000
91510
+ }), /*#__PURE__*/gn.createElement(CustomFilterValueInput, {
91511
+ onAdd: handleAddCustomFilterValue
91357
91512
  })));
91358
91513
  })(), /*#__PURE__*/gn.createElement(Box, {
91359
91514
  sx: {
@@ -91643,6 +91798,8 @@ const Filters = ({
91643
91798
  loading: loadingFilterValues,
91644
91799
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
91645
91800
  debounceMs: 1000
91801
+ }), /*#__PURE__*/gn.createElement(CustomFilterValueInput, {
91802
+ onAdd: handleAddCustomFilterValue
91646
91803
  })), /*#__PURE__*/gn.createElement(Box, {
91647
91804
  sx: {
91648
91805
  display: 'flex',
@@ -91869,7 +92026,7 @@ const ColumnOrder = ({
91869
92026
  key: `${item.kind}-${item.key}`,
91870
92027
  id: index,
91871
92028
  label: labelFor(item),
91872
- fullPath: item.kind === "computed" ? item.data.value : item.key,
92029
+ fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
91873
92030
  kind: item.kind,
91874
92031
  source: item.kind === "metric" ? item.data.metric?.source : undefined,
91875
92032
  onMoveUp: () => handleMoveUp(index),
@@ -91895,11 +92052,17 @@ const formatValue = (value, def) => {
91895
92052
  const isNumericType = type => type === 'integer' || type === 'currency';
91896
92053
 
91897
92054
  // Builds a single DataGrid column definition for one resolved column item
91898
- // (a dimension, computed dimension, or metric). Field naming logic:
92055
+ // (a dimension, computed dimension, or metric). The DataGrid `field` is the
92056
+ // item's instance id (so two duplicate instances of the same underlying
92057
+ // dimension/metric still get distinct columns); the actual cell value is
92058
+ // read via `valueGetter` from `dataFieldName`, the key the API response
92059
+ // actually uses - shared by every duplicate instance, since the backend
92060
+ // only ever returns one value per fullPath. dataFieldName rules:
91899
92061
  // - Dimensions from base provider: baseAlias.field -> baseAlias_field
91900
92062
  // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
91901
92063
  // - Computed dimensions have no provider/relations chain, so the API returns
91902
- // each row keyed by the computed dimension's own `name`.
92064
+ // each row keyed by the computed dimension's own `name` (and can't be
92065
+ // duplicated - names must be unique - so field/dataFieldName coincide).
91903
92066
  // - Metrics: API returns metric keys in different forms depending on path depth
91904
92067
  // (see comment inline below), normalised to match the row-key normalisation
91905
92068
  // done when building `rows`.
@@ -91917,13 +92080,13 @@ const buildColumn = (item, titleOverrides, parameters) => {
91917
92080
  if (item.kind === 'metric') {
91918
92081
  const metric = item.data;
91919
92082
  const metricDef = metric.metric;
91920
- let fieldName;
92083
+ let dataFieldName;
91921
92084
  const dotCount = (metric.fullPath.match(/\./g) || []).length;
91922
92085
  if (dotCount >= 2) {
91923
92086
  // 3+-part namespaced path: API returns the full dotted path as the key.
91924
92087
  // Normalise dots to underscores to match the row key normalisation below.
91925
92088
  // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
91926
- fieldName = metric.fullPath.replace(/\./g, '_');
92089
+ dataFieldName = metric.fullPath.replace(/\./g, '_');
91927
92090
  } else if (metric.relations && metric.relations.length > 0) {
91928
92091
  // 2-part path from a nested provider: API drops the base provider alias.
91929
92092
  // Example: "mc_fa.total_amount" -> "fa_total_amount"
@@ -91934,19 +92097,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
91934
92097
  const pathParts = pathWithoutField.split('_');
91935
92098
  pathParts.shift(); // remove base provider alias
91936
92099
  const pathWithoutBase = pathParts.join('_');
91937
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
92100
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
91938
92101
  } else {
91939
92102
  // 2-part path from the base provider: API returns just the metric name.
91940
92103
  // Example: "mc.record_count" -> "record_count"
91941
- fieldName = metric.metricName;
92104
+ dataFieldName = metric.metricName;
91942
92105
  }
91943
- const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
92106
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
91944
92107
  return {
91945
- field: fieldName,
92108
+ // `field` is the instance id (unique even for duplicate columns
92109
+ // pointing at the same underlying metric); the actual value is
92110
+ // read from the row via valueGetter using dataFieldName, which is
92111
+ // the key the API response actually uses (shared across duplicates).
92112
+ field: metric.id.replace(/[.#]/g, '_'),
91946
92113
  headerName: headerName,
91947
92114
  flex: 1,
91948
92115
  minWidth: 150,
91949
92116
  type: isNumericType(metricDef?.type) ? 'number' : 'string',
92117
+ valueGetter: (_value, row) => row[dataFieldName],
91950
92118
  renderHeader: () => /*#__PURE__*/gn.createElement(Box, {
91951
92119
  sx: {
91952
92120
  display: 'flex',
@@ -91962,7 +92130,7 @@ const buildColumn = (item, titleOverrides, parameters) => {
91962
92130
 
91963
92131
  // Dimension
91964
92132
  const dim = item.data;
91965
- let fieldName;
92133
+ let dataFieldName;
91966
92134
  if (dim.relations && dim.relations.length > 0) {
91967
92135
  // From nested provider: drop the base provider alias
91968
92136
  // Example: ft_fa_db.currency -> fa_db_currency
@@ -91974,20 +92142,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
91974
92142
  pathParts.shift(); // Remove base provider alias
91975
92143
  const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
91976
92144
 
91977
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
92145
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
91978
92146
  } else {
91979
92147
  // From base provider: keep the full path with underscore
91980
92148
  // Example: ba.created_at -> ba_created_at
91981
- fieldName = dim.fullPath.replace('.', '_');
92149
+ dataFieldName = dim.fullPath.replace('.', '_');
91982
92150
  }
91983
- const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
92151
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
91984
92152
  const dimDef = dim.dimension;
91985
92153
  return {
91986
- field: fieldName,
92154
+ // Same rationale as the metric branch above: `field` must be unique
92155
+ // per instance, while the value always comes from the one real
92156
+ // underlying row key (dataFieldName), shared by every duplicate.
92157
+ field: dim.id.replace(/[.#]/g, '_'),
91987
92158
  headerName: headerName,
91988
92159
  flex: 1,
91989
92160
  minWidth: 150,
91990
92161
  type: isNumericType(dimDef?.type) ? 'number' : 'string',
92162
+ valueGetter: (_value, row) => row[dataFieldName],
91991
92163
  valueFormatter: value => formatValue(value, dimDef)
91992
92164
  };
91993
92165
  };
@@ -92313,6 +92485,72 @@ const ReportBuilder = ({
92313
92485
  }
92314
92486
  };
92315
92487
 
92488
+ // Reconstructs saved dimensions as independent, individually-identified
92489
+ // instances so that deliberate duplicates (the same field added more than
92490
+ // once) survive a save/reload round trip. Prefers `ordered_dimensions`
92491
+ // (one ref per instance - the nth duplicate of a path is suffixed
92492
+ // `#n`) when present; falls back to one instance per entry in the
92493
+ // (necessarily duplicate-free) `dimensions` path list for reports saved
92494
+ // before this field existed.
92495
+ const reconstructDimensions = (reportDef, providersData) => {
92496
+ const orderByMap = {};
92497
+ if (reportDef.definition?.doc?.query?.order_by) {
92498
+ for (const orderItem of reportDef.definition.doc.query.order_by) {
92499
+ orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
92500
+ }
92501
+ }
92502
+ const reconstructed = [];
92503
+ const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
92504
+ if (orderedRefs.length > 0) {
92505
+ orderedRefs.forEach(ref => {
92506
+ const fullPath = ref.split("#")[0];
92507
+ const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
92508
+ if (dim) {
92509
+ dim.id = ref;
92510
+ dim.sortOrder = orderByMap[fullPath] || null;
92511
+ reconstructed.push(dim);
92512
+ }
92513
+ });
92514
+ } else if (reportDef.definition?.doc?.query?.dimensions) {
92515
+ for (const dimPath of reportDef.definition.doc.query.dimensions) {
92516
+ const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
92517
+ if (dim) {
92518
+ dim.id = dimPath;
92519
+ dim.sortOrder = orderByMap[dimPath] || null;
92520
+ reconstructed.push(dim);
92521
+ }
92522
+ }
92523
+ }
92524
+ return reconstructed;
92525
+ };
92526
+
92527
+ // Mirrors reconstructDimensions for metrics, using the analogous
92528
+ // `ordered_metrics` field (metrics have no other unified-order concept to
92529
+ // fall back on, so this is always written whenever there are metrics).
92530
+ const reconstructMetrics = (reportDef, providersData) => {
92531
+ const reconstructed = [];
92532
+ const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
92533
+ if (orderedRefs.length > 0) {
92534
+ orderedRefs.forEach(ref => {
92535
+ const fullPath = ref.split("#")[0];
92536
+ const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
92537
+ if (metric) {
92538
+ metric.id = ref;
92539
+ reconstructed.push(metric);
92540
+ }
92541
+ });
92542
+ } else if (reportDef.definition?.doc?.query?.metrics) {
92543
+ for (const metricPath of reportDef.definition.doc.query.metrics) {
92544
+ const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
92545
+ if (metric) {
92546
+ metric.id = metricPath;
92547
+ reconstructed.push(metric);
92548
+ }
92549
+ }
92550
+ }
92551
+ return reconstructed;
92552
+ };
92553
+
92316
92554
  // Build the unified display/execution order for dimensions + computed
92317
92555
  // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
92318
92556
  // to "dimensions first, then computed dimensions" for report definitions
@@ -92323,7 +92561,7 @@ const ReportBuilder = ({
92323
92561
  const seenComputedKeys = new Set();
92324
92562
  if (Array.isArray(rawOrderedDimensions)) {
92325
92563
  rawOrderedDimensions.forEach(entry => {
92326
- if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
92564
+ if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
92327
92565
  dimensionOrder.push({
92328
92566
  type: "dimension",
92329
92567
  key: entry.ref
@@ -92341,10 +92579,10 @@ const ReportBuilder = ({
92341
92579
 
92342
92580
  // Append anything not covered by ordered_dimensions (older reports, or drift)
92343
92581
  dimensions.forEach(d => {
92344
- if (!seenDimensionKeys.has(d.fullPath)) {
92582
+ if (!seenDimensionKeys.has(d.id)) {
92345
92583
  dimensionOrder.push({
92346
92584
  type: "dimension",
92347
- key: d.fullPath
92585
+ key: d.id
92348
92586
  });
92349
92587
  }
92350
92588
  });
@@ -92367,12 +92605,12 @@ const ReportBuilder = ({
92367
92605
  // "fall back to the default order".
92368
92606
  const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
92369
92607
  if (!Array.isArray(rawOrderedColumns)) return [];
92370
- const dimensionPaths = new Set(dimensions.map(d => d.fullPath));
92608
+ const dimensionIds = new Set(dimensions.map(d => d.id));
92371
92609
  const computedNames = new Set(computedDimensions.map(cd => cd.name));
92372
- const metricPaths = new Set(metrics.map(m => m.fullPath));
92610
+ const metricIds = new Set(metrics.map(m => m.id));
92373
92611
  const columnOrder = [];
92374
92612
  rawOrderedColumns.forEach(entry => {
92375
- if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
92613
+ if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
92376
92614
  columnOrder.push({
92377
92615
  type: "dimension",
92378
92616
  key: entry.ref
@@ -92382,7 +92620,7 @@ const ReportBuilder = ({
92382
92620
  type: "computed",
92383
92621
  key: entry.ref
92384
92622
  });
92385
- } else if (entry.type === "metric" && metricPaths.has(entry.ref)) {
92623
+ } else if (entry.type === "metric" && metricIds.has(entry.ref)) {
92386
92624
  columnOrder.push({
92387
92625
  type: "metric",
92388
92626
  key: entry.ref
@@ -92412,43 +92650,10 @@ const ReportBuilder = ({
92412
92650
  // Set the title
92413
92651
  setReportTitle(reportDef.title || "");
92414
92652
 
92415
- // Reconstruct dimensions
92416
- const reconstructedDimensions = [];
92417
- const orderByMap = {}; // Map to store sort order from order_by array
92418
-
92419
- // First, build a map of fullPath -> sortOrder from order_by
92420
- if (reportDef.definition?.doc?.query?.order_by) {
92421
- for (const orderItem of reportDef.definition.doc.query.order_by) {
92422
- if (orderItem.desc === true) {
92423
- orderByMap[orderItem.name] = "desc";
92424
- } else {
92425
- orderByMap[orderItem.name] = "asc";
92426
- }
92427
- }
92428
- }
92429
-
92430
- // Now reconstruct dimensions and add sortOrder from the map
92431
- if (reportDef.definition?.doc?.query?.dimensions) {
92432
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
92433
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
92434
- if (dim) {
92435
- // Add sortOrder from order_by if it exists
92436
- dim.sortOrder = orderByMap[dimPath] || null;
92437
- reconstructedDimensions.push(dim);
92438
- }
92439
- }
92440
- }
92441
-
92442
- // Reconstruct metrics
92443
- const reconstructedMetrics = [];
92444
- if (reportDef.definition?.doc?.query?.metrics) {
92445
- for (const metricPath of reportDef.definition.doc.query.metrics) {
92446
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
92447
- if (metric) {
92448
- reconstructedMetrics.push(metric);
92449
- }
92450
- }
92451
- }
92653
+ // Reconstruct dimensions and metrics as independent instances
92654
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
92655
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
92656
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
92452
92657
 
92453
92658
  // Load title overrides if they exist
92454
92659
  const loadedTitleOverrides = {
@@ -92532,43 +92737,10 @@ const ReportBuilder = ({
92532
92737
  // Set the title (already modified with " (Copy)" suffix)
92533
92738
  setReportTitle(reportDef.title || "");
92534
92739
 
92535
- // Reconstruct dimensions
92536
- const reconstructedDimensions = [];
92537
- const orderByMap = {}; // Map to store sort order from order_by array
92538
-
92539
- // First, build a map of fullPath -> sortOrder from order_by
92540
- if (reportDef.definition?.doc?.query?.order_by) {
92541
- for (const orderItem of reportDef.definition.doc.query.order_by) {
92542
- if (orderItem.desc === true) {
92543
- orderByMap[orderItem.name] = "desc";
92544
- } else {
92545
- orderByMap[orderItem.name] = "asc";
92546
- }
92547
- }
92548
- }
92549
-
92550
- // Now reconstruct dimensions and add sortOrder from the map
92551
- if (reportDef.definition?.doc?.query?.dimensions) {
92552
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
92553
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
92554
- if (dim) {
92555
- // Add sortOrder from order_by if it exists
92556
- dim.sortOrder = orderByMap[dimPath] || null;
92557
- reconstructedDimensions.push(dim);
92558
- }
92559
- }
92560
- }
92561
-
92562
- // Reconstruct metrics
92563
- const reconstructedMetrics = [];
92564
- if (reportDef.definition?.doc?.query?.metrics) {
92565
- for (const metricPath of reportDef.definition.doc.query.metrics) {
92566
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
92567
- if (metric) {
92568
- reconstructedMetrics.push(metric);
92569
- }
92570
- }
92571
- }
92740
+ // Reconstruct dimensions and metrics as independent instances
92741
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
92742
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
92743
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
92572
92744
 
92573
92745
  // Load title overrides if they exist
92574
92746
  const loadedTitleOverrides = {
@@ -92667,42 +92839,45 @@ const ReportBuilder = ({
92667
92839
  });
92668
92840
  console.log("Selected root provider:", event.target.value);
92669
92841
  };
92670
- const handleUpdateDimensionTitle = (fullPath, customTitle) => {
92842
+
92843
+ // Keyed by instance id, so two instances of the same dimension can carry
92844
+ // independent custom titles.
92845
+ const handleUpdateDimensionTitle = (id, customTitle) => {
92671
92846
  setTitleOverrides(prev => ({
92672
92847
  ...prev,
92673
92848
  dimensions: {
92674
92849
  ...prev.dimensions,
92675
- [fullPath]: customTitle
92850
+ [id]: customTitle
92676
92851
  }
92677
92852
  }));
92678
92853
  };
92679
- const handleResetDimensionTitle = fullPath => {
92854
+ const handleResetDimensionTitle = id => {
92680
92855
  setTitleOverrides(prev => {
92681
92856
  const newDimensions = {
92682
92857
  ...prev.dimensions
92683
92858
  };
92684
- delete newDimensions[fullPath];
92859
+ delete newDimensions[id];
92685
92860
  return {
92686
92861
  ...prev,
92687
92862
  dimensions: newDimensions
92688
92863
  };
92689
92864
  });
92690
92865
  };
92691
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
92866
+ const handleUpdateMetricTitle = (id, customTitle) => {
92692
92867
  setTitleOverrides(prev => ({
92693
92868
  ...prev,
92694
92869
  metrics: {
92695
92870
  ...prev.metrics,
92696
- [fullPath]: customTitle
92871
+ [id]: customTitle
92697
92872
  }
92698
92873
  }));
92699
92874
  };
92700
- const handleResetMetricTitle = fullPath => {
92875
+ const handleResetMetricTitle = id => {
92701
92876
  setTitleOverrides(prev => {
92702
92877
  const newMetrics = {
92703
92878
  ...prev.metrics
92704
92879
  };
92705
- delete newMetrics[fullPath];
92880
+ delete newMetrics[id];
92706
92881
  return {
92707
92882
  ...prev,
92708
92883
  metrics: newMetrics
@@ -92737,11 +92912,11 @@ const ReportBuilder = ({
92737
92912
  dimensions: [...prev.dimensions, dimensionData],
92738
92913
  dimensionOrder: [...prev.dimensionOrder, {
92739
92914
  type: "dimension",
92740
- key: dimensionData.fullPath
92915
+ key: dimensionData.id
92741
92916
  }],
92742
92917
  columnOrder: appendToColumnOrder(prev.columnOrder, {
92743
92918
  type: "dimension",
92744
- key: dimensionData.fullPath
92919
+ key: dimensionData.id
92745
92920
  })
92746
92921
  };
92747
92922
  console.log("Dimension saved:", dimensionData);
@@ -92749,22 +92924,30 @@ const ReportBuilder = ({
92749
92924
  return newReport;
92750
92925
  });
92751
92926
  };
92752
- const handleRemoveDimension = fullPath => {
92927
+ const handleRemoveDimension = id => {
92753
92928
  setReport(prev => ({
92754
92929
  ...prev,
92755
- dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
92756
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath)),
92757
- columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
92930
+ dimensions: prev.dimensions.filter(d => d.id !== id),
92931
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
92932
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
92758
92933
  }));
92759
92934
  };
92760
- const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
92761
- setReport(prev => ({
92762
- ...prev,
92763
- dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
92764
- ...d,
92765
- sortOrder
92766
- } : d)
92767
- }));
92935
+
92936
+ // sortOrder is fundamentally a per-field (query-level) setting - SQL can
92937
+ // only sort by a given column once - so toggling it on any instance of a
92938
+ // duplicated dimension applies to every instance sharing that fullPath.
92939
+ const handleUpdateDimensionSortOrder = (id, sortOrder) => {
92940
+ setReport(prev => {
92941
+ const target = prev.dimensions.find(d => d.id === id);
92942
+ if (!target) return prev;
92943
+ return {
92944
+ ...prev,
92945
+ dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
92946
+ ...d,
92947
+ sortOrder
92948
+ } : d)
92949
+ };
92950
+ });
92768
92951
  };
92769
92952
 
92770
92953
  // Reorders the single unified dimensions + computed-dimensions list;
@@ -92819,7 +93002,7 @@ const ReportBuilder = ({
92819
93002
  metrics: [...prev.metrics, metricData],
92820
93003
  columnOrder: appendToColumnOrder(prev.columnOrder, {
92821
93004
  type: "metric",
92822
- key: metricData.fullPath
93005
+ key: metricData.id
92823
93006
  })
92824
93007
  };
92825
93008
  console.log("Metric saved:", metricData);
@@ -92827,15 +93010,12 @@ const ReportBuilder = ({
92827
93010
  return newReport;
92828
93011
  });
92829
93012
  };
92830
- const handleRemoveMetric = index => {
92831
- setReport(prev => {
92832
- const fullPath = prev.metrics[index]?.fullPath;
92833
- return {
92834
- ...prev,
92835
- metrics: prev.metrics.filter((_, i) => i !== index),
92836
- columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
92837
- };
92838
- });
93013
+ const handleRemoveMetric = id => {
93014
+ setReport(prev => ({
93015
+ ...prev,
93016
+ metrics: prev.metrics.filter(m => m.id !== id),
93017
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
93018
+ }));
92839
93019
  };
92840
93020
  const handleReorderMetrics = newOrder => {
92841
93021
  setReport(prev => ({
@@ -92890,13 +93070,30 @@ const ReportBuilder = ({
92890
93070
  // persisted `ordered_dimensions` field all stay consistent with whatever
92891
93071
  // order the user arranged on screen.
92892
93072
  const buildQueryObject = () => {
92893
- const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
93073
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
92894
93074
  const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
92895
- const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionByPath[entry.key]).filter(Boolean);
93075
+
93076
+ // May contain more than one instance of the same fullPath (deliberate
93077
+ // duplicates), kept in dimensionOrder's display order.
93078
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
92896
93079
  const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
92897
93080
 
93081
+ // The API keys each result row by fullPath, so duplicate instances of
93082
+ // the same field can only ever carry one value/one sort direction at
93083
+ // the query level - dedupe by fullPath (keeping first-occurrence order)
93084
+ // before sending. The builder still shows/orders/titles each instance
93085
+ // independently; they just read from the same underlying query column.
93086
+ const uniqueDimensionsByPath = [];
93087
+ const seenDimensionPaths = new Set();
93088
+ orderedDimensionsOnly.forEach(dim => {
93089
+ if (!seenDimensionPaths.has(dim.fullPath)) {
93090
+ seenDimensionPaths.add(dim.fullPath);
93091
+ uniqueDimensionsByPath.push(dim);
93092
+ }
93093
+ });
93094
+
92898
93095
  // Build order_by array - include all dimensions, add desc property only when needed
92899
- const orderBy = orderedDimensionsOnly.map(dim => {
93096
+ const orderBy = uniqueDimensionsByPath.map(dim => {
92900
93097
  const orderItem = {
92901
93098
  name: dim.fullPath
92902
93099
  };
@@ -92951,9 +93148,20 @@ const ReportBuilder = ({
92951
93148
  if (Object.keys(titleOverrides.filters).length > 0) {
92952
93149
  titles.filters = titleOverrides.filters;
92953
93150
  }
93151
+
93152
+ // Same dedupe rationale as dimensions above: the query only needs each
93153
+ // metric fullPath once, however many display instances reference it.
93154
+ const uniqueMetricPaths = [];
93155
+ const seenMetricPaths = new Set();
93156
+ report.metrics.forEach(metric => {
93157
+ if (!seenMetricPaths.has(metric.fullPath)) {
93158
+ seenMetricPaths.add(metric.fullPath);
93159
+ uniqueMetricPaths.push(metric.fullPath);
93160
+ }
93161
+ });
92954
93162
  const queryObj = {
92955
- dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
92956
- metrics: report.metrics.map(metric => metric.fullPath),
93163
+ dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
93164
+ metrics: uniqueMetricPaths,
92957
93165
  order_by: orderBy
92958
93166
  };
92959
93167
 
@@ -92967,7 +93175,9 @@ const ReportBuilder = ({
92967
93175
 
92968
93176
  // Persist the unified dimension + computed-dimension order so it can be
92969
93177
  // reconstructed on reload and so executed reports render columns in the
92970
- // same order they were arranged in the builder.
93178
+ // same order they were arranged in the builder. Entries use each
93179
+ // dimension's instance id as `ref`, so deliberate duplicates (the nth
93180
+ // occurrence of a fullPath is suffixed `#n`) round-trip correctly.
92971
93181
  if (report.dimensionOrder.length > 0) {
92972
93182
  queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
92973
93183
  type: entry.type,
@@ -92975,14 +93185,30 @@ const ReportBuilder = ({
92975
93185
  }));
92976
93186
  }
92977
93187
 
92978
- // Persist the optional unified column order (Column Order tab) so the
92979
- // results grid can reproduce it on reload. Absent when no custom order
92980
- // has been defined, so old/untouched reports keep their current
92981
- // dimensions-then-metrics column order unchanged.
92982
- if (report.columnOrder.length > 0) {
92983
- queryObj.ordered_columns = report.columnOrder.map(entry => ({
92984
- type: entry.type,
92985
- ref: entry.key
93188
+ // Persist metric instance order/identity the same way (metrics have no
93189
+ // other unified-order field to piggyback on).
93190
+ if (report.metrics.length > 0) {
93191
+ queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
93192
+ }
93193
+
93194
+ // Persist the full resolved column order - the explicit Column Order tab
93195
+ // arrangement when defined, otherwise dimensions/computed (in their own
93196
+ // order) followed by metrics (in their own order), same as what the
93197
+ // results grid renders. Always emitted (not just when a custom order
93198
+ // exists) so the API/results grid get an explicit order even for
93199
+ // untouched reports, rather than having to fall back to guessing it.
93200
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
93201
+ kind: "dimension",
93202
+ data: dimensionById[entry.key]
93203
+ } : {
93204
+ kind: "computed",
93205
+ data: computedByName[entry.key]
93206
+ }).filter(item => item.data);
93207
+ const resolvedColumnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
93208
+ if (resolvedColumnItems.length > 0) {
93209
+ queryObj.ordered_columns = resolvedColumnItems.map(item => ({
93210
+ type: item.kind,
93211
+ ref: item.key
92986
93212
  }));
92987
93213
  }
92988
93214
 
@@ -93144,11 +93370,11 @@ const ReportBuilder = ({
93144
93370
 
93145
93371
  // Resolve the unified dimensionOrder into actual dimension/computed-dimension
93146
93372
  // objects so the results grid can render columns in that same order.
93147
- const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
93373
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
93148
93374
  const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
93149
93375
  const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
93150
93376
  kind: "dimension",
93151
- data: dimensionByFullPath[entry.key]
93377
+ data: dimensionById[entry.key]
93152
93378
  } : {
93153
93379
  kind: "computed",
93154
93380
  data: computedDimensionByName[entry.key]