@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.
@@ -20,6 +20,7 @@ var reactIntersectionObserver = require('react-intersection-observer');
20
20
  var material = require('@mui/material');
21
21
  var CheckBoxOutlineBlankIcon = require('@mui/icons-material/CheckBoxOutlineBlank');
22
22
  var CheckBoxIcon = require('@mui/icons-material/CheckBox');
23
+ var CheckCircleIcon = require('@mui/icons-material/CheckCircle');
23
24
  var EventEmitter = require('eventemitter3');
24
25
  var Grid = require('@mui/material/Grid');
25
26
  var IconButton = require('@mui/material/IconButton');
@@ -67,6 +68,7 @@ var LocalizationProvider = require('@mui/x-date-pickers/LocalizationProvider');
67
68
  var DatePicker = require('@mui/x-date-pickers/DatePicker');
68
69
  var AdapterDayjs = require('@mui/x-date-pickers/AdapterDayjs');
69
70
  var dayjs = require('dayjs');
71
+ var CalculateIcon = require('@mui/icons-material/Calculate');
70
72
  var styles = require('@mui/material/styles');
71
73
 
72
74
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -104,6 +106,7 @@ var Select__default = /*#__PURE__*/_interopDefault(Select);
104
106
  var MenuItem__default = /*#__PURE__*/_interopDefault(MenuItem);
105
107
  var CheckBoxOutlineBlankIcon__default = /*#__PURE__*/_interopDefault(CheckBoxOutlineBlankIcon);
106
108
  var CheckBoxIcon__default = /*#__PURE__*/_interopDefault(CheckBoxIcon);
109
+ var CheckCircleIcon__default = /*#__PURE__*/_interopDefault(CheckCircleIcon);
107
110
  var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
108
111
  var Grid__default = /*#__PURE__*/_interopDefault(Grid);
109
112
  var IconButton__default = /*#__PURE__*/_interopDefault(IconButton);
@@ -144,6 +147,7 @@ var BoltIcon__default = /*#__PURE__*/_interopDefault(BoltIcon);
144
147
  var FilterAltIcon__default = /*#__PURE__*/_interopDefault(FilterAltIcon);
145
148
  var InfoOutlinedIcon__default = /*#__PURE__*/_interopDefault(InfoOutlinedIcon);
146
149
  var dayjs__default = /*#__PURE__*/_interopDefault(dayjs);
150
+ var CalculateIcon__default = /*#__PURE__*/_interopDefault(CalculateIcon);
147
151
 
148
152
  function _extends() {
149
153
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -1133,7 +1137,8 @@ function SingleSelect({
1133
1137
  key,
1134
1138
  value,
1135
1139
  disabled,
1136
- icon
1140
+ icon,
1141
+ alreadySelected
1137
1142
  } = itm;
1138
1143
  return /*#__PURE__*/React__namespace.default.createElement(MenuItem__default.default, {
1139
1144
  key: key,
@@ -1144,7 +1149,12 @@ function SingleSelect({
1144
1149
  minHeight: "36px",
1145
1150
  display: "flex",
1146
1151
  alignItems: "center",
1147
- gap: "6px"
1152
+ gap: "6px",
1153
+ ...(alreadySelected && {
1154
+ color: "rgb(70, 134, 128)",
1155
+ backgroundColor: "rgba(70, 134, 128, 0.08)",
1156
+ fontWeight: 600
1157
+ })
1148
1158
  }
1149
1159
  }, icon && /*#__PURE__*/React__namespace.default.createElement(Box__default.default, {
1150
1160
  component: "span",
@@ -1152,7 +1162,13 @@ function SingleSelect({
1152
1162
  display: "inline-flex",
1153
1163
  alignItems: "center"
1154
1164
  }
1155
- }, icon), formatLabel(value));
1165
+ }, icon), alreadySelected && /*#__PURE__*/React__namespace.default.createElement(CheckCircleIcon__default.default, {
1166
+ fontSize: "small",
1167
+ sx: {
1168
+ color: "rgb(70, 134, 128)",
1169
+ fontSize: "16px"
1170
+ }
1171
+ }), formatLabel(value));
1156
1172
  }))));
1157
1173
  }
1158
1174
 
@@ -2607,6 +2623,163 @@ const ProviderSelection = ({
2607
2623
  }, renderSelectionChain()));
2608
2624
  };
2609
2625
 
2626
+ // Shared drag-and-drop primitives for the index-identified, arrow-reorderable
2627
+ // lists used across the report builder (Dimensions, Metrics, Column Order).
2628
+ // Previously duplicated per-tab; consolidated here so all three behave and
2629
+ // look identical and only need to be fixed in one place.
2630
+
2631
+ const useSortableListSensors = () => core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
2632
+ coordinateGetter: sortable.sortableKeyboardCoordinates
2633
+ }));
2634
+
2635
+ // Builds drag/move handlers for a list where dnd-kit item ids are the array
2636
+ // index. `onReorder` receives the whole reordered list.
2637
+ const makeListReorderHandlers = (list, onReorder) => {
2638
+ const handleDragEnd = ({
2639
+ active,
2640
+ over
2641
+ }) => {
2642
+ if (over && active.id !== over.id) {
2643
+ const oldIndex = list.findIndex((_, i) => i === active.id);
2644
+ const newIndex = list.findIndex((_, i) => i === over.id);
2645
+ onReorder(sortable.arrayMove(list, oldIndex, newIndex));
2646
+ }
2647
+ };
2648
+ const handleMoveUp = index => {
2649
+ if (index > 0) {
2650
+ onReorder(sortable.arrayMove(list, index, index - 1));
2651
+ }
2652
+ };
2653
+ const handleMoveDown = index => {
2654
+ if (index < list.length - 1) {
2655
+ onReorder(sortable.arrayMove(list, index, index + 1));
2656
+ }
2657
+ };
2658
+ return {
2659
+ handleDragEnd,
2660
+ handleMoveUp,
2661
+ handleMoveDown
2662
+ };
2663
+ };
2664
+
2665
+ // Wraps DndContext + SortableContext + the vertical list container.
2666
+ // `itemIds` must line up with the ids passed to each item's useSortable().
2667
+ const SortableListContext = ({
2668
+ sensors,
2669
+ itemIds,
2670
+ onDragEnd,
2671
+ children
2672
+ }) => /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
2673
+ sensors: sensors,
2674
+ collisionDetection: core.closestCenter,
2675
+ onDragEnd: onDragEnd
2676
+ }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
2677
+ items: itemIds,
2678
+ strategy: sortable.verticalListSortingStrategy
2679
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2680
+ sx: {
2681
+ display: "flex",
2682
+ flexDirection: "column",
2683
+ gap: 1
2684
+ }
2685
+ }, children)));
2686
+
2687
+ // Per-row useSortable() wiring shared by every sortable chip/row component.
2688
+ const useSortableRow = id => {
2689
+ const {
2690
+ attributes,
2691
+ listeners,
2692
+ setNodeRef,
2693
+ transform,
2694
+ transition,
2695
+ isDragging
2696
+ } = sortable.useSortable({
2697
+ id
2698
+ });
2699
+ const style = {
2700
+ transform: utilities.CSS.Transform.toString(transform),
2701
+ transition,
2702
+ opacity: isDragging ? 0.5 : 1,
2703
+ display: "flex",
2704
+ alignItems: "center",
2705
+ width: "100%"
2706
+ };
2707
+ return {
2708
+ attributes,
2709
+ listeners,
2710
+ setNodeRef,
2711
+ style
2712
+ };
2713
+ };
2714
+ const DragHandle = ({
2715
+ listeners
2716
+ }) => /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2717
+ sx: {
2718
+ display: "flex",
2719
+ alignItems: "center",
2720
+ cursor: "grab",
2721
+ "&:active": {
2722
+ cursor: "grabbing"
2723
+ }
2724
+ }
2725
+ }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
2726
+ sx: {
2727
+ cursor: "grab",
2728
+ color: "rgba(110, 110, 110, 0.62)"
2729
+ }
2730
+ }));
2731
+ const MoveButtons = ({
2732
+ onMoveUp,
2733
+ onMoveDown,
2734
+ isFirst,
2735
+ isLast
2736
+ }) => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2737
+ className: "hover-icons",
2738
+ sx: {
2739
+ display: "flex",
2740
+ gap: 0.5,
2741
+ opacity: 0,
2742
+ transition: "opacity 0.2s"
2743
+ }
2744
+ }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2745
+ size: "small",
2746
+ onClick: onMoveUp,
2747
+ disabled: isFirst,
2748
+ "aria-label": "move up"
2749
+ }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
2750
+ fontSize: "small"
2751
+ })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2752
+ size: "small",
2753
+ onClick: onMoveDown,
2754
+ disabled: isLast,
2755
+ "aria-label": "move down"
2756
+ }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
2757
+ fontSize: "small"
2758
+ })));
2759
+
2760
+ // Builds an identity string for a saved dimension/metric instance. Deliberate
2761
+ // duplicates (the same field added more than once) are allowed - the first
2762
+ // occurrence keeps the plain fullPath (so existing saved reports and their
2763
+ // title-override keys stay backward compatible), later occurrences get a
2764
+ // `#n` suffix.
2765
+ const makeInstanceId = (fullPath, occurrenceIndex) => occurrenceIndex === 0 ? fullPath : `${fullPath}#${occurrenceIndex}`;
2766
+
2767
+ // Picks the next free instance id for a newly-added fullPath, given the
2768
+ // items already saved. Scans occurrence numbers from 0 rather than just
2769
+ // counting existing instances with this fullPath, because counting alone
2770
+ // can collide: e.g. add "ft.currency" twice (ids "ft.currency",
2771
+ // "ft.currency#1"), remove the first, then add "ft.currency" again - a
2772
+ // plain count of 1 would regenerate "ft.currency#1", clashing with the
2773
+ // surviving duplicate.
2774
+ const nextInstanceId = (fullPath, existingItems) => {
2775
+ const existingIds = new Set(existingItems.map(item => item.id));
2776
+ let occurrenceIndex = 0;
2777
+ while (existingIds.has(makeInstanceId(fullPath, occurrenceIndex))) {
2778
+ occurrenceIndex += 1;
2779
+ }
2780
+ return makeInstanceId(fullPath, occurrenceIndex);
2781
+ };
2782
+
2610
2783
  // Replace {{key}} placeholders in a title with values from the report
2611
2784
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
2612
2785
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -2618,6 +2791,53 @@ const interpolateTitle = (template, params) => {
2618
2791
  });
2619
2792
  };
2620
2793
 
2794
+ // Resolves the final column display order for the results grid: dimensions,
2795
+ // computed dimensions, and metrics combined into a single list.
2796
+ //
2797
+ // `columnOrder` is the optional, explicitly-arranged order (raw entries
2798
+ // `{ type: 'dimension' | 'computed' | 'metric', key }`) set on the Column
2799
+ // Order tab. When present, its entries come first, in that order. Anything
2800
+ // not covered by it (new fields added since it was last touched, or when
2801
+ // it's empty/undefined because no custom order has ever been defined) is
2802
+ // appended in the pre-existing default order: dimensions/computed (in their
2803
+ // own `dimensionOrder`) first, then metrics (in their own array order) —
2804
+ // this keeps behavior unchanged for reports that never define a column order.
2805
+ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
2806
+ // Normalise every candidate item to a uniform { kind, key, data } shape
2807
+ // up front, so callers (e.g. the Column Order tab) can always read
2808
+ // `.key` regardless of where the item came from.
2809
+ // Keyed by instance id (not fullPath) so that two instances of the same
2810
+ // dimension/metric - added deliberately as duplicates - are treated as
2811
+ // distinct columns rather than collapsing into one.
2812
+ const normalizedDimensionItems = orderedDimensionItems.map(item => ({
2813
+ kind: item.kind,
2814
+ key: item.kind === 'computed' ? item.data.name : item.data.id,
2815
+ data: item.data
2816
+ }));
2817
+ const normalizedMetricItems = metrics.map(m => ({
2818
+ kind: 'metric',
2819
+ key: m.id,
2820
+ data: m
2821
+ }));
2822
+ const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
2823
+ const resolved = [];
2824
+ const seen = new Set();
2825
+ (columnOrder || []).forEach(entry => {
2826
+ const seenKey = `${entry.type}:${entry.key}`;
2827
+ if (seen.has(seenKey) || !byKey[seenKey]) return;
2828
+ resolved.push(byKey[seenKey]);
2829
+ seen.add(seenKey);
2830
+ });
2831
+ [...normalizedDimensionItems, ...normalizedMetricItems].forEach(item => {
2832
+ const seenKey = `${item.kind}:${item.key}`;
2833
+ if (!seen.has(seenKey)) {
2834
+ resolved.push(item);
2835
+ seen.add(seenKey);
2836
+ }
2837
+ });
2838
+ return resolved;
2839
+ };
2840
+
2621
2841
  // Sortable Chip Component
2622
2842
  const SortableChip$1 = ({
2623
2843
  id,
@@ -2630,7 +2850,7 @@ const SortableChip$1 = ({
2630
2850
  isLast,
2631
2851
  sortOrder,
2632
2852
  onSortOrderChange,
2633
- fullPath,
2853
+ instanceId,
2634
2854
  defaultTitle,
2635
2855
  customTitle,
2636
2856
  onUpdateTitle,
@@ -2646,20 +2866,8 @@ const SortableChip$1 = ({
2646
2866
  attributes,
2647
2867
  listeners,
2648
2868
  setNodeRef,
2649
- transform,
2650
- transition,
2651
- isDragging
2652
- } = sortable.useSortable({
2653
- id
2654
- });
2655
- const style = {
2656
- transform: utilities.CSS.Transform.toString(transform),
2657
- transition,
2658
- opacity: isDragging ? 0.5 : 1,
2659
- display: "flex",
2660
- alignItems: "center",
2661
- width: "100%"
2662
- };
2869
+ style
2870
+ } = useSortableRow(id);
2663
2871
 
2664
2872
  // Focus input when entering edit mode
2665
2873
  React.useEffect(() => {
@@ -2725,7 +2933,7 @@ const SortableChip$1 = ({
2725
2933
  handleCancel();
2726
2934
  return;
2727
2935
  }
2728
- onUpdateTitle(fullPath, trimmedValue);
2936
+ onUpdateTitle(instanceId, trimmedValue);
2729
2937
  setIsEditing(false);
2730
2938
  };
2731
2939
  const handleCancel = () => {
@@ -2733,7 +2941,7 @@ const SortableChip$1 = ({
2733
2941
  setEditValue("");
2734
2942
  };
2735
2943
  const handleReset = () => {
2736
- onResetTitle(fullPath);
2944
+ onResetTitle(instanceId);
2737
2945
  setIsEditing(false);
2738
2946
  setEditValue("");
2739
2947
  };
@@ -2768,21 +2976,9 @@ const SortableChip$1 = ({
2768
2976
  opacity: 1 // show icons on hover
2769
2977
  }
2770
2978
  }
2771
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2772
- sx: {
2773
- display: "flex",
2774
- alignItems: "center",
2775
- cursor: "grab",
2776
- "&:active": {
2777
- cursor: "grabbing"
2778
- }
2779
- }
2780
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
2781
- sx: {
2782
- cursor: "grab",
2783
- color: "rgba(110, 110, 110, 0.62)"
2784
- }
2785
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2979
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
2980
+ listeners: listeners
2981
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2786
2982
  sx: {
2787
2983
  minWidth: 0
2788
2984
  }
@@ -2845,29 +3041,12 @@ const SortableChip$1 = ({
2845
3041
  sx: {
2846
3042
  flex: 1
2847
3043
  }
2848
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2849
- className: "hover-icons",
2850
- sx: {
2851
- display: "flex",
2852
- gap: 0.5,
2853
- opacity: 0,
2854
- transition: "opacity 0.2s"
2855
- }
2856
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2857
- size: "small",
2858
- onClick: onMoveUp,
2859
- disabled: isFirst,
2860
- "aria-label": "move up"
2861
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
2862
- fontSize: "small"
2863
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2864
- size: "small",
2865
- onClick: onMoveDown,
2866
- disabled: isLast,
2867
- "aria-label": "move down"
2868
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
2869
- fontSize: "small"
2870
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3044
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
3045
+ onMoveUp: onMoveUp,
3046
+ onMoveDown: onMoveDown,
3047
+ isFirst: isFirst,
3048
+ isLast: isLast
3049
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
2871
3050
  inputRef: inputRef,
2872
3051
  value: editValue,
2873
3052
  onChange: e => setEditValue(e.target.value),
@@ -2943,24 +3122,12 @@ const SortableComputedChip = ({
2943
3122
  attributes,
2944
3123
  listeners,
2945
3124
  setNodeRef,
2946
- transform,
2947
- transition,
2948
- isDragging
2949
- } = sortable.useSortable({
2950
- id
2951
- });
3125
+ style
3126
+ } = useSortableRow(id);
2952
3127
  const [isEditing, setIsEditing] = React.useState(false);
2953
3128
  const [editName, setEditName] = React.useState("");
2954
3129
  const [editValue, setEditValue] = React.useState("");
2955
3130
  const containerRef = React.useRef(null);
2956
- const style = {
2957
- transform: utilities.CSS.Transform.toString(transform),
2958
- transition,
2959
- opacity: isDragging ? 0.5 : 1,
2960
- display: "flex",
2961
- alignItems: "center",
2962
- width: "100%"
2963
- };
2964
3131
 
2965
3132
  // Handle click outside to cancel
2966
3133
  React.useEffect(() => {
@@ -2985,7 +3152,7 @@ const SortableComputedChip = ({
2985
3152
  const handleSave = () => {
2986
3153
  const trimmedName = editName.trim();
2987
3154
  const trimmedValue = editValue.trim();
2988
- if (!trimmedName || !trimmedValue) return;
3155
+ if (!trimmedName) return;
2989
3156
  if (isNameTaken(trimmedName, name)) return;
2990
3157
  onUpdate(name, {
2991
3158
  name: trimmedName,
@@ -3023,21 +3190,9 @@ const SortableComputedChip = ({
3023
3190
  opacity: 1
3024
3191
  }
3025
3192
  }
3026
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
3027
- sx: {
3028
- display: "flex",
3029
- alignItems: "center",
3030
- cursor: "grab",
3031
- "&:active": {
3032
- cursor: "grabbing"
3033
- }
3034
- }
3035
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
3036
- sx: {
3037
- cursor: "grab",
3038
- color: "rgba(110, 110, 110, 0.62)"
3039
- }
3040
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
3193
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
3194
+ listeners: listeners
3195
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
3041
3196
  icon: /*#__PURE__*/React__namespace.default.createElement(FunctionsIcon__default.default, {
3042
3197
  sx: {
3043
3198
  fontSize: "15px !important"
@@ -3101,29 +3256,12 @@ const SortableComputedChip = ({
3101
3256
  sx: {
3102
3257
  flex: 1
3103
3258
  }
3104
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3105
- className: "hover-icons",
3106
- sx: {
3107
- display: "flex",
3108
- gap: 0.5,
3109
- opacity: 0,
3110
- transition: "opacity 0.2s"
3111
- }
3112
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3113
- size: "small",
3114
- onClick: onMoveUp,
3115
- disabled: isFirst,
3116
- "aria-label": "move up"
3117
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
3118
- fontSize: "small"
3119
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3120
- size: "small",
3121
- onClick: onMoveDown,
3122
- disabled: isLast,
3123
- "aria-label": "move down"
3124
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
3125
- fontSize: "small"
3126
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3259
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
3260
+ onMoveUp: onMoveUp,
3261
+ onMoveDown: onMoveDown,
3262
+ isFirst: isFirst,
3263
+ isLast: isLast
3264
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3127
3265
  value: editName,
3128
3266
  onChange: e => setEditName(e.target.value),
3129
3267
  onKeyDown: handleKeyDown,
@@ -3164,7 +3302,7 @@ const SortableComputedChip = ({
3164
3302
  onClick: handleSave,
3165
3303
  color: "primary",
3166
3304
  "aria-label": "save computed dimension",
3167
- disabled: !editName.trim() || !editValue.trim() || nameError
3305
+ disabled: !editName.trim() || nameError
3168
3306
  }, /*#__PURE__*/React__namespace.default.createElement(CheckIcon__default.default, {
3169
3307
  fontSize: "small"
3170
3308
  })))), /*#__PURE__*/React__namespace.default.createElement(material.Tooltip, {
@@ -3208,55 +3346,39 @@ const Dimensions = ({
3208
3346
  const [computedValue, setComputedValue] = React.useState("");
3209
3347
 
3210
3348
  // Setup drag and drop sensors
3211
- const sensors = core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
3212
- coordinateGetter: sortable.sortableKeyboardCoordinates
3213
- }));
3349
+ const sensors = useSortableListSensors();
3214
3350
 
3215
3351
  // Resolve the unified dimensionOrder into the actual dimension / computed
3216
3352
  // dimension objects, so dimensions and computed dimensions can be shown
3217
3353
  // (and dragged) as a single list while staying stored in their own arrays.
3218
- const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
3354
+ // Keyed by instance id rather than fullPath, so two instances of the same
3355
+ // dimension (added deliberately as a duplicate) stay distinct entries.
3356
+ const dimensionById = Object.fromEntries(savedDimensions.map(d => [d.id, d]));
3219
3357
  const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
3220
3358
  const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
3221
3359
  kind: "dimension",
3222
3360
  key: entry.key,
3223
- data: dimensionByFullPath[entry.key]
3361
+ data: dimensionById[entry.key]
3224
3362
  } : {
3225
3363
  kind: "computed",
3226
3364
  key: entry.key,
3227
3365
  data: computedDimensionByName[entry.key]
3228
3366
  }).filter(item => item.data);
3229
3367
 
3230
- // Handle drag end for the unified list
3231
- const handleDragEnd = event => {
3232
- const {
3233
- active,
3234
- over
3235
- } = event;
3236
- if (over && active.id !== over.id) {
3237
- const oldIndex = dimensionOrder.findIndex((_, i) => i === active.id);
3238
- const newIndex = dimensionOrder.findIndex((_, i) => i === over.id);
3239
- onReorderDimensionItems(sortable.arrayMove(dimensionOrder, oldIndex, newIndex));
3240
- }
3241
- };
3242
-
3243
- // Handle move up
3244
- const handleMoveUp = index => {
3245
- if (index > 0) {
3246
- onReorderDimensionItems(sortable.arrayMove(dimensionOrder, index, index - 1));
3247
- }
3248
- };
3249
-
3250
- // Handle move down
3251
- const handleMoveDown = index => {
3252
- if (index < dimensionOrder.length - 1) {
3253
- onReorderDimensionItems(sortable.arrayMove(dimensionOrder, index, index + 1));
3254
- }
3255
- };
3368
+ // Handle drag/move for the unified list
3369
+ const {
3370
+ handleDragEnd,
3371
+ handleMoveUp,
3372
+ handleMoveDown
3373
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3256
3374
 
3257
- // Handle sort order change (dimensions only - computed dimensions have no order_by)
3258
- const handleSortOrderChange = (fullPath, sortOrder) => {
3259
- onUpdateDimensionSortOrder(fullPath, sortOrder);
3375
+ // Handle sort order change (dimensions only - computed dimensions have no
3376
+ // order_by). Keyed by instance id but applied query-wide: SQL can only
3377
+ // sort by a given column once, so toggling sort on any duplicate instance
3378
+ // of the same field is propagated to all instances sharing its fullPath
3379
+ // (handled in ReportBuilder's onUpdateDimensionSortOrder).
3380
+ const handleSortOrderChange = (id, sortOrder) => {
3381
+ onUpdateDimensionSortOrder(id, sortOrder);
3260
3382
  };
3261
3383
 
3262
3384
  // Computed Dimensions: { name, value } pairs independent of any provider.
@@ -3277,7 +3399,7 @@ const Dimensions = ({
3277
3399
  const handleSaveComputed = () => {
3278
3400
  const trimmedName = computedName.trim();
3279
3401
  const trimmedValue = computedValue.trim();
3280
- if (!trimmedName || !trimmedValue) return;
3402
+ if (!trimmedName) return;
3281
3403
  if (isComputedNameTaken(trimmedName)) return;
3282
3404
  onSaveComputedDimension({
3283
3405
  name: trimmedName,
@@ -3319,7 +3441,10 @@ const Dimensions = ({
3319
3441
  currentProviderKey = selection.targetKey;
3320
3442
  });
3321
3443
 
3322
- // Create a Set of already selected dimension fullPaths for quick lookup
3444
+ // Create a Set of already selected dimension fullPaths for quick lookup.
3445
+ // Already-selected items stay selectable (so the same dimension can be
3446
+ // added again on purpose) - they're just flagged so the dropdown can
3447
+ // highlight them instead of blocking the click.
3323
3448
  const selectedFullPaths = new Set(savedDimensions.map(dim => dim.fullPath));
3324
3449
  const items = [];
3325
3450
  // Iterate through aliases (e.g., 'ba', 'pc')
@@ -3331,9 +3456,6 @@ const Dimensions = ({
3331
3456
 
3332
3457
  // Construct the fullPath for this dimension
3333
3458
  const fullPath = `${aliasPath.join("_")}.${dimKey}`;
3334
-
3335
- // Check if this dimension is already selected
3336
- const isAlreadySelected = selectedFullPaths.has(fullPath);
3337
3459
  items.push({
3338
3460
  // Include provider name to ensure uniqueness across different providers
3339
3461
  key: `${currentProvider}_${alias}.${dimKey}`,
@@ -3341,7 +3463,7 @@ const Dimensions = ({
3341
3463
  dimensionKey: dimKey,
3342
3464
  alias: alias,
3343
3465
  dimension: dimension,
3344
- disabled: isAlreadySelected
3466
+ alreadySelected: selectedFullPaths.has(fullPath)
3345
3467
  });
3346
3468
  });
3347
3469
  });
@@ -3403,7 +3525,10 @@ const Dimensions = ({
3403
3525
  dimensionKey: selectedItem.dimensionKey,
3404
3526
  dimensionTitle: selectedItem.value,
3405
3527
  // The constructed path for server
3406
- fullPath: fullPath
3528
+ fullPath: fullPath,
3529
+ // Unique per-instance identity, so this field can be added more than
3530
+ // once and each occurrence stays independently orderable/removable.
3531
+ id: nextInstanceId(fullPath, savedDimensions)
3407
3532
  };
3408
3533
  onSaveDimension(dimensionData);
3409
3534
  handleCancel();
@@ -3414,8 +3539,9 @@ const Dimensions = ({
3414
3539
  const handleAddAll = () => {
3415
3540
  const dimensionItems = getDimensionItems();
3416
3541
 
3417
- // Filter out already selected dimensions (disabled items)
3418
- const availableItems = dimensionItems.filter(item => !item.disabled);
3542
+ // Filter out already selected dimensions - "Add all" only fills in
3543
+ // missing ones; intentional duplicates are added one at a time above.
3544
+ const availableItems = dimensionItems.filter(item => !item.alreadySelected);
3419
3545
  if (availableItems.length === 0) return;
3420
3546
 
3421
3547
  // Build the complete relation objects array (same for all dimensions in this provider)
@@ -3460,7 +3586,10 @@ const Dimensions = ({
3460
3586
  dimensionKey: item.dimensionKey,
3461
3587
  dimensionTitle: item.value,
3462
3588
  // The constructed path for server
3463
- fullPath: fullPath
3589
+ fullPath: fullPath,
3590
+ // "Add all" only ever adds fields that aren't already selected, so
3591
+ // this is always the first (and only) occurrence of this fullPath.
3592
+ id: fullPath
3464
3593
  };
3465
3594
  onSaveDimension(dimensionData);
3466
3595
  });
@@ -3594,7 +3723,7 @@ const Dimensions = ({
3594
3723
  }, "Cancel"), /*#__PURE__*/React__namespace.default.createElement(material.Button, {
3595
3724
  variant: "contained",
3596
3725
  onClick: handleSaveComputed,
3597
- disabled: !computedName.trim() || !computedValue.trim() || computedNameError,
3726
+ disabled: !computedName.trim() || computedNameError,
3598
3727
  sx: {
3599
3728
  height: "40px",
3600
3729
  fontFamily: "system-ui",
@@ -3670,7 +3799,7 @@ const Dimensions = ({
3670
3799
  placement: "top"
3671
3800
  }, /*#__PURE__*/React__namespace.default.createElement("span", null, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3672
3801
  onClick: handleAddAll,
3673
- disabled: getDimensionItems().filter(item => !item.disabled).length === 0,
3802
+ disabled: getDimensionItems().filter(item => !item.alreadySelected).length === 0,
3674
3803
  "aria-label": "add all dimensions",
3675
3804
  sx: {
3676
3805
  color: "rgb(70, 134, 128)",
@@ -3745,19 +3874,10 @@ const Dimensions = ({
3745
3874
  color: "#666",
3746
3875
  marginBottom: 2
3747
3876
  }
3748
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
3877
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
3749
3878
  sensors: sensors,
3750
- collisionDetection: core.closestCenter,
3879
+ itemIds: orderedItems.map((_, index) => index),
3751
3880
  onDragEnd: handleDragEnd
3752
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
3753
- items: orderedItems.map((_, index) => index),
3754
- strategy: sortable.verticalListSortingStrategy
3755
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3756
- sx: {
3757
- display: "flex",
3758
- flexDirection: "column",
3759
- gap: 1
3760
- }
3761
3881
  }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/React__namespace.default.createElement(SortableChip$1, {
3762
3882
  key: `dim-${item.key}`,
3763
3883
  id: index,
@@ -3770,9 +3890,9 @@ const Dimensions = ({
3770
3890
  isLast: index === orderedItems.length - 1,
3771
3891
  sortOrder: item.data.sortOrder || null,
3772
3892
  onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
3773
- fullPath: item.data.fullPath,
3893
+ instanceId: item.data.id,
3774
3894
  defaultTitle: item.data.dimensionTitle,
3775
- customTitle: titleOverrides[item.data.fullPath],
3895
+ customTitle: titleOverrides[item.data.id],
3776
3896
  onUpdateTitle: onUpdateTitle,
3777
3897
  onResetTitle: onResetTitle
3778
3898
  }) : /*#__PURE__*/React__namespace.default.createElement(SortableComputedChip, {
@@ -3787,7 +3907,7 @@ const Dimensions = ({
3787
3907
  onMoveDown: () => handleMoveDown(index),
3788
3908
  isFirst: index === 0,
3789
3909
  isLast: index === orderedItems.length - 1
3790
- })))))));
3910
+ })))));
3791
3911
  };
3792
3912
 
3793
3913
  const MetricSourceIcon = ({
@@ -3845,7 +3965,7 @@ const SortableChip = ({
3845
3965
  onMoveDown,
3846
3966
  isFirst,
3847
3967
  isLast,
3848
- fullPath,
3968
+ instanceId,
3849
3969
  defaultTitle,
3850
3970
  customTitle,
3851
3971
  onUpdateTitle,
@@ -3862,20 +3982,8 @@ const SortableChip = ({
3862
3982
  attributes,
3863
3983
  listeners,
3864
3984
  setNodeRef,
3865
- transform,
3866
- transition,
3867
- isDragging
3868
- } = sortable.useSortable({
3869
- id
3870
- });
3871
- const style = {
3872
- transform: utilities.CSS.Transform.toString(transform),
3873
- transition,
3874
- opacity: isDragging ? 0.5 : 1,
3875
- display: 'flex',
3876
- alignItems: 'center',
3877
- width: '100%'
3878
- };
3985
+ style
3986
+ } = useSortableRow(id);
3879
3987
 
3880
3988
  // Focus input when entering edit mode
3881
3989
  React.useEffect(() => {
@@ -3910,7 +4018,7 @@ const SortableChip = ({
3910
4018
  handleCancel();
3911
4019
  return;
3912
4020
  }
3913
- onUpdateTitle(fullPath, trimmedValue);
4021
+ onUpdateTitle(instanceId, trimmedValue);
3914
4022
  setIsEditing(false);
3915
4023
  };
3916
4024
  const handleCancel = () => {
@@ -3918,7 +4026,7 @@ const SortableChip = ({
3918
4026
  setEditValue('');
3919
4027
  };
3920
4028
  const handleReset = () => {
3921
- onResetTitle(fullPath);
4029
+ onResetTitle(instanceId);
3922
4030
  setIsEditing(false);
3923
4031
  setEditValue('');
3924
4032
  };
@@ -3953,21 +4061,9 @@ const SortableChip = ({
3953
4061
  opacity: 1 // show icons on hover
3954
4062
  }
3955
4063
  }
3956
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
3957
- sx: {
3958
- display: "flex",
3959
- alignItems: "center",
3960
- cursor: "grab",
3961
- "&:active": {
3962
- cursor: "grabbing"
3963
- }
3964
- }
3965
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
3966
- sx: {
3967
- cursor: "grab",
3968
- color: "rgba(110, 110, 110, 0.62)"
3969
- }
3970
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, source && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4064
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
4065
+ listeners: listeners
4066
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, source && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3971
4067
  sx: {
3972
4068
  display: 'flex',
3973
4069
  alignItems: 'center',
@@ -4029,29 +4125,12 @@ const SortableChip = ({
4029
4125
  sx: {
4030
4126
  flex: 1
4031
4127
  }
4032
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4033
- className: "hover-icons",
4034
- sx: {
4035
- display: "flex",
4036
- gap: 0.5,
4037
- opacity: 0,
4038
- transition: "opacity 0.2s"
4039
- }
4040
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4041
- size: "small",
4042
- onClick: onMoveUp,
4043
- disabled: isFirst,
4044
- "aria-label": "move up"
4045
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
4046
- fontSize: "small"
4047
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4048
- size: "small",
4049
- onClick: onMoveDown,
4050
- disabled: isLast,
4051
- "aria-label": "move down"
4052
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
4053
- fontSize: "small"
4054
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4128
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
4129
+ onMoveUp: onMoveUp,
4130
+ onMoveDown: onMoveDown,
4131
+ isFirst: isFirst,
4132
+ isLast: isLast
4133
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4055
4134
  inputRef: inputRef,
4056
4135
  value: editValue,
4057
4136
  onChange: e => setEditValue(e.target.value),
@@ -4126,39 +4205,14 @@ const Metrics = ({
4126
4205
  const [selectedMetric, setSelectedMetric] = React.useState('');
4127
4206
 
4128
4207
  // Setup drag and drop sensors
4129
- const sensors = core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
4130
- coordinateGetter: sortable.sortableKeyboardCoordinates
4131
- }));
4132
-
4133
- // Handle drag end
4134
- const handleDragEnd = event => {
4135
- const {
4136
- active,
4137
- over
4138
- } = event;
4139
- if (over && active.id !== over.id) {
4140
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
4141
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
4142
- const newOrder = sortable.arrayMove(savedMetrics, oldIndex, newIndex);
4143
- onReorderMetrics(newOrder);
4144
- }
4145
- };
4208
+ const sensors = useSortableListSensors();
4146
4209
 
4147
- // Handle move up
4148
- const handleMoveUp = index => {
4149
- if (index > 0) {
4150
- const newOrder = sortable.arrayMove(savedMetrics, index, index - 1);
4151
- onReorderMetrics(newOrder);
4152
- }
4153
- };
4154
-
4155
- // Handle move down
4156
- const handleMoveDown = index => {
4157
- if (index < savedMetrics.length - 1) {
4158
- const newOrder = sortable.arrayMove(savedMetrics, index, index + 1);
4159
- onReorderMetrics(newOrder);
4160
- }
4161
- };
4210
+ // Handle drag/move
4211
+ const {
4212
+ handleDragEnd,
4213
+ handleMoveUp,
4214
+ handleMoveDown
4215
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
4162
4216
 
4163
4217
  // Get the current provider based on selection chain
4164
4218
  const getCurrentProvider = () => {
@@ -4193,7 +4247,10 @@ const Metrics = ({
4193
4247
  currentProviderKey = selection.targetKey;
4194
4248
  });
4195
4249
 
4196
- // Create a Set of already selected metric fullPaths for quick lookup
4250
+ // Create a Set of already selected metric fullPaths for quick lookup.
4251
+ // Already-selected items stay selectable (so the same metric can be
4252
+ // added again on purpose) - they're just flagged so the dropdown can
4253
+ // highlight them instead of blocking the click.
4197
4254
  const selectedFullPaths = new Set(savedMetrics.map(metric => metric.fullPath));
4198
4255
 
4199
4256
  // Metrics are a direct array, not nested by alias
@@ -4201,15 +4258,12 @@ const Metrics = ({
4201
4258
  const items = provider.metrics.map((metric, index) => {
4202
4259
  // Construct the fullPath for this metric
4203
4260
  const fullPath = `${aliasPath.join('_')}.${metric.name}`;
4204
-
4205
- // Check if this metric is already selected
4206
- const isAlreadySelected = selectedFullPaths.has(fullPath);
4207
4261
  return {
4208
4262
  key: `${currentProvider}_${metric.name}_${index}`,
4209
4263
  value: metric.title || metric.name,
4210
4264
  metricName: metric.name,
4211
4265
  metric: metric,
4212
- disabled: isAlreadySelected,
4266
+ alreadySelected: selectedFullPaths.has(fullPath),
4213
4267
  icon: metric.source ? /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
4214
4268
  source: metric.source
4215
4269
  }) : undefined
@@ -4272,7 +4326,11 @@ const Metrics = ({
4272
4326
  metricName: selectedItem.metricName,
4273
4327
  metricTitle: selectedItem.value,
4274
4328
  // The constructed path for server
4275
- fullPath: fullPath
4329
+ fullPath: fullPath,
4330
+ // Unique per-instance identity, so this field can be added more
4331
+ // than once and each occurrence stays independently
4332
+ // orderable/removable/titleable.
4333
+ id: nextInstanceId(fullPath, savedMetrics)
4276
4334
  };
4277
4335
  onSaveMetric(metricData);
4278
4336
  handleCancel();
@@ -4283,8 +4341,9 @@ const Metrics = ({
4283
4341
  const handleAddAll = () => {
4284
4342
  const metricItems = getMetricItems();
4285
4343
 
4286
- // Filter out already selected metrics (disabled items)
4287
- const availableItems = metricItems.filter(item => !item.disabled);
4344
+ // Filter out already selected metrics - "Add all" only fills in
4345
+ // missing ones; intentional duplicates are added one at a time above.
4346
+ const availableItems = metricItems.filter(item => !item.alreadySelected);
4288
4347
  if (availableItems.length === 0) return;
4289
4348
 
4290
4349
  // Build the complete relation objects array (same for all metrics in this provider)
@@ -4329,7 +4388,10 @@ const Metrics = ({
4329
4388
  metricName: item.metricName,
4330
4389
  metricTitle: item.value,
4331
4390
  // The constructed path for server
4332
- fullPath: fullPath
4391
+ fullPath: fullPath,
4392
+ // "Add all" only ever adds fields that aren't already
4393
+ // selected, so this is always the first occurrence.
4394
+ id: fullPath
4333
4395
  };
4334
4396
  onSaveMetric(metricData);
4335
4397
  });
@@ -4428,7 +4490,7 @@ const Metrics = ({
4428
4490
  placement: "top"
4429
4491
  }, /*#__PURE__*/React__namespace.default.createElement("span", null, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4430
4492
  onClick: handleAddAll,
4431
- disabled: getMetricItems().filter(item => !item.disabled).length === 0,
4493
+ disabled: getMetricItems().filter(item => !item.alreadySelected).length === 0,
4432
4494
  "aria-label": "add all metrics",
4433
4495
  sx: {
4434
4496
  color: "rgb(70, 134, 128)",
@@ -4497,36 +4559,27 @@ const Metrics = ({
4497
4559
  marginBottom: 1,
4498
4560
  color: "rgb(37, 37, 37)"
4499
4561
  }
4500
- }, "Saved Metrics"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
4562
+ }, "Saved Metrics"), /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
4501
4563
  sensors: sensors,
4502
- collisionDetection: core.closestCenter,
4564
+ itemIds: savedMetrics.map((_, index) => index),
4503
4565
  onDragEnd: handleDragEnd
4504
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
4505
- items: savedMetrics.map((_, index) => index),
4506
- strategy: sortable.verticalListSortingStrategy
4507
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4508
- sx: {
4509
- display: 'flex',
4510
- flexDirection: 'column',
4511
- gap: 1
4512
- }
4513
4566
  }, savedMetrics.map((metric, index) => /*#__PURE__*/React__namespace.default.createElement(SortableChip, {
4514
4567
  key: index,
4515
4568
  id: index,
4516
4569
  label: metric.metricTitle,
4517
4570
  fullLabel: `${formatProviderPath(metric)} → ${metric.metricTitle} (${metric.fullPath})`,
4518
- onDelete: () => onRemoveMetric(index),
4571
+ onDelete: () => onRemoveMetric(metric.id),
4519
4572
  onMoveUp: () => handleMoveUp(index),
4520
4573
  onMoveDown: () => handleMoveDown(index),
4521
4574
  isFirst: index === 0,
4522
4575
  isLast: index === savedMetrics.length - 1,
4523
- fullPath: metric.fullPath,
4576
+ instanceId: metric.id,
4524
4577
  defaultTitle: metric.metricTitle,
4525
- customTitle: titleOverrides[metric.fullPath],
4578
+ customTitle: titleOverrides[metric.id],
4526
4579
  onUpdateTitle: onUpdateTitle,
4527
4580
  onResetTitle: onResetTitle,
4528
4581
  source: metric.metric?.source
4529
- })))))));
4582
+ })))));
4530
4583
  };
4531
4584
 
4532
4585
  const Filters = ({
@@ -5359,7 +5412,12 @@ const Filters = ({
5359
5412
  mb: 3
5360
5413
  }
5361
5414
  }, /*#__PURE__*/React__namespace.default.createElement(SingleSelect, {
5362
- items: existingDimensions.map(dim => ({
5415
+ items: Array.from(
5416
+ // Dimensions can now be added more than once (same fullPath,
5417
+ // different instance), but a filter targets the underlying
5418
+ // field regardless of how many columns display it - so this
5419
+ // list is deduped by fullPath to avoid showing it twice.
5420
+ new Map(existingDimensions.map(dim => [dim.fullPath, dim])).values()).map(dim => ({
5363
5421
  key: dim.fullPath,
5364
5422
  value: interpolateTitle(dimensionTitleOverrides[dim.fullPath] || dim.dimensionTitle, parameters)
5365
5423
  })).sort((a, b) => a.value.localeCompare(b.value)),
@@ -5753,6 +5811,205 @@ const Filters = ({
5753
5811
  }))));
5754
5812
  };
5755
5813
 
5814
+ const KIND_LABEL = {
5815
+ dimension: "Dimension",
5816
+ computed: "Computed",
5817
+ metric: "Metric"
5818
+ };
5819
+ const KindChip = ({
5820
+ kind,
5821
+ source
5822
+ }) => {
5823
+ if (kind === "metric") {
5824
+ return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5825
+ sx: {
5826
+ display: "flex",
5827
+ alignItems: "center",
5828
+ flexShrink: 0
5829
+ }
5830
+ }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
5831
+ source: source
5832
+ }));
5833
+ }
5834
+ return /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
5835
+ icon: kind === "computed" ? /*#__PURE__*/React__namespace.default.createElement(CalculateIcon__default.default, {
5836
+ sx: {
5837
+ fontSize: "15px !important"
5838
+ }
5839
+ }) : undefined,
5840
+ label: KIND_LABEL[kind],
5841
+ size: "small",
5842
+ sx: {
5843
+ fontWeight: 500,
5844
+ backgroundColor: "rgba(70, 134, 128, 0.1)",
5845
+ color: "rgb(70, 134, 128)",
5846
+ flexShrink: 0
5847
+ }
5848
+ });
5849
+ };
5850
+
5851
+ // Read-only sortable row: this tab only reorders columns. Adding, removing,
5852
+ // and renaming stay on the Dimensions/Metrics tabs so there's one place each
5853
+ // field is managed.
5854
+ const SortableColumnRow = ({
5855
+ id,
5856
+ label,
5857
+ fullPath,
5858
+ kind,
5859
+ source,
5860
+ onMoveUp,
5861
+ onMoveDown,
5862
+ isFirst,
5863
+ isLast
5864
+ }) => {
5865
+ const reportingContext = useReportingContextOptional();
5866
+ const parameters = reportingContext?.parameters;
5867
+ const {
5868
+ attributes,
5869
+ listeners,
5870
+ setNodeRef,
5871
+ style
5872
+ } = useSortableRow(id);
5873
+ const displayLabel = interpolateTitle(label, parameters);
5874
+ return /*#__PURE__*/React__namespace.default.createElement("div", _extends({
5875
+ ref: setNodeRef,
5876
+ style: style
5877
+ }, attributes), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5878
+ sx: {
5879
+ display: "flex",
5880
+ alignItems: "center",
5881
+ width: "100%",
5882
+ gap: 1,
5883
+ backgroundColor: "white",
5884
+ border: "1px solid #e0e0e0",
5885
+ borderRadius: 2,
5886
+ padding: 1,
5887
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
5888
+ "&:hover .hover-icons": {
5889
+ opacity: 1
5890
+ }
5891
+ }
5892
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
5893
+ listeners: listeners
5894
+ }), /*#__PURE__*/React__namespace.default.createElement(KindChip, {
5895
+ kind: kind,
5896
+ source: source
5897
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Tooltip, {
5898
+ title: fullPath,
5899
+ arrow: true,
5900
+ placement: "top"
5901
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5902
+ variant: "h6",
5903
+ sx: {
5904
+ fontWeight: 500,
5905
+ color: "#1a1a1a",
5906
+ fontSize: "14px",
5907
+ whiteSpace: "nowrap",
5908
+ overflow: "hidden",
5909
+ textOverflow: "ellipsis"
5910
+ }
5911
+ }, displayLabel)), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5912
+ sx: {
5913
+ flex: 1
5914
+ }
5915
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
5916
+ onMoveUp: onMoveUp,
5917
+ onMoveDown: onMoveDown,
5918
+ isFirst: isFirst,
5919
+ isLast: isLast
5920
+ })));
5921
+ };
5922
+ const ColumnOrder = ({
5923
+ orderedDimensionItems = [],
5924
+ metrics = [],
5925
+ columnOrder = [],
5926
+ onReorderColumnItems,
5927
+ titleOverrides = {
5928
+ dimensions: {},
5929
+ metrics: {}
5930
+ }
5931
+ }) => {
5932
+ const isCustomized = columnOrder.length > 0;
5933
+ const resolvedItems = resolveColumnOrder(columnOrder, orderedDimensionItems, metrics);
5934
+ const sensors = useSortableListSensors();
5935
+ const reorderResolved = newResolvedItems => {
5936
+ onReorderColumnItems(newResolvedItems.map(item => ({
5937
+ type: item.kind,
5938
+ key: item.key
5939
+ })));
5940
+ };
5941
+ const {
5942
+ handleDragEnd,
5943
+ handleMoveUp,
5944
+ handleMoveDown
5945
+ } = makeListReorderHandlers(resolvedItems, reorderResolved);
5946
+ const labelFor = item => {
5947
+ if (item.kind === "computed") return item.data.name;
5948
+ if (item.kind === "metric") {
5949
+ return titleOverrides.metrics[item.key] || item.data.metricTitle || item.key;
5950
+ }
5951
+ return titleOverrides.dimensions[item.key] || item.data.dimensionTitle || item.key;
5952
+ };
5953
+ return /*#__PURE__*/React__namespace.default.createElement("div", null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5954
+ sx: {
5955
+ display: "flex",
5956
+ justifyContent: "space-between",
5957
+ alignItems: "flex-start",
5958
+ mb: 2,
5959
+ gap: 2
5960
+ }
5961
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5962
+ sx: {
5963
+ fontSize: "13px",
5964
+ color: "#666"
5965
+ }
5966
+ }, "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__namespace.default.createElement(material.Tooltip, {
5967
+ title: "Discard the custom order and go back to the default (Dimensions, then Metrics)",
5968
+ arrow: true
5969
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Button, {
5970
+ variant: "outlined",
5971
+ size: "small",
5972
+ startIcon: /*#__PURE__*/React__namespace.default.createElement(RestartAltIcon__default.default, null),
5973
+ onClick: () => onReorderColumnItems([]),
5974
+ sx: {
5975
+ height: "36px",
5976
+ flexShrink: 0,
5977
+ fontFamily: "system-ui",
5978
+ fontSize: "13px",
5979
+ fontWeight: 500,
5980
+ borderRadius: "8px",
5981
+ boxShadow: "none",
5982
+ textTransform: "none",
5983
+ borderColor: "#e0e0e0",
5984
+ color: "rgb(37, 37, 37)",
5985
+ "&:hover": {
5986
+ backgroundColor: "#f5f5f5",
5987
+ borderColor: "#d0d0d0"
5988
+ }
5989
+ }
5990
+ }, "Reset to Default"))), resolvedItems.length === 0 ? /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5991
+ sx: {
5992
+ fontSize: "13px",
5993
+ color: "#999"
5994
+ }
5995
+ }, "Add dimensions or metrics in the other tabs first.") : /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
5996
+ sensors: sensors,
5997
+ itemIds: resolvedItems.map((_, index) => index),
5998
+ onDragEnd: handleDragEnd
5999
+ }, resolvedItems.map((item, index) => /*#__PURE__*/React__namespace.default.createElement(SortableColumnRow, {
6000
+ key: `${item.kind}-${item.key}`,
6001
+ id: index,
6002
+ label: labelFor(item),
6003
+ fullPath: item.kind === "computed" ? item.data.value : item.data.fullPath,
6004
+ kind: item.kind,
6005
+ source: item.kind === "metric" ? item.data.metric?.source : undefined,
6006
+ onMoveUp: () => handleMoveUp(index),
6007
+ onMoveDown: () => handleMoveDown(index),
6008
+ isFirst: index === 0,
6009
+ isLast: index === resolvedItems.length - 1
6010
+ }))));
6011
+ };
6012
+
5756
6013
  // Default numeral.js formats applied when a dimension/metric definition has a
5757
6014
  // known type but no explicit `format` set on the provider.
5758
6015
  const DEFAULT_FORMATS_BY_TYPE = {
@@ -5767,10 +6024,122 @@ const formatValue = (value, def) => {
5767
6024
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
5768
6025
  };
5769
6026
  const isNumericType = type => type === 'integer' || type === 'currency';
6027
+
6028
+ // Builds a single DataGrid column definition for one resolved column item
6029
+ // (a dimension, computed dimension, or metric). The DataGrid `field` is the
6030
+ // item's instance id (so two duplicate instances of the same underlying
6031
+ // dimension/metric still get distinct columns); the actual cell value is
6032
+ // read via `valueGetter` from `dataFieldName`, the key the API response
6033
+ // actually uses - shared by every duplicate instance, since the backend
6034
+ // only ever returns one value per fullPath. dataFieldName rules:
6035
+ // - Dimensions from base provider: baseAlias.field -> baseAlias_field
6036
+ // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
6037
+ // - Computed dimensions have no provider/relations chain, so the API returns
6038
+ // each row keyed by the computed dimension's own `name` (and can't be
6039
+ // duplicated - names must be unique - so field/dataFieldName coincide).
6040
+ // - Metrics: API returns metric keys in different forms depending on path depth
6041
+ // (see comment inline below), normalised to match the row-key normalisation
6042
+ // done when building `rows`.
6043
+ const buildColumn = (item, titleOverrides, parameters) => {
6044
+ if (item.kind === 'computed') {
6045
+ const cd = item.data;
6046
+ return {
6047
+ field: cd.name,
6048
+ headerName: cd.name,
6049
+ flex: 1,
6050
+ minWidth: 150,
6051
+ type: 'string'
6052
+ };
6053
+ }
6054
+ if (item.kind === 'metric') {
6055
+ const metric = item.data;
6056
+ const metricDef = metric.metric;
6057
+ let dataFieldName;
6058
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
6059
+ if (dotCount >= 2) {
6060
+ // 3+-part namespaced path: API returns the full dotted path as the key.
6061
+ // Normalise dots to underscores to match the row key normalisation below.
6062
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
6063
+ dataFieldName = metric.fullPath.replace(/\./g, '_');
6064
+ } else if (metric.relations && metric.relations.length > 0) {
6065
+ // 2-part path from a nested provider: API drops the base provider alias.
6066
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
6067
+ const parts = metric.fullPath.split('.');
6068
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
6069
+ const field = parts[1]; // e.g., "total_amount"
6070
+
6071
+ const pathParts = pathWithoutField.split('_');
6072
+ pathParts.shift(); // remove base provider alias
6073
+ const pathWithoutBase = pathParts.join('_');
6074
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6075
+ } else {
6076
+ // 2-part path from the base provider: API returns just the metric name.
6077
+ // Example: "mc.record_count" -> "record_count"
6078
+ dataFieldName = metric.metricName;
6079
+ }
6080
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.id] || metric.metricTitle || dataFieldName, parameters);
6081
+ return {
6082
+ // `field` is the instance id (unique even for duplicate columns
6083
+ // pointing at the same underlying metric); the actual value is
6084
+ // read from the row via valueGetter using dataFieldName, which is
6085
+ // the key the API response actually uses (shared across duplicates).
6086
+ field: metric.id.replace(/[.#]/g, '_'),
6087
+ headerName: headerName,
6088
+ flex: 1,
6089
+ minWidth: 150,
6090
+ type: isNumericType(metricDef?.type) ? 'number' : 'string',
6091
+ valueGetter: (_value, row) => row[dataFieldName],
6092
+ renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6093
+ sx: {
6094
+ display: 'flex',
6095
+ alignItems: 'center',
6096
+ gap: 0.5
6097
+ }
6098
+ }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
6099
+ source: metricDef?.source
6100
+ }), /*#__PURE__*/React__namespace.default.createElement("span", null, headerName)),
6101
+ valueFormatter: value => formatValue(value, metricDef)
6102
+ };
6103
+ }
6104
+
6105
+ // Dimension
6106
+ const dim = item.data;
6107
+ let dataFieldName;
6108
+ if (dim.relations && dim.relations.length > 0) {
6109
+ // From nested provider: drop the base provider alias
6110
+ // Example: ft_fa_db.currency -> fa_db_currency
6111
+ const parts = dim.fullPath.split('.');
6112
+ const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
6113
+ const field = parts[1]; // e.g., "currency"
6114
+
6115
+ const pathParts = pathWithoutField.split('_');
6116
+ pathParts.shift(); // Remove base provider alias
6117
+ const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
6118
+
6119
+ dataFieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6120
+ } else {
6121
+ // From base provider: keep the full path with underscore
6122
+ // Example: ba.created_at -> ba_created_at
6123
+ dataFieldName = dim.fullPath.replace('.', '_');
6124
+ }
6125
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.id] || dim.dimensionTitle || dataFieldName, parameters);
6126
+ const dimDef = dim.dimension;
6127
+ return {
6128
+ // Same rationale as the metric branch above: `field` must be unique
6129
+ // per instance, while the value always comes from the one real
6130
+ // underlying row key (dataFieldName), shared by every duplicate.
6131
+ field: dim.id.replace(/[.#]/g, '_'),
6132
+ headerName: headerName,
6133
+ flex: 1,
6134
+ minWidth: 150,
6135
+ type: isNumericType(dimDef?.type) ? 'number' : 'string',
6136
+ valueGetter: (_value, row) => row[dataFieldName],
6137
+ valueFormatter: value => formatValue(value, dimDef)
6138
+ };
6139
+ };
5770
6140
  const ReportDataGrid = ({
5771
6141
  reportData,
5772
- orderedDimensionItems = [],
5773
- metrics,
6142
+ columnItems = [],
5774
6143
  loading,
5775
6144
  onPageChange,
5776
6145
  totalRows = 0,
@@ -5786,127 +6155,10 @@ const ReportDataGrid = ({
5786
6155
  pageSize: 50
5787
6156
  });
5788
6157
 
5789
- // Generate columns dynamically from dimensions and metrics
5790
- const columns = React__namespace.default.useMemo(() => {
5791
- const cols = [];
5792
-
5793
- // Add dimension + computed-dimension columns, in the single unified
5794
- // order the user arranged them in on the Dimensions tab.
5795
- // Field naming logic (dimensions):
5796
- // - If from base provider: baseAlias.field -> baseAlias_field
5797
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5798
- // Computed dimensions have no provider/relations chain, so the API
5799
- // returns each row keyed by the computed dimension's own `name`.
5800
- orderedDimensionItems.forEach(item => {
5801
- if (item.kind === 'computed') {
5802
- const cd = item.data;
5803
- cols.push({
5804
- field: cd.name,
5805
- headerName: cd.name,
5806
- flex: 1,
5807
- minWidth: 150,
5808
- type: 'string'
5809
- });
5810
- return;
5811
- }
5812
- const dim = item.data;
5813
- let fieldName;
5814
- let headerName;
5815
-
5816
- // Check if there are relations (nested providers)
5817
- if (dim.relations && dim.relations.length > 0) {
5818
- // From nested provider: drop the base provider alias
5819
- // Example: ft_fa_db.currency -> fa_db_currency
5820
- const parts = dim.fullPath.split('.');
5821
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5822
- const field = parts[1]; // e.g., "currency"
5823
-
5824
- // Remove the base provider alias (first part before first underscore)
5825
- const pathParts = pathWithoutField.split('_');
5826
- pathParts.shift(); // Remove base provider alias
5827
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5828
-
5829
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5830
- } else {
5831
- // From base provider: keep the full path with underscore
5832
- // Example: ba.created_at -> ba_created_at
5833
- fieldName = dim.fullPath.replace('.', '_');
5834
- }
5835
-
5836
- // Check for title override, otherwise use the friendly dimension title from provider
5837
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
5838
- headerName = interpolateTitle(headerName, parameters);
5839
- const dimDef = dim.dimension;
5840
- cols.push({
5841
- field: fieldName,
5842
- headerName: headerName,
5843
- flex: 1,
5844
- minWidth: 150,
5845
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
5846
- valueFormatter: value => formatValue(value, dimDef)
5847
- });
5848
- });
5849
-
5850
- // Add metric columns
5851
- // The API returns metric keys in two different forms depending on the path depth:
5852
- //
5853
- // • 2-part path "mc.record_count" → API key: "record_count"
5854
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
5855
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
5856
- //
5857
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
5858
- // in field names as nested-object accessors, so the rows useMemo normalises
5859
- // those keys (dots → underscores). The column field must match that normalised key.
5860
- metrics.forEach(metric => {
5861
- const metricDef = metric.metric;
5862
- let fieldName;
5863
- let headerName;
5864
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
5865
- if (dotCount >= 2) {
5866
- // 3+-part namespaced path: API returns the full dotted path as the key.
5867
- // Normalise dots to underscores to match the row key normalisation below.
5868
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5869
- fieldName = metric.fullPath.replace(/\./g, '_');
5870
- } else if (metric.relations && metric.relations.length > 0) {
5871
- // 2-part path from a nested provider: API drops the base provider alias.
5872
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
5873
- const parts = metric.fullPath.split('.');
5874
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
5875
- const field = parts[1]; // e.g., "total_amount"
5876
-
5877
- const pathParts = pathWithoutField.split('_');
5878
- pathParts.shift(); // remove base provider alias
5879
- const pathWithoutBase = pathParts.join('_');
5880
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5881
- } else {
5882
- // 2-part path from the base provider: API returns just the metric name.
5883
- // Example: "mc.record_count" -> "record_count"
5884
- fieldName = metric.metricName;
5885
- }
5886
-
5887
- // Check for title override, otherwise use the friendly metric title from provider
5888
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
5889
- headerName = interpolateTitle(headerName, parameters);
5890
- cols.push({
5891
- field: fieldName,
5892
- headerName: headerName,
5893
- flex: 1,
5894
- minWidth: 150,
5895
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
5896
- renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5897
- sx: {
5898
- display: 'flex',
5899
- alignItems: 'center',
5900
- gap: 0.5
5901
- }
5902
- }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
5903
- source: metricDef?.source
5904
- }), /*#__PURE__*/React__namespace.default.createElement("span", null, headerName)),
5905
- valueFormatter: value => formatValue(value, metricDef)
5906
- });
5907
- });
5908
- return cols;
5909
- }, [orderedDimensionItems, metrics, titleOverrides, parameters]);
6158
+ // Generate columns dynamically, in the final resolved column order
6159
+ // (custom Column Order tab arrangement when defined, otherwise
6160
+ // dimensions/computed then metrics).
6161
+ const columns = React__namespace.default.useMemo(() => columnItems.map(item => buildColumn(item, titleOverrides, parameters)), [columnItems, titleOverrides, parameters]);
5910
6162
 
5911
6163
  // Transform report data to rows with unique IDs.
5912
6164
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -6016,7 +6268,12 @@ const ReportBuilder = ({
6016
6268
  computedDimensions: [],
6017
6269
  // Unified display/execution order across dimensions + computedDimensions.
6018
6270
  // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
6019
- dimensionOrder: []
6271
+ dimensionOrder: [],
6272
+ // Optional unified display order across dimensions + computedDimensions +
6273
+ // metrics, set on the Column Order tab. Empty means "not defined" — the
6274
+ // results grid falls back to dimensionOrder followed by metrics order.
6275
+ // Entries: { type: 'dimension' | 'computed' | 'metric', key }
6276
+ columnOrder: []
6020
6277
  });
6021
6278
  const [titleOverrides, setTitleOverrides] = React.useState({
6022
6279
  dimensions: {},
@@ -6202,6 +6459,72 @@ const ReportBuilder = ({
6202
6459
  }
6203
6460
  };
6204
6461
 
6462
+ // Reconstructs saved dimensions as independent, individually-identified
6463
+ // instances so that deliberate duplicates (the same field added more than
6464
+ // once) survive a save/reload round trip. Prefers `ordered_dimensions`
6465
+ // (one ref per instance - the nth duplicate of a path is suffixed
6466
+ // `#n`) when present; falls back to one instance per entry in the
6467
+ // (necessarily duplicate-free) `dimensions` path list for reports saved
6468
+ // before this field existed.
6469
+ const reconstructDimensions = (reportDef, providersData) => {
6470
+ const orderByMap = {};
6471
+ if (reportDef.definition?.doc?.query?.order_by) {
6472
+ for (const orderItem of reportDef.definition.doc.query.order_by) {
6473
+ orderByMap[orderItem.name] = orderItem.desc === true ? "desc" : "asc";
6474
+ }
6475
+ }
6476
+ const reconstructed = [];
6477
+ const orderedRefs = (reportDef.definition?.doc?.query?.ordered_dimensions || []).filter(entry => entry.type === "dimension").map(entry => entry.ref);
6478
+ if (orderedRefs.length > 0) {
6479
+ orderedRefs.forEach(ref => {
6480
+ const fullPath = ref.split("#")[0];
6481
+ const dim = reconstructDimensionFromPath(fullPath, providersData, reportDef.provider);
6482
+ if (dim) {
6483
+ dim.id = ref;
6484
+ dim.sortOrder = orderByMap[fullPath] || null;
6485
+ reconstructed.push(dim);
6486
+ }
6487
+ });
6488
+ } else if (reportDef.definition?.doc?.query?.dimensions) {
6489
+ for (const dimPath of reportDef.definition.doc.query.dimensions) {
6490
+ const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6491
+ if (dim) {
6492
+ dim.id = dimPath;
6493
+ dim.sortOrder = orderByMap[dimPath] || null;
6494
+ reconstructed.push(dim);
6495
+ }
6496
+ }
6497
+ }
6498
+ return reconstructed;
6499
+ };
6500
+
6501
+ // Mirrors reconstructDimensions for metrics, using the analogous
6502
+ // `ordered_metrics` field (metrics have no other unified-order concept to
6503
+ // fall back on, so this is always written whenever there are metrics).
6504
+ const reconstructMetrics = (reportDef, providersData) => {
6505
+ const reconstructed = [];
6506
+ const orderedRefs = reportDef.definition?.doc?.query?.ordered_metrics || [];
6507
+ if (orderedRefs.length > 0) {
6508
+ orderedRefs.forEach(ref => {
6509
+ const fullPath = ref.split("#")[0];
6510
+ const metric = reconstructMetricFromPath(fullPath, providersData, reportDef.provider);
6511
+ if (metric) {
6512
+ metric.id = ref;
6513
+ reconstructed.push(metric);
6514
+ }
6515
+ });
6516
+ } else if (reportDef.definition?.doc?.query?.metrics) {
6517
+ for (const metricPath of reportDef.definition.doc.query.metrics) {
6518
+ const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6519
+ if (metric) {
6520
+ metric.id = metricPath;
6521
+ reconstructed.push(metric);
6522
+ }
6523
+ }
6524
+ }
6525
+ return reconstructed;
6526
+ };
6527
+
6205
6528
  // Build the unified display/execution order for dimensions + computed
6206
6529
  // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
6207
6530
  // to "dimensions first, then computed dimensions" for report definitions
@@ -6212,7 +6535,7 @@ const ReportBuilder = ({
6212
6535
  const seenComputedKeys = new Set();
6213
6536
  if (Array.isArray(rawOrderedDimensions)) {
6214
6537
  rawOrderedDimensions.forEach(entry => {
6215
- if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
6538
+ if (entry.type === "dimension" && dimensions.some(d => d.id === entry.ref)) {
6216
6539
  dimensionOrder.push({
6217
6540
  type: "dimension",
6218
6541
  key: entry.ref
@@ -6230,10 +6553,10 @@ const ReportBuilder = ({
6230
6553
 
6231
6554
  // Append anything not covered by ordered_dimensions (older reports, or drift)
6232
6555
  dimensions.forEach(d => {
6233
- if (!seenDimensionKeys.has(d.fullPath)) {
6556
+ if (!seenDimensionKeys.has(d.id)) {
6234
6557
  dimensionOrder.push({
6235
6558
  type: "dimension",
6236
- key: d.fullPath
6559
+ key: d.id
6237
6560
  });
6238
6561
  }
6239
6562
  });
@@ -6247,6 +6570,46 @@ const ReportBuilder = ({
6247
6570
  });
6248
6571
  return dimensionOrder;
6249
6572
  };
6573
+
6574
+ // Build the optional unified column order (dimensions + computed
6575
+ // dimensions + metrics) from the persisted `ordered_columns` field.
6576
+ // Unlike buildDimensionOrder, entries that no longer resolve are simply
6577
+ // dropped rather than backfilled — an empty/partial result just means "no
6578
+ // custom order defined (yet)", which resolveColumnOrder treats as
6579
+ // "fall back to the default order".
6580
+ const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
6581
+ if (!Array.isArray(rawOrderedColumns)) return [];
6582
+ const dimensionIds = new Set(dimensions.map(d => d.id));
6583
+ const computedNames = new Set(computedDimensions.map(cd => cd.name));
6584
+ const metricIds = new Set(metrics.map(m => m.id));
6585
+ const columnOrder = [];
6586
+ rawOrderedColumns.forEach(entry => {
6587
+ if (entry.type === "dimension" && dimensionIds.has(entry.ref)) {
6588
+ columnOrder.push({
6589
+ type: "dimension",
6590
+ key: entry.ref
6591
+ });
6592
+ } else if (entry.type === "computed" && computedNames.has(entry.ref)) {
6593
+ columnOrder.push({
6594
+ type: "computed",
6595
+ key: entry.ref
6596
+ });
6597
+ } else if (entry.type === "metric" && metricIds.has(entry.ref)) {
6598
+ columnOrder.push({
6599
+ type: "metric",
6600
+ key: entry.ref
6601
+ });
6602
+ }
6603
+ });
6604
+ return columnOrder;
6605
+ };
6606
+
6607
+ // Auto-append a newly added dimension/computed dimension/metric to the end
6608
+ // of columnOrder — but only once a custom order actually exists. While
6609
+ // columnOrder is empty (no custom order defined) it's left untouched, so
6610
+ // the results grid keeps falling back to the default dimensions-then-
6611
+ // metrics order until the user visits the Column Order tab.
6612
+ const appendToColumnOrder = (columnOrder, entry) => columnOrder.length > 0 ? [...columnOrder, entry] : columnOrder;
6250
6613
  const loadReportDefinition = async id => {
6251
6614
  try {
6252
6615
  console.log("Loading report definition:", id);
@@ -6261,43 +6624,10 @@ const ReportBuilder = ({
6261
6624
  // Set the title
6262
6625
  setReportTitle(reportDef.title || "");
6263
6626
 
6264
- // Reconstruct dimensions
6265
- const reconstructedDimensions = [];
6266
- const orderByMap = {}; // Map to store sort order from order_by array
6267
-
6268
- // First, build a map of fullPath -> sortOrder from order_by
6269
- if (reportDef.definition?.doc?.query?.order_by) {
6270
- for (const orderItem of reportDef.definition.doc.query.order_by) {
6271
- if (orderItem.desc === true) {
6272
- orderByMap[orderItem.name] = "desc";
6273
- } else {
6274
- orderByMap[orderItem.name] = "asc";
6275
- }
6276
- }
6277
- }
6278
-
6279
- // Now reconstruct dimensions and add sortOrder from the map
6280
- if (reportDef.definition?.doc?.query?.dimensions) {
6281
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
6282
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6283
- if (dim) {
6284
- // Add sortOrder from order_by if it exists
6285
- dim.sortOrder = orderByMap[dimPath] || null;
6286
- reconstructedDimensions.push(dim);
6287
- }
6288
- }
6289
- }
6290
-
6291
- // Reconstruct metrics
6292
- const reconstructedMetrics = [];
6293
- if (reportDef.definition?.doc?.query?.metrics) {
6294
- for (const metricPath of reportDef.definition.doc.query.metrics) {
6295
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6296
- if (metric) {
6297
- reconstructedMetrics.push(metric);
6298
- }
6299
- }
6300
- }
6627
+ // Reconstruct dimensions and metrics as independent instances
6628
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
6629
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
6630
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
6301
6631
 
6302
6632
  // Load title overrides if they exist
6303
6633
  const loadedTitleOverrides = {
@@ -6343,6 +6673,7 @@ const ReportBuilder = ({
6343
6673
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6344
6674
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6345
6675
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6676
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6346
6677
 
6347
6678
  // Set the report state
6348
6679
  setReport({
@@ -6350,7 +6681,8 @@ const ReportBuilder = ({
6350
6681
  metrics: reconstructedMetrics,
6351
6682
  filters: loadedFilters,
6352
6683
  computedDimensions,
6353
- dimensionOrder
6684
+ dimensionOrder,
6685
+ columnOrder
6354
6686
  });
6355
6687
 
6356
6688
  // Set title overrides
@@ -6379,43 +6711,10 @@ const ReportBuilder = ({
6379
6711
  // Set the title (already modified with " (Copy)" suffix)
6380
6712
  setReportTitle(reportDef.title || "");
6381
6713
 
6382
- // Reconstruct dimensions
6383
- const reconstructedDimensions = [];
6384
- const orderByMap = {}; // Map to store sort order from order_by array
6385
-
6386
- // First, build a map of fullPath -> sortOrder from order_by
6387
- if (reportDef.definition?.doc?.query?.order_by) {
6388
- for (const orderItem of reportDef.definition.doc.query.order_by) {
6389
- if (orderItem.desc === true) {
6390
- orderByMap[orderItem.name] = "desc";
6391
- } else {
6392
- orderByMap[orderItem.name] = "asc";
6393
- }
6394
- }
6395
- }
6396
-
6397
- // Now reconstruct dimensions and add sortOrder from the map
6398
- if (reportDef.definition?.doc?.query?.dimensions) {
6399
- for (const dimPath of reportDef.definition.doc.query.dimensions) {
6400
- const dim = reconstructDimensionFromPath(dimPath, providersData, reportDef.provider);
6401
- if (dim) {
6402
- // Add sortOrder from order_by if it exists
6403
- dim.sortOrder = orderByMap[dimPath] || null;
6404
- reconstructedDimensions.push(dim);
6405
- }
6406
- }
6407
- }
6408
-
6409
- // Reconstruct metrics
6410
- const reconstructedMetrics = [];
6411
- if (reportDef.definition?.doc?.query?.metrics) {
6412
- for (const metricPath of reportDef.definition.doc.query.metrics) {
6413
- const metric = reconstructMetricFromPath(metricPath, providersData, reportDef.provider);
6414
- if (metric) {
6415
- reconstructedMetrics.push(metric);
6416
- }
6417
- }
6418
- }
6714
+ // Reconstruct dimensions and metrics as independent instances
6715
+ // (preserving deliberate duplicates - see reconstructDimensions/Metrics)
6716
+ const reconstructedDimensions = reconstructDimensions(reportDef, providersData);
6717
+ const reconstructedMetrics = reconstructMetrics(reportDef, providersData);
6419
6718
 
6420
6719
  // Load title overrides if they exist
6421
6720
  const loadedTitleOverrides = {
@@ -6460,6 +6759,7 @@ const ReportBuilder = ({
6460
6759
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6461
6760
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6462
6761
  const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6762
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6463
6763
 
6464
6764
  // Set the report state
6465
6765
  setReport({
@@ -6467,7 +6767,8 @@ const ReportBuilder = ({
6467
6767
  metrics: reconstructedMetrics,
6468
6768
  filters: loadedFilters,
6469
6769
  computedDimensions,
6470
- dimensionOrder
6770
+ dimensionOrder,
6771
+ columnOrder
6471
6772
  });
6472
6773
 
6473
6774
  // Set title overrides
@@ -6501,7 +6802,8 @@ const ReportBuilder = ({
6501
6802
  metrics: [],
6502
6803
  filters: {},
6503
6804
  computedDimensions: [],
6504
- dimensionOrder: []
6805
+ dimensionOrder: [],
6806
+ columnOrder: []
6505
6807
  });
6506
6808
  // Reset title overrides
6507
6809
  setTitleOverrides({
@@ -6511,42 +6813,45 @@ const ReportBuilder = ({
6511
6813
  });
6512
6814
  console.log("Selected root provider:", event.target.value);
6513
6815
  };
6514
- const handleUpdateDimensionTitle = (fullPath, customTitle) => {
6816
+
6817
+ // Keyed by instance id, so two instances of the same dimension can carry
6818
+ // independent custom titles.
6819
+ const handleUpdateDimensionTitle = (id, customTitle) => {
6515
6820
  setTitleOverrides(prev => ({
6516
6821
  ...prev,
6517
6822
  dimensions: {
6518
6823
  ...prev.dimensions,
6519
- [fullPath]: customTitle
6824
+ [id]: customTitle
6520
6825
  }
6521
6826
  }));
6522
6827
  };
6523
- const handleResetDimensionTitle = fullPath => {
6828
+ const handleResetDimensionTitle = id => {
6524
6829
  setTitleOverrides(prev => {
6525
6830
  const newDimensions = {
6526
6831
  ...prev.dimensions
6527
6832
  };
6528
- delete newDimensions[fullPath];
6833
+ delete newDimensions[id];
6529
6834
  return {
6530
6835
  ...prev,
6531
6836
  dimensions: newDimensions
6532
6837
  };
6533
6838
  });
6534
6839
  };
6535
- const handleUpdateMetricTitle = (fullPath, customTitle) => {
6840
+ const handleUpdateMetricTitle = (id, customTitle) => {
6536
6841
  setTitleOverrides(prev => ({
6537
6842
  ...prev,
6538
6843
  metrics: {
6539
6844
  ...prev.metrics,
6540
- [fullPath]: customTitle
6845
+ [id]: customTitle
6541
6846
  }
6542
6847
  }));
6543
6848
  };
6544
- const handleResetMetricTitle = fullPath => {
6849
+ const handleResetMetricTitle = id => {
6545
6850
  setTitleOverrides(prev => {
6546
6851
  const newMetrics = {
6547
6852
  ...prev.metrics
6548
6853
  };
6549
- delete newMetrics[fullPath];
6854
+ delete newMetrics[id];
6550
6855
  return {
6551
6856
  ...prev,
6552
6857
  metrics: newMetrics
@@ -6581,29 +6886,42 @@ const ReportBuilder = ({
6581
6886
  dimensions: [...prev.dimensions, dimensionData],
6582
6887
  dimensionOrder: [...prev.dimensionOrder, {
6583
6888
  type: "dimension",
6584
- key: dimensionData.fullPath
6585
- }]
6889
+ key: dimensionData.id
6890
+ }],
6891
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6892
+ type: "dimension",
6893
+ key: dimensionData.id
6894
+ })
6586
6895
  };
6587
6896
  console.log("Dimension saved:", dimensionData);
6588
6897
  console.log("Complete report:", newReport);
6589
6898
  return newReport;
6590
6899
  });
6591
6900
  };
6592
- const handleRemoveDimension = fullPath => {
6901
+ const handleRemoveDimension = id => {
6593
6902
  setReport(prev => ({
6594
6903
  ...prev,
6595
- dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6596
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
6904
+ dimensions: prev.dimensions.filter(d => d.id !== id),
6905
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === id)),
6906
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === id))
6597
6907
  }));
6598
6908
  };
6599
- const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
6600
- setReport(prev => ({
6601
- ...prev,
6602
- dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
6603
- ...d,
6604
- sortOrder
6605
- } : d)
6606
- }));
6909
+
6910
+ // sortOrder is fundamentally a per-field (query-level) setting - SQL can
6911
+ // only sort by a given column once - so toggling it on any instance of a
6912
+ // duplicated dimension applies to every instance sharing that fullPath.
6913
+ const handleUpdateDimensionSortOrder = (id, sortOrder) => {
6914
+ setReport(prev => {
6915
+ const target = prev.dimensions.find(d => d.id === id);
6916
+ if (!target) return prev;
6917
+ return {
6918
+ ...prev,
6919
+ dimensions: prev.dimensions.map(d => d.fullPath === target.fullPath ? {
6920
+ ...d,
6921
+ sortOrder
6922
+ } : d)
6923
+ };
6924
+ });
6607
6925
  };
6608
6926
 
6609
6927
  // Reorders the single unified dimensions + computed-dimensions list;
@@ -6622,7 +6940,11 @@ const ReportBuilder = ({
6622
6940
  dimensionOrder: [...prev.dimensionOrder, {
6623
6941
  type: "computed",
6624
6942
  key: computedDimensionData.name
6625
- }]
6943
+ }],
6944
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6945
+ type: "computed",
6946
+ key: computedDimensionData.name
6947
+ })
6626
6948
  }));
6627
6949
  };
6628
6950
  const handleUpdateComputedDimension = (name, computedDimensionData) => {
@@ -6632,6 +6954,10 @@ const ReportBuilder = ({
6632
6954
  dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6633
6955
  ...entry,
6634
6956
  key: computedDimensionData.name
6957
+ } : entry),
6958
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6959
+ ...entry,
6960
+ key: computedDimensionData.name
6635
6961
  } : entry)
6636
6962
  }));
6637
6963
  };
@@ -6639,24 +6965,30 @@ const ReportBuilder = ({
6639
6965
  setReport(prev => ({
6640
6966
  ...prev,
6641
6967
  computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
6642
- dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6968
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name)),
6969
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6643
6970
  }));
6644
6971
  };
6645
6972
  const handleSaveMetric = metricData => {
6646
6973
  setReport(prev => {
6647
6974
  const newReport = {
6648
6975
  ...prev,
6649
- metrics: [...prev.metrics, metricData]
6976
+ metrics: [...prev.metrics, metricData],
6977
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6978
+ type: "metric",
6979
+ key: metricData.id
6980
+ })
6650
6981
  };
6651
6982
  console.log("Metric saved:", metricData);
6652
6983
  console.log("Complete report:", newReport);
6653
6984
  return newReport;
6654
6985
  });
6655
6986
  };
6656
- const handleRemoveMetric = index => {
6987
+ const handleRemoveMetric = id => {
6657
6988
  setReport(prev => ({
6658
6989
  ...prev,
6659
- metrics: prev.metrics.filter((_, i) => i !== index)
6990
+ metrics: prev.metrics.filter(m => m.id !== id),
6991
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === id))
6660
6992
  }));
6661
6993
  };
6662
6994
  const handleReorderMetrics = newOrder => {
@@ -6665,6 +6997,16 @@ const ReportBuilder = ({
6665
6997
  metrics: newOrder
6666
6998
  }));
6667
6999
  };
7000
+
7001
+ // Sets the unified column order shown on the Column Order tab. An empty
7002
+ // array (e.g. via "Reset to Default") clears the customization, and the
7003
+ // results grid falls back to dimensionOrder followed by metrics order.
7004
+ const handleReorderColumnItems = newColumnOrder => {
7005
+ setReport(prev => ({
7006
+ ...prev,
7007
+ columnOrder: newColumnOrder
7008
+ }));
7009
+ };
6668
7010
  const handleSaveFilter = (fullPath, filterData) => {
6669
7011
  setReport(prev => {
6670
7012
  const newReport = {
@@ -6702,13 +7044,30 @@ const ReportBuilder = ({
6702
7044
  // persisted `ordered_dimensions` field all stay consistent with whatever
6703
7045
  // order the user arranged on screen.
6704
7046
  const buildQueryObject = () => {
6705
- const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7047
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
6706
7048
  const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
6707
- const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionByPath[entry.key]).filter(Boolean);
7049
+
7050
+ // May contain more than one instance of the same fullPath (deliberate
7051
+ // duplicates), kept in dimensionOrder's display order.
7052
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionById[entry.key]).filter(Boolean);
6708
7053
  const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
6709
7054
 
7055
+ // The API keys each result row by fullPath, so duplicate instances of
7056
+ // the same field can only ever carry one value/one sort direction at
7057
+ // the query level - dedupe by fullPath (keeping first-occurrence order)
7058
+ // before sending. The builder still shows/orders/titles each instance
7059
+ // independently; they just read from the same underlying query column.
7060
+ const uniqueDimensionsByPath = [];
7061
+ const seenDimensionPaths = new Set();
7062
+ orderedDimensionsOnly.forEach(dim => {
7063
+ if (!seenDimensionPaths.has(dim.fullPath)) {
7064
+ seenDimensionPaths.add(dim.fullPath);
7065
+ uniqueDimensionsByPath.push(dim);
7066
+ }
7067
+ });
7068
+
6710
7069
  // Build order_by array - include all dimensions, add desc property only when needed
6711
- const orderBy = orderedDimensionsOnly.map(dim => {
7070
+ const orderBy = uniqueDimensionsByPath.map(dim => {
6712
7071
  const orderItem = {
6713
7072
  name: dim.fullPath
6714
7073
  };
@@ -6763,9 +7122,20 @@ const ReportBuilder = ({
6763
7122
  if (Object.keys(titleOverrides.filters).length > 0) {
6764
7123
  titles.filters = titleOverrides.filters;
6765
7124
  }
7125
+
7126
+ // Same dedupe rationale as dimensions above: the query only needs each
7127
+ // metric fullPath once, however many display instances reference it.
7128
+ const uniqueMetricPaths = [];
7129
+ const seenMetricPaths = new Set();
7130
+ report.metrics.forEach(metric => {
7131
+ if (!seenMetricPaths.has(metric.fullPath)) {
7132
+ seenMetricPaths.add(metric.fullPath);
7133
+ uniqueMetricPaths.push(metric.fullPath);
7134
+ }
7135
+ });
6766
7136
  const queryObj = {
6767
- dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
6768
- metrics: report.metrics.map(metric => metric.fullPath),
7137
+ dimensions: uniqueDimensionsByPath.map(dim => dim.fullPath),
7138
+ metrics: uniqueMetricPaths,
6769
7139
  order_by: orderBy
6770
7140
  };
6771
7141
 
@@ -6779,7 +7149,9 @@ const ReportBuilder = ({
6779
7149
 
6780
7150
  // Persist the unified dimension + computed-dimension order so it can be
6781
7151
  // reconstructed on reload and so executed reports render columns in the
6782
- // same order they were arranged in the builder.
7152
+ // same order they were arranged in the builder. Entries use each
7153
+ // dimension's instance id as `ref`, so deliberate duplicates (the nth
7154
+ // occurrence of a fullPath is suffixed `#n`) round-trip correctly.
6783
7155
  if (report.dimensionOrder.length > 0) {
6784
7156
  queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
6785
7157
  type: entry.type,
@@ -6787,6 +7159,23 @@ const ReportBuilder = ({
6787
7159
  }));
6788
7160
  }
6789
7161
 
7162
+ // Persist metric instance order/identity the same way (metrics have no
7163
+ // other unified-order field to piggyback on).
7164
+ if (report.metrics.length > 0) {
7165
+ queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
7166
+ }
7167
+
7168
+ // Persist the optional unified column order (Column Order tab) so the
7169
+ // results grid can reproduce it on reload. Absent when no custom order
7170
+ // has been defined, so old/untouched reports keep their current
7171
+ // dimensions-then-metrics column order unchanged.
7172
+ if (report.columnOrder.length > 0) {
7173
+ queryObj.ordered_columns = report.columnOrder.map(entry => ({
7174
+ type: entry.type,
7175
+ ref: entry.key
7176
+ }));
7177
+ }
7178
+
6790
7179
  // Only add titles if there are any overrides
6791
7180
  if (Object.keys(titles).length > 0) {
6792
7181
  queryObj.titles = titles;
@@ -6846,8 +7235,8 @@ const ReportBuilder = ({
6846
7235
  const handleRunReport = async () => {
6847
7236
  setCurrentPage(0);
6848
7237
  await runReportWithPagination(0, pageSize);
6849
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
6850
- setActiveTab(3);
7238
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
7239
+ setActiveTab(4);
6851
7240
  };
6852
7241
  const handleDownloadReport = async () => {
6853
7242
  try {
@@ -6945,15 +7334,20 @@ const ReportBuilder = ({
6945
7334
 
6946
7335
  // Resolve the unified dimensionOrder into actual dimension/computed-dimension
6947
7336
  // objects so the results grid can render columns in that same order.
6948
- const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7337
+ const dimensionById = Object.fromEntries(report.dimensions.map(d => [d.id, d]));
6949
7338
  const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
6950
7339
  const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
6951
7340
  kind: "dimension",
6952
- data: dimensionByFullPath[entry.key]
7341
+ data: dimensionById[entry.key]
6953
7342
  } : {
6954
7343
  kind: "computed",
6955
7344
  data: computedDimensionByName[entry.key]
6956
7345
  }).filter(item => item.data);
7346
+
7347
+ // Final column list for the results grid: the explicit Column Order tab
7348
+ // arrangement when defined, otherwise dimensions/computed (in their own
7349
+ // order) followed by metrics (in their own order) — today's behavior.
7350
+ const columnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
6957
7351
  return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6958
7352
  sx: {
6959
7353
  p: 3,
@@ -7208,9 +7602,36 @@ const ReportBuilder = ({
7208
7602
  }
7209
7603
  }
7210
7604
  }), /*#__PURE__*/React__namespace.default.createElement(material.Tab, {
7211
- label: reportData ? "Results" : "Results (Run report first)",
7605
+ label: /*#__PURE__*/React__namespace.default.createElement(material.Badge, {
7606
+ variant: "dot",
7607
+ invisible: report.columnOrder.length === 0,
7608
+ sx: {
7609
+ "& .MuiBadge-dot": {
7610
+ backgroundColor: "rgb(70, 134, 128)"
7611
+ }
7612
+ }
7613
+ }, /*#__PURE__*/React__namespace.default.createElement("span", {
7614
+ style: {
7615
+ marginRight: report.columnOrder.length > 0 ? "8px" : "0"
7616
+ }
7617
+ }, "Column Order")),
7212
7618
  id: "report-tab-3",
7213
7619
  "aria-controls": "report-tabpanel-3",
7620
+ sx: {
7621
+ height: "41px",
7622
+ fontFamily: "system-ui",
7623
+ borderRadius: "0.5rem",
7624
+ boxShadow: "none",
7625
+ textTransform: "none",
7626
+ color: "rgb(37, 37, 37)",
7627
+ "&.Mui-selected": {
7628
+ color: "rgb(70, 134, 128)"
7629
+ }
7630
+ }
7631
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Tab, {
7632
+ label: reportData ? "Results" : "Results (Run report first)",
7633
+ id: "report-tab-4",
7634
+ "aria-controls": "report-tabpanel-4",
7214
7635
  disabled: !reportData,
7215
7636
  sx: {
7216
7637
  height: "41px",
@@ -7277,10 +7698,18 @@ const ReportBuilder = ({
7277
7698
  })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7278
7699
  value: activeTab,
7279
7700
  index: 3
7280
- }, reportData && /*#__PURE__*/React__namespace.default.createElement(ReportDataGrid, {
7281
- reportData: reportData,
7701
+ }, /*#__PURE__*/React__namespace.default.createElement(ColumnOrder, {
7282
7702
  orderedDimensionItems: orderedDimensionItems,
7283
7703
  metrics: report.metrics,
7704
+ columnOrder: report.columnOrder,
7705
+ onReorderColumnItems: handleReorderColumnItems,
7706
+ titleOverrides: titleOverrides
7707
+ })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7708
+ value: activeTab,
7709
+ index: 4
7710
+ }, reportData && /*#__PURE__*/React__namespace.default.createElement(ReportDataGrid, {
7711
+ reportData: reportData,
7712
+ columnItems: columnItems,
7284
7713
  loading: loading,
7285
7714
  onPageChange: handlePageChange,
7286
7715
  totalRows: totalRows,
@@ -7368,6 +7797,12 @@ const CurrencySelector = () => {
7368
7797
  parameters,
7369
7798
  setParameters
7370
7799
  } = useReportingContext();
7800
+
7801
+ // Hidden by default; only shown when a `cur` query parameter is present in the top-level URL.
7802
+ const showCurrencySelector = new URLSearchParams(window.location.search).has("cur");
7803
+ if (!showCurrencySelector) {
7804
+ return null;
7805
+ }
7371
7806
  return /*#__PURE__*/React__namespace.default.createElement(SingleSelect, {
7372
7807
  items: CURRENCY_OPTIONS,
7373
7808
  value: parameters.base_currency,