@bygd/nc-report-ui 0.1.41 → 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
 
@@ -77168,6 +77186,140 @@ var ArrowDownwardIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
77168
77186
  d: "m20 12-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8z"
77169
77187
  }));
77170
77188
 
77189
+ // Shared drag-and-drop primitives for the index-identified, arrow-reorderable
77190
+ // lists used across the report builder (Dimensions, Metrics, Column Order).
77191
+ // Previously duplicated per-tab; consolidated here so all three behave and
77192
+ // look identical and only need to be fixed in one place.
77193
+
77194
+ const useSortableListSensors = () => useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
77195
+ coordinateGetter: sortableKeyboardCoordinates
77196
+ }));
77197
+
77198
+ // Builds drag/move handlers for a list where dnd-kit item ids are the array
77199
+ // index. `onReorder` receives the whole reordered list.
77200
+ const makeListReorderHandlers = (list, onReorder) => {
77201
+ const handleDragEnd = ({
77202
+ active,
77203
+ over
77204
+ }) => {
77205
+ if (over && active.id !== over.id) {
77206
+ const oldIndex = list.findIndex((_, i) => i === active.id);
77207
+ const newIndex = list.findIndex((_, i) => i === over.id);
77208
+ onReorder(arrayMove(list, oldIndex, newIndex));
77209
+ }
77210
+ };
77211
+ const handleMoveUp = index => {
77212
+ if (index > 0) {
77213
+ onReorder(arrayMove(list, index, index - 1));
77214
+ }
77215
+ };
77216
+ const handleMoveDown = index => {
77217
+ if (index < list.length - 1) {
77218
+ onReorder(arrayMove(list, index, index + 1));
77219
+ }
77220
+ };
77221
+ return {
77222
+ handleDragEnd,
77223
+ handleMoveUp,
77224
+ handleMoveDown
77225
+ };
77226
+ };
77227
+
77228
+ // Wraps DndContext + SortableContext + the vertical list container.
77229
+ // `itemIds` must line up with the ids passed to each item's useSortable().
77230
+ const SortableListContext = ({
77231
+ sensors,
77232
+ itemIds,
77233
+ onDragEnd,
77234
+ children
77235
+ }) => /*#__PURE__*/gn.createElement(DndContext, {
77236
+ sensors: sensors,
77237
+ collisionDetection: closestCenter,
77238
+ onDragEnd: onDragEnd
77239
+ }, /*#__PURE__*/gn.createElement(SortableContext, {
77240
+ items: itemIds,
77241
+ strategy: verticalListSortingStrategy
77242
+ }, /*#__PURE__*/gn.createElement(Box, {
77243
+ sx: {
77244
+ display: "flex",
77245
+ flexDirection: "column",
77246
+ gap: 1
77247
+ }
77248
+ }, children)));
77249
+
77250
+ // Per-row useSortable() wiring shared by every sortable chip/row component.
77251
+ const useSortableRow = id => {
77252
+ const {
77253
+ attributes,
77254
+ listeners,
77255
+ setNodeRef,
77256
+ transform,
77257
+ transition,
77258
+ isDragging
77259
+ } = useSortable({
77260
+ id
77261
+ });
77262
+ const style = {
77263
+ transform: CSS$1.Transform.toString(transform),
77264
+ transition,
77265
+ opacity: isDragging ? 0.5 : 1,
77266
+ display: "flex",
77267
+ alignItems: "center",
77268
+ width: "100%"
77269
+ };
77270
+ return {
77271
+ attributes,
77272
+ listeners,
77273
+ setNodeRef,
77274
+ style
77275
+ };
77276
+ };
77277
+ const DragHandle = ({
77278
+ listeners
77279
+ }) => /*#__PURE__*/gn.createElement(Box, _extends({}, listeners, {
77280
+ sx: {
77281
+ display: "flex",
77282
+ alignItems: "center",
77283
+ cursor: "grab",
77284
+ "&:active": {
77285
+ cursor: "grabbing"
77286
+ }
77287
+ }
77288
+ }), /*#__PURE__*/gn.createElement(DragIndicatorIcon, {
77289
+ sx: {
77290
+ cursor: "grab",
77291
+ color: "rgba(110, 110, 110, 0.62)"
77292
+ }
77293
+ }));
77294
+ const MoveButtons = ({
77295
+ onMoveUp,
77296
+ onMoveDown,
77297
+ isFirst,
77298
+ isLast
77299
+ }) => /*#__PURE__*/gn.createElement(Box, {
77300
+ className: "hover-icons",
77301
+ sx: {
77302
+ display: "flex",
77303
+ gap: 0.5,
77304
+ opacity: 0,
77305
+ transition: "opacity 0.2s"
77306
+ }
77307
+ }, /*#__PURE__*/gn.createElement(IconButton, {
77308
+ size: "small",
77309
+ onClick: onMoveUp,
77310
+ disabled: isFirst,
77311
+ "aria-label": "move up"
77312
+ }, /*#__PURE__*/gn.createElement(ArrowUpwardIcon, {
77313
+ fontSize: "small"
77314
+ })), /*#__PURE__*/gn.createElement(IconButton, {
77315
+ size: "small",
77316
+ onClick: onMoveDown,
77317
+ disabled: isLast,
77318
+ "aria-label": "move down"
77319
+ }, /*#__PURE__*/gn.createElement(ArrowDownwardIcon, {
77320
+ fontSize: "small"
77321
+ })));
77322
+
77171
77323
  var SortIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
77172
77324
  d: "M3 18h6v-2H3zM3 6v2h18V6zm0 7h12v-2H3z"
77173
77325
  }));
@@ -77192,6 +77344,29 @@ var FunctionsIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
77192
77344
  d: "M18 4H6v2l6.5 6L6 18v2h12v-3h-7l5-5-5-5h7z"
77193
77345
  }));
77194
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
+
77195
77370
  // Replace {{key}} placeholders in a title with values from the report
77196
77371
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
77197
77372
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -77203,6 +77378,53 @@ const interpolateTitle = (template, params) => {
77203
77378
  });
77204
77379
  };
77205
77380
 
77381
+ // Resolves the final column display order for the results grid: dimensions,
77382
+ // computed dimensions, and metrics combined into a single list.
77383
+ //
77384
+ // `columnOrder` is the optional, explicitly-arranged order (raw entries
77385
+ // `{ type: 'dimension' | 'computed' | 'metric', key }`) set on the Column
77386
+ // Order tab. When present, its entries come first, in that order. Anything
77387
+ // not covered by it (new fields added since it was last touched, or when
77388
+ // it's empty/undefined because no custom order has ever been defined) is
77389
+ // appended in the pre-existing default order: dimensions/computed (in their
77390
+ // own `dimensionOrder`) first, then metrics (in their own array order) —
77391
+ // this keeps behavior unchanged for reports that never define a column order.
77392
+ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
77393
+ // Normalise every candidate item to a uniform { kind, key, data } shape
77394
+ // up front, so callers (e.g. the Column Order tab) can always read
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.
77399
+ const normalizedDimensionItems = orderedDimensionItems.map(item => ({
77400
+ kind: item.kind,
77401
+ key: item.kind === 'computed' ? item.data.name : item.data.id,
77402
+ data: item.data
77403
+ }));
77404
+ const normalizedMetricItems = metrics.map(m => ({
77405
+ kind: 'metric',
77406
+ key: m.id,
77407
+ data: m
77408
+ }));
77409
+ const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
77410
+ const resolved = [];
77411
+ const seen = new Set();
77412
+ (columnOrder || []).forEach(entry => {
77413
+ const seenKey = `${entry.type}:${entry.key}`;
77414
+ if (seen.has(seenKey) || !byKey[seenKey]) return;
77415
+ resolved.push(byKey[seenKey]);
77416
+ seen.add(seenKey);
77417
+ });
77418
+ [...normalizedDimensionItems, ...normalizedMetricItems].forEach(item => {
77419
+ const seenKey = `${item.kind}:${item.key}`;
77420
+ if (!seen.has(seenKey)) {
77421
+ resolved.push(item);
77422
+ seen.add(seenKey);
77423
+ }
77424
+ });
77425
+ return resolved;
77426
+ };
77427
+
77206
77428
  // Sortable Chip Component
77207
77429
  const SortableChip$1 = ({
77208
77430
  id,
@@ -77215,7 +77437,7 @@ const SortableChip$1 = ({
77215
77437
  isLast,
77216
77438
  sortOrder,
77217
77439
  onSortOrderChange,
77218
- fullPath,
77440
+ instanceId,
77219
77441
  defaultTitle,
77220
77442
  customTitle,
77221
77443
  onUpdateTitle,
@@ -77231,20 +77453,8 @@ const SortableChip$1 = ({
77231
77453
  attributes,
77232
77454
  listeners,
77233
77455
  setNodeRef,
77234
- transform,
77235
- transition,
77236
- isDragging
77237
- } = useSortable({
77238
- id
77239
- });
77240
- const style = {
77241
- transform: CSS$1.Transform.toString(transform),
77242
- transition,
77243
- opacity: isDragging ? 0.5 : 1,
77244
- display: "flex",
77245
- alignItems: "center",
77246
- width: "100%"
77247
- };
77456
+ style
77457
+ } = useSortableRow(id);
77248
77458
 
77249
77459
  // Focus input when entering edit mode
77250
77460
  h(() => {
@@ -77310,7 +77520,7 @@ const SortableChip$1 = ({
77310
77520
  handleCancel();
77311
77521
  return;
77312
77522
  }
77313
- onUpdateTitle(fullPath, trimmedValue);
77523
+ onUpdateTitle(instanceId, trimmedValue);
77314
77524
  setIsEditing(false);
77315
77525
  };
77316
77526
  const handleCancel = () => {
@@ -77318,7 +77528,7 @@ const SortableChip$1 = ({
77318
77528
  setEditValue("");
77319
77529
  };
77320
77530
  const handleReset = () => {
77321
- onResetTitle(fullPath);
77531
+ onResetTitle(instanceId);
77322
77532
  setIsEditing(false);
77323
77533
  setEditValue("");
77324
77534
  };
@@ -77353,21 +77563,9 @@ const SortableChip$1 = ({
77353
77563
  opacity: 1 // show icons on hover
77354
77564
  }
77355
77565
  }
77356
- }, /*#__PURE__*/gn.createElement(Box, _extends({}, listeners, {
77357
- sx: {
77358
- display: "flex",
77359
- alignItems: "center",
77360
- cursor: "grab",
77361
- "&:active": {
77362
- cursor: "grabbing"
77363
- }
77364
- }
77365
- }), /*#__PURE__*/gn.createElement(DragIndicatorIcon, {
77366
- sx: {
77367
- cursor: "grab",
77368
- color: "rgba(110, 110, 110, 0.62)"
77369
- }
77370
- })), !isEditing ? /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(Box, {
77566
+ }, /*#__PURE__*/gn.createElement(DragHandle, {
77567
+ listeners: listeners
77568
+ }), !isEditing ? /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(Box, {
77371
77569
  sx: {
77372
77570
  minWidth: 0
77373
77571
  }
@@ -77430,29 +77628,12 @@ const SortableChip$1 = ({
77430
77628
  sx: {
77431
77629
  flex: 1
77432
77630
  }
77433
- }), /*#__PURE__*/gn.createElement(Box, {
77434
- className: "hover-icons",
77435
- sx: {
77436
- display: "flex",
77437
- gap: 0.5,
77438
- opacity: 0,
77439
- transition: "opacity 0.2s"
77440
- }
77441
- }, /*#__PURE__*/gn.createElement(IconButton, {
77442
- size: "small",
77443
- onClick: onMoveUp,
77444
- disabled: isFirst,
77445
- "aria-label": "move up"
77446
- }, /*#__PURE__*/gn.createElement(ArrowUpwardIcon, {
77447
- fontSize: "small"
77448
- })), /*#__PURE__*/gn.createElement(IconButton, {
77449
- size: "small",
77450
- onClick: onMoveDown,
77451
- disabled: isLast,
77452
- "aria-label": "move down"
77453
- }, /*#__PURE__*/gn.createElement(ArrowDownwardIcon, {
77454
- fontSize: "small"
77455
- })))) : /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(TextField, {
77631
+ }), /*#__PURE__*/gn.createElement(MoveButtons, {
77632
+ onMoveUp: onMoveUp,
77633
+ onMoveDown: onMoveDown,
77634
+ isFirst: isFirst,
77635
+ isLast: isLast
77636
+ })) : /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(TextField, {
77456
77637
  inputRef: inputRef,
77457
77638
  value: editValue,
77458
77639
  onChange: e => setEditValue(e.target.value),
@@ -77528,24 +77709,12 @@ const SortableComputedChip = ({
77528
77709
  attributes,
77529
77710
  listeners,
77530
77711
  setNodeRef,
77531
- transform,
77532
- transition,
77533
- isDragging
77534
- } = useSortable({
77535
- id
77536
- });
77712
+ style
77713
+ } = useSortableRow(id);
77537
77714
  const [isEditing, setIsEditing] = d(false);
77538
77715
  const [editName, setEditName] = d("");
77539
77716
  const [editValue, setEditValue] = d("");
77540
77717
  const containerRef = A$1(null);
77541
- const style = {
77542
- transform: CSS$1.Transform.toString(transform),
77543
- transition,
77544
- opacity: isDragging ? 0.5 : 1,
77545
- display: "flex",
77546
- alignItems: "center",
77547
- width: "100%"
77548
- };
77549
77718
 
77550
77719
  // Handle click outside to cancel
77551
77720
  h(() => {
@@ -77570,7 +77739,7 @@ const SortableComputedChip = ({
77570
77739
  const handleSave = () => {
77571
77740
  const trimmedName = editName.trim();
77572
77741
  const trimmedValue = editValue.trim();
77573
- if (!trimmedName || !trimmedValue) return;
77742
+ if (!trimmedName) return;
77574
77743
  if (isNameTaken(trimmedName, name)) return;
77575
77744
  onUpdate(name, {
77576
77745
  name: trimmedName,
@@ -77608,21 +77777,9 @@ const SortableComputedChip = ({
77608
77777
  opacity: 1
77609
77778
  }
77610
77779
  }
77611
- }, /*#__PURE__*/gn.createElement(Box, _extends({}, listeners, {
77612
- sx: {
77613
- display: "flex",
77614
- alignItems: "center",
77615
- cursor: "grab",
77616
- "&:active": {
77617
- cursor: "grabbing"
77618
- }
77619
- }
77620
- }), /*#__PURE__*/gn.createElement(DragIndicatorIcon, {
77621
- sx: {
77622
- cursor: "grab",
77623
- color: "rgba(110, 110, 110, 0.62)"
77624
- }
77625
- })), !isEditing ? /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(Chip, {
77780
+ }, /*#__PURE__*/gn.createElement(DragHandle, {
77781
+ listeners: listeners
77782
+ }), !isEditing ? /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(Chip, {
77626
77783
  icon: /*#__PURE__*/gn.createElement(FunctionsIcon, {
77627
77784
  sx: {
77628
77785
  fontSize: "15px !important"
@@ -77686,29 +77843,12 @@ const SortableComputedChip = ({
77686
77843
  sx: {
77687
77844
  flex: 1
77688
77845
  }
77689
- }), /*#__PURE__*/gn.createElement(Box, {
77690
- className: "hover-icons",
77691
- sx: {
77692
- display: "flex",
77693
- gap: 0.5,
77694
- opacity: 0,
77695
- transition: "opacity 0.2s"
77696
- }
77697
- }, /*#__PURE__*/gn.createElement(IconButton, {
77698
- size: "small",
77699
- onClick: onMoveUp,
77700
- disabled: isFirst,
77701
- "aria-label": "move up"
77702
- }, /*#__PURE__*/gn.createElement(ArrowUpwardIcon, {
77703
- fontSize: "small"
77704
- })), /*#__PURE__*/gn.createElement(IconButton, {
77705
- size: "small",
77706
- onClick: onMoveDown,
77707
- disabled: isLast,
77708
- "aria-label": "move down"
77709
- }, /*#__PURE__*/gn.createElement(ArrowDownwardIcon, {
77710
- fontSize: "small"
77711
- })))) : /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(TextField, {
77846
+ }), /*#__PURE__*/gn.createElement(MoveButtons, {
77847
+ onMoveUp: onMoveUp,
77848
+ onMoveDown: onMoveDown,
77849
+ isFirst: isFirst,
77850
+ isLast: isLast
77851
+ })) : /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(TextField, {
77712
77852
  value: editName,
77713
77853
  onChange: e => setEditName(e.target.value),
77714
77854
  onKeyDown: handleKeyDown,
@@ -77749,7 +77889,7 @@ const SortableComputedChip = ({
77749
77889
  onClick: handleSave,
77750
77890
  color: "primary",
77751
77891
  "aria-label": "save computed dimension",
77752
- disabled: !editName.trim() || !editValue.trim() || nameError
77892
+ disabled: !editName.trim() || nameError
77753
77893
  }, /*#__PURE__*/gn.createElement(CheckIcon, {
77754
77894
  fontSize: "small"
77755
77895
  })))), /*#__PURE__*/gn.createElement(Tooltip, {
@@ -77793,55 +77933,39 @@ const Dimensions = ({
77793
77933
  const [computedValue, setComputedValue] = d("");
77794
77934
 
77795
77935
  // Setup drag and drop sensors
77796
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
77797
- coordinateGetter: sortableKeyboardCoordinates
77798
- }));
77936
+ const sensors = useSortableListSensors();
77799
77937
 
77800
77938
  // Resolve the unified dimensionOrder into the actual dimension / computed
77801
77939
  // dimension objects, so dimensions and computed dimensions can be shown
77802
77940
  // (and dragged) as a single list while staying stored in their own arrays.
77803
- 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]));
77804
77944
  const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
77805
77945
  const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
77806
77946
  kind: "dimension",
77807
77947
  key: entry.key,
77808
- data: dimensionByFullPath[entry.key]
77948
+ data: dimensionById[entry.key]
77809
77949
  } : {
77810
77950
  kind: "computed",
77811
77951
  key: entry.key,
77812
77952
  data: computedDimensionByName[entry.key]
77813
77953
  }).filter(item => item.data);
77814
77954
 
77815
- // Handle drag end for the unified list
77816
- const handleDragEnd = event => {
77817
- const {
77818
- active,
77819
- over
77820
- } = event;
77821
- if (over && active.id !== over.id) {
77822
- const oldIndex = dimensionOrder.findIndex((_, i) => i === active.id);
77823
- const newIndex = dimensionOrder.findIndex((_, i) => i === over.id);
77824
- onReorderDimensionItems(arrayMove(dimensionOrder, oldIndex, newIndex));
77825
- }
77826
- };
77827
-
77828
- // Handle move up
77829
- const handleMoveUp = index => {
77830
- if (index > 0) {
77831
- onReorderDimensionItems(arrayMove(dimensionOrder, index, index - 1));
77832
- }
77833
- };
77834
-
77835
- // Handle move down
77836
- const handleMoveDown = index => {
77837
- if (index < dimensionOrder.length - 1) {
77838
- onReorderDimensionItems(arrayMove(dimensionOrder, index, index + 1));
77839
- }
77840
- };
77955
+ // Handle drag/move for the unified list
77956
+ const {
77957
+ handleDragEnd,
77958
+ handleMoveUp,
77959
+ handleMoveDown
77960
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
77841
77961
 
77842
- // Handle sort order change (dimensions only - computed dimensions have no order_by)
77843
- const handleSortOrderChange = (fullPath, sortOrder) => {
77844
- 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);
77845
77969
  };
77846
77970
 
77847
77971
  // Computed Dimensions: { name, value } pairs independent of any provider.
@@ -77862,7 +77986,7 @@ const Dimensions = ({
77862
77986
  const handleSaveComputed = () => {
77863
77987
  const trimmedName = computedName.trim();
77864
77988
  const trimmedValue = computedValue.trim();
77865
- if (!trimmedName || !trimmedValue) return;
77989
+ if (!trimmedName) return;
77866
77990
  if (isComputedNameTaken(trimmedName)) return;
77867
77991
  onSaveComputedDimension({
77868
77992
  name: trimmedName,
@@ -77904,7 +78028,10 @@ const Dimensions = ({
77904
78028
  currentProviderKey = selection.targetKey;
77905
78029
  });
77906
78030
 
77907
- // 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.
77908
78035
  const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
77909
78036
  const items = [];
77910
78037
  // Iterate through aliases (e.g., 'ba', 'pc')
@@ -77916,9 +78043,6 @@ const Dimensions = ({
77916
78043
 
77917
78044
  // Construct the fullPath for this dimension
77918
78045
  const fullPath = `${aliasPath.join("_")}.${dimKey}`;
77919
-
77920
- // Check if this dimension is already selected
77921
- const isAlreadySelected = selectedFullPaths.has(fullPath);
77922
78046
  items.push({
77923
78047
  // Include provider name to ensure uniqueness across different providers
77924
78048
  key: `${currentProvider}_${alias}.${dimKey}`,
@@ -77926,7 +78050,7 @@ const Dimensions = ({
77926
78050
  dimensionKey: dimKey,
77927
78051
  alias: alias,
77928
78052
  dimension: dimension,
77929
- disabled: isAlreadySelected
78053
+ alreadySelected: selectedFullPaths.has(fullPath)
77930
78054
  });
77931
78055
  });
77932
78056
  });
@@ -77988,7 +78112,10 @@ const Dimensions = ({
77988
78112
  dimensionKey: selectedItem.dimensionKey,
77989
78113
  dimensionTitle: selectedItem.value,
77990
78114
  // The constructed path for server
77991
- 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)
77992
78119
  };
77993
78120
  onSaveDimension(dimensionData);
77994
78121
  handleCancel();
@@ -77999,8 +78126,9 @@ const Dimensions = ({
77999
78126
  const handleAddAll = () => {
78000
78127
  const dimensionItems = getDimensionItems();
78001
78128
 
78002
- // Filter out already selected dimensions (disabled items)
78003
- 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);
78004
78132
  if (availableItems.length === 0) return;
78005
78133
 
78006
78134
  // Build the complete relation objects array (same for all dimensions in this provider)
@@ -78045,7 +78173,10 @@ const Dimensions = ({
78045
78173
  dimensionKey: item.dimensionKey,
78046
78174
  dimensionTitle: item.value,
78047
78175
  // The constructed path for server
78048
- 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
78049
78180
  };
78050
78181
  onSaveDimension(dimensionData);
78051
78182
  });
@@ -78179,7 +78310,7 @@ const Dimensions = ({
78179
78310
  }, "Cancel"), /*#__PURE__*/gn.createElement(Button, {
78180
78311
  variant: "contained",
78181
78312
  onClick: handleSaveComputed,
78182
- disabled: !computedName.trim() || !computedValue.trim() || computedNameError,
78313
+ disabled: !computedName.trim() || computedNameError,
78183
78314
  sx: {
78184
78315
  height: "40px",
78185
78316
  fontFamily: "system-ui",
@@ -78255,7 +78386,7 @@ const Dimensions = ({
78255
78386
  placement: "top"
78256
78387
  }, /*#__PURE__*/gn.createElement("span", null, /*#__PURE__*/gn.createElement(IconButton, {
78257
78388
  onClick: handleAddAll,
78258
- disabled: getDimensionItems().filter(item => !item.disabled).length === 0,
78389
+ disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
78259
78390
  "aria-label": "add all dimensions",
78260
78391
  sx: {
78261
78392
  color: "rgb(70, 134, 128)",
@@ -78330,19 +78461,10 @@ const Dimensions = ({
78330
78461
  color: "#666",
78331
78462
  marginBottom: 2
78332
78463
  }
78333
- }, "Drag to reorder or use arrows"), /*#__PURE__*/gn.createElement(DndContext, {
78464
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/gn.createElement(SortableListContext, {
78334
78465
  sensors: sensors,
78335
- collisionDetection: closestCenter,
78466
+ itemIds: orderedItems.map((_, index) => index),
78336
78467
  onDragEnd: handleDragEnd
78337
- }, /*#__PURE__*/gn.createElement(SortableContext, {
78338
- items: orderedItems.map((_, index) => index),
78339
- strategy: verticalListSortingStrategy
78340
- }, /*#__PURE__*/gn.createElement(Box, {
78341
- sx: {
78342
- display: "flex",
78343
- flexDirection: "column",
78344
- gap: 1
78345
- }
78346
78468
  }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/gn.createElement(SortableChip$1, {
78347
78469
  key: `dim-${item.key}`,
78348
78470
  id: index,
@@ -78355,9 +78477,9 @@ const Dimensions = ({
78355
78477
  isLast: index === orderedItems.length - 1,
78356
78478
  sortOrder: item.data.sortOrder || null,
78357
78479
  onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
78358
- fullPath: item.data.fullPath,
78480
+ instanceId: item.data.id,
78359
78481
  defaultTitle: item.data.dimensionTitle,
78360
- customTitle: titleOverrides[item.data.fullPath],
78482
+ customTitle: titleOverrides[item.data.id],
78361
78483
  onUpdateTitle: onUpdateTitle,
78362
78484
  onResetTitle: onResetTitle
78363
78485
  }) : /*#__PURE__*/gn.createElement(SortableComputedChip, {
@@ -78372,7 +78494,7 @@ const Dimensions = ({
78372
78494
  onMoveDown: () => handleMoveDown(index),
78373
78495
  isFirst: index === 0,
78374
78496
  isLast: index === orderedItems.length - 1
78375
- })))))));
78497
+ })))));
78376
78498
  };
78377
78499
 
78378
78500
  var StorageIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
@@ -78442,7 +78564,7 @@ const SortableChip = ({
78442
78564
  onMoveDown,
78443
78565
  isFirst,
78444
78566
  isLast,
78445
- fullPath,
78567
+ instanceId,
78446
78568
  defaultTitle,
78447
78569
  customTitle,
78448
78570
  onUpdateTitle,
@@ -78459,20 +78581,8 @@ const SortableChip = ({
78459
78581
  attributes,
78460
78582
  listeners,
78461
78583
  setNodeRef,
78462
- transform,
78463
- transition,
78464
- isDragging
78465
- } = useSortable({
78466
- id
78467
- });
78468
- const style = {
78469
- transform: CSS$1.Transform.toString(transform),
78470
- transition,
78471
- opacity: isDragging ? 0.5 : 1,
78472
- display: 'flex',
78473
- alignItems: 'center',
78474
- width: '100%'
78475
- };
78584
+ style
78585
+ } = useSortableRow(id);
78476
78586
 
78477
78587
  // Focus input when entering edit mode
78478
78588
  h(() => {
@@ -78507,7 +78617,7 @@ const SortableChip = ({
78507
78617
  handleCancel();
78508
78618
  return;
78509
78619
  }
78510
- onUpdateTitle(fullPath, trimmedValue);
78620
+ onUpdateTitle(instanceId, trimmedValue);
78511
78621
  setIsEditing(false);
78512
78622
  };
78513
78623
  const handleCancel = () => {
@@ -78515,7 +78625,7 @@ const SortableChip = ({
78515
78625
  setEditValue('');
78516
78626
  };
78517
78627
  const handleReset = () => {
78518
- onResetTitle(fullPath);
78628
+ onResetTitle(instanceId);
78519
78629
  setIsEditing(false);
78520
78630
  setEditValue('');
78521
78631
  };
@@ -78550,21 +78660,9 @@ const SortableChip = ({
78550
78660
  opacity: 1 // show icons on hover
78551
78661
  }
78552
78662
  }
78553
- }, /*#__PURE__*/gn.createElement(Box, _extends({}, listeners, {
78554
- sx: {
78555
- display: "flex",
78556
- alignItems: "center",
78557
- cursor: "grab",
78558
- "&:active": {
78559
- cursor: "grabbing"
78560
- }
78561
- }
78562
- }), /*#__PURE__*/gn.createElement(DragIndicatorIcon, {
78563
- sx: {
78564
- cursor: "grab",
78565
- color: "rgba(110, 110, 110, 0.62)"
78566
- }
78567
- })), !isEditing ? /*#__PURE__*/gn.createElement(gn.Fragment, null, source && /*#__PURE__*/gn.createElement(Box, {
78663
+ }, /*#__PURE__*/gn.createElement(DragHandle, {
78664
+ listeners: listeners
78665
+ }), !isEditing ? /*#__PURE__*/gn.createElement(gn.Fragment, null, source && /*#__PURE__*/gn.createElement(Box, {
78568
78666
  sx: {
78569
78667
  display: 'flex',
78570
78668
  alignItems: 'center',
@@ -78626,29 +78724,12 @@ const SortableChip = ({
78626
78724
  sx: {
78627
78725
  flex: 1
78628
78726
  }
78629
- }), /*#__PURE__*/gn.createElement(Box, {
78630
- className: "hover-icons",
78631
- sx: {
78632
- display: "flex",
78633
- gap: 0.5,
78634
- opacity: 0,
78635
- transition: "opacity 0.2s"
78636
- }
78637
- }, /*#__PURE__*/gn.createElement(IconButton, {
78638
- size: "small",
78639
- onClick: onMoveUp,
78640
- disabled: isFirst,
78641
- "aria-label": "move up"
78642
- }, /*#__PURE__*/gn.createElement(ArrowUpwardIcon, {
78643
- fontSize: "small"
78644
- })), /*#__PURE__*/gn.createElement(IconButton, {
78645
- size: "small",
78646
- onClick: onMoveDown,
78647
- disabled: isLast,
78648
- "aria-label": "move down"
78649
- }, /*#__PURE__*/gn.createElement(ArrowDownwardIcon, {
78650
- fontSize: "small"
78651
- })))) : /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(TextField, {
78727
+ }), /*#__PURE__*/gn.createElement(MoveButtons, {
78728
+ onMoveUp: onMoveUp,
78729
+ onMoveDown: onMoveDown,
78730
+ isFirst: isFirst,
78731
+ isLast: isLast
78732
+ })) : /*#__PURE__*/gn.createElement(gn.Fragment, null, /*#__PURE__*/gn.createElement(TextField, {
78652
78733
  inputRef: inputRef,
78653
78734
  value: editValue,
78654
78735
  onChange: e => setEditValue(e.target.value),
@@ -78723,39 +78804,14 @@ const Metrics = ({
78723
78804
  const [selectedMetric, setSelectedMetric] = d('');
78724
78805
 
78725
78806
  // Setup drag and drop sensors
78726
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
78727
- coordinateGetter: sortableKeyboardCoordinates
78728
- }));
78729
-
78730
- // Handle drag end
78731
- const handleDragEnd = event => {
78732
- const {
78733
- active,
78734
- over
78735
- } = event;
78736
- if (over && active.id !== over.id) {
78737
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
78738
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
78739
- const newOrder = arrayMove(savedMetrics, oldIndex, newIndex);
78740
- onReorderMetrics(newOrder);
78741
- }
78742
- };
78807
+ const sensors = useSortableListSensors();
78743
78808
 
78744
- // Handle move up
78745
- const handleMoveUp = index => {
78746
- if (index > 0) {
78747
- const newOrder = arrayMove(savedMetrics, index, index - 1);
78748
- onReorderMetrics(newOrder);
78749
- }
78750
- };
78751
-
78752
- // Handle move down
78753
- const handleMoveDown = index => {
78754
- if (index < savedMetrics.length - 1) {
78755
- const newOrder = arrayMove(savedMetrics, index, index + 1);
78756
- onReorderMetrics(newOrder);
78757
- }
78758
- };
78809
+ // Handle drag/move
78810
+ const {
78811
+ handleDragEnd,
78812
+ handleMoveUp,
78813
+ handleMoveDown
78814
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
78759
78815
 
78760
78816
  // Get the current provider based on selection chain
78761
78817
  const getCurrentProvider = () => {
@@ -78790,7 +78846,10 @@ const Metrics = ({
78790
78846
  currentProviderKey = selection.targetKey;
78791
78847
  });
78792
78848
 
78793
- // 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.
78794
78853
  const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
78795
78854
 
78796
78855
  // Metrics are a direct array, not nested by alias
@@ -78798,15 +78857,12 @@ const Metrics = ({
78798
78857
  const items = provider.metrics.map((metric, index) => {
78799
78858
  // Construct the fullPath for this metric
78800
78859
  const fullPath = `${aliasPath.join('_')}.${metric.name}`;
78801
-
78802
- // Check if this metric is already selected
78803
- const isAlreadySelected = selectedFullPaths.has(fullPath);
78804
78860
  return {
78805
78861
  key: `${currentProvider}_${metric.name}_${index}`,
78806
78862
  value: metric.title || metric.name,
78807
78863
  metricName: metric.name,
78808
78864
  metric: metric,
78809
- disabled: isAlreadySelected,
78865
+ alreadySelected: selectedFullPaths.has(fullPath),
78810
78866
  icon: metric.source ? /*#__PURE__*/gn.createElement(MetricSourceIcon, {
78811
78867
  source: metric.source
78812
78868
  }) : undefined
@@ -78869,7 +78925,11 @@ const Metrics = ({
78869
78925
  metricName: selectedItem.metricName,
78870
78926
  metricTitle: selectedItem.value,
78871
78927
  // The constructed path for server
78872
- 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)
78873
78933
  };
78874
78934
  onSaveMetric(metricData);
78875
78935
  handleCancel();
@@ -78880,8 +78940,9 @@ const Metrics = ({
78880
78940
  const handleAddAll = () => {
78881
78941
  const metricItems = getMetricItems();
78882
78942
 
78883
- // Filter out already selected metrics (disabled items)
78884
- 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);
78885
78946
  if (availableItems.length === 0) return;
78886
78947
 
78887
78948
  // Build the complete relation objects array (same for all metrics in this provider)
@@ -78926,7 +78987,10 @@ const Metrics = ({
78926
78987
  metricName: item.metricName,
78927
78988
  metricTitle: item.value,
78928
78989
  // The constructed path for server
78929
- 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
78930
78994
  };
78931
78995
  onSaveMetric(metricData);
78932
78996
  });
@@ -79025,7 +79089,7 @@ const Metrics = ({
79025
79089
  placement: "top"
79026
79090
  }, /*#__PURE__*/gn.createElement("span", null, /*#__PURE__*/gn.createElement(IconButton, {
79027
79091
  onClick: handleAddAll,
79028
- disabled: getMetricItems().filter(item => !item.disabled).length === 0,
79092
+ disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
79029
79093
  "aria-label": "add all metrics",
79030
79094
  sx: {
79031
79095
  color: "rgb(70, 134, 128)",
@@ -79094,36 +79158,27 @@ const Metrics = ({
79094
79158
  marginBottom: 1,
79095
79159
  color: "rgb(37, 37, 37)"
79096
79160
  }
79097
- }, "Saved Metrics"), /*#__PURE__*/gn.createElement(DndContext, {
79161
+ }, "Saved Metrics"), /*#__PURE__*/gn.createElement(SortableListContext, {
79098
79162
  sensors: sensors,
79099
- collisionDetection: closestCenter,
79163
+ itemIds: savedMetrics.map((_, index) => index),
79100
79164
  onDragEnd: handleDragEnd
79101
- }, /*#__PURE__*/gn.createElement(SortableContext, {
79102
- items: savedMetrics.map((_, index) => index),
79103
- strategy: verticalListSortingStrategy
79104
- }, /*#__PURE__*/gn.createElement(Box, {
79105
- sx: {
79106
- display: 'flex',
79107
- flexDirection: 'column',
79108
- gap: 1
79109
- }
79110
79165
  }, savedMetrics.map((metric, index) => /*#__PURE__*/gn.createElement(SortableChip, {
79111
79166
  key: index,
79112
79167
  id: index,
79113
79168
  label: metric.metricTitle,
79114
79169
  fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
79115
- onDelete: () => onRemoveMetric(index),
79170
+ onDelete: () => onRemoveMetric(metric.id),
79116
79171
  onMoveUp: () => handleMoveUp(index),
79117
79172
  onMoveDown: () => handleMoveDown(index),
79118
79173
  isFirst: index === 0,
79119
79174
  isLast: index === savedMetrics.length - 1,
79120
- fullPath: metric.fullPath,
79175
+ instanceId: metric.id,
79121
79176
  defaultTitle: metric.metricTitle,
79122
- customTitle: titleOverrides[metric.fullPath],
79177
+ customTitle: titleOverrides[metric.id],
79123
79178
  onUpdateTitle: onUpdateTitle,
79124
79179
  onResetTitle: onResetTitle,
79125
79180
  source: metric.metric?.source
79126
- })))))));
79181
+ })))));
79127
79182
  };
79128
79183
 
79129
79184
  var FilterAltIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
@@ -91292,7 +91347,12 @@ const Filters = ({
91292
91347
  mb: 3
91293
91348
  }
91294
91349
  }, /*#__PURE__*/gn.createElement(SingleSelect, {
91295
- 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 => ({
91296
91356
  key: dim.fullPath,
91297
91357
  value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
91298
91358
  })).sort((a, b) => a.value.localeCompare(b.value)),
@@ -91686,6 +91746,209 @@ const Filters = ({
91686
91746
  }))));
91687
91747
  };
91688
91748
 
91749
+ var CalculateIcon = createSvgIcon$1(/*#__PURE__*/u("path", {
91750
+ d: "M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m-5.97 4.06L14.09 6l1.41 1.41L16.91 6l1.06 1.06-1.41 1.41 1.41 1.41-1.06 1.06-1.41-1.4-1.41 1.41-1.06-1.06 1.41-1.41zm-6.78.66h5v1.5h-5zM11.5 16h-2v2H8v-2H6v-1.5h2v-2h1.5v2h2zm6.5 1.25h-5v-1.5h5zm0-2.5h-5v-1.5h5z"
91751
+ }));
91752
+
91753
+ const KIND_LABEL = {
91754
+ dimension: "Dimension",
91755
+ computed: "Computed",
91756
+ metric: "Metric"
91757
+ };
91758
+ const KindChip = ({
91759
+ kind,
91760
+ source
91761
+ }) => {
91762
+ if (kind === "metric") {
91763
+ return /*#__PURE__*/gn.createElement(Box, {
91764
+ sx: {
91765
+ display: "flex",
91766
+ alignItems: "center",
91767
+ flexShrink: 0
91768
+ }
91769
+ }, /*#__PURE__*/gn.createElement(MetricSourceIcon, {
91770
+ source: source
91771
+ }));
91772
+ }
91773
+ return /*#__PURE__*/gn.createElement(Chip, {
91774
+ icon: kind === "computed" ? /*#__PURE__*/gn.createElement(CalculateIcon, {
91775
+ sx: {
91776
+ fontSize: "15px !important"
91777
+ }
91778
+ }) : undefined,
91779
+ label: KIND_LABEL[kind],
91780
+ size: "small",
91781
+ sx: {
91782
+ fontWeight: 500,
91783
+ backgroundColor: "rgba(70, 134, 128, 0.1)",
91784
+ color: "rgb(70, 134, 128)",
91785
+ flexShrink: 0
91786
+ }
91787
+ });
91788
+ };
91789
+
91790
+ // Read-only sortable row: this tab only reorders columns. Adding, removing,
91791
+ // and renaming stay on the Dimensions/Metrics tabs so there's one place each
91792
+ // field is managed.
91793
+ const SortableColumnRow = ({
91794
+ id,
91795
+ label,
91796
+ fullPath,
91797
+ kind,
91798
+ source,
91799
+ onMoveUp,
91800
+ onMoveDown,
91801
+ isFirst,
91802
+ isLast
91803
+ }) => {
91804
+ const reportingContext = useReportingContextOptional();
91805
+ const parameters = reportingContext?.parameters;
91806
+ const {
91807
+ attributes,
91808
+ listeners,
91809
+ setNodeRef,
91810
+ style
91811
+ } = useSortableRow(id);
91812
+ const displayLabel = interpolateTitle(label, parameters);
91813
+ return /*#__PURE__*/gn.createElement("div", _extends({
91814
+ ref: setNodeRef,
91815
+ style: style
91816
+ }, attributes), /*#__PURE__*/gn.createElement(Box, {
91817
+ sx: {
91818
+ display: "flex",
91819
+ alignItems: "center",
91820
+ width: "100%",
91821
+ gap: 1,
91822
+ backgroundColor: "white",
91823
+ border: "1px solid #e0e0e0",
91824
+ borderRadius: 2,
91825
+ padding: 1,
91826
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
91827
+ "&:hover .hover-icons": {
91828
+ opacity: 1
91829
+ }
91830
+ }
91831
+ }, /*#__PURE__*/gn.createElement(DragHandle, {
91832
+ listeners: listeners
91833
+ }), /*#__PURE__*/gn.createElement(KindChip, {
91834
+ kind: kind,
91835
+ source: source
91836
+ }), /*#__PURE__*/gn.createElement(Tooltip, {
91837
+ title: fullPath,
91838
+ arrow: true,
91839
+ placement: "top"
91840
+ }, /*#__PURE__*/gn.createElement(Typography, {
91841
+ variant: "h6",
91842
+ sx: {
91843
+ fontWeight: 500,
91844
+ color: "#1a1a1a",
91845
+ fontSize: "14px",
91846
+ whiteSpace: "nowrap",
91847
+ overflow: "hidden",
91848
+ textOverflow: "ellipsis"
91849
+ }
91850
+ }, displayLabel)), /*#__PURE__*/gn.createElement(Box, {
91851
+ sx: {
91852
+ flex: 1
91853
+ }
91854
+ }), /*#__PURE__*/gn.createElement(MoveButtons, {
91855
+ onMoveUp: onMoveUp,
91856
+ onMoveDown: onMoveDown,
91857
+ isFirst: isFirst,
91858
+ isLast: isLast
91859
+ })));
91860
+ };
91861
+ const ColumnOrder = ({
91862
+ orderedDimensionItems = [],
91863
+ metrics = [],
91864
+ columnOrder = [],
91865
+ onReorderColumnItems,
91866
+ titleOverrides = {
91867
+ dimensions: {},
91868
+ metrics: {}
91869
+ }
91870
+ }) => {
91871
+ const isCustomized = columnOrder.length > 0;
91872
+ const resolvedItems = resolveColumnOrder(columnOrder, orderedDimensionItems, metrics);
91873
+ const sensors = useSortableListSensors();
91874
+ const reorderResolved = newResolvedItems => {
91875
+ onReorderColumnItems(newResolvedItems.map(item => ({
91876
+ type: item.kind,
91877
+ key: item.key
91878
+ })));
91879
+ };
91880
+ const {
91881
+ handleDragEnd,
91882
+ handleMoveUp,
91883
+ handleMoveDown
91884
+ } = makeListReorderHandlers(resolvedItems, reorderResolved);
91885
+ const labelFor = item => {
91886
+ if (item.kind === "computed") return item.data.name;
91887
+ if (item.kind === "metric") {
91888
+ return titleOverrides.metrics[item.key] || item.data.metricTitle || item.key;
91889
+ }
91890
+ return titleOverrides.dimensions[item.key] || item.data.dimensionTitle || item.key;
91891
+ };
91892
+ return /*#__PURE__*/gn.createElement("div", null, /*#__PURE__*/gn.createElement(Box, {
91893
+ sx: {
91894
+ display: "flex",
91895
+ justifyContent: "space-between",
91896
+ alignItems: "flex-start",
91897
+ mb: 2,
91898
+ gap: 2
91899
+ }
91900
+ }, /*#__PURE__*/gn.createElement(Typography, {
91901
+ sx: {
91902
+ fontSize: "13px",
91903
+ color: "#666"
91904
+ }
91905
+ }, "Optional: define a single order for all columns (dimensions, computed dimensions and metrics together) in the results grid. Drag to reorder or use the arrows. Newly added dimensions/metrics are appended to the end automatically. Without a custom order, columns follow the Dimensions tab order, then the Metrics tab order."), isCustomized && /*#__PURE__*/gn.createElement(Tooltip, {
91906
+ title: "Discard the custom order and go back to the default (Dimensions, then Metrics)",
91907
+ arrow: true
91908
+ }, /*#__PURE__*/gn.createElement(Button, {
91909
+ variant: "outlined",
91910
+ size: "small",
91911
+ startIcon: /*#__PURE__*/gn.createElement(RestartAltIcon, null),
91912
+ onClick: () => onReorderColumnItems([]),
91913
+ sx: {
91914
+ height: "36px",
91915
+ flexShrink: 0,
91916
+ fontFamily: "system-ui",
91917
+ fontSize: "13px",
91918
+ fontWeight: 500,
91919
+ borderRadius: "8px",
91920
+ boxShadow: "none",
91921
+ textTransform: "none",
91922
+ borderColor: "#e0e0e0",
91923
+ color: "rgb(37, 37, 37)",
91924
+ "&:hover": {
91925
+ backgroundColor: "#f5f5f5",
91926
+ borderColor: "#d0d0d0"
91927
+ }
91928
+ }
91929
+ }, "Reset to Default"))), resolvedItems.length === 0 ? /*#__PURE__*/gn.createElement(Typography, {
91930
+ sx: {
91931
+ fontSize: "13px",
91932
+ color: "#999"
91933
+ }
91934
+ }, "Add dimensions or metrics in the other tabs first.") : /*#__PURE__*/gn.createElement(SortableListContext, {
91935
+ sensors: sensors,
91936
+ itemIds: resolvedItems.map((_, index) => index),
91937
+ onDragEnd: handleDragEnd
91938
+ }, resolvedItems.map((item, index) => /*#__PURE__*/gn.createElement(SortableColumnRow, {
91939
+ key: `${item.kind}-${item.key}`,
91940
+ id: index,
91941
+ label: labelFor(item),
91942
+ fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
91943
+ kind: item.kind,
91944
+ source: item.kind === "metric" ? item.data.metric?.source : undefined,
91945
+ onMoveUp: () => handleMoveUp(index),
91946
+ onMoveDown: () => handleMoveDown(index),
91947
+ isFirst: index === 0,
91948
+ isLast: index === resolvedItems.length - 1
91949
+ }))));
91950
+ };
91951
+
91689
91952
  // Default numeral.js formats applied when a dimension/metric definition has a
91690
91953
  // known type but no explicit `format` set on the provider.
91691
91954
  const DEFAULT_FORMATS_BY_TYPE = {
@@ -91700,10 +91963,122 @@ const formatValue = (value, def) => {
91700
91963
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
91701
91964
  };
91702
91965
  const isNumericType = type => type === 'integer' || type === 'currency';
91966
+
91967
+ // Builds a single DataGrid column definition for one resolved column item
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:
91974
+ // - Dimensions from base provider: baseAlias.field -> baseAlias_field
91975
+ // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
91976
+ // - Computed dimensions have no provider/relations chain, so the API returns
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).
91979
+ // - Metrics: API returns metric keys in different forms depending on path depth
91980
+ // (see comment inline below), normalised to match the row-key normalisation
91981
+ // done when building `rows`.
91982
+ const buildColumn = (item, titleOverrides, parameters) => {
91983
+ if (item.kind === 'computed') {
91984
+ const cd = item.data;
91985
+ return {
91986
+ field: cd.name,
91987
+ headerName: cd.name,
91988
+ flex: 1,
91989
+ minWidth: 150,
91990
+ type: 'string'
91991
+ };
91992
+ }
91993
+ if (item.kind === 'metric') {
91994
+ const metric = item.data;
91995
+ const metricDef = metric.metric;
91996
+ let dataFieldName;
91997
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
91998
+ if (dotCount >= 2) {
91999
+ // 3+-part namespaced path: API returns the full dotted path as the key.
92000
+ // Normalise dots to underscores to match the row key normalisation below.
92001
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
92002
+ dataFieldName = metric.fullPath.replace(/\./g, '_');
92003
+ } else if (metric.relations && metric.relations.length > 0) {
92004
+ // 2-part path from a nested provider: API drops the base provider alias.
92005
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
92006
+ const parts = metric.fullPath.split('.');
92007
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
92008
+ const field = parts[1]; // e.g., "total_amount"
92009
+
92010
+ const pathParts = pathWithoutField.split('_');
92011
+ pathParts.shift(); // remove base provider alias
92012
+ const pathWithoutBase = pathParts.join('_');
92013
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
92014
+ } else {
92015
+ // 2-part path from the base provider: API returns just the metric name.
92016
+ // Example: "mc.record_count" -> "record_count"
92017
+ dataFieldName = metric.metricName;
92018
+ }
92019
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
92020
+ return {
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, '_'),
92026
+ headerName: headerName,
92027
+ flex: 1,
92028
+ minWidth: 150,
92029
+ type: isNumericType(metricDef?.type) ? 'number' : 'string',
92030
+ valueGetter: (_value, row) => row[dataFieldName],
92031
+ renderHeader: () => /*#__PURE__*/gn.createElement(Box, {
92032
+ sx: {
92033
+ display: 'flex',
92034
+ alignItems: 'center',
92035
+ gap: 0.5
92036
+ }
92037
+ }, /*#__PURE__*/gn.createElement(MetricSourceIcon, {
92038
+ source: metricDef?.source
92039
+ }), /*#__PURE__*/gn.createElement("span", null, headerName)),
92040
+ valueFormatter: value => formatValue(value, metricDef)
92041
+ };
92042
+ }
92043
+
92044
+ // Dimension
92045
+ const dim = item.data;
92046
+ let dataFieldName;
92047
+ if (dim.relations && dim.relations.length > 0) {
92048
+ // From nested provider: drop the base provider alias
92049
+ // Example: ft_fa_db.currency -> fa_db_currency
92050
+ const parts = dim.fullPath.split('.');
92051
+ const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
92052
+ const field = parts[1]; // e.g., "currency"
92053
+
92054
+ const pathParts = pathWithoutField.split('_');
92055
+ pathParts.shift(); // Remove base provider alias
92056
+ const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
92057
+
92058
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
92059
+ } else {
92060
+ // From base provider: keep the full path with underscore
92061
+ // Example: ba.created_at -> ba_created_at
92062
+ dataFieldName = dim.fullPath.replace('.', '_');
92063
+ }
92064
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
92065
+ const dimDef = dim.dimension;
92066
+ return {
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, '_'),
92071
+ headerName: headerName,
92072
+ flex: 1,
92073
+ minWidth: 150,
92074
+ type: isNumericType(dimDef?.type) ? 'number' : 'string',
92075
+ valueGetter: (_value, row) => row[dataFieldName],
92076
+ valueFormatter: value => formatValue(value, dimDef)
92077
+ };
92078
+ };
91703
92079
  const ReportDataGrid = ({
91704
92080
  reportData,
91705
- orderedDimensionItems = [],
91706
- metrics,
92081
+ columnItems = [],
91707
92082
  loading,
91708
92083
  onPageChange,
91709
92084
  totalRows = 0,
@@ -91719,127 +92094,10 @@ const ReportDataGrid = ({
91719
92094
  pageSize: 50
91720
92095
  });
91721
92096
 
91722
- // Generate columns dynamically from dimensions and metrics
91723
- const columns = gn.useMemo(() => {
91724
- const cols = [];
91725
-
91726
- // Add dimension + computed-dimension columns, in the single unified
91727
- // order the user arranged them in on the Dimensions tab.
91728
- // Field naming logic (dimensions):
91729
- // - If from base provider: baseAlias.field -> baseAlias_field
91730
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
91731
- // Computed dimensions have no provider/relations chain, so the API
91732
- // returns each row keyed by the computed dimension's own `name`.
91733
- orderedDimensionItems.forEach(item => {
91734
- if (item.kind === 'computed') {
91735
- const cd = item.data;
91736
- cols.push({
91737
- field: cd.name,
91738
- headerName: cd.name,
91739
- flex: 1,
91740
- minWidth: 150,
91741
- type: 'string'
91742
- });
91743
- return;
91744
- }
91745
- const dim = item.data;
91746
- let fieldName;
91747
- let headerName;
91748
-
91749
- // Check if there are relations (nested providers)
91750
- if (dim.relations && dim.relations.length > 0) {
91751
- // From nested provider: drop the base provider alias
91752
- // Example: ft_fa_db.currency -> fa_db_currency
91753
- const parts = dim.fullPath.split('.');
91754
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
91755
- const field = parts[1]; // e.g., "currency"
91756
-
91757
- // Remove the base provider alias (first part before first underscore)
91758
- const pathParts = pathWithoutField.split('_');
91759
- pathParts.shift(); // Remove base provider alias
91760
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
91761
-
91762
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
91763
- } else {
91764
- // From base provider: keep the full path with underscore
91765
- // Example: ba.created_at -> ba_created_at
91766
- fieldName = dim.fullPath.replace('.', '_');
91767
- }
91768
-
91769
- // Check for title override, otherwise use the friendly dimension title from provider
91770
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
91771
- headerName = interpolateTitle(headerName, parameters);
91772
- const dimDef = dim.dimension;
91773
- cols.push({
91774
- field: fieldName,
91775
- headerName: headerName,
91776
- flex: 1,
91777
- minWidth: 150,
91778
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
91779
- valueFormatter: value => formatValue(value, dimDef)
91780
- });
91781
- });
91782
-
91783
- // Add metric columns
91784
- // The API returns metric keys in two different forms depending on the path depth:
91785
- //
91786
- // • 2-part path "mc.record_count" → API key: "record_count"
91787
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
91788
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
91789
- //
91790
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
91791
- // in field names as nested-object accessors, so the rows useMemo normalises
91792
- // those keys (dots → underscores). The column field must match that normalised key.
91793
- metrics.forEach(metric => {
91794
- const metricDef = metric.metric;
91795
- let fieldName;
91796
- let headerName;
91797
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
91798
- if (dotCount >= 2) {
91799
- // 3+-part namespaced path: API returns the full dotted path as the key.
91800
- // Normalise dots to underscores to match the row key normalisation below.
91801
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
91802
- fieldName = metric.fullPath.replace(/\./g, '_');
91803
- } else if (metric.relations && metric.relations.length > 0) {
91804
- // 2-part path from a nested provider: API drops the base provider alias.
91805
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
91806
- const parts = metric.fullPath.split('.');
91807
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
91808
- const field = parts[1]; // e.g., "total_amount"
91809
-
91810
- const pathParts = pathWithoutField.split('_');
91811
- pathParts.shift(); // remove base provider alias
91812
- const pathWithoutBase = pathParts.join('_');
91813
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
91814
- } else {
91815
- // 2-part path from the base provider: API returns just the metric name.
91816
- // Example: "mc.record_count" -> "record_count"
91817
- fieldName = metric.metricName;
91818
- }
91819
-
91820
- // Check for title override, otherwise use the friendly metric title from provider
91821
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
91822
- headerName = interpolateTitle(headerName, parameters);
91823
- cols.push({
91824
- field: fieldName,
91825
- headerName: headerName,
91826
- flex: 1,
91827
- minWidth: 150,
91828
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
91829
- renderHeader: () => /*#__PURE__*/gn.createElement(Box, {
91830
- sx: {
91831
- display: 'flex',
91832
- alignItems: 'center',
91833
- gap: 0.5
91834
- }
91835
- }, /*#__PURE__*/gn.createElement(MetricSourceIcon, {
91836
- source: metricDef?.source
91837
- }), /*#__PURE__*/gn.createElement("span", null, headerName)),
91838
- valueFormatter: value => formatValue(value, metricDef)
91839
- });
91840
- });
91841
- return cols;
91842
- }, [orderedDimensionItems, metrics, titleOverrides, parameters]);
92097
+ // Generate columns dynamically, in the final resolved column order
92098
+ // (custom Column Order tab arrangement when defined, otherwise
92099
+ // dimensions/computed then metrics).
92100
+ const columns = gn.useMemo(() => columnItems.map(item => buildColumn(item, titleOverrides, parameters)), [columnItems, titleOverrides, parameters]);
91843
92101
 
91844
92102
  // Transform report data to rows with unique IDs.
91845
92103
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -91949,7 +92207,12 @@ const ReportBuilder = ({
91949
92207
  computedDimensions: [],
91950
92208
  // Unified display/execution order across dimensions + computedDimensions.
91951
92209
  // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
91952
- dimensionOrder: []
92210
+ dimensionOrder: [],
92211
+ // Optional unified display order across dimensions + computedDimensions +
92212
+ // metrics, set on the Column Order tab. Empty means "not defined" — the
92213
+ // results grid falls back to dimensionOrder followed by metrics order.
92214
+ // Entries: { type: 'dimension' | 'computed' | 'metric', key }
92215
+ columnOrder: []
91953
92216
  });
91954
92217
  const [titleOverrides, setTitleOverrides] = d({
91955
92218
  dimensions: {},
@@ -92135,6 +92398,72 @@ const ReportBuilder = ({
92135
92398
  }
92136
92399
  };
92137
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
+
92138
92467
  // Build the unified display/execution order for dimensions + computed
92139
92468
  // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
92140
92469
  // to "dimensions first, then computed dimensions" for report definitions
@@ -92145,7 +92474,7 @@ const ReportBuilder = ({
92145
92474
  const seenComputedKeys = new Set();
92146
92475
  if (Array.isArray(rawOrderedDimensions)) {
92147
92476
  rawOrderedDimensions.forEach(entry => {
92148
- if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
92477
+ if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
92149
92478
  dimensionOrder.push({
92150
92479
  type: "dimension",
92151
92480
  key: entry.ref
@@ -92163,10 +92492,10 @@ const ReportBuilder = ({
92163
92492
 
92164
92493
  // Append anything not covered by ordered_dimensions (older reports, or drift)
92165
92494
  dimensions.forEach(d => {
92166
- if (!seenDimensionKeys.has(d.fullPath)) {
92495
+ if (!seenDimensionKeys.has(d.id)) {
92167
92496
  dimensionOrder.push({
92168
92497
  type: "dimension",
92169
- key: d.fullPath
92498
+ key: d.id
92170
92499
  });
92171
92500
  }
92172
92501
  });
@@ -92180,6 +92509,46 @@ const ReportBuilder = ({
92180
92509
  });
92181
92510
  return dimensionOrder;
92182
92511
  };
92512
+
92513
+ // Build the optional unified column order (dimensions + computed
92514
+ // dimensions + metrics) from the persisted `ordered_columns` field.
92515
+ // Unlike buildDimensionOrder, entries that no longer resolve are simply
92516
+ // dropped rather than backfilled — an empty/partial result just means "no
92517
+ // custom order defined (yet)", which resolveColumnOrder treats as
92518
+ // "fall back to the default order".
92519
+ const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
92520
+ if (!Array.isArray(rawOrderedColumns)) return [];
92521
+ const dimensionIds = new Set(dimensions.map(d => d.id));
92522
+ const computedNames = new Set(computedDimensions.map(cd => cd.name));
92523
+ const metricIds = new Set(metrics.map(m => m.id));
92524
+ const columnOrder = [];
92525
+ rawOrderedColumns.forEach(entry => {
92526
+ if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
92527
+ columnOrder.push({
92528
+ type: "dimension",
92529
+ key: entry.ref
92530
+ });
92531
+ } else if (entry.type === "computed" && computedNames.has(entry.ref)) {
92532
+ columnOrder.push({
92533
+ type: "computed",
92534
+ key: entry.ref
92535
+ });
92536
+ } else if (entry.type === "metric" && metricIds.has(entry.ref)) {
92537
+ columnOrder.push({
92538
+ type: "metric",
92539
+ key: entry.ref
92540
+ });
92541
+ }
92542
+ });
92543
+ return columnOrder;
92544
+ };
92545
+
92546
+ // Auto-append a newly added dimension/computed dimension/metric to the end
92547
+ // of columnOrder — but only once a custom order actually exists. While
92548
+ // columnOrder is empty (no custom order defined) it's left untouched, so
92549
+ // the results grid keeps falling back to the default dimensions-then-
92550
+ // metrics order until the user visits the Column Order tab.
92551
+ const appendToColumnOrder = (columnOrder, entry) => columnOrder.length > 0 ? [...columnOrder, entry] : columnOrder;
92183
92552
  const loadReportDefinition = async id => {
92184
92553
  try {
92185
92554
  console.log("Loading report definition:", id);
@@ -92194,43 +92563,10 @@ const ReportBuilder = ({
92194
92563
  // Set the title
92195
92564
  setReportTitle(reportDef.title || "");
92196
92565
 
92197
- // Reconstruct dimensions
92198
- const reconstructedDimensions = [];
92199
- const orderByMap = {}; // Map to store sort order from order_by array
92200
-
92201
- // First, build a map of fullPath -> sortOrder from order_by
92202
- if (reportDef.definition?.doc?.query?.order_by) {
92203
- for (const orderItem of reportDef.definition.doc.query.order_by) {
92204
- if (orderItem.desc === true) {
92205
- orderByMap[orderItem.name] = "desc";
92206
- } else {
92207
- orderByMap[orderItem.name] = "asc";
92208
- }
92209
- }
92210
- }
92211
-
92212
- // Now reconstruct dimensions and add sortOrder from the map
92213
- if (reportDef.definition?.doc?.query?.dimensions) {
92214
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
92215
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
92216
- if (dim) {
92217
- // Add sortOrder from order_by if it exists
92218
- dim.sortOrder = orderByMap[dimPath] || null;
92219
- reconstructedDimensions.push(dim);
92220
- }
92221
- }
92222
- }
92223
-
92224
- // Reconstruct metrics
92225
- const reconstructedMetrics = [];
92226
- if (reportDef.definition?.doc?.query?.metrics) {
92227
- for (const metricPath of reportDef.definition.doc.query.metrics) {
92228
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
92229
- if (metric) {
92230
- reconstructedMetrics.push(metric);
92231
- }
92232
- }
92233
- }
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);
92234
92570
 
92235
92571
  // Load title overrides if they exist
92236
92572
  const loadedTitleOverrides = {
@@ -92276,6 +92612,7 @@ const ReportBuilder = ({
92276
92612
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
92277
92613
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
92278
92614
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
92615
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
92279
92616
 
92280
92617
  // Set the report state
92281
92618
  setReport({
@@ -92283,7 +92620,8 @@ const ReportBuilder = ({
92283
92620
  metrics: reconstructedMetrics,
92284
92621
  filters: loadedFilters,
92285
92622
  computedDimensions,
92286
- dimensionOrder
92623
+ dimensionOrder,
92624
+ columnOrder
92287
92625
  });
92288
92626
 
92289
92627
  // Set title overrides
@@ -92312,43 +92650,10 @@ const ReportBuilder = ({
92312
92650
  // Set the title (already modified with " (Copy)" suffix)
92313
92651
  setReportTitle(reportDef.title || "");
92314
92652
 
92315
- // Reconstruct dimensions
92316
- const reconstructedDimensions = [];
92317
- const orderByMap = {}; // Map to store sort order from order_by array
92318
-
92319
- // First, build a map of fullPath -> sortOrder from order_by
92320
- if (reportDef.definition?.doc?.query?.order_by) {
92321
- for (const orderItem of reportDef.definition.doc.query.order_by) {
92322
- if (orderItem.desc === true) {
92323
- orderByMap[orderItem.name] = "desc";
92324
- } else {
92325
- orderByMap[orderItem.name] = "asc";
92326
- }
92327
- }
92328
- }
92329
-
92330
- // Now reconstruct dimensions and add sortOrder from the map
92331
- if (reportDef.definition?.doc?.query?.dimensions) {
92332
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
92333
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
92334
- if (dim) {
92335
- // Add sortOrder from order_by if it exists
92336
- dim.sortOrder = orderByMap[dimPath] || null;
92337
- reconstructedDimensions.push(dim);
92338
- }
92339
- }
92340
- }
92341
-
92342
- // Reconstruct metrics
92343
- const reconstructedMetrics = [];
92344
- if (reportDef.definition?.doc?.query?.metrics) {
92345
- for (const metricPath of reportDef.definition.doc.query.metrics) {
92346
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
92347
- if (metric) {
92348
- reconstructedMetrics.push(metric);
92349
- }
92350
- }
92351
- }
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);
92352
92657
 
92353
92658
  // Load title overrides if they exist
92354
92659
  const loadedTitleOverrides = {
@@ -92393,6 +92698,7 @@ const ReportBuilder = ({
92393
92698
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
92394
92699
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
92395
92700
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
92701
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
92396
92702
 
92397
92703
  // Set the report state
92398
92704
  setReport({
@@ -92400,7 +92706,8 @@ const ReportBuilder = ({
92400
92706
  metrics: reconstructedMetrics,
92401
92707
  filters: loadedFilters,
92402
92708
  computedDimensions,
92403
- dimensionOrder
92709
+ dimensionOrder,
92710
+ columnOrder
92404
92711
  });
92405
92712
 
92406
92713
  // Set title overrides
@@ -92434,7 +92741,8 @@ const ReportBuilder = ({
92434
92741
  metrics: [],
92435
92742
  filters: {},
92436
92743
  computedDimensions: [],
92437
- dimensionOrder: []
92744
+ dimensionOrder: [],
92745
+ columnOrder: []
92438
92746
  });
92439
92747
  // Reset title overrides
92440
92748
  setTitleOverrides({
@@ -92444,42 +92752,45 @@ const ReportBuilder = ({
92444
92752
  });
92445
92753
  console.log("Selected root provider:", event.target.value);
92446
92754
  };
92447
- 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) => {
92448
92759
  setTitleOverrides(prev => ({
92449
92760
  ...prev,
92450
92761
  dimensions: {
92451
92762
  ...prev.dimensions,
92452
- [fullPath]: customTitle
92763
+ [id]: customTitle
92453
92764
  }
92454
92765
  }));
92455
92766
  };
92456
- const handleResetDimensionTitle = fullPath => {
92767
+ const handleResetDimensionTitle = id => {
92457
92768
  setTitleOverrides(prev => {
92458
92769
  const newDimensions = {
92459
92770
  ...prev.dimensions
92460
92771
  };
92461
- delete newDimensions[fullPath];
92772
+ delete newDimensions[id];
92462
92773
  return {
92463
92774
  ...prev,
92464
92775
  dimensions: newDimensions
92465
92776
  };
92466
92777
  });
92467
92778
  };
92468
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
92779
+ const handleUpdateMetricTitle = (id, customTitle) => {
92469
92780
  setTitleOverrides(prev => ({
92470
92781
  ...prev,
92471
92782
  metrics: {
92472
92783
  ...prev.metrics,
92473
- [fullPath]: customTitle
92784
+ [id]: customTitle
92474
92785
  }
92475
92786
  }));
92476
92787
  };
92477
- const handleResetMetricTitle = fullPath => {
92788
+ const handleResetMetricTitle = id => {
92478
92789
  setTitleOverrides(prev => {
92479
92790
  const newMetrics = {
92480
92791
  ...prev.metrics
92481
92792
  };
92482
- delete newMetrics[fullPath];
92793
+ delete newMetrics[id];
92483
92794
  return {
92484
92795
  ...prev,
92485
92796
  metrics: newMetrics
@@ -92514,29 +92825,42 @@ const ReportBuilder = ({
92514
92825
  dimensions: [...prev.dimensions, dimensionData],
92515
92826
  dimensionOrder: [...prev.dimensionOrder, {
92516
92827
  type: "dimension",
92517
- key: dimensionData.fullPath
92518
- }]
92828
+ key: dimensionData.id
92829
+ }],
92830
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
92831
+ type: "dimension",
92832
+ key: dimensionData.id
92833
+ })
92519
92834
  };
92520
92835
  console.log("Dimension saved:", dimensionData);
92521
92836
  console.log("Complete report:", newReport);
92522
92837
  return newReport;
92523
92838
  });
92524
92839
  };
92525
- const handleRemoveDimension = fullPath => {
92840
+ const handleRemoveDimension = id => {
92526
92841
  setReport(prev => ({
92527
92842
  ...prev,
92528
- dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
92529
- dimensionOrder: prev.dimensionOrder.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))
92530
92846
  }));
92531
92847
  };
92532
- const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
92533
- setReport(prev => ({
92534
- ...prev,
92535
- dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
92536
- ...d,
92537
- sortOrder
92538
- } : d)
92539
- }));
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
+ });
92540
92864
  };
92541
92865
 
92542
92866
  // Reorders the single unified dimensions + computed-dimensions list;
@@ -92555,7 +92879,11 @@ const ReportBuilder = ({
92555
92879
  dimensionOrder: [...prev.dimensionOrder, {
92556
92880
  type: "computed",
92557
92881
  key: computedDimensionData.name
92558
- }]
92882
+ }],
92883
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
92884
+ type: "computed",
92885
+ key: computedDimensionData.name
92886
+ })
92559
92887
  }));
92560
92888
  };
92561
92889
  const handleUpdateComputedDimension = (name, computedDimensionData) => {
@@ -92565,6 +92893,10 @@ const ReportBuilder = ({
92565
92893
  dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
92566
92894
  ...entry,
92567
92895
  key: computedDimensionData.name
92896
+ } : entry),
92897
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
92898
+ ...entry,
92899
+ key: computedDimensionData.name
92568
92900
  } : entry)
92569
92901
  }));
92570
92902
  };
@@ -92572,24 +92904,30 @@ const ReportBuilder = ({
92572
92904
  setReport(prev => ({
92573
92905
  ...prev,
92574
92906
  computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
92575
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
92907
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name)),
92908
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
92576
92909
  }));
92577
92910
  };
92578
92911
  const handleSaveMetric = metricData => {
92579
92912
  setReport(prev => {
92580
92913
  const newReport = {
92581
92914
  ...prev,
92582
- metrics: [...prev.metrics, metricData]
92915
+ metrics: [...prev.metrics, metricData],
92916
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
92917
+ type: "metric",
92918
+ key: metricData.id
92919
+ })
92583
92920
  };
92584
92921
  console.log("Metric saved:", metricData);
92585
92922
  console.log("Complete report:", newReport);
92586
92923
  return newReport;
92587
92924
  });
92588
92925
  };
92589
- const handleRemoveMetric = index => {
92926
+ const handleRemoveMetric = id => {
92590
92927
  setReport(prev => ({
92591
92928
  ...prev,
92592
- metrics: prev.metrics.filter((_, i) => i !== index)
92929
+ metrics: prev.metrics.filter(m => m.id !== id),
92930
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
92593
92931
  }));
92594
92932
  };
92595
92933
  const handleReorderMetrics = newOrder => {
@@ -92598,6 +92936,16 @@ const ReportBuilder = ({
92598
92936
  metrics: newOrder
92599
92937
  }));
92600
92938
  };
92939
+
92940
+ // Sets the unified column order shown on the Column Order tab. An empty
92941
+ // array (e.g. via "Reset to Default") clears the customization, and the
92942
+ // results grid falls back to dimensionOrder followed by metrics order.
92943
+ const handleReorderColumnItems = newColumnOrder => {
92944
+ setReport(prev => ({
92945
+ ...prev,
92946
+ columnOrder: newColumnOrder
92947
+ }));
92948
+ };
92601
92949
  const handleSaveFilter = (fullPath, filterData) => {
92602
92950
  setReport(prev => {
92603
92951
  const newReport = {
@@ -92635,13 +92983,30 @@ const ReportBuilder = ({
92635
92983
  // persisted `ordered_dimensions` field all stay consistent with whatever
92636
92984
  // order the user arranged on screen.
92637
92985
  const buildQueryObject = () => {
92638
- const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
92986
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
92639
92987
  const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
92640
- 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);
92641
92992
  const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
92642
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
+
92643
93008
  // Build order_by array - include all dimensions, add desc property only when needed
92644
- const orderBy = orderedDimensionsOnly.map(dim => {
93009
+ const orderBy = uniqueDimensionsByPath.map(dim => {
92645
93010
  const orderItem = {
92646
93011
  name: dim.fullPath
92647
93012
  };
@@ -92696,9 +93061,20 @@ const ReportBuilder = ({
92696
93061
  if (Object.keys(titleOverrides.filters).length > 0) {
92697
93062
  titles.filters = titleOverrides.filters;
92698
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
+ });
92699
93075
  const queryObj = {
92700
- dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
92701
- metrics: report.metrics.map(metric => metric.fullPath),
93076
+ dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
93077
+ metrics: uniqueMetricPaths,
92702
93078
  order_by: orderBy
92703
93079
  };
92704
93080
 
@@ -92712,7 +93088,9 @@ const ReportBuilder = ({
92712
93088
 
92713
93089
  // Persist the unified dimension + computed-dimension order so it can be
92714
93090
  // reconstructed on reload and so executed reports render columns in the
92715
- // 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.
92716
93094
  if (report.dimensionOrder.length > 0) {
92717
93095
  queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
92718
93096
  type: entry.type,
@@ -92720,6 +93098,23 @@ const ReportBuilder = ({
92720
93098
  }));
92721
93099
  }
92722
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
+
93107
+ // Persist the optional unified column order (Column Order tab) so the
93108
+ // results grid can reproduce it on reload. Absent when no custom order
93109
+ // has been defined, so old/untouched reports keep their current
93110
+ // dimensions-then-metrics column order unchanged.
93111
+ if (report.columnOrder.length > 0) {
93112
+ queryObj.ordered_columns = report.columnOrder.map(entry => ({
93113
+ type: entry.type,
93114
+ ref: entry.key
93115
+ }));
93116
+ }
93117
+
92723
93118
  // Only add titles if there are any overrides
92724
93119
  if (Object.keys(titles).length > 0) {
92725
93120
  queryObj.titles = titles;
@@ -92779,8 +93174,8 @@ const ReportBuilder = ({
92779
93174
  const handleRunReport = async () => {
92780
93175
  setCurrentPage(0);
92781
93176
  await runReportWithPagination(0, pageSize);
92782
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
92783
- setActiveTab(3);
93177
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
93178
+ setActiveTab(4);
92784
93179
  };
92785
93180
  const handleDownloadReport = async () => {
92786
93181
  try {
@@ -92878,15 +93273,20 @@ const ReportBuilder = ({
92878
93273
 
92879
93274
  // Resolve the unified dimensionOrder into actual dimension/computed-dimension
92880
93275
  // objects so the results grid can render columns in that same order.
92881
- const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
93276
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
92882
93277
  const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
92883
93278
  const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
92884
93279
  kind: "dimension",
92885
- data: dimensionByFullPath[entry.key]
93280
+ data: dimensionById[entry.key]
92886
93281
  } : {
92887
93282
  kind: "computed",
92888
93283
  data: computedDimensionByName[entry.key]
92889
93284
  }).filter(item => item.data);
93285
+
93286
+ // Final column list for the results grid: the explicit Column Order tab
93287
+ // arrangement when defined, otherwise dimensions/computed (in their own
93288
+ // order) followed by metrics (in their own order) — today's behavior.
93289
+ const columnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
92890
93290
  return /*#__PURE__*/gn.createElement(Box, {
92891
93291
  sx: {
92892
93292
  p: 3,
@@ -93141,9 +93541,36 @@ const ReportBuilder = ({
93141
93541
  }
93142
93542
  }
93143
93543
  }), /*#__PURE__*/gn.createElement(Tab, {
93144
- label: reportData ? "Results" : "Results (Run report first)",
93544
+ label: /*#__PURE__*/gn.createElement(Badge, {
93545
+ variant: "dot",
93546
+ invisible: report.columnOrder.length === 0,
93547
+ sx: {
93548
+ "& .MuiBadge-dot": {
93549
+ backgroundColor: "rgb(70, 134, 128)"
93550
+ }
93551
+ }
93552
+ }, /*#__PURE__*/gn.createElement("span", {
93553
+ style: {
93554
+ marginRight: report.columnOrder.length > 0 ? "8px" : "0"
93555
+ }
93556
+ }, "Column Order")),
93145
93557
  id: "report-tab-3",
93146
93558
  "aria-controls": "report-tabpanel-3",
93559
+ sx: {
93560
+ height: "41px",
93561
+ fontFamily: "system-ui",
93562
+ borderRadius: "0.5rem",
93563
+ boxShadow: "none",
93564
+ textTransform: "none",
93565
+ color: "rgb(37, 37, 37)",
93566
+ "&.Mui-selected": {
93567
+ color: "rgb(70, 134, 128)"
93568
+ }
93569
+ }
93570
+ }), /*#__PURE__*/gn.createElement(Tab, {
93571
+ label: reportData ? "Results" : "Results (Run report first)",
93572
+ id: "report-tab-4",
93573
+ "aria-controls": "report-tabpanel-4",
93147
93574
  disabled: !reportData,
93148
93575
  sx: {
93149
93576
  height: "41px",
@@ -93210,10 +93637,18 @@ const ReportBuilder = ({
93210
93637
  })), /*#__PURE__*/gn.createElement(TabPanel, {
93211
93638
  value: activeTab,
93212
93639
  index: 3
93213
- }, reportData && /*#__PURE__*/gn.createElement(ReportDataGrid, {
93214
- reportData: reportData,
93640
+ }, /*#__PURE__*/gn.createElement(ColumnOrder, {
93215
93641
  orderedDimensionItems: orderedDimensionItems,
93216
93642
  metrics: report.metrics,
93643
+ columnOrder: report.columnOrder,
93644
+ onReorderColumnItems: handleReorderColumnItems,
93645
+ titleOverrides: titleOverrides
93646
+ })), /*#__PURE__*/gn.createElement(TabPanel, {
93647
+ value: activeTab,
93648
+ index: 4
93649
+ }, reportData && /*#__PURE__*/gn.createElement(ReportDataGrid, {
93650
+ reportData: reportData,
93651
+ columnItems: columnItems,
93217
93652
  loading: loading,
93218
93653
  onPageChange: handlePageChange,
93219
93654
  totalRows: totalRows,
@@ -93301,6 +93736,12 @@ const CurrencySelector = () => {
93301
93736
  parameters,
93302
93737
  setParameters
93303
93738
  } = useReportingContext();
93739
+
93740
+ // Hidden by default; only shown when a `cur` query parameter is present in the top-level URL.
93741
+ const showCurrencySelector = new URLSearchParams(window.location.search).has("cur");
93742
+ if (!showCurrencySelector) {
93743
+ return null;
93744
+ }
93304
93745
  return /*#__PURE__*/gn.createElement(SingleSelect, {
93305
93746
  items: CURRENCY_OPTIONS,
93306
93747
  value: parameters.base_currency,