@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.
@@ -17,6 +17,7 @@ import { useInView } from 'react-intersection-observer';
17
17
  import { FormControl, Autocomplete, TextField, CircularProgress as CircularProgress$1, Chip, Checkbox, FormHelperText, Snackbar, Alert, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, Box as Box$1, Typography as Typography$1, Tooltip as Tooltip$1, IconButton as IconButton$1, Paper as Paper$1, Tabs, Tab, Badge } from '@mui/material';
18
18
  import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
19
19
  import CheckBoxIcon from '@mui/icons-material/CheckBox';
20
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
20
21
  import EventEmitter from 'eventemitter3';
21
22
  import Grid from '@mui/material/Grid';
22
23
  import IconButton from '@mui/material/IconButton';
@@ -44,7 +45,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
44
45
  import SaveIcon from '@mui/icons-material/Save';
45
46
  import DownloadIcon from '@mui/icons-material/Download';
46
47
  import { useSensors, useSensor, PointerSensor, KeyboardSensor, DndContext, closestCenter } from '@dnd-kit/core';
47
- import { sortableKeyboardCoordinates, SortableContext, verticalListSortingStrategy, arrayMove, useSortable } from '@dnd-kit/sortable';
48
+ import { sortableKeyboardCoordinates, SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
48
49
  import { CSS } from '@dnd-kit/utilities';
49
50
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
50
51
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
@@ -64,6 +65,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
64
65
  import { DatePicker } from '@mui/x-date-pickers/DatePicker';
65
66
  import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
66
67
  import dayjs from 'dayjs';
68
+ import CalculateIcon from '@mui/icons-material/Calculate';
67
69
  import { ThemeProvider, createTheme } from '@mui/material/styles';
68
70
 
69
71
  function _extends() {
@@ -1054,7 +1056,8 @@ function SingleSelect({
1054
1056
  key,
1055
1057
  value,
1056
1058
  disabled,
1057
- icon
1059
+ icon,
1060
+ alreadySelected
1058
1061
  } = itm;
1059
1062
  return /*#__PURE__*/React__default.createElement(MenuItem, {
1060
1063
  key: key,
@@ -1065,7 +1068,12 @@ function SingleSelect({
1065
1068
  minHeight: "36px",
1066
1069
  display: "flex",
1067
1070
  alignItems: "center",
1068
- gap: "6px"
1071
+ gap: "6px",
1072
+ ...(alreadySelected && {
1073
+ color: "rgb(70, 134, 128)",
1074
+ backgroundColor: "rgba(70, 134, 128, 0.08)",
1075
+ fontWeight: 600
1076
+ })
1069
1077
  }
1070
1078
  }, icon && /*#__PURE__*/React__default.createElement(Box, {
1071
1079
  component: "span",
@@ -1073,7 +1081,13 @@ function SingleSelect({
1073
1081
  display: "inline-flex",
1074
1082
  alignItems: "center"
1075
1083
  }
1076
- }, icon), formatLabel(value));
1084
+ }, icon), alreadySelected && /*#__PURE__*/React__default.createElement(CheckCircleIcon, {
1085
+ fontSize: "small",
1086
+ sx: {
1087
+ color: "rgb(70, 134, 128)",
1088
+ fontSize: "16px"
1089
+ }
1090
+ }), formatLabel(value));
1077
1091
  }))));
1078
1092
  }
1079
1093
 
@@ -2528,6 +2542,163 @@ const ProviderSelection = ({
2528
2542
  }, renderSelectionChain()));
2529
2543
  };
2530
2544
 
2545
+ // Shared drag-and-drop primitives for the index-identified, arrow-reorderable
2546
+ // lists used across the report builder (Dimensions, Metrics, Column Order).
2547
+ // Previously duplicated per-tab; consolidated here so all three behave and
2548
+ // look identical and only need to be fixed in one place.
2549
+
2550
+ const useSortableListSensors = () => useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
2551
+ coordinateGetter: sortableKeyboardCoordinates
2552
+ }));
2553
+
2554
+ // Builds drag/move handlers for a list where dnd-kit item ids are the array
2555
+ // index. `onReorder` receives the whole reordered list.
2556
+ const makeListReorderHandlers = (list, onReorder) => {
2557
+ const handleDragEnd = ({
2558
+ active,
2559
+ over
2560
+ }) => {
2561
+ if (over && active.id !== over.id) {
2562
+ const oldIndex = list.findIndex((_, i) => i === active.id);
2563
+ const newIndex = list.findIndex((_, i) => i === over.id);
2564
+ onReorder(arrayMove(list, oldIndex, newIndex));
2565
+ }
2566
+ };
2567
+ const handleMoveUp = index => {
2568
+ if (index > 0) {
2569
+ onReorder(arrayMove(list, index, index - 1));
2570
+ }
2571
+ };
2572
+ const handleMoveDown = index => {
2573
+ if (index < list.length - 1) {
2574
+ onReorder(arrayMove(list, index, index + 1));
2575
+ }
2576
+ };
2577
+ return {
2578
+ handleDragEnd,
2579
+ handleMoveUp,
2580
+ handleMoveDown
2581
+ };
2582
+ };
2583
+
2584
+ // Wraps DndContext + SortableContext + the vertical list container.
2585
+ // `itemIds` must line up with the ids passed to each item's useSortable().
2586
+ const SortableListContext = ({
2587
+ sensors,
2588
+ itemIds,
2589
+ onDragEnd,
2590
+ children
2591
+ }) => /*#__PURE__*/React__default.createElement(DndContext, {
2592
+ sensors: sensors,
2593
+ collisionDetection: closestCenter,
2594
+ onDragEnd: onDragEnd
2595
+ }, /*#__PURE__*/React__default.createElement(SortableContext, {
2596
+ items: itemIds,
2597
+ strategy: verticalListSortingStrategy
2598
+ }, /*#__PURE__*/React__default.createElement(Box$1, {
2599
+ sx: {
2600
+ display: "flex",
2601
+ flexDirection: "column",
2602
+ gap: 1
2603
+ }
2604
+ }, children)));
2605
+
2606
+ // Per-row useSortable() wiring shared by every sortable chip/row component.
2607
+ const useSortableRow = id => {
2608
+ const {
2609
+ attributes,
2610
+ listeners,
2611
+ setNodeRef,
2612
+ transform,
2613
+ transition,
2614
+ isDragging
2615
+ } = useSortable({
2616
+ id
2617
+ });
2618
+ const style = {
2619
+ transform: CSS.Transform.toString(transform),
2620
+ transition,
2621
+ opacity: isDragging ? 0.5 : 1,
2622
+ display: "flex",
2623
+ alignItems: "center",
2624
+ width: "100%"
2625
+ };
2626
+ return {
2627
+ attributes,
2628
+ listeners,
2629
+ setNodeRef,
2630
+ style
2631
+ };
2632
+ };
2633
+ const DragHandle = ({
2634
+ listeners
2635
+ }) => /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2636
+ sx: {
2637
+ display: "flex",
2638
+ alignItems: "center",
2639
+ cursor: "grab",
2640
+ "&:active": {
2641
+ cursor: "grabbing"
2642
+ }
2643
+ }
2644
+ }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2645
+ sx: {
2646
+ cursor: "grab",
2647
+ color: "rgba(110, 110, 110, 0.62)"
2648
+ }
2649
+ }));
2650
+ const MoveButtons = ({
2651
+ onMoveUp,
2652
+ onMoveDown,
2653
+ isFirst,
2654
+ isLast
2655
+ }) => /*#__PURE__*/React__default.createElement(Box$1, {
2656
+ className: "hover-icons",
2657
+ sx: {
2658
+ display: "flex",
2659
+ gap: 0.5,
2660
+ opacity: 0,
2661
+ transition: "opacity 0.2s"
2662
+ }
2663
+ }, /*#__PURE__*/React__default.createElement(IconButton$1, {
2664
+ size: "small",
2665
+ onClick: onMoveUp,
2666
+ disabled: isFirst,
2667
+ "aria-label": "move up"
2668
+ }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
2669
+ fontSize: "small"
2670
+ })), /*#__PURE__*/React__default.createElement(IconButton$1, {
2671
+ size: "small",
2672
+ onClick: onMoveDown,
2673
+ disabled: isLast,
2674
+ "aria-label": "move down"
2675
+ }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
2676
+ fontSize: "small"
2677
+ })));
2678
+
2679
+ // Builds an identity string for a saved dimension/metric instance. Deliberate
2680
+ // duplicates (the same field added more than once) are allowed - the first
2681
+ // occurrence keeps the plain fullPath (so existing saved reports and their
2682
+ // title-override keys stay backward compatible), later occurrences get a
2683
+ // `#n` suffix.
2684
+ const makeInstanceId = (fullPath, occurrenceIndex) => occurrenceIndex === 0 ? fullPath : `${fullPath}#${occurrenceIndex}`;
2685
+
2686
+ // Picks the next free instance id for a newly-added fullPath, given the
2687
+ // items already saved. Scans occurrence numbers from 0 rather than just
2688
+ // counting existing instances with this fullPath, because counting alone
2689
+ // can collide: e.g. add "ft.currency" twice (ids "ft.currency",
2690
+ // "ft.currency#1"), remove the first, then add "ft.currency" again - a
2691
+ // plain count of 1 would regenerate "ft.currency#1", clashing with the
2692
+ // surviving duplicate.
2693
+ const nextInstanceId = (fullPath, existingItems) => {
2694
+ const existingIds = new Set(existingItems.map(item => item.id));
2695
+ let occurrenceIndex = 0;
2696
+ while (existingIds.has(makeInstanceId(fullPath, occurrenceIndex))) {
2697
+ occurrenceIndex += 1;
2698
+ }
2699
+ return makeInstanceId(fullPath, occurrenceIndex);
2700
+ };
2701
+
2531
2702
  // Replace {{key}} placeholders in a title with values from the report
2532
2703
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
2533
2704
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -2539,6 +2710,53 @@ const interpolateTitle = (template, params) => {
2539
2710
  });
2540
2711
  };
2541
2712
 
2713
+ // Resolves the final column display order for the results grid: dimensions,
2714
+ // computed dimensions, and metrics combined into a single list.
2715
+ //
2716
+ // `columnOrder` is the optional, explicitly-arranged order (raw entries
2717
+ // `{ type: 'dimension' | 'computed' | 'metric', key }`) set on the Column
2718
+ // Order tab. When present, its entries come first, in that order. Anything
2719
+ // not covered by it (new fields added since it was last touched, or when
2720
+ // it's empty/undefined because no custom order has ever been defined) is
2721
+ // appended in the pre-existing default order: dimensions/computed (in their
2722
+ // own `dimensionOrder`) first, then metrics (in their own array order) —
2723
+ // this keeps behavior unchanged for reports that never define a column order.
2724
+ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
2725
+ // Normalise every candidate item to a uniform { kind, key, data } shape
2726
+ // up front, so callers (e.g. the Column Order tab) can always read
2727
+ // `.key` regardless of where the item came from.
2728
+ // Keyed by instance id (not fullPath) so that two instances of the same
2729
+ // dimension/metric - added deliberately as duplicates - are treated as
2730
+ // distinct columns rather than collapsing into one.
2731
+ const normalizedDimensionItems = orderedDimensionItems.map(item => ({
2732
+ kind: item.kind,
2733
+ key: item.kind === 'computed' ? item.data.name : item.data.id,
2734
+ data: item.data
2735
+ }));
2736
+ const normalizedMetricItems = metrics.map(m => ({
2737
+ kind: 'metric',
2738
+ key: m.id,
2739
+ data: m
2740
+ }));
2741
+ const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
2742
+ const resolved = [];
2743
+ const seen = new Set();
2744
+ (columnOrder || []).forEach(entry => {
2745
+ const seenKey = `${entry.type}:${entry.key}`;
2746
+ if (seen.has(seenKey) || !byKey[seenKey]) return;
2747
+ resolved.push(byKey[seenKey]);
2748
+ seen.add(seenKey);
2749
+ });
2750
+ [...normalizedDimensionItems, ...normalizedMetricItems].forEach(item => {
2751
+ const seenKey = `${item.kind}:${item.key}`;
2752
+ if (!seen.has(seenKey)) {
2753
+ resolved.push(item);
2754
+ seen.add(seenKey);
2755
+ }
2756
+ });
2757
+ return resolved;
2758
+ };
2759
+
2542
2760
  // Sortable Chip Component
2543
2761
  const SortableChip$1 = ({
2544
2762
  id,
@@ -2551,7 +2769,7 @@ const SortableChip$1 = ({
2551
2769
  isLast,
2552
2770
  sortOrder,
2553
2771
  onSortOrderChange,
2554
- fullPath,
2772
+ instanceId,
2555
2773
  defaultTitle,
2556
2774
  customTitle,
2557
2775
  onUpdateTitle,
@@ -2567,20 +2785,8 @@ const SortableChip$1 = ({
2567
2785
  attributes,
2568
2786
  listeners,
2569
2787
  setNodeRef,
2570
- transform,
2571
- transition,
2572
- isDragging
2573
- } = useSortable({
2574
- id
2575
- });
2576
- const style = {
2577
- transform: CSS.Transform.toString(transform),
2578
- transition,
2579
- opacity: isDragging ? 0.5 : 1,
2580
- display: "flex",
2581
- alignItems: "center",
2582
- width: "100%"
2583
- };
2788
+ style
2789
+ } = useSortableRow(id);
2584
2790
 
2585
2791
  // Focus input when entering edit mode
2586
2792
  useEffect(() => {
@@ -2646,7 +2852,7 @@ const SortableChip$1 = ({
2646
2852
  handleCancel();
2647
2853
  return;
2648
2854
  }
2649
- onUpdateTitle(fullPath, trimmedValue);
2855
+ onUpdateTitle(instanceId, trimmedValue);
2650
2856
  setIsEditing(false);
2651
2857
  };
2652
2858
  const handleCancel = () => {
@@ -2654,7 +2860,7 @@ const SortableChip$1 = ({
2654
2860
  setEditValue("");
2655
2861
  };
2656
2862
  const handleReset = () => {
2657
- onResetTitle(fullPath);
2863
+ onResetTitle(instanceId);
2658
2864
  setIsEditing(false);
2659
2865
  setEditValue("");
2660
2866
  };
@@ -2689,21 +2895,9 @@ const SortableChip$1 = ({
2689
2895
  opacity: 1 // show icons on hover
2690
2896
  }
2691
2897
  }
2692
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2693
- sx: {
2694
- display: "flex",
2695
- alignItems: "center",
2696
- cursor: "grab",
2697
- "&:active": {
2698
- cursor: "grabbing"
2699
- }
2700
- }
2701
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2702
- sx: {
2703
- cursor: "grab",
2704
- color: "rgba(110, 110, 110, 0.62)"
2705
- }
2706
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Box$1, {
2898
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
2899
+ listeners: listeners
2900
+ }), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Box$1, {
2707
2901
  sx: {
2708
2902
  minWidth: 0
2709
2903
  }
@@ -2766,29 +2960,12 @@ const SortableChip$1 = ({
2766
2960
  sx: {
2767
2961
  flex: 1
2768
2962
  }
2769
- }), /*#__PURE__*/React__default.createElement(Box$1, {
2770
- className: "hover-icons",
2771
- sx: {
2772
- display: "flex",
2773
- gap: 0.5,
2774
- opacity: 0,
2775
- transition: "opacity 0.2s"
2776
- }
2777
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
2778
- size: "small",
2779
- onClick: onMoveUp,
2780
- disabled: isFirst,
2781
- "aria-label": "move up"
2782
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
2783
- fontSize: "small"
2784
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
2785
- size: "small",
2786
- onClick: onMoveDown,
2787
- disabled: isLast,
2788
- "aria-label": "move down"
2789
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
2790
- fontSize: "small"
2791
- })))) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
2963
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
2964
+ onMoveUp: onMoveUp,
2965
+ onMoveDown: onMoveDown,
2966
+ isFirst: isFirst,
2967
+ isLast: isLast
2968
+ })) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
2792
2969
  inputRef: inputRef,
2793
2970
  value: editValue,
2794
2971
  onChange: e => setEditValue(e.target.value),
@@ -2864,24 +3041,12 @@ const SortableComputedChip = ({
2864
3041
  attributes,
2865
3042
  listeners,
2866
3043
  setNodeRef,
2867
- transform,
2868
- transition,
2869
- isDragging
2870
- } = useSortable({
2871
- id
2872
- });
3044
+ style
3045
+ } = useSortableRow(id);
2873
3046
  const [isEditing, setIsEditing] = useState(false);
2874
3047
  const [editName, setEditName] = useState("");
2875
3048
  const [editValue, setEditValue] = useState("");
2876
3049
  const containerRef = useRef(null);
2877
- const style = {
2878
- transform: CSS.Transform.toString(transform),
2879
- transition,
2880
- opacity: isDragging ? 0.5 : 1,
2881
- display: "flex",
2882
- alignItems: "center",
2883
- width: "100%"
2884
- };
2885
3050
 
2886
3051
  // Handle click outside to cancel
2887
3052
  useEffect(() => {
@@ -2906,7 +3071,7 @@ const SortableComputedChip = ({
2906
3071
  const handleSave = () => {
2907
3072
  const trimmedName = editName.trim();
2908
3073
  const trimmedValue = editValue.trim();
2909
- if (!trimmedName || !trimmedValue) return;
3074
+ if (!trimmedName) return;
2910
3075
  if (isNameTaken(trimmedName, name)) return;
2911
3076
  onUpdate(name, {
2912
3077
  name: trimmedName,
@@ -2944,21 +3109,9 @@ const SortableComputedChip = ({
2944
3109
  opacity: 1
2945
3110
  }
2946
3111
  }
2947
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
2948
- sx: {
2949
- display: "flex",
2950
- alignItems: "center",
2951
- cursor: "grab",
2952
- "&:active": {
2953
- cursor: "grabbing"
2954
- }
2955
- }
2956
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
2957
- sx: {
2958
- cursor: "grab",
2959
- color: "rgba(110, 110, 110, 0.62)"
2960
- }
2961
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Chip, {
3112
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
3113
+ listeners: listeners
3114
+ }), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Chip, {
2962
3115
  icon: /*#__PURE__*/React__default.createElement(FunctionsIcon, {
2963
3116
  sx: {
2964
3117
  fontSize: "15px !important"
@@ -3022,29 +3175,12 @@ const SortableComputedChip = ({
3022
3175
  sx: {
3023
3176
  flex: 1
3024
3177
  }
3025
- }), /*#__PURE__*/React__default.createElement(Box$1, {
3026
- className: "hover-icons",
3027
- sx: {
3028
- display: "flex",
3029
- gap: 0.5,
3030
- opacity: 0,
3031
- transition: "opacity 0.2s"
3032
- }
3033
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
3034
- size: "small",
3035
- onClick: onMoveUp,
3036
- disabled: isFirst,
3037
- "aria-label": "move up"
3038
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
3039
- fontSize: "small"
3040
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
3041
- size: "small",
3042
- onClick: onMoveDown,
3043
- disabled: isLast,
3044
- "aria-label": "move down"
3045
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
3046
- fontSize: "small"
3047
- })))) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3178
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
3179
+ onMoveUp: onMoveUp,
3180
+ onMoveDown: onMoveDown,
3181
+ isFirst: isFirst,
3182
+ isLast: isLast
3183
+ })) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3048
3184
  value: editName,
3049
3185
  onChange: e => setEditName(e.target.value),
3050
3186
  onKeyDown: handleKeyDown,
@@ -3085,7 +3221,7 @@ const SortableComputedChip = ({
3085
3221
  onClick: handleSave,
3086
3222
  color: "primary",
3087
3223
  "aria-label": "save computed dimension",
3088
- disabled: !editName.trim() || !editValue.trim() || nameError
3224
+ disabled: !editName.trim() || nameError
3089
3225
  }, /*#__PURE__*/React__default.createElement(CheckIcon, {
3090
3226
  fontSize: "small"
3091
3227
  })))), /*#__PURE__*/React__default.createElement(Tooltip$1, {
@@ -3129,55 +3265,39 @@ const Dimensions = ({
3129
3265
  const [computedValue, setComputedValue] = useState("");
3130
3266
 
3131
3267
  // Setup drag and drop sensors
3132
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
3133
- coordinateGetter: sortableKeyboardCoordinates
3134
- }));
3268
+ const sensors = useSortableListSensors();
3135
3269
 
3136
3270
  // Resolve the unified dimensionOrder into the actual dimension / computed
3137
3271
  // dimension objects, so dimensions and computed dimensions can be shown
3138
3272
  // (and dragged) as a single list while staying stored in their own arrays.
3139
- const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
3273
+ // Keyed by instance id rather than fullPath, so two instances of the same
3274
+ // dimension (added deliberately as a duplicate) stay distinct entries.
3275
+ const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
3140
3276
  const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
3141
3277
  const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
3142
3278
  kind: "dimension",
3143
3279
  key: entry.key,
3144
- data: dimensionByFullPath[entry.key]
3280
+ data: dimensionById[entry.key]
3145
3281
  } : {
3146
3282
  kind: "computed",
3147
3283
  key: entry.key,
3148
3284
  data: computedDimensionByName[entry.key]
3149
3285
  }).filter(item => item.data);
3150
3286
 
3151
- // Handle drag end for the unified list
3152
- const handleDragEnd = event => {
3153
- const {
3154
- active,
3155
- over
3156
- } = event;
3157
- if (over && active.id !== over.id) {
3158
- const oldIndex = dimensionOrder.findIndex((_, i) => i === active.id);
3159
- const newIndex = dimensionOrder.findIndex((_, i) => i === over.id);
3160
- onReorderDimensionItems(arrayMove(dimensionOrder, oldIndex, newIndex));
3161
- }
3162
- };
3163
-
3164
- // Handle move up
3165
- const handleMoveUp = index => {
3166
- if (index > 0) {
3167
- onReorderDimensionItems(arrayMove(dimensionOrder, index, index - 1));
3168
- }
3169
- };
3170
-
3171
- // Handle move down
3172
- const handleMoveDown = index => {
3173
- if (index < dimensionOrder.length - 1) {
3174
- onReorderDimensionItems(arrayMove(dimensionOrder, index, index + 1));
3175
- }
3176
- };
3287
+ // Handle drag/move for the unified list
3288
+ const {
3289
+ handleDragEnd,
3290
+ handleMoveUp,
3291
+ handleMoveDown
3292
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3177
3293
 
3178
- // Handle sort order change (dimensions only - computed dimensions have no order_by)
3179
- const handleSortOrderChange = (fullPath, sortOrder) => {
3180
- onUpdateDimensionSortOrder(fullPath, sortOrder);
3294
+ // Handle sort order change (dimensions only - computed dimensions have no
3295
+ // order_by). Keyed by instance id but applied query-wide: SQL can only
3296
+ // sort by a given column once, so toggling sort on any duplicate instance
3297
+ // of the same field is propagated to all instances sharing its fullPath
3298
+ // (handled in ReportBuilder's onUpdateDimensionSortOrder).
3299
+ const handleSortOrderChange = (id, sortOrder) => {
3300
+ onUpdateDimensionSortOrder(id, sortOrder);
3181
3301
  };
3182
3302
 
3183
3303
  // Computed Dimensions: { name, value } pairs independent of any provider.
@@ -3198,7 +3318,7 @@ const Dimensions = ({
3198
3318
  const handleSaveComputed = () => {
3199
3319
  const trimmedName = computedName.trim();
3200
3320
  const trimmedValue = computedValue.trim();
3201
- if (!trimmedName || !trimmedValue) return;
3321
+ if (!trimmedName) return;
3202
3322
  if (isComputedNameTaken(trimmedName)) return;
3203
3323
  onSaveComputedDimension({
3204
3324
  name: trimmedName,
@@ -3240,7 +3360,10 @@ const Dimensions = ({
3240
3360
  currentProviderKey = selection.targetKey;
3241
3361
  });
3242
3362
 
3243
- // Create a Set of already selected dimension fullPaths for quick lookup
3363
+ // Create a Set of already selected dimension fullPaths for quick lookup.
3364
+ // Already-selected items stay selectable (so the same dimension can be
3365
+ // added again on purpose) - they're just flagged so the dropdown can
3366
+ // highlight them instead of blocking the click.
3244
3367
  const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
3245
3368
  const items = [];
3246
3369
  // Iterate through aliases (e.g., 'ba', 'pc')
@@ -3252,9 +3375,6 @@ const Dimensions = ({
3252
3375
 
3253
3376
  // Construct the fullPath for this dimension
3254
3377
  const fullPath = `${aliasPath.join("_")}.${dimKey}`;
3255
-
3256
- // Check if this dimension is already selected
3257
- const isAlreadySelected = selectedFullPaths.has(fullPath);
3258
3378
  items.push({
3259
3379
  // Include provider name to ensure uniqueness across different providers
3260
3380
  key: `${currentProvider}_${alias}.${dimKey}`,
@@ -3262,7 +3382,7 @@ const Dimensions = ({
3262
3382
  dimensionKey: dimKey,
3263
3383
  alias: alias,
3264
3384
  dimension: dimension,
3265
- disabled: isAlreadySelected
3385
+ alreadySelected: selectedFullPaths.has(fullPath)
3266
3386
  });
3267
3387
  });
3268
3388
  });
@@ -3324,7 +3444,10 @@ const Dimensions = ({
3324
3444
  dimensionKey: selectedItem.dimensionKey,
3325
3445
  dimensionTitle: selectedItem.value,
3326
3446
  // The constructed path for server
3327
- fullPath: fullPath
3447
+ fullPath: fullPath,
3448
+ // Unique per-instance identity, so this field can be added more than
3449
+ // once and each occurrence stays independently orderable/removable.
3450
+ id: nextInstanceId(fullPath, savedDimensions)
3328
3451
  };
3329
3452
  onSaveDimension(dimensionData);
3330
3453
  handleCancel();
@@ -3335,8 +3458,9 @@ const Dimensions = ({
3335
3458
  const handleAddAll = () => {
3336
3459
  const dimensionItems = getDimensionItems();
3337
3460
 
3338
- // Filter out already selected dimensions (disabled items)
3339
- const availableItems = dimensionItems.filter(item => !item.disabled);
3461
+ // Filter out already selected dimensions - "Add all" only fills in
3462
+ // missing ones; intentional duplicates are added one at a time above.
3463
+ const availableItems = dimensionItems.filter(item => !item.alreadySelected);
3340
3464
  if (availableItems.length === 0) return;
3341
3465
 
3342
3466
  // Build the complete relation objects array (same for all dimensions in this provider)
@@ -3381,7 +3505,10 @@ const Dimensions = ({
3381
3505
  dimensionKey: item.dimensionKey,
3382
3506
  dimensionTitle: item.value,
3383
3507
  // The constructed path for server
3384
- fullPath: fullPath
3508
+ fullPath: fullPath,
3509
+ // "Add all" only ever adds fields that aren't already selected, so
3510
+ // this is always the first (and only) occurrence of this fullPath.
3511
+ id: fullPath
3385
3512
  };
3386
3513
  onSaveDimension(dimensionData);
3387
3514
  });
@@ -3515,7 +3642,7 @@ const Dimensions = ({
3515
3642
  }, "Cancel"), /*#__PURE__*/React__default.createElement(Button, {
3516
3643
  variant: "contained",
3517
3644
  onClick: handleSaveComputed,
3518
- disabled: !computedName.trim() || !computedValue.trim() || computedNameError,
3645
+ disabled: !computedName.trim() || computedNameError,
3519
3646
  sx: {
3520
3647
  height: "40px",
3521
3648
  fontFamily: "system-ui",
@@ -3591,7 +3718,7 @@ const Dimensions = ({
3591
3718
  placement: "top"
3592
3719
  }, /*#__PURE__*/React__default.createElement("span", null, /*#__PURE__*/React__default.createElement(IconButton$1, {
3593
3720
  onClick: handleAddAll,
3594
- disabled: getDimensionItems().filter(item => !item.disabled).length === 0,
3721
+ disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
3595
3722
  "aria-label": "add all dimensions",
3596
3723
  sx: {
3597
3724
  color: "rgb(70, 134, 128)",
@@ -3666,19 +3793,10 @@ const Dimensions = ({
3666
3793
  color: "#666",
3667
3794
  marginBottom: 2
3668
3795
  }
3669
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(DndContext, {
3796
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/React__default.createElement(SortableListContext, {
3670
3797
  sensors: sensors,
3671
- collisionDetection: closestCenter,
3798
+ itemIds: orderedItems.map((_, index) => index),
3672
3799
  onDragEnd: handleDragEnd
3673
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
3674
- items: orderedItems.map((_, index) => index),
3675
- strategy: verticalListSortingStrategy
3676
- }, /*#__PURE__*/React__default.createElement(Box$1, {
3677
- sx: {
3678
- display: "flex",
3679
- flexDirection: "column",
3680
- gap: 1
3681
- }
3682
3800
  }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/React__default.createElement(SortableChip$1, {
3683
3801
  key: `dim-${item.key}`,
3684
3802
  id: index,
@@ -3691,9 +3809,9 @@ const Dimensions = ({
3691
3809
  isLast: index === orderedItems.length - 1,
3692
3810
  sortOrder: item.data.sortOrder || null,
3693
3811
  onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
3694
- fullPath: item.data.fullPath,
3812
+ instanceId: item.data.id,
3695
3813
  defaultTitle: item.data.dimensionTitle,
3696
- customTitle: titleOverrides[item.data.fullPath],
3814
+ customTitle: titleOverrides[item.data.id],
3697
3815
  onUpdateTitle: onUpdateTitle,
3698
3816
  onResetTitle: onResetTitle
3699
3817
  }) : /*#__PURE__*/React__default.createElement(SortableComputedChip, {
@@ -3708,7 +3826,7 @@ const Dimensions = ({
3708
3826
  onMoveDown: () => handleMoveDown(index),
3709
3827
  isFirst: index === 0,
3710
3828
  isLast: index === orderedItems.length - 1
3711
- })))))));
3829
+ })))));
3712
3830
  };
3713
3831
 
3714
3832
  const MetricSourceIcon = ({
@@ -3766,7 +3884,7 @@ const SortableChip = ({
3766
3884
  onMoveDown,
3767
3885
  isFirst,
3768
3886
  isLast,
3769
- fullPath,
3887
+ instanceId,
3770
3888
  defaultTitle,
3771
3889
  customTitle,
3772
3890
  onUpdateTitle,
@@ -3783,20 +3901,8 @@ const SortableChip = ({
3783
3901
  attributes,
3784
3902
  listeners,
3785
3903
  setNodeRef,
3786
- transform,
3787
- transition,
3788
- isDragging
3789
- } = useSortable({
3790
- id
3791
- });
3792
- const style = {
3793
- transform: CSS.Transform.toString(transform),
3794
- transition,
3795
- opacity: isDragging ? 0.5 : 1,
3796
- display: 'flex',
3797
- alignItems: 'center',
3798
- width: '100%'
3799
- };
3904
+ style
3905
+ } = useSortableRow(id);
3800
3906
 
3801
3907
  // Focus input when entering edit mode
3802
3908
  useEffect(() => {
@@ -3831,7 +3937,7 @@ const SortableChip = ({
3831
3937
  handleCancel();
3832
3938
  return;
3833
3939
  }
3834
- onUpdateTitle(fullPath, trimmedValue);
3940
+ onUpdateTitle(instanceId, trimmedValue);
3835
3941
  setIsEditing(false);
3836
3942
  };
3837
3943
  const handleCancel = () => {
@@ -3839,7 +3945,7 @@ const SortableChip = ({
3839
3945
  setEditValue('');
3840
3946
  };
3841
3947
  const handleReset = () => {
3842
- onResetTitle(fullPath);
3948
+ onResetTitle(instanceId);
3843
3949
  setIsEditing(false);
3844
3950
  setEditValue('');
3845
3951
  };
@@ -3874,21 +3980,9 @@ const SortableChip = ({
3874
3980
  opacity: 1 // show icons on hover
3875
3981
  }
3876
3982
  }
3877
- }, /*#__PURE__*/React__default.createElement(Box$1, _extends({}, listeners, {
3878
- sx: {
3879
- display: "flex",
3880
- alignItems: "center",
3881
- cursor: "grab",
3882
- "&:active": {
3883
- cursor: "grabbing"
3884
- }
3885
- }
3886
- }), /*#__PURE__*/React__default.createElement(DragIndicatorIcon, {
3887
- sx: {
3888
- cursor: "grab",
3889
- color: "rgba(110, 110, 110, 0.62)"
3890
- }
3891
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, source && /*#__PURE__*/React__default.createElement(Box$1, {
3983
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
3984
+ listeners: listeners
3985
+ }), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, source && /*#__PURE__*/React__default.createElement(Box$1, {
3892
3986
  sx: {
3893
3987
  display: 'flex',
3894
3988
  alignItems: 'center',
@@ -3950,29 +4044,12 @@ const SortableChip = ({
3950
4044
  sx: {
3951
4045
  flex: 1
3952
4046
  }
3953
- }), /*#__PURE__*/React__default.createElement(Box$1, {
3954
- className: "hover-icons",
3955
- sx: {
3956
- display: "flex",
3957
- gap: 0.5,
3958
- opacity: 0,
3959
- transition: "opacity 0.2s"
3960
- }
3961
- }, /*#__PURE__*/React__default.createElement(IconButton$1, {
3962
- size: "small",
3963
- onClick: onMoveUp,
3964
- disabled: isFirst,
3965
- "aria-label": "move up"
3966
- }, /*#__PURE__*/React__default.createElement(ArrowUpwardIcon, {
3967
- fontSize: "small"
3968
- })), /*#__PURE__*/React__default.createElement(IconButton$1, {
3969
- size: "small",
3970
- onClick: onMoveDown,
3971
- disabled: isLast,
3972
- "aria-label": "move down"
3973
- }, /*#__PURE__*/React__default.createElement(ArrowDownwardIcon, {
3974
- fontSize: "small"
3975
- })))) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
4047
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
4048
+ onMoveUp: onMoveUp,
4049
+ onMoveDown: onMoveDown,
4050
+ isFirst: isFirst,
4051
+ isLast: isLast
4052
+ })) : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(TextField, {
3976
4053
  inputRef: inputRef,
3977
4054
  value: editValue,
3978
4055
  onChange: e => setEditValue(e.target.value),
@@ -4047,39 +4124,14 @@ const Metrics = ({
4047
4124
  const [selectedMetric, setSelectedMetric] = useState('');
4048
4125
 
4049
4126
  // Setup drag and drop sensors
4050
- const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, {
4051
- coordinateGetter: sortableKeyboardCoordinates
4052
- }));
4053
-
4054
- // Handle drag end
4055
- const handleDragEnd = event => {
4056
- const {
4057
- active,
4058
- over
4059
- } = event;
4060
- if (over && active.id !== over.id) {
4061
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
4062
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
4063
- const newOrder = arrayMove(savedMetrics, oldIndex, newIndex);
4064
- onReorderMetrics(newOrder);
4065
- }
4066
- };
4127
+ const sensors = useSortableListSensors();
4067
4128
 
4068
- // Handle move up
4069
- const handleMoveUp = index => {
4070
- if (index > 0) {
4071
- const newOrder = arrayMove(savedMetrics, index, index - 1);
4072
- onReorderMetrics(newOrder);
4073
- }
4074
- };
4075
-
4076
- // Handle move down
4077
- const handleMoveDown = index => {
4078
- if (index < savedMetrics.length - 1) {
4079
- const newOrder = arrayMove(savedMetrics, index, index + 1);
4080
- onReorderMetrics(newOrder);
4081
- }
4082
- };
4129
+ // Handle drag/move
4130
+ const {
4131
+ handleDragEnd,
4132
+ handleMoveUp,
4133
+ handleMoveDown
4134
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
4083
4135
 
4084
4136
  // Get the current provider based on selection chain
4085
4137
  const getCurrentProvider = () => {
@@ -4114,7 +4166,10 @@ const Metrics = ({
4114
4166
  currentProviderKey = selection.targetKey;
4115
4167
  });
4116
4168
 
4117
- // Create a Set of already selected metric fullPaths for quick lookup
4169
+ // Create a Set of already selected metric fullPaths for quick lookup.
4170
+ // Already-selected items stay selectable (so the same metric can be
4171
+ // added again on purpose) - they're just flagged so the dropdown can
4172
+ // highlight them instead of blocking the click.
4118
4173
  const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
4119
4174
 
4120
4175
  // Metrics are a direct array, not nested by alias
@@ -4122,15 +4177,12 @@ const Metrics = ({
4122
4177
  const items = provider.metrics.map((metric, index) => {
4123
4178
  // Construct the fullPath for this metric
4124
4179
  const fullPath = `${aliasPath.join('_')}.${metric.name}`;
4125
-
4126
- // Check if this metric is already selected
4127
- const isAlreadySelected = selectedFullPaths.has(fullPath);
4128
4180
  return {
4129
4181
  key: `${currentProvider}_${metric.name}_${index}`,
4130
4182
  value: metric.title || metric.name,
4131
4183
  metricName: metric.name,
4132
4184
  metric: metric,
4133
- disabled: isAlreadySelected,
4185
+ alreadySelected: selectedFullPaths.has(fullPath),
4134
4186
  icon: metric.source ? /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
4135
4187
  source: metric.source
4136
4188
  }) : undefined
@@ -4193,7 +4245,11 @@ const Metrics = ({
4193
4245
  metricName: selectedItem.metricName,
4194
4246
  metricTitle: selectedItem.value,
4195
4247
  // The constructed path for server
4196
- fullPath: fullPath
4248
+ fullPath: fullPath,
4249
+ // Unique per-instance identity, so this field can be added more
4250
+ // than once and each occurrence stays independently
4251
+ // orderable/removable/titleable.
4252
+ id: nextInstanceId(fullPath, savedMetrics)
4197
4253
  };
4198
4254
  onSaveMetric(metricData);
4199
4255
  handleCancel();
@@ -4204,8 +4260,9 @@ const Metrics = ({
4204
4260
  const handleAddAll = () => {
4205
4261
  const metricItems = getMetricItems();
4206
4262
 
4207
- // Filter out already selected metrics (disabled items)
4208
- const availableItems = metricItems.filter(item => !item.disabled);
4263
+ // Filter out already selected metrics - "Add all" only fills in
4264
+ // missing ones; intentional duplicates are added one at a time above.
4265
+ const availableItems = metricItems.filter(item => !item.alreadySelected);
4209
4266
  if (availableItems.length === 0) return;
4210
4267
 
4211
4268
  // Build the complete relation objects array (same for all metrics in this provider)
@@ -4250,7 +4307,10 @@ const Metrics = ({
4250
4307
  metricName: item.metricName,
4251
4308
  metricTitle: item.value,
4252
4309
  // The constructed path for server
4253
- fullPath: fullPath
4310
+ fullPath: fullPath,
4311
+ // "Add all" only ever adds fields that aren't already
4312
+ // selected, so this is always the first occurrence.
4313
+ id: fullPath
4254
4314
  };
4255
4315
  onSaveMetric(metricData);
4256
4316
  });
@@ -4349,7 +4409,7 @@ const Metrics = ({
4349
4409
  placement: "top"
4350
4410
  }, /*#__PURE__*/React__default.createElement("span", null, /*#__PURE__*/React__default.createElement(IconButton$1, {
4351
4411
  onClick: handleAddAll,
4352
- disabled: getMetricItems().filter(item => !item.disabled).length === 0,
4412
+ disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
4353
4413
  "aria-label": "add all metrics",
4354
4414
  sx: {
4355
4415
  color: "rgb(70, 134, 128)",
@@ -4418,36 +4478,27 @@ const Metrics = ({
4418
4478
  marginBottom: 1,
4419
4479
  color: "rgb(37, 37, 37)"
4420
4480
  }
4421
- }, "Saved Metrics"), /*#__PURE__*/React__default.createElement(DndContext, {
4481
+ }, "Saved Metrics"), /*#__PURE__*/React__default.createElement(SortableListContext, {
4422
4482
  sensors: sensors,
4423
- collisionDetection: closestCenter,
4483
+ itemIds: savedMetrics.map((_, index) => index),
4424
4484
  onDragEnd: handleDragEnd
4425
- }, /*#__PURE__*/React__default.createElement(SortableContext, {
4426
- items: savedMetrics.map((_, index) => index),
4427
- strategy: verticalListSortingStrategy
4428
- }, /*#__PURE__*/React__default.createElement(Box$1, {
4429
- sx: {
4430
- display: 'flex',
4431
- flexDirection: 'column',
4432
- gap: 1
4433
- }
4434
4485
  }, savedMetrics.map((metric, index) => /*#__PURE__*/React__default.createElement(SortableChip, {
4435
4486
  key: index,
4436
4487
  id: index,
4437
4488
  label: metric.metricTitle,
4438
4489
  fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
4439
- onDelete: () => onRemoveMetric(index),
4490
+ onDelete: () => onRemoveMetric(metric.id),
4440
4491
  onMoveUp: () => handleMoveUp(index),
4441
4492
  onMoveDown: () => handleMoveDown(index),
4442
4493
  isFirst: index === 0,
4443
4494
  isLast: index === savedMetrics.length - 1,
4444
- fullPath: metric.fullPath,
4495
+ instanceId: metric.id,
4445
4496
  defaultTitle: metric.metricTitle,
4446
- customTitle: titleOverrides[metric.fullPath],
4497
+ customTitle: titleOverrides[metric.id],
4447
4498
  onUpdateTitle: onUpdateTitle,
4448
4499
  onResetTitle: onResetTitle,
4449
4500
  source: metric.metric?.source
4450
- })))))));
4501
+ })))));
4451
4502
  };
4452
4503
 
4453
4504
  const Filters = ({
@@ -5280,7 +5331,12 @@ const Filters = ({
5280
5331
  mb: 3
5281
5332
  }
5282
5333
  }, /*#__PURE__*/React__default.createElement(SingleSelect, {
5283
- items: existingDimensions.map(dim => ({
5334
+ items: Array.from(
5335
+ // Dimensions can now be added more than once (same fullPath,
5336
+ // different instance), but a filter targets the underlying
5337
+ // field regardless of how many columns display it - so this
5338
+ // list is deduped by fullPath to avoid showing it twice.
5339
+ new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
5284
5340
  key: dim.fullPath,
5285
5341
  value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
5286
5342
  })).sort((a, b) => a.value.localeCompare(b.value)),
@@ -5674,6 +5730,205 @@ const Filters = ({
5674
5730
  }))));
5675
5731
  };
5676
5732
 
5733
+ const KIND_LABEL = {
5734
+ dimension: "Dimension",
5735
+ computed: "Computed",
5736
+ metric: "Metric"
5737
+ };
5738
+ const KindChip = ({
5739
+ kind,
5740
+ source
5741
+ }) => {
5742
+ if (kind === "metric") {
5743
+ return /*#__PURE__*/React__default.createElement(Box$1, {
5744
+ sx: {
5745
+ display: "flex",
5746
+ alignItems: "center",
5747
+ flexShrink: 0
5748
+ }
5749
+ }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
5750
+ source: source
5751
+ }));
5752
+ }
5753
+ return /*#__PURE__*/React__default.createElement(Chip, {
5754
+ icon: kind === "computed" ? /*#__PURE__*/React__default.createElement(CalculateIcon, {
5755
+ sx: {
5756
+ fontSize: "15px !important"
5757
+ }
5758
+ }) : undefined,
5759
+ label: KIND_LABEL[kind],
5760
+ size: "small",
5761
+ sx: {
5762
+ fontWeight: 500,
5763
+ backgroundColor: "rgba(70, 134, 128, 0.1)",
5764
+ color: "rgb(70, 134, 128)",
5765
+ flexShrink: 0
5766
+ }
5767
+ });
5768
+ };
5769
+
5770
+ // Read-only sortable row: this tab only reorders columns. Adding, removing,
5771
+ // and renaming stay on the Dimensions/Metrics tabs so there's one place each
5772
+ // field is managed.
5773
+ const SortableColumnRow = ({
5774
+ id,
5775
+ label,
5776
+ fullPath,
5777
+ kind,
5778
+ source,
5779
+ onMoveUp,
5780
+ onMoveDown,
5781
+ isFirst,
5782
+ isLast
5783
+ }) => {
5784
+ const reportingContext = useReportingContextOptional();
5785
+ const parameters = reportingContext?.parameters;
5786
+ const {
5787
+ attributes,
5788
+ listeners,
5789
+ setNodeRef,
5790
+ style
5791
+ } = useSortableRow(id);
5792
+ const displayLabel = interpolateTitle(label, parameters);
5793
+ return /*#__PURE__*/React__default.createElement("div", _extends({
5794
+ ref: setNodeRef,
5795
+ style: style
5796
+ }, attributes), /*#__PURE__*/React__default.createElement(Box$1, {
5797
+ sx: {
5798
+ display: "flex",
5799
+ alignItems: "center",
5800
+ width: "100%",
5801
+ gap: 1,
5802
+ backgroundColor: "white",
5803
+ border: "1px solid #e0e0e0",
5804
+ borderRadius: 2,
5805
+ padding: 1,
5806
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
5807
+ "&:hover .hover-icons": {
5808
+ opacity: 1
5809
+ }
5810
+ }
5811
+ }, /*#__PURE__*/React__default.createElement(DragHandle, {
5812
+ listeners: listeners
5813
+ }), /*#__PURE__*/React__default.createElement(KindChip, {
5814
+ kind: kind,
5815
+ source: source
5816
+ }), /*#__PURE__*/React__default.createElement(Tooltip$1, {
5817
+ title: fullPath,
5818
+ arrow: true,
5819
+ placement: "top"
5820
+ }, /*#__PURE__*/React__default.createElement(Typography$1, {
5821
+ variant: "h6",
5822
+ sx: {
5823
+ fontWeight: 500,
5824
+ color: "#1a1a1a",
5825
+ fontSize: "14px",
5826
+ whiteSpace: "nowrap",
5827
+ overflow: "hidden",
5828
+ textOverflow: "ellipsis"
5829
+ }
5830
+ }, displayLabel)), /*#__PURE__*/React__default.createElement(Box$1, {
5831
+ sx: {
5832
+ flex: 1
5833
+ }
5834
+ }), /*#__PURE__*/React__default.createElement(MoveButtons, {
5835
+ onMoveUp: onMoveUp,
5836
+ onMoveDown: onMoveDown,
5837
+ isFirst: isFirst,
5838
+ isLast: isLast
5839
+ })));
5840
+ };
5841
+ const ColumnOrder = ({
5842
+ orderedDimensionItems = [],
5843
+ metrics = [],
5844
+ columnOrder = [],
5845
+ onReorderColumnItems,
5846
+ titleOverrides = {
5847
+ dimensions: {},
5848
+ metrics: {}
5849
+ }
5850
+ }) => {
5851
+ const isCustomized = columnOrder.length > 0;
5852
+ const resolvedItems = resolveColumnOrder(columnOrder, orderedDimensionItems, metrics);
5853
+ const sensors = useSortableListSensors();
5854
+ const reorderResolved = newResolvedItems => {
5855
+ onReorderColumnItems(newResolvedItems.map(item => ({
5856
+ type: item.kind,
5857
+ key: item.key
5858
+ })));
5859
+ };
5860
+ const {
5861
+ handleDragEnd,
5862
+ handleMoveUp,
5863
+ handleMoveDown
5864
+ } = makeListReorderHandlers(resolvedItems, reorderResolved);
5865
+ const labelFor = item => {
5866
+ if (item.kind === "computed") return item.data.name;
5867
+ if (item.kind === "metric") {
5868
+ return titleOverrides.metrics[item.key] || item.data.metricTitle || item.key;
5869
+ }
5870
+ return titleOverrides.dimensions[item.key] || item.data.dimensionTitle || item.key;
5871
+ };
5872
+ return /*#__PURE__*/React__default.createElement("div", null, /*#__PURE__*/React__default.createElement(Box$1, {
5873
+ sx: {
5874
+ display: "flex",
5875
+ justifyContent: "space-between",
5876
+ alignItems: "flex-start",
5877
+ mb: 2,
5878
+ gap: 2
5879
+ }
5880
+ }, /*#__PURE__*/React__default.createElement(Typography$1, {
5881
+ sx: {
5882
+ fontSize: "13px",
5883
+ color: "#666"
5884
+ }
5885
+ }, "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__*/React__default.createElement(Tooltip$1, {
5886
+ title: "Discard the custom order and go back to the default (Dimensions, then Metrics)",
5887
+ arrow: true
5888
+ }, /*#__PURE__*/React__default.createElement(Button, {
5889
+ variant: "outlined",
5890
+ size: "small",
5891
+ startIcon: /*#__PURE__*/React__default.createElement(RestartAltIcon, null),
5892
+ onClick: () => onReorderColumnItems([]),
5893
+ sx: {
5894
+ height: "36px",
5895
+ flexShrink: 0,
5896
+ fontFamily: "system-ui",
5897
+ fontSize: "13px",
5898
+ fontWeight: 500,
5899
+ borderRadius: "8px",
5900
+ boxShadow: "none",
5901
+ textTransform: "none",
5902
+ borderColor: "#e0e0e0",
5903
+ color: "rgb(37, 37, 37)",
5904
+ "&:hover": {
5905
+ backgroundColor: "#f5f5f5",
5906
+ borderColor: "#d0d0d0"
5907
+ }
5908
+ }
5909
+ }, "Reset to Default"))), resolvedItems.length === 0 ? /*#__PURE__*/React__default.createElement(Typography$1, {
5910
+ sx: {
5911
+ fontSize: "13px",
5912
+ color: "#999"
5913
+ }
5914
+ }, "Add dimensions or metrics in the other tabs first.") : /*#__PURE__*/React__default.createElement(SortableListContext, {
5915
+ sensors: sensors,
5916
+ itemIds: resolvedItems.map((_, index) => index),
5917
+ onDragEnd: handleDragEnd
5918
+ }, resolvedItems.map((item, index) => /*#__PURE__*/React__default.createElement(SortableColumnRow, {
5919
+ key: `${item.kind}-${item.key}`,
5920
+ id: index,
5921
+ label: labelFor(item),
5922
+ fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
5923
+ kind: item.kind,
5924
+ source: item.kind === "metric" ? item.data.metric?.source : undefined,
5925
+ onMoveUp: () => handleMoveUp(index),
5926
+ onMoveDown: () => handleMoveDown(index),
5927
+ isFirst: index === 0,
5928
+ isLast: index === resolvedItems.length - 1
5929
+ }))));
5930
+ };
5931
+
5677
5932
  // Default numeral.js formats applied when a dimension/metric definition has a
5678
5933
  // known type but no explicit `format` set on the provider.
5679
5934
  const DEFAULT_FORMATS_BY_TYPE = {
@@ -5688,10 +5943,122 @@ const formatValue = (value, def) => {
5688
5943
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
5689
5944
  };
5690
5945
  const isNumericType = type => type === 'integer' || type === 'currency';
5946
+
5947
+ // Builds a single DataGrid column definition for one resolved column item
5948
+ // (a dimension, computed dimension, or metric). The DataGrid `field` is the
5949
+ // item's instance id (so two duplicate instances of the same underlying
5950
+ // dimension/metric still get distinct columns); the actual cell value is
5951
+ // read via `valueGetter` from `dataFieldName`, the key the API response
5952
+ // actually uses - shared by every duplicate instance, since the backend
5953
+ // only ever returns one value per fullPath. dataFieldName rules:
5954
+ // - Dimensions from base provider: baseAlias.field -> baseAlias_field
5955
+ // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5956
+ // - Computed dimensions have no provider/relations chain, so the API returns
5957
+ // each row keyed by the computed dimension's own `name` (and can't be
5958
+ // duplicated - names must be unique - so field/dataFieldName coincide).
5959
+ // - Metrics: API returns metric keys in different forms depending on path depth
5960
+ // (see comment inline below), normalised to match the row-key normalisation
5961
+ // done when building `rows`.
5962
+ const buildColumn = (item, titleOverrides, parameters) => {
5963
+ if (item.kind === 'computed') {
5964
+ const cd = item.data;
5965
+ return {
5966
+ field: cd.name,
5967
+ headerName: cd.name,
5968
+ flex: 1,
5969
+ minWidth: 150,
5970
+ type: 'string'
5971
+ };
5972
+ }
5973
+ if (item.kind === 'metric') {
5974
+ const metric = item.data;
5975
+ const metricDef = metric.metric;
5976
+ let dataFieldName;
5977
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
5978
+ if (dotCount >= 2) {
5979
+ // 3+-part namespaced path: API returns the full dotted path as the key.
5980
+ // Normalise dots to underscores to match the row key normalisation below.
5981
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5982
+ dataFieldName = metric.fullPath.replace(/\./g, '_');
5983
+ } else if (metric.relations && metric.relations.length > 0) {
5984
+ // 2-part path from a nested provider: API drops the base provider alias.
5985
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
5986
+ const parts = metric.fullPath.split('.');
5987
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
5988
+ const field = parts[1]; // e.g., "total_amount"
5989
+
5990
+ const pathParts = pathWithoutField.split('_');
5991
+ pathParts.shift(); // remove base provider alias
5992
+ const pathWithoutBase = pathParts.join('_');
5993
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5994
+ } else {
5995
+ // 2-part path from the base provider: API returns just the metric name.
5996
+ // Example: "mc.record_count" -> "record_count"
5997
+ dataFieldName = metric.metricName;
5998
+ }
5999
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
6000
+ return {
6001
+ // `field` is the instance id (unique even for duplicate columns
6002
+ // pointing at the same underlying metric); the actual value is
6003
+ // read from the row via valueGetter using dataFieldName, which is
6004
+ // the key the API response actually uses (shared across duplicates).
6005
+ field: metric.id.replace(/[.#]/g, '_'),
6006
+ headerName: headerName,
6007
+ flex: 1,
6008
+ minWidth: 150,
6009
+ type: isNumericType(metricDef?.type) ? 'number' : 'string',
6010
+ valueGetter: (_value, row) => row[dataFieldName],
6011
+ renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
6012
+ sx: {
6013
+ display: 'flex',
6014
+ alignItems: 'center',
6015
+ gap: 0.5
6016
+ }
6017
+ }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
6018
+ source: metricDef?.source
6019
+ }), /*#__PURE__*/React__default.createElement("span", null, headerName)),
6020
+ valueFormatter: value => formatValue(value, metricDef)
6021
+ };
6022
+ }
6023
+
6024
+ // Dimension
6025
+ const dim = item.data;
6026
+ let dataFieldName;
6027
+ if (dim.relations && dim.relations.length > 0) {
6028
+ // From nested provider: drop the base provider alias
6029
+ // Example: ft_fa_db.currency -> fa_db_currency
6030
+ const parts = dim.fullPath.split('.');
6031
+ const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
6032
+ const field = parts[1]; // e.g., "currency"
6033
+
6034
+ const pathParts = pathWithoutField.split('_');
6035
+ pathParts.shift(); // Remove base provider alias
6036
+ const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
6037
+
6038
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6039
+ } else {
6040
+ // From base provider: keep the full path with underscore
6041
+ // Example: ba.created_at -> ba_created_at
6042
+ dataFieldName = dim.fullPath.replace('.', '_');
6043
+ }
6044
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
6045
+ const dimDef = dim.dimension;
6046
+ return {
6047
+ // Same rationale as the metric branch above: `field` must be unique
6048
+ // per instance, while the value always comes from the one real
6049
+ // underlying row key (dataFieldName), shared by every duplicate.
6050
+ field: dim.id.replace(/[.#]/g, '_'),
6051
+ headerName: headerName,
6052
+ flex: 1,
6053
+ minWidth: 150,
6054
+ type: isNumericType(dimDef?.type) ? 'number' : 'string',
6055
+ valueGetter: (_value, row) => row[dataFieldName],
6056
+ valueFormatter: value => formatValue(value, dimDef)
6057
+ };
6058
+ };
5691
6059
  const ReportDataGrid = ({
5692
6060
  reportData,
5693
- orderedDimensionItems = [],
5694
- metrics,
6061
+ columnItems = [],
5695
6062
  loading,
5696
6063
  onPageChange,
5697
6064
  totalRows = 0,
@@ -5707,127 +6074,10 @@ const ReportDataGrid = ({
5707
6074
  pageSize: 50
5708
6075
  });
5709
6076
 
5710
- // Generate columns dynamically from dimensions and metrics
5711
- const columns = React__default.useMemo(() => {
5712
- const cols = [];
5713
-
5714
- // Add dimension + computed-dimension columns, in the single unified
5715
- // order the user arranged them in on the Dimensions tab.
5716
- // Field naming logic (dimensions):
5717
- // - If from base provider: baseAlias.field -> baseAlias_field
5718
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5719
- // Computed dimensions have no provider/relations chain, so the API
5720
- // returns each row keyed by the computed dimension's own `name`.
5721
- orderedDimensionItems.forEach(item => {
5722
- if (item.kind === 'computed') {
5723
- const cd = item.data;
5724
- cols.push({
5725
- field: cd.name,
5726
- headerName: cd.name,
5727
- flex: 1,
5728
- minWidth: 150,
5729
- type: 'string'
5730
- });
5731
- return;
5732
- }
5733
- const dim = item.data;
5734
- let fieldName;
5735
- let headerName;
5736
-
5737
- // Check if there are relations (nested providers)
5738
- if (dim.relations && dim.relations.length > 0) {
5739
- // From nested provider: drop the base provider alias
5740
- // Example: ft_fa_db.currency -> fa_db_currency
5741
- const parts = dim.fullPath.split('.');
5742
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5743
- const field = parts[1]; // e.g., "currency"
5744
-
5745
- // Remove the base provider alias (first part before first underscore)
5746
- const pathParts = pathWithoutField.split('_');
5747
- pathParts.shift(); // Remove base provider alias
5748
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5749
-
5750
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5751
- } else {
5752
- // From base provider: keep the full path with underscore
5753
- // Example: ba.created_at -> ba_created_at
5754
- fieldName = dim.fullPath.replace('.', '_');
5755
- }
5756
-
5757
- // Check for title override, otherwise use the friendly dimension title from provider
5758
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
5759
- headerName = interpolateTitle(headerName, parameters);
5760
- const dimDef = dim.dimension;
5761
- cols.push({
5762
- field: fieldName,
5763
- headerName: headerName,
5764
- flex: 1,
5765
- minWidth: 150,
5766
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
5767
- valueFormatter: value => formatValue(value, dimDef)
5768
- });
5769
- });
5770
-
5771
- // Add metric columns
5772
- // The API returns metric keys in two different forms depending on the path depth:
5773
- //
5774
- // • 2-part path "mc.record_count" → API key: "record_count"
5775
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
5776
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
5777
- //
5778
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
5779
- // in field names as nested-object accessors, so the rows useMemo normalises
5780
- // those keys (dots → underscores). The column field must match that normalised key.
5781
- metrics.forEach(metric => {
5782
- const metricDef = metric.metric;
5783
- let fieldName;
5784
- let headerName;
5785
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
5786
- if (dotCount >= 2) {
5787
- // 3+-part namespaced path: API returns the full dotted path as the key.
5788
- // Normalise dots to underscores to match the row key normalisation below.
5789
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5790
- fieldName = metric.fullPath.replace(/\./g, '_');
5791
- } else if (metric.relations && metric.relations.length > 0) {
5792
- // 2-part path from a nested provider: API drops the base provider alias.
5793
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
5794
- const parts = metric.fullPath.split('.');
5795
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
5796
- const field = parts[1]; // e.g., "total_amount"
5797
-
5798
- const pathParts = pathWithoutField.split('_');
5799
- pathParts.shift(); // remove base provider alias
5800
- const pathWithoutBase = pathParts.join('_');
5801
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5802
- } else {
5803
- // 2-part path from the base provider: API returns just the metric name.
5804
- // Example: "mc.record_count" -> "record_count"
5805
- fieldName = metric.metricName;
5806
- }
5807
-
5808
- // Check for title override, otherwise use the friendly metric title from provider
5809
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
5810
- headerName = interpolateTitle(headerName, parameters);
5811
- cols.push({
5812
- field: fieldName,
5813
- headerName: headerName,
5814
- flex: 1,
5815
- minWidth: 150,
5816
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
5817
- renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
5818
- sx: {
5819
- display: 'flex',
5820
- alignItems: 'center',
5821
- gap: 0.5
5822
- }
5823
- }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
5824
- source: metricDef?.source
5825
- }), /*#__PURE__*/React__default.createElement("span", null, headerName)),
5826
- valueFormatter: value => formatValue(value, metricDef)
5827
- });
5828
- });
5829
- return cols;
5830
- }, [orderedDimensionItems, metrics, titleOverrides, parameters]);
6077
+ // Generate columns dynamically, in the final resolved column order
6078
+ // (custom Column Order tab arrangement when defined, otherwise
6079
+ // dimensions/computed then metrics).
6080
+ const columns = React__default.useMemo(() => columnItems.map(item => buildColumn(item, titleOverrides, parameters)), [columnItems, titleOverrides, parameters]);
5831
6081
 
5832
6082
  // Transform report data to rows with unique IDs.
5833
6083
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -5937,7 +6187,12 @@ const ReportBuilder = ({
5937
6187
  computedDimensions: [],
5938
6188
  // Unified display/execution order across dimensions + computedDimensions.
5939
6189
  // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
5940
- dimensionOrder: []
6190
+ dimensionOrder: [],
6191
+ // Optional unified display order across dimensions + computedDimensions +
6192
+ // metrics, set on the Column Order tab. Empty means "not defined" — the
6193
+ // results grid falls back to dimensionOrder followed by metrics order.
6194
+ // Entries: { type: 'dimension' | 'computed' | 'metric', key }
6195
+ columnOrder: []
5941
6196
  });
5942
6197
  const [titleOverrides, setTitleOverrides] = useState({
5943
6198
  dimensions: {},
@@ -6123,6 +6378,72 @@ const ReportBuilder = ({
6123
6378
  }
6124
6379
  };
6125
6380
 
6381
+ // Reconstructs saved dimensions as independent, individually-identified
6382
+ // instances so that deliberate duplicates (the same field added more than
6383
+ // once) survive a save/reload round trip. Prefers `ordered_dimensions`
6384
+ // (one ref per instance - the nth duplicate of a path is suffixed
6385
+ // `#n`) when present; falls back to one instance per entry in the
6386
+ // (necessarily duplicate-free) `dimensions` path list for reports saved
6387
+ // before this field existed.
6388
+ const reconstructDimensions = (reportDef, providersData) => {
6389
+ const orderByMap = {};
6390
+ if (reportDef.definition?.doc?.query?.order_by) {
6391
+ for (const orderItem of reportDef.definition.doc.query.order_by) {
6392
+ orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
6393
+ }
6394
+ }
6395
+ const reconstructed = [];
6396
+ const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
6397
+ if (orderedRefs.length > 0) {
6398
+ orderedRefs.forEach(ref => {
6399
+ const fullPath = ref.split("#")[0];
6400
+ const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
6401
+ if (dim) {
6402
+ dim.id = ref;
6403
+ dim.sortOrder = orderByMap[fullPath] || null;
6404
+ reconstructed.push(dim);
6405
+ }
6406
+ });
6407
+ } else if (reportDef.definition?.doc?.query?.dimensions) {
6408
+ for (const dimPath of reportDef.definition.doc.query.dimensions) {
6409
+ const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6410
+ if (dim) {
6411
+ dim.id = dimPath;
6412
+ dim.sortOrder = orderByMap[dimPath] || null;
6413
+ reconstructed.push(dim);
6414
+ }
6415
+ }
6416
+ }
6417
+ return reconstructed;
6418
+ };
6419
+
6420
+ // Mirrors reconstructDimensions for metrics, using the analogous
6421
+ // `ordered_metrics` field (metrics have no other unified-order concept to
6422
+ // fall back on, so this is always written whenever there are metrics).
6423
+ const reconstructMetrics = (reportDef, providersData) => {
6424
+ const reconstructed = [];
6425
+ const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
6426
+ if (orderedRefs.length > 0) {
6427
+ orderedRefs.forEach(ref => {
6428
+ const fullPath = ref.split("#")[0];
6429
+ const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
6430
+ if (metric) {
6431
+ metric.id = ref;
6432
+ reconstructed.push(metric);
6433
+ }
6434
+ });
6435
+ } else if (reportDef.definition?.doc?.query?.metrics) {
6436
+ for (const metricPath of reportDef.definition.doc.query.metrics) {
6437
+ const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6438
+ if (metric) {
6439
+ metric.id = metricPath;
6440
+ reconstructed.push(metric);
6441
+ }
6442
+ }
6443
+ }
6444
+ return reconstructed;
6445
+ };
6446
+
6126
6447
  // Build the unified display/execution order for dimensions + computed
6127
6448
  // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
6128
6449
  // to "dimensions first, then computed dimensions" for report definitions
@@ -6133,7 +6454,7 @@ const ReportBuilder = ({
6133
6454
  const seenComputedKeys = new Set();
6134
6455
  if (Array.isArray(rawOrderedDimensions)) {
6135
6456
  rawOrderedDimensions.forEach(entry => {
6136
- if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
6457
+ if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
6137
6458
  dimensionOrder.push({
6138
6459
  type: "dimension",
6139
6460
  key: entry.ref
@@ -6151,10 +6472,10 @@ const ReportBuilder = ({
6151
6472
 
6152
6473
  // Append anything not covered by ordered_dimensions (older reports, or drift)
6153
6474
  dimensions.forEach(d => {
6154
- if (!seenDimensionKeys.has(d.fullPath)) {
6475
+ if (!seenDimensionKeys.has(d.id)) {
6155
6476
  dimensionOrder.push({
6156
6477
  type: "dimension",
6157
- key: d.fullPath
6478
+ key: d.id
6158
6479
  });
6159
6480
  }
6160
6481
  });
@@ -6168,6 +6489,46 @@ const ReportBuilder = ({
6168
6489
  });
6169
6490
  return dimensionOrder;
6170
6491
  };
6492
+
6493
+ // Build the optional unified column order (dimensions + computed
6494
+ // dimensions + metrics) from the persisted `ordered_columns` field.
6495
+ // Unlike buildDimensionOrder, entries that no longer resolve are simply
6496
+ // dropped rather than backfilled — an empty/partial result just means "no
6497
+ // custom order defined (yet)", which resolveColumnOrder treats as
6498
+ // "fall back to the default order".
6499
+ const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
6500
+ if (!Array.isArray(rawOrderedColumns)) return [];
6501
+ const dimensionIds = new Set(dimensions.map(d => d.id));
6502
+ const computedNames = new Set(computedDimensions.map(cd => cd.name));
6503
+ const metricIds = new Set(metrics.map(m => m.id));
6504
+ const columnOrder = [];
6505
+ rawOrderedColumns.forEach(entry => {
6506
+ if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
6507
+ columnOrder.push({
6508
+ type: "dimension",
6509
+ key: entry.ref
6510
+ });
6511
+ } else if (entry.type === "computed" && computedNames.has(entry.ref)) {
6512
+ columnOrder.push({
6513
+ type: "computed",
6514
+ key: entry.ref
6515
+ });
6516
+ } else if (entry.type === "metric" && metricIds.has(entry.ref)) {
6517
+ columnOrder.push({
6518
+ type: "metric",
6519
+ key: entry.ref
6520
+ });
6521
+ }
6522
+ });
6523
+ return columnOrder;
6524
+ };
6525
+
6526
+ // Auto-append a newly added dimension/computed dimension/metric to the end
6527
+ // of columnOrder — but only once a custom order actually exists. While
6528
+ // columnOrder is empty (no custom order defined) it's left untouched, so
6529
+ // the results grid keeps falling back to the default dimensions-then-
6530
+ // metrics order until the user visits the Column Order tab.
6531
+ const appendToColumnOrder = (columnOrder, entry) => columnOrder.length > 0 ? [...columnOrder, entry] : columnOrder;
6171
6532
  const loadReportDefinition = async id => {
6172
6533
  try {
6173
6534
  console.log("Loading report definition:", id);
@@ -6182,43 +6543,10 @@ const ReportBuilder = ({
6182
6543
  // Set the title
6183
6544
  setReportTitle(reportDef.title || "");
6184
6545
 
6185
- // Reconstruct dimensions
6186
- const reconstructedDimensions = [];
6187
- const orderByMap = {}; // Map to store sort order from order_by array
6188
-
6189
- // First, build a map of fullPath -> sortOrder from order_by
6190
- if (reportDef.definition?.doc?.query?.order_by) {
6191
- for (const orderItem of reportDef.definition.doc.query.order_by) {
6192
- if (orderItem.desc === true) {
6193
- orderByMap[orderItem.name] = "desc";
6194
- } else {
6195
- orderByMap[orderItem.name] = "asc";
6196
- }
6197
- }
6198
- }
6199
-
6200
- // Now reconstruct dimensions and add sortOrder from the map
6201
- if (reportDef.definition?.doc?.query?.dimensions) {
6202
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
6203
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6204
- if (dim) {
6205
- // Add sortOrder from order_by if it exists
6206
- dim.sortOrder = orderByMap[dimPath] || null;
6207
- reconstructedDimensions.push(dim);
6208
- }
6209
- }
6210
- }
6211
-
6212
- // Reconstruct metrics
6213
- const reconstructedMetrics = [];
6214
- if (reportDef.definition?.doc?.query?.metrics) {
6215
- for (const metricPath of reportDef.definition.doc.query.metrics) {
6216
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6217
- if (metric) {
6218
- reconstructedMetrics.push(metric);
6219
- }
6220
- }
6221
- }
6546
+ // Reconstruct dimensions and metrics as independent instances
6547
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
6548
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
6549
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
6222
6550
 
6223
6551
  // Load title overrides if they exist
6224
6552
  const loadedTitleOverrides = {
@@ -6264,6 +6592,7 @@ const ReportBuilder = ({
6264
6592
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6265
6593
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6266
6594
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6595
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6267
6596
 
6268
6597
  // Set the report state
6269
6598
  setReport({
@@ -6271,7 +6600,8 @@ const ReportBuilder = ({
6271
6600
  metrics: reconstructedMetrics,
6272
6601
  filters: loadedFilters,
6273
6602
  computedDimensions,
6274
- dimensionOrder
6603
+ dimensionOrder,
6604
+ columnOrder
6275
6605
  });
6276
6606
 
6277
6607
  // Set title overrides
@@ -6300,43 +6630,10 @@ const ReportBuilder = ({
6300
6630
  // Set the title (already modified with " (Copy)" suffix)
6301
6631
  setReportTitle(reportDef.title || "");
6302
6632
 
6303
- // Reconstruct dimensions
6304
- const reconstructedDimensions = [];
6305
- const orderByMap = {}; // Map to store sort order from order_by array
6306
-
6307
- // First, build a map of fullPath -> sortOrder from order_by
6308
- if (reportDef.definition?.doc?.query?.order_by) {
6309
- for (const orderItem of reportDef.definition.doc.query.order_by) {
6310
- if (orderItem.desc === true) {
6311
- orderByMap[orderItem.name] = "desc";
6312
- } else {
6313
- orderByMap[orderItem.name] = "asc";
6314
- }
6315
- }
6316
- }
6317
-
6318
- // Now reconstruct dimensions and add sortOrder from the map
6319
- if (reportDef.definition?.doc?.query?.dimensions) {
6320
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
6321
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6322
- if (dim) {
6323
- // Add sortOrder from order_by if it exists
6324
- dim.sortOrder = orderByMap[dimPath] || null;
6325
- reconstructedDimensions.push(dim);
6326
- }
6327
- }
6328
- }
6329
-
6330
- // Reconstruct metrics
6331
- const reconstructedMetrics = [];
6332
- if (reportDef.definition?.doc?.query?.metrics) {
6333
- for (const metricPath of reportDef.definition.doc.query.metrics) {
6334
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6335
- if (metric) {
6336
- reconstructedMetrics.push(metric);
6337
- }
6338
- }
6339
- }
6633
+ // Reconstruct dimensions and metrics as independent instances
6634
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
6635
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
6636
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
6340
6637
 
6341
6638
  // Load title overrides if they exist
6342
6639
  const loadedTitleOverrides = {
@@ -6381,6 +6678,7 @@ const ReportBuilder = ({
6381
6678
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6382
6679
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6383
6680
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6681
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6384
6682
 
6385
6683
  // Set the report state
6386
6684
  setReport({
@@ -6388,7 +6686,8 @@ const ReportBuilder = ({
6388
6686
  metrics: reconstructedMetrics,
6389
6687
  filters: loadedFilters,
6390
6688
  computedDimensions,
6391
- dimensionOrder
6689
+ dimensionOrder,
6690
+ columnOrder
6392
6691
  });
6393
6692
 
6394
6693
  // Set title overrides
@@ -6422,7 +6721,8 @@ const ReportBuilder = ({
6422
6721
  metrics: [],
6423
6722
  filters: {},
6424
6723
  computedDimensions: [],
6425
- dimensionOrder: []
6724
+ dimensionOrder: [],
6725
+ columnOrder: []
6426
6726
  });
6427
6727
  // Reset title overrides
6428
6728
  setTitleOverrides({
@@ -6432,42 +6732,45 @@ const ReportBuilder = ({
6432
6732
  });
6433
6733
  console.log("Selected root provider:", event.target.value);
6434
6734
  };
6435
- const handleUpdateDimensionTitle = (fullPath, customTitle) => {
6735
+
6736
+ // Keyed by instance id, so two instances of the same dimension can carry
6737
+ // independent custom titles.
6738
+ const handleUpdateDimensionTitle = (id, customTitle) => {
6436
6739
  setTitleOverrides(prev => ({
6437
6740
  ...prev,
6438
6741
  dimensions: {
6439
6742
  ...prev.dimensions,
6440
- [fullPath]: customTitle
6743
+ [id]: customTitle
6441
6744
  }
6442
6745
  }));
6443
6746
  };
6444
- const handleResetDimensionTitle = fullPath => {
6747
+ const handleResetDimensionTitle = id => {
6445
6748
  setTitleOverrides(prev => {
6446
6749
  const newDimensions = {
6447
6750
  ...prev.dimensions
6448
6751
  };
6449
- delete newDimensions[fullPath];
6752
+ delete newDimensions[id];
6450
6753
  return {
6451
6754
  ...prev,
6452
6755
  dimensions: newDimensions
6453
6756
  };
6454
6757
  });
6455
6758
  };
6456
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
6759
+ const handleUpdateMetricTitle = (id, customTitle) => {
6457
6760
  setTitleOverrides(prev => ({
6458
6761
  ...prev,
6459
6762
  metrics: {
6460
6763
  ...prev.metrics,
6461
- [fullPath]: customTitle
6764
+ [id]: customTitle
6462
6765
  }
6463
6766
  }));
6464
6767
  };
6465
- const handleResetMetricTitle = fullPath => {
6768
+ const handleResetMetricTitle = id => {
6466
6769
  setTitleOverrides(prev => {
6467
6770
  const newMetrics = {
6468
6771
  ...prev.metrics
6469
6772
  };
6470
- delete newMetrics[fullPath];
6773
+ delete newMetrics[id];
6471
6774
  return {
6472
6775
  ...prev,
6473
6776
  metrics: newMetrics
@@ -6502,29 +6805,42 @@ const ReportBuilder = ({
6502
6805
  dimensions: [...prev.dimensions, dimensionData],
6503
6806
  dimensionOrder: [...prev.dimensionOrder, {
6504
6807
  type: "dimension",
6505
- key: dimensionData.fullPath
6506
- }]
6808
+ key: dimensionData.id
6809
+ }],
6810
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6811
+ type: "dimension",
6812
+ key: dimensionData.id
6813
+ })
6507
6814
  };
6508
6815
  console.log("Dimension saved:", dimensionData);
6509
6816
  console.log("Complete report:", newReport);
6510
6817
  return newReport;
6511
6818
  });
6512
6819
  };
6513
- const handleRemoveDimension = fullPath => {
6820
+ const handleRemoveDimension = id => {
6514
6821
  setReport(prev => ({
6515
6822
  ...prev,
6516
- dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6517
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
6823
+ dimensions: prev.dimensions.filter(d => d.id !== id),
6824
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
6825
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
6518
6826
  }));
6519
6827
  };
6520
- const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
6521
- setReport(prev => ({
6522
- ...prev,
6523
- dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
6524
- ...d,
6525
- sortOrder
6526
- } : d)
6527
- }));
6828
+
6829
+ // sortOrder is fundamentally a per-field (query-level) setting - SQL can
6830
+ // only sort by a given column once - so toggling it on any instance of a
6831
+ // duplicated dimension applies to every instance sharing that fullPath.
6832
+ const handleUpdateDimensionSortOrder = (id, sortOrder) => {
6833
+ setReport(prev => {
6834
+ const target = prev.dimensions.find(d => d.id === id);
6835
+ if (!target) return prev;
6836
+ return {
6837
+ ...prev,
6838
+ dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
6839
+ ...d,
6840
+ sortOrder
6841
+ } : d)
6842
+ };
6843
+ });
6528
6844
  };
6529
6845
 
6530
6846
  // Reorders the single unified dimensions + computed-dimensions list;
@@ -6543,7 +6859,11 @@ const ReportBuilder = ({
6543
6859
  dimensionOrder: [...prev.dimensionOrder, {
6544
6860
  type: "computed",
6545
6861
  key: computedDimensionData.name
6546
- }]
6862
+ }],
6863
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6864
+ type: "computed",
6865
+ key: computedDimensionData.name
6866
+ })
6547
6867
  }));
6548
6868
  };
6549
6869
  const handleUpdateComputedDimension = (name, computedDimensionData) => {
@@ -6553,6 +6873,10 @@ const ReportBuilder = ({
6553
6873
  dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6554
6874
  ...entry,
6555
6875
  key: computedDimensionData.name
6876
+ } : entry),
6877
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6878
+ ...entry,
6879
+ key: computedDimensionData.name
6556
6880
  } : entry)
6557
6881
  }));
6558
6882
  };
@@ -6560,24 +6884,30 @@ const ReportBuilder = ({
6560
6884
  setReport(prev => ({
6561
6885
  ...prev,
6562
6886
  computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
6563
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6887
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name)),
6888
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6564
6889
  }));
6565
6890
  };
6566
6891
  const handleSaveMetric = metricData => {
6567
6892
  setReport(prev => {
6568
6893
  const newReport = {
6569
6894
  ...prev,
6570
- metrics: [...prev.metrics, metricData]
6895
+ metrics: [...prev.metrics, metricData],
6896
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6897
+ type: "metric",
6898
+ key: metricData.id
6899
+ })
6571
6900
  };
6572
6901
  console.log("Metric saved:", metricData);
6573
6902
  console.log("Complete report:", newReport);
6574
6903
  return newReport;
6575
6904
  });
6576
6905
  };
6577
- const handleRemoveMetric = index => {
6906
+ const handleRemoveMetric = id => {
6578
6907
  setReport(prev => ({
6579
6908
  ...prev,
6580
- metrics: prev.metrics.filter((_, i) => i !== index)
6909
+ metrics: prev.metrics.filter(m => m.id !== id),
6910
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
6581
6911
  }));
6582
6912
  };
6583
6913
  const handleReorderMetrics = newOrder => {
@@ -6586,6 +6916,16 @@ const ReportBuilder = ({
6586
6916
  metrics: newOrder
6587
6917
  }));
6588
6918
  };
6919
+
6920
+ // Sets the unified column order shown on the Column Order tab. An empty
6921
+ // array (e.g. via "Reset to Default") clears the customization, and the
6922
+ // results grid falls back to dimensionOrder followed by metrics order.
6923
+ const handleReorderColumnItems = newColumnOrder => {
6924
+ setReport(prev => ({
6925
+ ...prev,
6926
+ columnOrder: newColumnOrder
6927
+ }));
6928
+ };
6589
6929
  const handleSaveFilter = (fullPath, filterData) => {
6590
6930
  setReport(prev => {
6591
6931
  const newReport = {
@@ -6623,13 +6963,30 @@ const ReportBuilder = ({
6623
6963
  // persisted `ordered_dimensions` field all stay consistent with whatever
6624
6964
  // order the user arranged on screen.
6625
6965
  const buildQueryObject = () => {
6626
- const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
6966
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
6627
6967
  const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
6628
- const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionByPath[entry.key]).filter(Boolean);
6968
+
6969
+ // May contain more than one instance of the same fullPath (deliberate
6970
+ // duplicates), kept in dimensionOrder's display order.
6971
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
6629
6972
  const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
6630
6973
 
6974
+ // The API keys each result row by fullPath, so duplicate instances of
6975
+ // the same field can only ever carry one value/one sort direction at
6976
+ // the query level - dedupe by fullPath (keeping first-occurrence order)
6977
+ // before sending. The builder still shows/orders/titles each instance
6978
+ // independently; they just read from the same underlying query column.
6979
+ const uniqueDimensionsByPath = [];
6980
+ const seenDimensionPaths = new Set();
6981
+ orderedDimensionsOnly.forEach(dim => {
6982
+ if (!seenDimensionPaths.has(dim.fullPath)) {
6983
+ seenDimensionPaths.add(dim.fullPath);
6984
+ uniqueDimensionsByPath.push(dim);
6985
+ }
6986
+ });
6987
+
6631
6988
  // Build order_by array - include all dimensions, add desc property only when needed
6632
- const orderBy = orderedDimensionsOnly.map(dim => {
6989
+ const orderBy = uniqueDimensionsByPath.map(dim => {
6633
6990
  const orderItem = {
6634
6991
  name: dim.fullPath
6635
6992
  };
@@ -6684,9 +7041,20 @@ const ReportBuilder = ({
6684
7041
  if (Object.keys(titleOverrides.filters).length > 0) {
6685
7042
  titles.filters = titleOverrides.filters;
6686
7043
  }
7044
+
7045
+ // Same dedupe rationale as dimensions above: the query only needs each
7046
+ // metric fullPath once, however many display instances reference it.
7047
+ const uniqueMetricPaths = [];
7048
+ const seenMetricPaths = new Set();
7049
+ report.metrics.forEach(metric => {
7050
+ if (!seenMetricPaths.has(metric.fullPath)) {
7051
+ seenMetricPaths.add(metric.fullPath);
7052
+ uniqueMetricPaths.push(metric.fullPath);
7053
+ }
7054
+ });
6687
7055
  const queryObj = {
6688
- dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
6689
- metrics: report.metrics.map(metric => metric.fullPath),
7056
+ dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
7057
+ metrics: uniqueMetricPaths,
6690
7058
  order_by: orderBy
6691
7059
  };
6692
7060
 
@@ -6700,7 +7068,9 @@ const ReportBuilder = ({
6700
7068
 
6701
7069
  // Persist the unified dimension + computed-dimension order so it can be
6702
7070
  // reconstructed on reload and so executed reports render columns in the
6703
- // same order they were arranged in the builder.
7071
+ // same order they were arranged in the builder. Entries use each
7072
+ // dimension's instance id as `ref`, so deliberate duplicates (the nth
7073
+ // occurrence of a fullPath is suffixed `#n`) round-trip correctly.
6704
7074
  if (report.dimensionOrder.length > 0) {
6705
7075
  queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
6706
7076
  type: entry.type,
@@ -6708,6 +7078,23 @@ const ReportBuilder = ({
6708
7078
  }));
6709
7079
  }
6710
7080
 
7081
+ // Persist metric instance order/identity the same way (metrics have no
7082
+ // other unified-order field to piggyback on).
7083
+ if (report.metrics.length > 0) {
7084
+ queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
7085
+ }
7086
+
7087
+ // Persist the optional unified column order (Column Order tab) so the
7088
+ // results grid can reproduce it on reload. Absent when no custom order
7089
+ // has been defined, so old/untouched reports keep their current
7090
+ // dimensions-then-metrics column order unchanged.
7091
+ if (report.columnOrder.length > 0) {
7092
+ queryObj.ordered_columns = report.columnOrder.map(entry => ({
7093
+ type: entry.type,
7094
+ ref: entry.key
7095
+ }));
7096
+ }
7097
+
6711
7098
  // Only add titles if there are any overrides
6712
7099
  if (Object.keys(titles).length > 0) {
6713
7100
  queryObj.titles = titles;
@@ -6767,8 +7154,8 @@ const ReportBuilder = ({
6767
7154
  const handleRunReport = async () => {
6768
7155
  setCurrentPage(0);
6769
7156
  await runReportWithPagination(0, pageSize);
6770
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
6771
- setActiveTab(3);
7157
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
7158
+ setActiveTab(4);
6772
7159
  };
6773
7160
  const handleDownloadReport = async () => {
6774
7161
  try {
@@ -6866,15 +7253,20 @@ const ReportBuilder = ({
6866
7253
 
6867
7254
  // Resolve the unified dimensionOrder into actual dimension/computed-dimension
6868
7255
  // objects so the results grid can render columns in that same order.
6869
- const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7256
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
6870
7257
  const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
6871
7258
  const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
6872
7259
  kind: "dimension",
6873
- data: dimensionByFullPath[entry.key]
7260
+ data: dimensionById[entry.key]
6874
7261
  } : {
6875
7262
  kind: "computed",
6876
7263
  data: computedDimensionByName[entry.key]
6877
7264
  }).filter(item => item.data);
7265
+
7266
+ // Final column list for the results grid: the explicit Column Order tab
7267
+ // arrangement when defined, otherwise dimensions/computed (in their own
7268
+ // order) followed by metrics (in their own order) — today's behavior.
7269
+ const columnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
6878
7270
  return /*#__PURE__*/React__default.createElement(Box$1, {
6879
7271
  sx: {
6880
7272
  p: 3,
@@ -7129,9 +7521,36 @@ const ReportBuilder = ({
7129
7521
  }
7130
7522
  }
7131
7523
  }), /*#__PURE__*/React__default.createElement(Tab, {
7132
- label: reportData ? "Results" : "Results (Run report first)",
7524
+ label: /*#__PURE__*/React__default.createElement(Badge, {
7525
+ variant: "dot",
7526
+ invisible: report.columnOrder.length === 0,
7527
+ sx: {
7528
+ "& .MuiBadge-dot": {
7529
+ backgroundColor: "rgb(70, 134, 128)"
7530
+ }
7531
+ }
7532
+ }, /*#__PURE__*/React__default.createElement("span", {
7533
+ style: {
7534
+ marginRight: report.columnOrder.length > 0 ? "8px" : "0"
7535
+ }
7536
+ }, "Column Order")),
7133
7537
  id: "report-tab-3",
7134
7538
  "aria-controls": "report-tabpanel-3",
7539
+ sx: {
7540
+ height: "41px",
7541
+ fontFamily: "system-ui",
7542
+ borderRadius: "0.5rem",
7543
+ boxShadow: "none",
7544
+ textTransform: "none",
7545
+ color: "rgb(37, 37, 37)",
7546
+ "&.Mui-selected": {
7547
+ color: "rgb(70, 134, 128)"
7548
+ }
7549
+ }
7550
+ }), /*#__PURE__*/React__default.createElement(Tab, {
7551
+ label: reportData ? "Results" : "Results (Run report first)",
7552
+ id: "report-tab-4",
7553
+ "aria-controls": "report-tabpanel-4",
7135
7554
  disabled: !reportData,
7136
7555
  sx: {
7137
7556
  height: "41px",
@@ -7198,10 +7617,18 @@ const ReportBuilder = ({
7198
7617
  })), /*#__PURE__*/React__default.createElement(TabPanel, {
7199
7618
  value: activeTab,
7200
7619
  index: 3
7201
- }, reportData && /*#__PURE__*/React__default.createElement(ReportDataGrid, {
7202
- reportData: reportData,
7620
+ }, /*#__PURE__*/React__default.createElement(ColumnOrder, {
7203
7621
  orderedDimensionItems: orderedDimensionItems,
7204
7622
  metrics: report.metrics,
7623
+ columnOrder: report.columnOrder,
7624
+ onReorderColumnItems: handleReorderColumnItems,
7625
+ titleOverrides: titleOverrides
7626
+ })), /*#__PURE__*/React__default.createElement(TabPanel, {
7627
+ value: activeTab,
7628
+ index: 4
7629
+ }, reportData && /*#__PURE__*/React__default.createElement(ReportDataGrid, {
7630
+ reportData: reportData,
7631
+ columnItems: columnItems,
7205
7632
  loading: loading,
7206
7633
  onPageChange: handlePageChange,
7207
7634
  totalRows: totalRows,
@@ -7289,6 +7716,12 @@ const CurrencySelector = () => {
7289
7716
  parameters,
7290
7717
  setParameters
7291
7718
  } = useReportingContext();
7719
+
7720
+ // Hidden by default; only shown when a `cur` query parameter is present in the top-level URL.
7721
+ const showCurrencySelector = new URLSearchParams(window.location.search).has("cur");
7722
+ if (!showCurrencySelector) {
7723
+ return null;
7724
+ }
7292
7725
  return /*#__PURE__*/React__default.createElement(SingleSelect, {
7293
7726
  items: CURRENCY_OPTIONS,
7294
7727
  value: parameters.base_currency,