@bygd/nc-report-ui 0.1.42 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -77845,7 +77889,7 @@ const SortableComputedChip = ({
77845
77889
  onClick: handleSave,
77846
77890
  color: "primary",
77847
77891
  "aria-label": "save computed dimension",
77848
- disabled: !editName.trim() || !editValue.trim() || nameError
77892
+ disabled: !editName.trim() || nameError
77849
77893
  }, /*#__PURE__*/gn.createElement(CheckIcon, {
77850
77894
  fontSize: "small"
77851
77895
  })))), /*#__PURE__*/gn.createElement(Tooltip, {
@@ -77894,12 +77938,14 @@ const Dimensions = ({
77894
77938
  // Resolve the unified dimensionOrder into the actual dimension / computed
77895
77939
  // dimension objects, so dimensions and computed dimensions can be shown
77896
77940
  // (and dragged) as a single list while staying stored in their own arrays.
77897
- const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
77941
+ // Keyed by instance id rather than fullPath, so two instances of the same
77942
+ // dimension (added deliberately as a duplicate) stay distinct entries.
77943
+ const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
77898
77944
  const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
77899
77945
  const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
77900
77946
  kind: "dimension",
77901
77947
  key: entry.key,
77902
- data: dimensionByFullPath[entry.key]
77948
+ data: dimensionById[entry.key]
77903
77949
  } : {
77904
77950
  kind: "computed",
77905
77951
  key: entry.key,
@@ -77913,9 +77959,13 @@ const Dimensions = ({
77913
77959
  handleMoveDown
77914
77960
  } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
77915
77961
 
77916
- // Handle sort order change (dimensions only - computed dimensions have no order_by)
77917
- const handleSortOrderChange = (fullPath, sortOrder) => {
77918
- onUpdateDimensionSortOrder(fullPath, sortOrder);
77962
+ // Handle sort order change (dimensions only - computed dimensions have no
77963
+ // order_by). Keyed by instance id but applied query-wide: SQL can only
77964
+ // sort by a given column once, so toggling sort on any duplicate instance
77965
+ // of the same field is propagated to all instances sharing its fullPath
77966
+ // (handled in ReportBuilder's onUpdateDimensionSortOrder).
77967
+ const handleSortOrderChange = (id, sortOrder) => {
77968
+ onUpdateDimensionSortOrder(id, sortOrder);
77919
77969
  };
77920
77970
 
77921
77971
  // Computed Dimensions: { name, value } pairs independent of any provider.
@@ -77936,7 +77986,7 @@ const Dimensions = ({
77936
77986
  const handleSaveComputed = () => {
77937
77987
  const trimmedName = computedName.trim();
77938
77988
  const trimmedValue = computedValue.trim();
77939
- if (!trimmedName || !trimmedValue) return;
77989
+ if (!trimmedName) return;
77940
77990
  if (isComputedNameTaken(trimmedName)) return;
77941
77991
  onSaveComputedDimension({
77942
77992
  name: trimmedName,
@@ -77978,7 +78028,10 @@ const Dimensions = ({
77978
78028
  currentProviderKey = selection.targetKey;
77979
78029
  });
77980
78030
 
77981
- // Create a Set of already selected dimension fullPaths for quick lookup
78031
+ // Create a Set of already selected dimension fullPaths for quick lookup.
78032
+ // Already-selected items stay selectable (so the same dimension can be
78033
+ // added again on purpose) - they're just flagged so the dropdown can
78034
+ // highlight them instead of blocking the click.
77982
78035
  const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
77983
78036
  const items = [];
77984
78037
  // Iterate through aliases (e.g., 'ba', 'pc')
@@ -77990,9 +78043,6 @@ const Dimensions = ({
77990
78043
 
77991
78044
  // Construct the fullPath for this dimension
77992
78045
  const fullPath = `${aliasPath.join("_")}.${dimKey}`;
77993
-
77994
- // Check if this dimension is already selected
77995
- const isAlreadySelected = selectedFullPaths.has(fullPath);
77996
78046
  items.push({
77997
78047
  // Include provider name to ensure uniqueness across different providers
77998
78048
  key: `${currentProvider}_${alias}.${dimKey}`,
@@ -78000,7 +78050,7 @@ const Dimensions = ({
78000
78050
  dimensionKey: dimKey,
78001
78051
  alias: alias,
78002
78052
  dimension: dimension,
78003
- disabled: isAlreadySelected
78053
+ alreadySelected: selectedFullPaths.has(fullPath)
78004
78054
  });
78005
78055
  });
78006
78056
  });
@@ -78062,7 +78112,10 @@ const Dimensions = ({
78062
78112
  dimensionKey: selectedItem.dimensionKey,
78063
78113
  dimensionTitle: selectedItem.value,
78064
78114
  // The constructed path for server
78065
- fullPath: fullPath
78115
+ fullPath: fullPath,
78116
+ // Unique per-instance identity, so this field can be added more than
78117
+ // once and each occurrence stays independently orderable/removable.
78118
+ id: nextInstanceId(fullPath, savedDimensions)
78066
78119
  };
78067
78120
  onSaveDimension(dimensionData);
78068
78121
  handleCancel();
@@ -78073,8 +78126,9 @@ const Dimensions = ({
78073
78126
  const handleAddAll = () => {
78074
78127
  const dimensionItems = getDimensionItems();
78075
78128
 
78076
- // Filter out already selected dimensions (disabled items)
78077
- const availableItems = dimensionItems.filter(item => !item.disabled);
78129
+ // Filter out already selected dimensions - "Add all" only fills in
78130
+ // missing ones; intentional duplicates are added one at a time above.
78131
+ const availableItems = dimensionItems.filter(item => !item.alreadySelected);
78078
78132
  if (availableItems.length === 0) return;
78079
78133
 
78080
78134
  // Build the complete relation objects array (same for all dimensions in this provider)
@@ -78119,7 +78173,10 @@ const Dimensions = ({
78119
78173
  dimensionKey: item.dimensionKey,
78120
78174
  dimensionTitle: item.value,
78121
78175
  // The constructed path for server
78122
- fullPath: fullPath
78176
+ fullPath: fullPath,
78177
+ // "Add all" only ever adds fields that aren't already selected, so
78178
+ // this is always the first (and only) occurrence of this fullPath.
78179
+ id: fullPath
78123
78180
  };
78124
78181
  onSaveDimension(dimensionData);
78125
78182
  });
@@ -78253,7 +78310,7 @@ const Dimensions = ({
78253
78310
  }, "Cancel"), /*#__PURE__*/gn.createElement(Button, {
78254
78311
  variant: "contained",
78255
78312
  onClick: handleSaveComputed,
78256
- disabled: !computedName.trim() || !computedValue.trim() || computedNameError,
78313
+ disabled: !computedName.trim() || computedNameError,
78257
78314
  sx: {
78258
78315
  height: "40px",
78259
78316
  fontFamily: "system-ui",
@@ -78329,7 +78386,7 @@ const Dimensions = ({
78329
78386
  placement: "top"
78330
78387
  }, /*#__PURE__*/gn.createElement("span", null, /*#__PURE__*/gn.createElement(IconButton, {
78331
78388
  onClick: handleAddAll,
78332
- disabled: getDimensionItems().filter(item => !item.disabled).length === 0,
78389
+ disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
78333
78390
  "aria-label": "add all dimensions",
78334
78391
  sx: {
78335
78392
  color: "rgb(70, 134, 128)",
@@ -78420,9 +78477,9 @@ const Dimensions = ({
78420
78477
  isLast: index === orderedItems.length - 1,
78421
78478
  sortOrder: item.data.sortOrder || null,
78422
78479
  onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
78423
- fullPath: item.data.fullPath,
78480
+ instanceId: item.data.id,
78424
78481
  defaultTitle: item.data.dimensionTitle,
78425
- customTitle: titleOverrides[item.data.fullPath],
78482
+ customTitle: titleOverrides[item.data.id],
78426
78483
  onUpdateTitle: onUpdateTitle,
78427
78484
  onResetTitle: onResetTitle
78428
78485
  }) : /*#__PURE__*/gn.createElement(SortableComputedChip, {
@@ -78507,7 +78564,7 @@ const SortableChip = ({
78507
78564
  onMoveDown,
78508
78565
  isFirst,
78509
78566
  isLast,
78510
- fullPath,
78567
+ instanceId,
78511
78568
  defaultTitle,
78512
78569
  customTitle,
78513
78570
  onUpdateTitle,
@@ -78560,7 +78617,7 @@ const SortableChip = ({
78560
78617
  handleCancel();
78561
78618
  return;
78562
78619
  }
78563
- onUpdateTitle(fullPath, trimmedValue);
78620
+ onUpdateTitle(instanceId, trimmedValue);
78564
78621
  setIsEditing(false);
78565
78622
  };
78566
78623
  const handleCancel = () => {
@@ -78568,7 +78625,7 @@ const SortableChip = ({
78568
78625
  setEditValue('');
78569
78626
  };
78570
78627
  const handleReset = () => {
78571
- onResetTitle(fullPath);
78628
+ onResetTitle(instanceId);
78572
78629
  setIsEditing(false);
78573
78630
  setEditValue('');
78574
78631
  };
@@ -78789,7 +78846,10 @@ const Metrics = ({
78789
78846
  currentProviderKey = selection.targetKey;
78790
78847
  });
78791
78848
 
78792
- // Create a Set of already selected metric fullPaths for quick lookup
78849
+ // Create a Set of already selected metric fullPaths for quick lookup.
78850
+ // Already-selected items stay selectable (so the same metric can be
78851
+ // added again on purpose) - they're just flagged so the dropdown can
78852
+ // highlight them instead of blocking the click.
78793
78853
  const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
78794
78854
 
78795
78855
  // Metrics are a direct array, not nested by alias
@@ -78797,15 +78857,12 @@ const Metrics = ({
78797
78857
  const items = provider.metrics.map((metric, index) => {
78798
78858
  // Construct the fullPath for this metric
78799
78859
  const fullPath = `${aliasPath.join('_')}.${metric.name}`;
78800
-
78801
- // Check if this metric is already selected
78802
- const isAlreadySelected = selectedFullPaths.has(fullPath);
78803
78860
  return {
78804
78861
  key: `${currentProvider}_${metric.name}_${index}`,
78805
78862
  value: metric.title || metric.name,
78806
78863
  metricName: metric.name,
78807
78864
  metric: metric,
78808
- disabled: isAlreadySelected,
78865
+ alreadySelected: selectedFullPaths.has(fullPath),
78809
78866
  icon: metric.source ? /*#__PURE__*/gn.createElement(MetricSourceIcon, {
78810
78867
  source: metric.source
78811
78868
  }) : undefined
@@ -78868,7 +78925,11 @@ const Metrics = ({
78868
78925
  metricName: selectedItem.metricName,
78869
78926
  metricTitle: selectedItem.value,
78870
78927
  // The constructed path for server
78871
- fullPath: fullPath
78928
+ fullPath: fullPath,
78929
+ // Unique per-instance identity, so this field can be added more
78930
+ // than once and each occurrence stays independently
78931
+ // orderable/removable/titleable.
78932
+ id: nextInstanceId(fullPath, savedMetrics)
78872
78933
  };
78873
78934
  onSaveMetric(metricData);
78874
78935
  handleCancel();
@@ -78879,8 +78940,9 @@ const Metrics = ({
78879
78940
  const handleAddAll = () => {
78880
78941
  const metricItems = getMetricItems();
78881
78942
 
78882
- // Filter out already selected metrics (disabled items)
78883
- const availableItems = metricItems.filter(item => !item.disabled);
78943
+ // Filter out already selected metrics - "Add all" only fills in
78944
+ // missing ones; intentional duplicates are added one at a time above.
78945
+ const availableItems = metricItems.filter(item => !item.alreadySelected);
78884
78946
  if (availableItems.length === 0) return;
78885
78947
 
78886
78948
  // Build the complete relation objects array (same for all metrics in this provider)
@@ -78925,7 +78987,10 @@ const Metrics = ({
78925
78987
  metricName: item.metricName,
78926
78988
  metricTitle: item.value,
78927
78989
  // The constructed path for server
78928
- fullPath: fullPath
78990
+ fullPath: fullPath,
78991
+ // "Add all" only ever adds fields that aren't already
78992
+ // selected, so this is always the first occurrence.
78993
+ id: fullPath
78929
78994
  };
78930
78995
  onSaveMetric(metricData);
78931
78996
  });
@@ -79024,7 +79089,7 @@ const Metrics = ({
79024
79089
  placement: "top"
79025
79090
  }, /*#__PURE__*/gn.createElement("span", null, /*#__PURE__*/gn.createElement(IconButton, {
79026
79091
  onClick: handleAddAll,
79027
- disabled: getMetricItems().filter(item => !item.disabled).length === 0,
79092
+ disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
79028
79093
  "aria-label": "add all metrics",
79029
79094
  sx: {
79030
79095
  color: "rgb(70, 134, 128)",
@@ -79102,14 +79167,14 @@ const Metrics = ({
79102
79167
  id: index,
79103
79168
  label: metric.metricTitle,
79104
79169
  fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
79105
- onDelete: () => onRemoveMetric(index),
79170
+ onDelete: () => onRemoveMetric(metric.id),
79106
79171
  onMoveUp: () => handleMoveUp(index),
79107
79172
  onMoveDown: () => handleMoveDown(index),
79108
79173
  isFirst: index === 0,
79109
79174
  isLast: index === savedMetrics.length - 1,
79110
- fullPath: metric.fullPath,
79175
+ instanceId: metric.id,
79111
79176
  defaultTitle: metric.metricTitle,
79112
- customTitle: titleOverrides[metric.fullPath],
79177
+ customTitle: titleOverrides[metric.id],
79113
79178
  onUpdateTitle: onUpdateTitle,
79114
79179
  onResetTitle: onResetTitle,
79115
79180
  source: metric.metric?.source
@@ -91282,7 +91347,12 @@ const Filters = ({
91282
91347
  mb: 3
91283
91348
  }
91284
91349
  }, /*#__PURE__*/gn.createElement(SingleSelect, {
91285
- items: existingDimensions.map(dim => ({
91350
+ items: Array.from(
91351
+ // Dimensions can now be added more than once (same fullPath,
91352
+ // different instance), but a filter targets the underlying
91353
+ // field regardless of how many columns display it - so this
91354
+ // list is deduped by fullPath to avoid showing it twice.
91355
+ new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
91286
91356
  key: dim.fullPath,
91287
91357
  value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
91288
91358
  })).sort((a, b) => a.value.localeCompare(b.value)),
@@ -91869,7 +91939,7 @@ const ColumnOrder = ({
91869
91939
  key: `${item.kind}-${item.key}`,
91870
91940
  id: index,
91871
91941
  label: labelFor(item),
91872
- fullPath: item.kind === "computed" ? item.data.value : item.key,
91942
+ fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
91873
91943
  kind: item.kind,
91874
91944
  source: item.kind === "metric" ? item.data.metric?.source : undefined,
91875
91945
  onMoveUp: () => handleMoveUp(index),
@@ -91895,11 +91965,17 @@ const formatValue = (value, def) => {
91895
91965
  const isNumericType = type => type === 'integer' || type === 'currency';
91896
91966
 
91897
91967
  // Builds a single DataGrid column definition for one resolved column item
91898
- // (a dimension, computed dimension, or metric). Field naming logic:
91968
+ // (a dimension, computed dimension, or metric). The DataGrid `field` is the
91969
+ // item's instance id (so two duplicate instances of the same underlying
91970
+ // dimension/metric still get distinct columns); the actual cell value is
91971
+ // read via `valueGetter` from `dataFieldName`, the key the API response
91972
+ // actually uses - shared by every duplicate instance, since the backend
91973
+ // only ever returns one value per fullPath. dataFieldName rules:
91899
91974
  // - Dimensions from base provider: baseAlias.field -> baseAlias_field
91900
91975
  // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
91901
91976
  // - Computed dimensions have no provider/relations chain, so the API returns
91902
- // each row keyed by the computed dimension's own `name`.
91977
+ // each row keyed by the computed dimension's own `name` (and can't be
91978
+ // duplicated - names must be unique - so field/dataFieldName coincide).
91903
91979
  // - Metrics: API returns metric keys in different forms depending on path depth
91904
91980
  // (see comment inline below), normalised to match the row-key normalisation
91905
91981
  // done when building `rows`.
@@ -91917,13 +91993,13 @@ const buildColumn = (item, titleOverrides, parameters) => {
91917
91993
  if (item.kind === 'metric') {
91918
91994
  const metric = item.data;
91919
91995
  const metricDef = metric.metric;
91920
- let fieldName;
91996
+ let dataFieldName;
91921
91997
  const dotCount = (metric.fullPath.match(/\./g) || []).length;
91922
91998
  if (dotCount >= 2) {
91923
91999
  // 3+-part namespaced path: API returns the full dotted path as the key.
91924
92000
  // Normalise dots to underscores to match the row key normalisation below.
91925
92001
  // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
91926
- fieldName = metric.fullPath.replace(/\./g, '_');
92002
+ dataFieldName = metric.fullPath.replace(/\./g, '_');
91927
92003
  } else if (metric.relations && metric.relations.length > 0) {
91928
92004
  // 2-part path from a nested provider: API drops the base provider alias.
91929
92005
  // Example: "mc_fa.total_amount" -> "fa_total_amount"
@@ -91934,19 +92010,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
91934
92010
  const pathParts = pathWithoutField.split('_');
91935
92011
  pathParts.shift(); // remove base provider alias
91936
92012
  const pathWithoutBase = pathParts.join('_');
91937
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
92013
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
91938
92014
  } else {
91939
92015
  // 2-part path from the base provider: API returns just the metric name.
91940
92016
  // Example: "mc.record_count" -> "record_count"
91941
- fieldName = metric.metricName;
92017
+ dataFieldName = metric.metricName;
91942
92018
  }
91943
- const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
92019
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
91944
92020
  return {
91945
- field: fieldName,
92021
+ // `field` is the instance id (unique even for duplicate columns
92022
+ // pointing at the same underlying metric); the actual value is
92023
+ // read from the row via valueGetter using dataFieldName, which is
92024
+ // the key the API response actually uses (shared across duplicates).
92025
+ field: metric.id.replace(/[.#]/g, '_'),
91946
92026
  headerName: headerName,
91947
92027
  flex: 1,
91948
92028
  minWidth: 150,
91949
92029
  type: isNumericType(metricDef?.type) ? 'number' : 'string',
92030
+ valueGetter: (_value, row) => row[dataFieldName],
91950
92031
  renderHeader: () => /*#__PURE__*/gn.createElement(Box, {
91951
92032
  sx: {
91952
92033
  display: 'flex',
@@ -91962,7 +92043,7 @@ const buildColumn = (item, titleOverrides, parameters) => {
91962
92043
 
91963
92044
  // Dimension
91964
92045
  const dim = item.data;
91965
- let fieldName;
92046
+ let dataFieldName;
91966
92047
  if (dim.relations && dim.relations.length > 0) {
91967
92048
  // From nested provider: drop the base provider alias
91968
92049
  // Example: ft_fa_db.currency -> fa_db_currency
@@ -91974,20 +92055,24 @@ const buildColumn = (item, titleOverrides, parameters) => {
91974
92055
  pathParts.shift(); // Remove base provider alias
91975
92056
  const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
91976
92057
 
91977
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
92058
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
91978
92059
  } else {
91979
92060
  // From base provider: keep the full path with underscore
91980
92061
  // Example: ba.created_at -> ba_created_at
91981
- fieldName = dim.fullPath.replace('.', '_');
92062
+ dataFieldName = dim.fullPath.replace('.', '_');
91982
92063
  }
91983
- const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
92064
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
91984
92065
  const dimDef = dim.dimension;
91985
92066
  return {
91986
- field: fieldName,
92067
+ // Same rationale as the metric branch above: `field` must be unique
92068
+ // per instance, while the value always comes from the one real
92069
+ // underlying row key (dataFieldName), shared by every duplicate.
92070
+ field: dim.id.replace(/[.#]/g, '_'),
91987
92071
  headerName: headerName,
91988
92072
  flex: 1,
91989
92073
  minWidth: 150,
91990
92074
  type: isNumericType(dimDef?.type) ? 'number' : 'string',
92075
+ valueGetter: (_value, row) => row[dataFieldName],
91991
92076
  valueFormatter: value => formatValue(value, dimDef)
91992
92077
  };
91993
92078
  };
@@ -92313,6 +92398,72 @@ const ReportBuilder = ({
92313
92398
  }
92314
92399
  };
92315
92400
 
92401
+ // Reconstructs saved dimensions as independent, individually-identified
92402
+ // instances so that deliberate duplicates (the same field added more than
92403
+ // once) survive a save/reload round trip. Prefers `ordered_dimensions`
92404
+ // (one ref per instance - the nth duplicate of a path is suffixed
92405
+ // `#n`) when present; falls back to one instance per entry in the
92406
+ // (necessarily duplicate-free) `dimensions` path list for reports saved
92407
+ // before this field existed.
92408
+ const reconstructDimensions = (reportDef, providersData) => {
92409
+ const orderByMap = {};
92410
+ if (reportDef.definition?.doc?.query?.order_by) {
92411
+ for (const orderItem of reportDef.definition.doc.query.order_by) {
92412
+ orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
92413
+ }
92414
+ }
92415
+ const reconstructed = [];
92416
+ const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
92417
+ if (orderedRefs.length > 0) {
92418
+ orderedRefs.forEach(ref => {
92419
+ const fullPath = ref.split("#")[0];
92420
+ const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
92421
+ if (dim) {
92422
+ dim.id = ref;
92423
+ dim.sortOrder = orderByMap[fullPath] || null;
92424
+ reconstructed.push(dim);
92425
+ }
92426
+ });
92427
+ } else if (reportDef.definition?.doc?.query?.dimensions) {
92428
+ for (const dimPath of reportDef.definition.doc.query.dimensions) {
92429
+ const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
92430
+ if (dim) {
92431
+ dim.id = dimPath;
92432
+ dim.sortOrder = orderByMap[dimPath] || null;
92433
+ reconstructed.push(dim);
92434
+ }
92435
+ }
92436
+ }
92437
+ return reconstructed;
92438
+ };
92439
+
92440
+ // Mirrors reconstructDimensions for metrics, using the analogous
92441
+ // `ordered_metrics` field (metrics have no other unified-order concept to
92442
+ // fall back on, so this is always written whenever there are metrics).
92443
+ const reconstructMetrics = (reportDef, providersData) => {
92444
+ const reconstructed = [];
92445
+ const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
92446
+ if (orderedRefs.length > 0) {
92447
+ orderedRefs.forEach(ref => {
92448
+ const fullPath = ref.split("#")[0];
92449
+ const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
92450
+ if (metric) {
92451
+ metric.id = ref;
92452
+ reconstructed.push(metric);
92453
+ }
92454
+ });
92455
+ } else if (reportDef.definition?.doc?.query?.metrics) {
92456
+ for (const metricPath of reportDef.definition.doc.query.metrics) {
92457
+ const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
92458
+ if (metric) {
92459
+ metric.id = metricPath;
92460
+ reconstructed.push(metric);
92461
+ }
92462
+ }
92463
+ }
92464
+ return reconstructed;
92465
+ };
92466
+
92316
92467
  // Build the unified display/execution order for dimensions + computed
92317
92468
  // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
92318
92469
  // to "dimensions first, then computed dimensions" for report definitions
@@ -92323,7 +92474,7 @@ const ReportBuilder = ({
92323
92474
  const seenComputedKeys = new Set();
92324
92475
  if (Array.isArray(rawOrderedDimensions)) {
92325
92476
  rawOrderedDimensions.forEach(entry => {
92326
- if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
92477
+ if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
92327
92478
  dimensionOrder.push({
92328
92479
  type: "dimension",
92329
92480
  key: entry.ref
@@ -92341,10 +92492,10 @@ const ReportBuilder = ({
92341
92492
 
92342
92493
  // Append anything not covered by ordered_dimensions (older reports, or drift)
92343
92494
  dimensions.forEach(d => {
92344
- if (!seenDimensionKeys.has(d.fullPath)) {
92495
+ if (!seenDimensionKeys.has(d.id)) {
92345
92496
  dimensionOrder.push({
92346
92497
  type: "dimension",
92347
- key: d.fullPath
92498
+ key: d.id
92348
92499
  });
92349
92500
  }
92350
92501
  });
@@ -92367,12 +92518,12 @@ const ReportBuilder = ({
92367
92518
  // "fall back to the default order".
92368
92519
  const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
92369
92520
  if (!Array.isArray(rawOrderedColumns)) return [];
92370
- const dimensionPaths = new Set(dimensions.map(d => d.fullPath));
92521
+ const dimensionIds = new Set(dimensions.map(d => d.id));
92371
92522
  const computedNames = new Set(computedDimensions.map(cd => cd.name));
92372
- const metricPaths = new Set(metrics.map(m => m.fullPath));
92523
+ const metricIds = new Set(metrics.map(m => m.id));
92373
92524
  const columnOrder = [];
92374
92525
  rawOrderedColumns.forEach(entry => {
92375
- if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
92526
+ if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
92376
92527
  columnOrder.push({
92377
92528
  type: "dimension",
92378
92529
  key: entry.ref
@@ -92382,7 +92533,7 @@ const ReportBuilder = ({
92382
92533
  type: "computed",
92383
92534
  key: entry.ref
92384
92535
  });
92385
- } else if (entry.type === "metric" && metricPaths.has(entry.ref)) {
92536
+ } else if (entry.type === "metric" && metricIds.has(entry.ref)) {
92386
92537
  columnOrder.push({
92387
92538
  type: "metric",
92388
92539
  key: entry.ref
@@ -92412,43 +92563,10 @@ const ReportBuilder = ({
92412
92563
  // Set the title
92413
92564
  setReportTitle(reportDef.title || "");
92414
92565
 
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
- }
92566
+ // Reconstruct dimensions and metrics as independent instances
92567
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
92568
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
92569
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
92452
92570
 
92453
92571
  // Load title overrides if they exist
92454
92572
  const loadedTitleOverrides = {
@@ -92532,43 +92650,10 @@ const ReportBuilder = ({
92532
92650
  // Set the title (already modified with " (Copy)" suffix)
92533
92651
  setReportTitle(reportDef.title || "");
92534
92652
 
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
- }
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);
92572
92657
 
92573
92658
  // Load title overrides if they exist
92574
92659
  const loadedTitleOverrides = {
@@ -92667,42 +92752,45 @@ const ReportBuilder = ({
92667
92752
  });
92668
92753
  console.log("Selected root provider:", event.target.value);
92669
92754
  };
92670
- const handleUpdateDimensionTitle = (fullPath, customTitle) => {
92755
+
92756
+ // Keyed by instance id, so two instances of the same dimension can carry
92757
+ // independent custom titles.
92758
+ const handleUpdateDimensionTitle = (id, customTitle) => {
92671
92759
  setTitleOverrides(prev => ({
92672
92760
  ...prev,
92673
92761
  dimensions: {
92674
92762
  ...prev.dimensions,
92675
- [fullPath]: customTitle
92763
+ [id]: customTitle
92676
92764
  }
92677
92765
  }));
92678
92766
  };
92679
- const handleResetDimensionTitle = fullPath => {
92767
+ const handleResetDimensionTitle = id => {
92680
92768
  setTitleOverrides(prev => {
92681
92769
  const newDimensions = {
92682
92770
  ...prev.dimensions
92683
92771
  };
92684
- delete newDimensions[fullPath];
92772
+ delete newDimensions[id];
92685
92773
  return {
92686
92774
  ...prev,
92687
92775
  dimensions: newDimensions
92688
92776
  };
92689
92777
  });
92690
92778
  };
92691
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
92779
+ const handleUpdateMetricTitle = (id, customTitle) => {
92692
92780
  setTitleOverrides(prev => ({
92693
92781
  ...prev,
92694
92782
  metrics: {
92695
92783
  ...prev.metrics,
92696
- [fullPath]: customTitle
92784
+ [id]: customTitle
92697
92785
  }
92698
92786
  }));
92699
92787
  };
92700
- const handleResetMetricTitle = fullPath => {
92788
+ const handleResetMetricTitle = id => {
92701
92789
  setTitleOverrides(prev => {
92702
92790
  const newMetrics = {
92703
92791
  ...prev.metrics
92704
92792
  };
92705
- delete newMetrics[fullPath];
92793
+ delete newMetrics[id];
92706
92794
  return {
92707
92795
  ...prev,
92708
92796
  metrics: newMetrics
@@ -92737,11 +92825,11 @@ const ReportBuilder = ({
92737
92825
  dimensions: [...prev.dimensions, dimensionData],
92738
92826
  dimensionOrder: [...prev.dimensionOrder, {
92739
92827
  type: "dimension",
92740
- key: dimensionData.fullPath
92828
+ key: dimensionData.id
92741
92829
  }],
92742
92830
  columnOrder: appendToColumnOrder(prev.columnOrder, {
92743
92831
  type: "dimension",
92744
- key: dimensionData.fullPath
92832
+ key: dimensionData.id
92745
92833
  })
92746
92834
  };
92747
92835
  console.log("Dimension saved:", dimensionData);
@@ -92749,22 +92837,30 @@ const ReportBuilder = ({
92749
92837
  return newReport;
92750
92838
  });
92751
92839
  };
92752
- const handleRemoveDimension = fullPath => {
92840
+ const handleRemoveDimension = id => {
92753
92841
  setReport(prev => ({
92754
92842
  ...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))
92843
+ dimensions: prev.dimensions.filter(d => d.id !== id),
92844
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
92845
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
92758
92846
  }));
92759
92847
  };
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
- }));
92848
+
92849
+ // sortOrder is fundamentally a per-field (query-level) setting - SQL can
92850
+ // only sort by a given column once - so toggling it on any instance of a
92851
+ // duplicated dimension applies to every instance sharing that fullPath.
92852
+ const handleUpdateDimensionSortOrder = (id, sortOrder) => {
92853
+ setReport(prev => {
92854
+ const target = prev.dimensions.find(d => d.id === id);
92855
+ if (!target) return prev;
92856
+ return {
92857
+ ...prev,
92858
+ dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
92859
+ ...d,
92860
+ sortOrder
92861
+ } : d)
92862
+ };
92863
+ });
92768
92864
  };
92769
92865
 
92770
92866
  // Reorders the single unified dimensions + computed-dimensions list;
@@ -92819,7 +92915,7 @@ const ReportBuilder = ({
92819
92915
  metrics: [...prev.metrics, metricData],
92820
92916
  columnOrder: appendToColumnOrder(prev.columnOrder, {
92821
92917
  type: "metric",
92822
- key: metricData.fullPath
92918
+ key: metricData.id
92823
92919
  })
92824
92920
  };
92825
92921
  console.log("Metric saved:", metricData);
@@ -92827,15 +92923,12 @@ const ReportBuilder = ({
92827
92923
  return newReport;
92828
92924
  });
92829
92925
  };
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
- });
92926
+ const handleRemoveMetric = id => {
92927
+ setReport(prev => ({
92928
+ ...prev,
92929
+ metrics: prev.metrics.filter(m => m.id !== id),
92930
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
92931
+ }));
92839
92932
  };
92840
92933
  const handleReorderMetrics = newOrder => {
92841
92934
  setReport(prev => ({
@@ -92890,13 +92983,30 @@ const ReportBuilder = ({
92890
92983
  // persisted `ordered_dimensions` field all stay consistent with whatever
92891
92984
  // order the user arranged on screen.
92892
92985
  const buildQueryObject = () => {
92893
- const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
92986
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
92894
92987
  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);
92988
+
92989
+ // May contain more than one instance of the same fullPath (deliberate
92990
+ // duplicates), kept in dimensionOrder's display order.
92991
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
92896
92992
  const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
92897
92993
 
92994
+ // The API keys each result row by fullPath, so duplicate instances of
92995
+ // the same field can only ever carry one value/one sort direction at
92996
+ // the query level - dedupe by fullPath (keeping first-occurrence order)
92997
+ // before sending. The builder still shows/orders/titles each instance
92998
+ // independently; they just read from the same underlying query column.
92999
+ const uniqueDimensionsByPath = [];
93000
+ const seenDimensionPaths = new Set();
93001
+ orderedDimensionsOnly.forEach(dim => {
93002
+ if (!seenDimensionPaths.has(dim.fullPath)) {
93003
+ seenDimensionPaths.add(dim.fullPath);
93004
+ uniqueDimensionsByPath.push(dim);
93005
+ }
93006
+ });
93007
+
92898
93008
  // Build order_by array - include all dimensions, add desc property only when needed
92899
- const orderBy = orderedDimensionsOnly.map(dim => {
93009
+ const orderBy = uniqueDimensionsByPath.map(dim => {
92900
93010
  const orderItem = {
92901
93011
  name: dim.fullPath
92902
93012
  };
@@ -92951,9 +93061,20 @@ const ReportBuilder = ({
92951
93061
  if (Object.keys(titleOverrides.filters).length > 0) {
92952
93062
  titles.filters = titleOverrides.filters;
92953
93063
  }
93064
+
93065
+ // Same dedupe rationale as dimensions above: the query only needs each
93066
+ // metric fullPath once, however many display instances reference it.
93067
+ const uniqueMetricPaths = [];
93068
+ const seenMetricPaths = new Set();
93069
+ report.metrics.forEach(metric => {
93070
+ if (!seenMetricPaths.has(metric.fullPath)) {
93071
+ seenMetricPaths.add(metric.fullPath);
93072
+ uniqueMetricPaths.push(metric.fullPath);
93073
+ }
93074
+ });
92954
93075
  const queryObj = {
92955
- dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
92956
- metrics: report.metrics.map(metric => metric.fullPath),
93076
+ dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
93077
+ metrics: uniqueMetricPaths,
92957
93078
  order_by: orderBy
92958
93079
  };
92959
93080
 
@@ -92967,7 +93088,9 @@ const ReportBuilder = ({
92967
93088
 
92968
93089
  // Persist the unified dimension + computed-dimension order so it can be
92969
93090
  // reconstructed on reload and so executed reports render columns in the
92970
- // same order they were arranged in the builder.
93091
+ // same order they were arranged in the builder. Entries use each
93092
+ // dimension's instance id as `ref`, so deliberate duplicates (the nth
93093
+ // occurrence of a fullPath is suffixed `#n`) round-trip correctly.
92971
93094
  if (report.dimensionOrder.length > 0) {
92972
93095
  queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
92973
93096
  type: entry.type,
@@ -92975,6 +93098,12 @@ const ReportBuilder = ({
92975
93098
  }));
92976
93099
  }
92977
93100
 
93101
+ // Persist metric instance order/identity the same way (metrics have no
93102
+ // other unified-order field to piggyback on).
93103
+ if (report.metrics.length > 0) {
93104
+ queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
93105
+ }
93106
+
92978
93107
  // Persist the optional unified column order (Column Order tab) so the
92979
93108
  // results grid can reproduce it on reload. Absent when no custom order
92980
93109
  // has been defined, so old/untouched reports keep their current
@@ -93144,11 +93273,11 @@ const ReportBuilder = ({
93144
93273
 
93145
93274
  // Resolve the unified dimensionOrder into actual dimension/computed-dimension
93146
93275
  // objects so the results grid can render columns in that same order.
93147
- const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
93276
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
93148
93277
  const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
93149
93278
  const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
93150
93279
  kind: "dimension",
93151
- data: dimensionByFullPath[entry.key]
93280
+ data: dimensionById[entry.key]
93152
93281
  } : {
93153
93282
  kind: "computed",
93154
93283
  data: computedDimensionByName[entry.key]