@bygd/nc-report-ui 0.1.40 → 0.1.42

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.
@@ -67,6 +67,7 @@ var LocalizationProvider = require('@mui/x-date-pickers/LocalizationProvider');
67
67
  var DatePicker = require('@mui/x-date-pickers/DatePicker');
68
68
  var AdapterDayjs = require('@mui/x-date-pickers/AdapterDayjs');
69
69
  var dayjs = require('dayjs');
70
+ var CalculateIcon = require('@mui/icons-material/Calculate');
70
71
  var styles = require('@mui/material/styles');
71
72
 
72
73
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -144,6 +145,7 @@ var BoltIcon__default = /*#__PURE__*/_interopDefault(BoltIcon);
144
145
  var FilterAltIcon__default = /*#__PURE__*/_interopDefault(FilterAltIcon);
145
146
  var InfoOutlinedIcon__default = /*#__PURE__*/_interopDefault(InfoOutlinedIcon);
146
147
  var dayjs__default = /*#__PURE__*/_interopDefault(dayjs);
148
+ var CalculateIcon__default = /*#__PURE__*/_interopDefault(CalculateIcon);
147
149
 
148
150
  function _extends() {
149
151
  return _extends = Object.assign ? Object.assign.bind() : function (n) {
@@ -1653,6 +1655,39 @@ const ReportingProvider = ({
1653
1655
  }, children);
1654
1656
  };
1655
1657
 
1658
+ /**
1659
+ * useReportingContext - Hook to access reporting context
1660
+ *
1661
+ * @returns {Object} Context value with parameters, api, and setter functions
1662
+ * @returns {Object} return.parameters - Current parameters object
1663
+ * @returns {Function} return.setParameters - Function to update parameters
1664
+ * @returns {Object} return.api - Current API configuration
1665
+ * @returns {Function} return.setApi - Function to update API configuration
1666
+ * @returns {Function} return.setReportingContext - Function to update both parameters and api
1667
+ *
1668
+ * @example
1669
+ * const { parameters, setParameters, api, setApi, setReportingContext } = useReportingContext();
1670
+ *
1671
+ * // Update parameters only
1672
+ * setParameters({ base_currency: 'USD', merchants: [...] });
1673
+ *
1674
+ * // Update API only
1675
+ * setApi({ token: 'new-token', base_url: 'https://api.example.com' });
1676
+ *
1677
+ * // Update both at once
1678
+ * setReportingContext({
1679
+ * parameters: { base_currency: 'EUR' },
1680
+ * api: { token: 'token', base_url: 'https://api.example.com' }
1681
+ * });
1682
+ */
1683
+ const useReportingContext = () => {
1684
+ const context = React.useContext(ReportingContext);
1685
+ if (context === undefined) {
1686
+ throw new Error('useReportingContext must be used within a ReportingProvider');
1687
+ }
1688
+ return context;
1689
+ };
1690
+
1656
1691
  /**
1657
1692
  * useReportingContextOptional - Hook to optionally access reporting context
1658
1693
  * Returns null if used outside of ReportingProvider (useful for optional fallback)
@@ -2574,6 +2609,140 @@ const ProviderSelection = ({
2574
2609
  }, renderSelectionChain()));
2575
2610
  };
2576
2611
 
2612
+ // Shared drag-and-drop primitives for the index-identified, arrow-reorderable
2613
+ // lists used across the report builder (Dimensions, Metrics, Column Order).
2614
+ // Previously duplicated per-tab; consolidated here so all three behave and
2615
+ // look identical and only need to be fixed in one place.
2616
+
2617
+ const useSortableListSensors = () => core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
2618
+ coordinateGetter: sortable.sortableKeyboardCoordinates
2619
+ }));
2620
+
2621
+ // Builds drag/move handlers for a list where dnd-kit item ids are the array
2622
+ // index. `onReorder` receives the whole reordered list.
2623
+ const makeListReorderHandlers = (list, onReorder) => {
2624
+ const handleDragEnd = ({
2625
+ active,
2626
+ over
2627
+ }) => {
2628
+ if (over && active.id !== over.id) {
2629
+ const oldIndex = list.findIndex((_, i) => i === active.id);
2630
+ const newIndex = list.findIndex((_, i) => i === over.id);
2631
+ onReorder(sortable.arrayMove(list, oldIndex, newIndex));
2632
+ }
2633
+ };
2634
+ const handleMoveUp = index => {
2635
+ if (index > 0) {
2636
+ onReorder(sortable.arrayMove(list, index, index - 1));
2637
+ }
2638
+ };
2639
+ const handleMoveDown = index => {
2640
+ if (index < list.length - 1) {
2641
+ onReorder(sortable.arrayMove(list, index, index + 1));
2642
+ }
2643
+ };
2644
+ return {
2645
+ handleDragEnd,
2646
+ handleMoveUp,
2647
+ handleMoveDown
2648
+ };
2649
+ };
2650
+
2651
+ // Wraps DndContext + SortableContext + the vertical list container.
2652
+ // `itemIds` must line up with the ids passed to each item's useSortable().
2653
+ const SortableListContext = ({
2654
+ sensors,
2655
+ itemIds,
2656
+ onDragEnd,
2657
+ children
2658
+ }) => /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
2659
+ sensors: sensors,
2660
+ collisionDetection: core.closestCenter,
2661
+ onDragEnd: onDragEnd
2662
+ }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
2663
+ items: itemIds,
2664
+ strategy: sortable.verticalListSortingStrategy
2665
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2666
+ sx: {
2667
+ display: "flex",
2668
+ flexDirection: "column",
2669
+ gap: 1
2670
+ }
2671
+ }, children)));
2672
+
2673
+ // Per-row useSortable() wiring shared by every sortable chip/row component.
2674
+ const useSortableRow = id => {
2675
+ const {
2676
+ attributes,
2677
+ listeners,
2678
+ setNodeRef,
2679
+ transform,
2680
+ transition,
2681
+ isDragging
2682
+ } = sortable.useSortable({
2683
+ id
2684
+ });
2685
+ const style = {
2686
+ transform: utilities.CSS.Transform.toString(transform),
2687
+ transition,
2688
+ opacity: isDragging ? 0.5 : 1,
2689
+ display: "flex",
2690
+ alignItems: "center",
2691
+ width: "100%"
2692
+ };
2693
+ return {
2694
+ attributes,
2695
+ listeners,
2696
+ setNodeRef,
2697
+ style
2698
+ };
2699
+ };
2700
+ const DragHandle = ({
2701
+ listeners
2702
+ }) => /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2703
+ sx: {
2704
+ display: "flex",
2705
+ alignItems: "center",
2706
+ cursor: "grab",
2707
+ "&:active": {
2708
+ cursor: "grabbing"
2709
+ }
2710
+ }
2711
+ }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
2712
+ sx: {
2713
+ cursor: "grab",
2714
+ color: "rgba(110, 110, 110, 0.62)"
2715
+ }
2716
+ }));
2717
+ const MoveButtons = ({
2718
+ onMoveUp,
2719
+ onMoveDown,
2720
+ isFirst,
2721
+ isLast
2722
+ }) => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2723
+ className: "hover-icons",
2724
+ sx: {
2725
+ display: "flex",
2726
+ gap: 0.5,
2727
+ opacity: 0,
2728
+ transition: "opacity 0.2s"
2729
+ }
2730
+ }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2731
+ size: "small",
2732
+ onClick: onMoveUp,
2733
+ disabled: isFirst,
2734
+ "aria-label": "move up"
2735
+ }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
2736
+ fontSize: "small"
2737
+ })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2738
+ size: "small",
2739
+ onClick: onMoveDown,
2740
+ disabled: isLast,
2741
+ "aria-label": "move down"
2742
+ }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
2743
+ fontSize: "small"
2744
+ })));
2745
+
2577
2746
  // Replace {{key}} placeholders in a title with values from the report
2578
2747
  // parameters (e.g. "Balance ({{base_currency}})" -> "Balance (EUR)").
2579
2748
  // Unknown keys are left as-is so missing parameters remain visible.
@@ -2585,6 +2754,50 @@ const interpolateTitle = (template, params) => {
2585
2754
  });
2586
2755
  };
2587
2756
 
2757
+ // Resolves the final column display order for the results grid: dimensions,
2758
+ // computed dimensions, and metrics combined into a single list.
2759
+ //
2760
+ // `columnOrder` is the optional, explicitly-arranged order (raw entries
2761
+ // `{ type: 'dimension' | 'computed' | 'metric', key }`) set on the Column
2762
+ // Order tab. When present, its entries come first, in that order. Anything
2763
+ // not covered by it (new fields added since it was last touched, or when
2764
+ // it's empty/undefined because no custom order has ever been defined) is
2765
+ // appended in the pre-existing default order: dimensions/computed (in their
2766
+ // own `dimensionOrder`) first, then metrics (in their own array order) —
2767
+ // this keeps behavior unchanged for reports that never define a column order.
2768
+ const resolveColumnOrder = (columnOrder, orderedDimensionItems, metrics) => {
2769
+ // Normalise every candidate item to a uniform { kind, key, data } shape
2770
+ // up front, so callers (e.g. the Column Order tab) can always read
2771
+ // `.key` regardless of where the item came from.
2772
+ const normalizedDimensionItems = orderedDimensionItems.map(item => ({
2773
+ kind: item.kind,
2774
+ key: item.kind === 'computed' ? item.data.name : item.data.fullPath,
2775
+ data: item.data
2776
+ }));
2777
+ const normalizedMetricItems = metrics.map(m => ({
2778
+ kind: 'metric',
2779
+ key: m.fullPath,
2780
+ data: m
2781
+ }));
2782
+ const byKey = Object.fromEntries([...normalizedDimensionItems, ...normalizedMetricItems].map(item => [`${item.kind}:${item.key}`, item]));
2783
+ const resolved = [];
2784
+ const seen = new Set();
2785
+ (columnOrder || []).forEach(entry => {
2786
+ const seenKey = `${entry.type}:${entry.key}`;
2787
+ if (seen.has(seenKey) || !byKey[seenKey]) return;
2788
+ resolved.push(byKey[seenKey]);
2789
+ seen.add(seenKey);
2790
+ });
2791
+ [...normalizedDimensionItems, ...normalizedMetricItems].forEach(item => {
2792
+ const seenKey = `${item.kind}:${item.key}`;
2793
+ if (!seen.has(seenKey)) {
2794
+ resolved.push(item);
2795
+ seen.add(seenKey);
2796
+ }
2797
+ });
2798
+ return resolved;
2799
+ };
2800
+
2588
2801
  // Sortable Chip Component
2589
2802
  const SortableChip$1 = ({
2590
2803
  id,
@@ -2613,20 +2826,8 @@ const SortableChip$1 = ({
2613
2826
  attributes,
2614
2827
  listeners,
2615
2828
  setNodeRef,
2616
- transform,
2617
- transition,
2618
- isDragging
2619
- } = sortable.useSortable({
2620
- id
2621
- });
2622
- const style = {
2623
- transform: utilities.CSS.Transform.toString(transform),
2624
- transition,
2625
- opacity: isDragging ? 0.5 : 1,
2626
- display: "flex",
2627
- alignItems: "center",
2628
- width: "100%"
2629
- };
2829
+ style
2830
+ } = useSortableRow(id);
2630
2831
 
2631
2832
  // Focus input when entering edit mode
2632
2833
  React.useEffect(() => {
@@ -2735,21 +2936,9 @@ const SortableChip$1 = ({
2735
2936
  opacity: 1 // show icons on hover
2736
2937
  }
2737
2938
  }
2738
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2739
- sx: {
2740
- display: "flex",
2741
- alignItems: "center",
2742
- cursor: "grab",
2743
- "&:active": {
2744
- cursor: "grabbing"
2745
- }
2746
- }
2747
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
2748
- sx: {
2749
- cursor: "grab",
2750
- color: "rgba(110, 110, 110, 0.62)"
2751
- }
2752
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2939
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
2940
+ listeners: listeners
2941
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2753
2942
  sx: {
2754
2943
  minWidth: 0
2755
2944
  }
@@ -2812,29 +3001,12 @@ const SortableChip$1 = ({
2812
3001
  sx: {
2813
3002
  flex: 1
2814
3003
  }
2815
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
2816
- className: "hover-icons",
2817
- sx: {
2818
- display: "flex",
2819
- gap: 0.5,
2820
- opacity: 0,
2821
- transition: "opacity 0.2s"
2822
- }
2823
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2824
- size: "small",
2825
- onClick: onMoveUp,
2826
- disabled: isFirst,
2827
- "aria-label": "move up"
2828
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
2829
- fontSize: "small"
2830
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
2831
- size: "small",
2832
- onClick: onMoveDown,
2833
- disabled: isLast,
2834
- "aria-label": "move down"
2835
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
2836
- fontSize: "small"
2837
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3004
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
3005
+ onMoveUp: onMoveUp,
3006
+ onMoveDown: onMoveDown,
3007
+ isFirst: isFirst,
3008
+ isLast: isLast
3009
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
2838
3010
  inputRef: inputRef,
2839
3011
  value: editValue,
2840
3012
  onChange: e => setEditValue(e.target.value),
@@ -2896,7 +3068,6 @@ const SortableChip$1 = ({
2896
3068
  // the top-of-page Add form, so it stays visible regardless of scroll position.
2897
3069
  const SortableComputedChip = ({
2898
3070
  id,
2899
- index,
2900
3071
  name,
2901
3072
  value,
2902
3073
  onDelete,
@@ -2911,24 +3082,12 @@ const SortableComputedChip = ({
2911
3082
  attributes,
2912
3083
  listeners,
2913
3084
  setNodeRef,
2914
- transform,
2915
- transition,
2916
- isDragging
2917
- } = sortable.useSortable({
2918
- id
2919
- });
3085
+ style
3086
+ } = useSortableRow(id);
2920
3087
  const [isEditing, setIsEditing] = React.useState(false);
2921
3088
  const [editName, setEditName] = React.useState("");
2922
3089
  const [editValue, setEditValue] = React.useState("");
2923
3090
  const containerRef = React.useRef(null);
2924
- const style = {
2925
- transform: utilities.CSS.Transform.toString(transform),
2926
- transition,
2927
- opacity: isDragging ? 0.5 : 1,
2928
- display: "flex",
2929
- alignItems: "center",
2930
- width: "100%"
2931
- };
2932
3091
 
2933
3092
  // Handle click outside to cancel
2934
3093
  React.useEffect(() => {
@@ -2949,13 +3108,13 @@ const SortableComputedChip = ({
2949
3108
  setEditValue(value);
2950
3109
  setIsEditing(true);
2951
3110
  };
2952
- const nameError = editName.trim() !== "" && isNameTaken(editName.trim(), index);
3111
+ const nameError = editName.trim() !== "" && isNameTaken(editName.trim(), name);
2953
3112
  const handleSave = () => {
2954
3113
  const trimmedName = editName.trim();
2955
3114
  const trimmedValue = editValue.trim();
2956
3115
  if (!trimmedName || !trimmedValue) return;
2957
- if (isNameTaken(trimmedName, index)) return;
2958
- onUpdate(index, {
3116
+ if (isNameTaken(trimmedName, name)) return;
3117
+ onUpdate(name, {
2959
3118
  name: trimmedName,
2960
3119
  value: trimmedValue
2961
3120
  });
@@ -2991,21 +3150,9 @@ const SortableComputedChip = ({
2991
3150
  opacity: 1
2992
3151
  }
2993
3152
  }
2994
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
2995
- sx: {
2996
- display: "flex",
2997
- alignItems: "center",
2998
- cursor: "grab",
2999
- "&:active": {
3000
- cursor: "grabbing"
3001
- }
3002
- }
3003
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
3004
- sx: {
3005
- cursor: "grab",
3006
- color: "rgba(110, 110, 110, 0.62)"
3007
- }
3008
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
3153
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
3154
+ listeners: listeners
3155
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
3009
3156
  icon: /*#__PURE__*/React__namespace.default.createElement(FunctionsIcon__default.default, {
3010
3157
  sx: {
3011
3158
  fontSize: "15px !important"
@@ -3069,29 +3216,12 @@ const SortableComputedChip = ({
3069
3216
  sx: {
3070
3217
  flex: 1
3071
3218
  }
3072
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3073
- className: "hover-icons",
3074
- sx: {
3075
- display: "flex",
3076
- gap: 0.5,
3077
- opacity: 0,
3078
- transition: "opacity 0.2s"
3079
- }
3080
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3081
- size: "small",
3082
- onClick: onMoveUp,
3083
- disabled: isFirst,
3084
- "aria-label": "move up"
3085
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
3086
- fontSize: "small"
3087
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
3088
- size: "small",
3089
- onClick: onMoveDown,
3090
- disabled: isLast,
3091
- "aria-label": "move down"
3092
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
3093
- fontSize: "small"
3094
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3219
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
3220
+ onMoveUp: onMoveUp,
3221
+ onMoveDown: onMoveDown,
3222
+ isFirst: isFirst,
3223
+ isLast: isLast
3224
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3095
3225
  value: editName,
3096
3226
  onChange: e => setEditName(e.target.value),
3097
3227
  onKeyDown: handleKeyDown,
@@ -3153,7 +3283,7 @@ const Dimensions = ({
3153
3283
  savedDimensions = [],
3154
3284
  onSaveDimension,
3155
3285
  onRemoveDimension,
3156
- onReorderDimensions,
3286
+ onUpdateDimensionSortOrder,
3157
3287
  titleOverrides = {},
3158
3288
  onUpdateTitle,
3159
3289
  onResetTitle,
@@ -3163,7 +3293,8 @@ const Dimensions = ({
3163
3293
  onSaveComputedDimension,
3164
3294
  onUpdateComputedDimension,
3165
3295
  onRemoveComputedDimension,
3166
- onReorderComputedDimensions
3296
+ dimensionOrder = [],
3297
+ onReorderDimensionItems
3167
3298
  }) => {
3168
3299
  const reportingContext = useReportingContextOptional();
3169
3300
  const parameters = reportingContext?.parameters;
@@ -3175,53 +3306,38 @@ const Dimensions = ({
3175
3306
  const [computedValue, setComputedValue] = React.useState("");
3176
3307
 
3177
3308
  // Setup drag and drop sensors
3178
- const sensors = core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
3179
- coordinateGetter: sortable.sortableKeyboardCoordinates
3180
- }));
3181
-
3182
- // Handle drag end
3183
- const handleDragEnd = event => {
3184
- const {
3185
- active,
3186
- over
3187
- } = event;
3188
- if (over && active.id !== over.id) {
3189
- const oldIndex = savedDimensions.findIndex((_, i) => i === active.id);
3190
- const newIndex = savedDimensions.findIndex((_, i) => i === over.id);
3191
- const newOrder = sortable.arrayMove(savedDimensions, oldIndex, newIndex);
3192
- onReorderDimensions(newOrder);
3193
- }
3194
- };
3195
-
3196
- // Handle move up
3197
- const handleMoveUp = index => {
3198
- if (index > 0) {
3199
- const newOrder = sortable.arrayMove(savedDimensions, index, index - 1);
3200
- onReorderDimensions(newOrder);
3201
- }
3202
- };
3203
-
3204
- // Handle move down
3205
- const handleMoveDown = index => {
3206
- if (index < savedDimensions.length - 1) {
3207
- const newOrder = sortable.arrayMove(savedDimensions, index, index + 1);
3208
- onReorderDimensions(newOrder);
3209
- }
3210
- };
3309
+ const sensors = useSortableListSensors();
3310
+
3311
+ // Resolve the unified dimensionOrder into the actual dimension / computed
3312
+ // dimension objects, so dimensions and computed dimensions can be shown
3313
+ // (and dragged) as a single list while staying stored in their own arrays.
3314
+ const dimensionByFullPath = Object.fromEntries(savedDimensions.map(d => [d.fullPath, d]));
3315
+ const computedDimensionByName = Object.fromEntries(savedComputedDimensions.map(cd => [cd.name, cd]));
3316
+ const orderedItems = dimensionOrder.map(entry => entry.type === "dimension" ? {
3317
+ kind: "dimension",
3318
+ key: entry.key,
3319
+ data: dimensionByFullPath[entry.key]
3320
+ } : {
3321
+ kind: "computed",
3322
+ key: entry.key,
3323
+ data: computedDimensionByName[entry.key]
3324
+ }).filter(item => item.data);
3325
+
3326
+ // Handle drag/move for the unified list
3327
+ const {
3328
+ handleDragEnd,
3329
+ handleMoveUp,
3330
+ handleMoveDown
3331
+ } = makeListReorderHandlers(dimensionOrder, onReorderDimensionItems);
3211
3332
 
3212
- // Handle sort order change
3213
- const handleSortOrderChange = (index, sortOrder) => {
3214
- const newDimensions = [...savedDimensions];
3215
- newDimensions[index] = {
3216
- ...newDimensions[index],
3217
- sortOrder
3218
- };
3219
- onReorderDimensions(newDimensions);
3333
+ // Handle sort order change (dimensions only - computed dimensions have no order_by)
3334
+ const handleSortOrderChange = (fullPath, sortOrder) => {
3335
+ onUpdateDimensionSortOrder(fullPath, sortOrder);
3220
3336
  };
3221
3337
 
3222
3338
  // Computed Dimensions: { name, value } pairs independent of any provider.
3223
3339
  // Their `value` is a raw SQL literal/expression sent to the API as-is.
3224
- const isComputedNameTaken = (name, excludeIndex = null) => savedComputedDimensions.some((cd, i) => cd.name === name && i !== excludeIndex);
3340
+ const isComputedNameTaken = (name, excludeName = null) => savedComputedDimensions.some(cd => cd.name === name) && name !== excludeName;
3225
3341
  const computedNameError = computedName.trim() !== "" && isComputedNameTaken(computedName.trim());
3226
3342
  const handleAddComputedClick = () => {
3227
3343
  setIsAddingComputed(true);
@@ -3245,27 +3361,6 @@ const Dimensions = ({
3245
3361
  });
3246
3362
  handleCancelComputed();
3247
3363
  };
3248
- const handleComputedDragEnd = event => {
3249
- const {
3250
- active,
3251
- over
3252
- } = event;
3253
- if (over && active.id !== over.id) {
3254
- const oldIndex = savedComputedDimensions.findIndex((_, i) => i === active.id);
3255
- const newIndex = savedComputedDimensions.findIndex((_, i) => i === over.id);
3256
- onReorderComputedDimensions(sortable.arrayMove(savedComputedDimensions, oldIndex, newIndex));
3257
- }
3258
- };
3259
- const handleMoveComputedUp = index => {
3260
- if (index > 0) {
3261
- onReorderComputedDimensions(sortable.arrayMove(savedComputedDimensions, index, index - 1));
3262
- }
3263
- };
3264
- const handleMoveComputedDown = index => {
3265
- if (index < savedComputedDimensions.length - 1) {
3266
- onReorderComputedDimensions(sortable.arrayMove(savedComputedDimensions, index, index + 1));
3267
- }
3268
- };
3269
3364
 
3270
3365
  // Get the current provider based on selection chain
3271
3366
  const getCurrentProvider = () => {
@@ -3708,7 +3803,7 @@ const Dimensions = ({
3708
3803
  boxShadow: "none"
3709
3804
  }
3710
3805
  }
3711
- }, "Save Dimension"))), savedDimensions.length > 0 && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3806
+ }, "Save Dimension"))), orderedItems.length > 0 && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3712
3807
  sx: {
3713
3808
  marginTop: 0
3714
3809
  }
@@ -3726,81 +3821,40 @@ const Dimensions = ({
3726
3821
  color: "#666",
3727
3822
  marginBottom: 2
3728
3823
  }
3729
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
3824
+ }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
3730
3825
  sensors: sensors,
3731
- collisionDetection: core.closestCenter,
3826
+ itemIds: orderedItems.map((_, index) => index),
3732
3827
  onDragEnd: handleDragEnd
3733
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
3734
- items: savedDimensions.map((_, index) => index),
3735
- strategy: sortable.verticalListSortingStrategy
3736
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3737
- sx: {
3738
- display: "flex",
3739
- flexDirection: "column",
3740
- gap: 1
3741
- }
3742
- }, savedDimensions.map((dim, index) => /*#__PURE__*/React__namespace.default.createElement(SortableChip$1, {
3743
- key: index,
3828
+ }, orderedItems.map((item, index) => item.kind === "dimension" ? /*#__PURE__*/React__namespace.default.createElement(SortableChip$1, {
3829
+ key: `dim-${item.key}`,
3744
3830
  id: index,
3745
- label: dim.dimensionTitle,
3746
- fullLabel: `${formatProviderPath(dim)} → ${dim.dimensionTitle} (${dim.fullPath})`,
3747
- onDelete: () => onRemoveDimension(index),
3831
+ label: item.data.dimensionTitle,
3832
+ fullLabel: `${formatProviderPath(item.data)} → ${item.data.dimensionTitle} (${item.data.fullPath})`,
3833
+ onDelete: () => onRemoveDimension(item.key),
3748
3834
  onMoveUp: () => handleMoveUp(index),
3749
3835
  onMoveDown: () => handleMoveDown(index),
3750
3836
  isFirst: index === 0,
3751
- isLast: index === savedDimensions.length - 1,
3752
- sortOrder: dim.sortOrder || null,
3753
- onSortOrderChange: sortOrder => handleSortOrderChange(index, sortOrder),
3754
- fullPath: dim.fullPath,
3755
- defaultTitle: dim.dimensionTitle,
3756
- customTitle: titleOverrides[dim.fullPath],
3837
+ isLast: index === orderedItems.length - 1,
3838
+ sortOrder: item.data.sortOrder || null,
3839
+ onSortOrderChange: sortOrder => handleSortOrderChange(item.key, sortOrder),
3840
+ fullPath: item.data.fullPath,
3841
+ defaultTitle: item.data.dimensionTitle,
3842
+ customTitle: titleOverrides[item.data.fullPath],
3757
3843
  onUpdateTitle: onUpdateTitle,
3758
3844
  onResetTitle: onResetTitle
3759
- })))))), savedComputedDimensions.length > 0 && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3760
- sx: {
3761
- marginTop: 2
3762
- }
3763
- }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
3764
- variant: "h6",
3765
- sx: {
3766
- fontSize: "16px",
3767
- fontWeight: 600,
3768
- marginTop: 2,
3769
- color: "rgb(37, 37, 37)"
3770
- }
3771
- }, "Saved Computed Dimensions"), /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
3772
- sx: {
3773
- fontSize: "13px",
3774
- color: "#666",
3775
- marginBottom: 2
3776
- }
3777
- }, "Drag to reorder or use arrows"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
3778
- sensors: sensors,
3779
- collisionDetection: core.closestCenter,
3780
- onDragEnd: handleComputedDragEnd
3781
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
3782
- items: savedComputedDimensions.map((_, index) => index),
3783
- strategy: sortable.verticalListSortingStrategy
3784
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3785
- sx: {
3786
- display: "flex",
3787
- flexDirection: "column",
3788
- gap: 1
3789
- }
3790
- }, savedComputedDimensions.map((cd, index) => /*#__PURE__*/React__namespace.default.createElement(SortableComputedChip, {
3791
- key: index,
3845
+ }) : /*#__PURE__*/React__namespace.default.createElement(SortableComputedChip, {
3846
+ key: `comp-${item.key}`,
3792
3847
  id: index,
3793
- index: index,
3794
- name: cd.name,
3795
- value: cd.value,
3848
+ name: item.data.name,
3849
+ value: item.data.value,
3796
3850
  onUpdate: onUpdateComputedDimension,
3797
3851
  isNameTaken: isComputedNameTaken,
3798
- onDelete: () => onRemoveComputedDimension(index),
3799
- onMoveUp: () => handleMoveComputedUp(index),
3800
- onMoveDown: () => handleMoveComputedDown(index),
3852
+ onDelete: () => onRemoveComputedDimension(item.key),
3853
+ onMoveUp: () => handleMoveUp(index),
3854
+ onMoveDown: () => handleMoveDown(index),
3801
3855
  isFirst: index === 0,
3802
- isLast: index === savedComputedDimensions.length - 1
3803
- })))))));
3856
+ isLast: index === orderedItems.length - 1
3857
+ })))));
3804
3858
  };
3805
3859
 
3806
3860
  const MetricSourceIcon = ({
@@ -3875,20 +3929,8 @@ const SortableChip = ({
3875
3929
  attributes,
3876
3930
  listeners,
3877
3931
  setNodeRef,
3878
- transform,
3879
- transition,
3880
- isDragging
3881
- } = sortable.useSortable({
3882
- id
3883
- });
3884
- const style = {
3885
- transform: utilities.CSS.Transform.toString(transform),
3886
- transition,
3887
- opacity: isDragging ? 0.5 : 1,
3888
- display: 'flex',
3889
- alignItems: 'center',
3890
- width: '100%'
3891
- };
3932
+ style
3933
+ } = useSortableRow(id);
3892
3934
 
3893
3935
  // Focus input when entering edit mode
3894
3936
  React.useEffect(() => {
@@ -3966,21 +4008,9 @@ const SortableChip = ({
3966
4008
  opacity: 1 // show icons on hover
3967
4009
  }
3968
4010
  }
3969
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, _extends({}, listeners, {
3970
- sx: {
3971
- display: "flex",
3972
- alignItems: "center",
3973
- cursor: "grab",
3974
- "&:active": {
3975
- cursor: "grabbing"
3976
- }
3977
- }
3978
- }), /*#__PURE__*/React__namespace.default.createElement(DragIndicatorIcon__default.default, {
3979
- sx: {
3980
- cursor: "grab",
3981
- color: "rgba(110, 110, 110, 0.62)"
3982
- }
3983
- })), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, source && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4011
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
4012
+ listeners: listeners
4013
+ }), !isEditing ? /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, source && /*#__PURE__*/React__namespace.default.createElement(material.Box, {
3984
4014
  sx: {
3985
4015
  display: 'flex',
3986
4016
  alignItems: 'center',
@@ -4042,29 +4072,12 @@ const SortableChip = ({
4042
4072
  sx: {
4043
4073
  flex: 1
4044
4074
  }
4045
- }), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4046
- className: "hover-icons",
4047
- sx: {
4048
- display: "flex",
4049
- gap: 0.5,
4050
- opacity: 0,
4051
- transition: "opacity 0.2s"
4052
- }
4053
- }, /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4054
- size: "small",
4055
- onClick: onMoveUp,
4056
- disabled: isFirst,
4057
- "aria-label": "move up"
4058
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowUpwardIcon__default.default, {
4059
- fontSize: "small"
4060
- })), /*#__PURE__*/React__namespace.default.createElement(material.IconButton, {
4061
- size: "small",
4062
- onClick: onMoveDown,
4063
- disabled: isLast,
4064
- "aria-label": "move down"
4065
- }, /*#__PURE__*/React__namespace.default.createElement(ArrowDownwardIcon__default.default, {
4066
- fontSize: "small"
4067
- })))) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4075
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
4076
+ onMoveUp: onMoveUp,
4077
+ onMoveDown: onMoveDown,
4078
+ isFirst: isFirst,
4079
+ isLast: isLast
4080
+ })) : /*#__PURE__*/React__namespace.default.createElement(React__namespace.default.Fragment, null, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4068
4081
  inputRef: inputRef,
4069
4082
  value: editValue,
4070
4083
  onChange: e => setEditValue(e.target.value),
@@ -4139,39 +4152,14 @@ const Metrics = ({
4139
4152
  const [selectedMetric, setSelectedMetric] = React.useState('');
4140
4153
 
4141
4154
  // Setup drag and drop sensors
4142
- const sensors = core.useSensors(core.useSensor(core.PointerSensor), core.useSensor(core.KeyboardSensor, {
4143
- coordinateGetter: sortable.sortableKeyboardCoordinates
4144
- }));
4145
-
4146
- // Handle drag end
4147
- const handleDragEnd = event => {
4148
- const {
4149
- active,
4150
- over
4151
- } = event;
4152
- if (over && active.id !== over.id) {
4153
- const oldIndex = savedMetrics.findIndex((_, i) => i === active.id);
4154
- const newIndex = savedMetrics.findIndex((_, i) => i === over.id);
4155
- const newOrder = sortable.arrayMove(savedMetrics, oldIndex, newIndex);
4156
- onReorderMetrics(newOrder);
4157
- }
4158
- };
4159
-
4160
- // Handle move up
4161
- const handleMoveUp = index => {
4162
- if (index > 0) {
4163
- const newOrder = sortable.arrayMove(savedMetrics, index, index - 1);
4164
- onReorderMetrics(newOrder);
4165
- }
4166
- };
4155
+ const sensors = useSortableListSensors();
4167
4156
 
4168
- // Handle move down
4169
- const handleMoveDown = index => {
4170
- if (index < savedMetrics.length - 1) {
4171
- const newOrder = sortable.arrayMove(savedMetrics, index, index + 1);
4172
- onReorderMetrics(newOrder);
4173
- }
4174
- };
4157
+ // Handle drag/move
4158
+ const {
4159
+ handleDragEnd,
4160
+ handleMoveUp,
4161
+ handleMoveDown
4162
+ } = makeListReorderHandlers(savedMetrics, onReorderMetrics);
4175
4163
 
4176
4164
  // Get the current provider based on selection chain
4177
4165
  const getCurrentProvider = () => {
@@ -4510,19 +4498,10 @@ const Metrics = ({
4510
4498
  marginBottom: 1,
4511
4499
  color: "rgb(37, 37, 37)"
4512
4500
  }
4513
- }, "Saved Metrics"), /*#__PURE__*/React__namespace.default.createElement(core.DndContext, {
4501
+ }, "Saved Metrics"), /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
4514
4502
  sensors: sensors,
4515
- collisionDetection: core.closestCenter,
4503
+ itemIds: savedMetrics.map((_, index) => index),
4516
4504
  onDragEnd: handleDragEnd
4517
- }, /*#__PURE__*/React__namespace.default.createElement(sortable.SortableContext, {
4518
- items: savedMetrics.map((_, index) => index),
4519
- strategy: sortable.verticalListSortingStrategy
4520
- }, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4521
- sx: {
4522
- display: 'flex',
4523
- flexDirection: 'column',
4524
- gap: 1
4525
- }
4526
4505
  }, savedMetrics.map((metric, index) => /*#__PURE__*/React__namespace.default.createElement(SortableChip, {
4527
4506
  key: index,
4528
4507
  id: index,
@@ -4539,7 +4518,7 @@ const Metrics = ({
4539
4518
  onUpdateTitle: onUpdateTitle,
4540
4519
  onResetTitle: onResetTitle,
4541
4520
  source: metric.metric?.source
4542
- })))))));
4521
+ })))));
4543
4522
  };
4544
4523
 
4545
4524
  const Filters = ({
@@ -5766,9 +5745,208 @@ const Filters = ({
5766
5745
  }))));
5767
5746
  };
5768
5747
 
5769
- // Default numeral.js formats applied when a dimension/metric definition has a
5770
- // known type but no explicit `format` set on the provider.
5771
- const DEFAULT_FORMATS_BY_TYPE = {
5748
+ const KIND_LABEL = {
5749
+ dimension: "Dimension",
5750
+ computed: "Computed",
5751
+ metric: "Metric"
5752
+ };
5753
+ const KindChip = ({
5754
+ kind,
5755
+ source
5756
+ }) => {
5757
+ if (kind === "metric") {
5758
+ return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5759
+ sx: {
5760
+ display: "flex",
5761
+ alignItems: "center",
5762
+ flexShrink: 0
5763
+ }
5764
+ }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
5765
+ source: source
5766
+ }));
5767
+ }
5768
+ return /*#__PURE__*/React__namespace.default.createElement(material.Chip, {
5769
+ icon: kind === "computed" ? /*#__PURE__*/React__namespace.default.createElement(CalculateIcon__default.default, {
5770
+ sx: {
5771
+ fontSize: "15px !important"
5772
+ }
5773
+ }) : undefined,
5774
+ label: KIND_LABEL[kind],
5775
+ size: "small",
5776
+ sx: {
5777
+ fontWeight: 500,
5778
+ backgroundColor: "rgba(70, 134, 128, 0.1)",
5779
+ color: "rgb(70, 134, 128)",
5780
+ flexShrink: 0
5781
+ }
5782
+ });
5783
+ };
5784
+
5785
+ // Read-only sortable row: this tab only reorders columns. Adding, removing,
5786
+ // and renaming stay on the Dimensions/Metrics tabs so there's one place each
5787
+ // field is managed.
5788
+ const SortableColumnRow = ({
5789
+ id,
5790
+ label,
5791
+ fullPath,
5792
+ kind,
5793
+ source,
5794
+ onMoveUp,
5795
+ onMoveDown,
5796
+ isFirst,
5797
+ isLast
5798
+ }) => {
5799
+ const reportingContext = useReportingContextOptional();
5800
+ const parameters = reportingContext?.parameters;
5801
+ const {
5802
+ attributes,
5803
+ listeners,
5804
+ setNodeRef,
5805
+ style
5806
+ } = useSortableRow(id);
5807
+ const displayLabel = interpolateTitle(label, parameters);
5808
+ return /*#__PURE__*/React__namespace.default.createElement("div", _extends({
5809
+ ref: setNodeRef,
5810
+ style: style
5811
+ }, attributes), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5812
+ sx: {
5813
+ display: "flex",
5814
+ alignItems: "center",
5815
+ width: "100%",
5816
+ gap: 1,
5817
+ backgroundColor: "white",
5818
+ border: "1px solid #e0e0e0",
5819
+ borderRadius: 2,
5820
+ padding: 1,
5821
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
5822
+ "&:hover .hover-icons": {
5823
+ opacity: 1
5824
+ }
5825
+ }
5826
+ }, /*#__PURE__*/React__namespace.default.createElement(DragHandle, {
5827
+ listeners: listeners
5828
+ }), /*#__PURE__*/React__namespace.default.createElement(KindChip, {
5829
+ kind: kind,
5830
+ source: source
5831
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Tooltip, {
5832
+ title: fullPath,
5833
+ arrow: true,
5834
+ placement: "top"
5835
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5836
+ variant: "h6",
5837
+ sx: {
5838
+ fontWeight: 500,
5839
+ color: "#1a1a1a",
5840
+ fontSize: "14px",
5841
+ whiteSpace: "nowrap",
5842
+ overflow: "hidden",
5843
+ textOverflow: "ellipsis"
5844
+ }
5845
+ }, displayLabel)), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5846
+ sx: {
5847
+ flex: 1
5848
+ }
5849
+ }), /*#__PURE__*/React__namespace.default.createElement(MoveButtons, {
5850
+ onMoveUp: onMoveUp,
5851
+ onMoveDown: onMoveDown,
5852
+ isFirst: isFirst,
5853
+ isLast: isLast
5854
+ })));
5855
+ };
5856
+ const ColumnOrder = ({
5857
+ orderedDimensionItems = [],
5858
+ metrics = [],
5859
+ columnOrder = [],
5860
+ onReorderColumnItems,
5861
+ titleOverrides = {
5862
+ dimensions: {},
5863
+ metrics: {}
5864
+ }
5865
+ }) => {
5866
+ const isCustomized = columnOrder.length > 0;
5867
+ const resolvedItems = resolveColumnOrder(columnOrder, orderedDimensionItems, metrics);
5868
+ const sensors = useSortableListSensors();
5869
+ const reorderResolved = newResolvedItems => {
5870
+ onReorderColumnItems(newResolvedItems.map(item => ({
5871
+ type: item.kind,
5872
+ key: item.key
5873
+ })));
5874
+ };
5875
+ const {
5876
+ handleDragEnd,
5877
+ handleMoveUp,
5878
+ handleMoveDown
5879
+ } = makeListReorderHandlers(resolvedItems, reorderResolved);
5880
+ const labelFor = item => {
5881
+ if (item.kind === "computed") return item.data.name;
5882
+ if (item.kind === "metric") {
5883
+ return titleOverrides.metrics[item.key] || item.data.metricTitle || item.key;
5884
+ }
5885
+ return titleOverrides.dimensions[item.key] || item.data.dimensionTitle || item.key;
5886
+ };
5887
+ return /*#__PURE__*/React__namespace.default.createElement("div", null, /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5888
+ sx: {
5889
+ display: "flex",
5890
+ justifyContent: "space-between",
5891
+ alignItems: "flex-start",
5892
+ mb: 2,
5893
+ gap: 2
5894
+ }
5895
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5896
+ sx: {
5897
+ fontSize: "13px",
5898
+ color: "#666"
5899
+ }
5900
+ }, "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, {
5901
+ title: "Discard the custom order and go back to the default (Dimensions, then Metrics)",
5902
+ arrow: true
5903
+ }, /*#__PURE__*/React__namespace.default.createElement(material.Button, {
5904
+ variant: "outlined",
5905
+ size: "small",
5906
+ startIcon: /*#__PURE__*/React__namespace.default.createElement(RestartAltIcon__default.default, null),
5907
+ onClick: () => onReorderColumnItems([]),
5908
+ sx: {
5909
+ height: "36px",
5910
+ flexShrink: 0,
5911
+ fontFamily: "system-ui",
5912
+ fontSize: "13px",
5913
+ fontWeight: 500,
5914
+ borderRadius: "8px",
5915
+ boxShadow: "none",
5916
+ textTransform: "none",
5917
+ borderColor: "#e0e0e0",
5918
+ color: "rgb(37, 37, 37)",
5919
+ "&:hover": {
5920
+ backgroundColor: "#f5f5f5",
5921
+ borderColor: "#d0d0d0"
5922
+ }
5923
+ }
5924
+ }, "Reset to Default"))), resolvedItems.length === 0 ? /*#__PURE__*/React__namespace.default.createElement(material.Typography, {
5925
+ sx: {
5926
+ fontSize: "13px",
5927
+ color: "#999"
5928
+ }
5929
+ }, "Add dimensions or metrics in the other tabs first.") : /*#__PURE__*/React__namespace.default.createElement(SortableListContext, {
5930
+ sensors: sensors,
5931
+ itemIds: resolvedItems.map((_, index) => index),
5932
+ onDragEnd: handleDragEnd
5933
+ }, resolvedItems.map((item, index) => /*#__PURE__*/React__namespace.default.createElement(SortableColumnRow, {
5934
+ key: `${item.kind}-${item.key}`,
5935
+ id: index,
5936
+ label: labelFor(item),
5937
+ fullPath: item.kind === "computed" ? item.data.value : item.key,
5938
+ kind: item.kind,
5939
+ source: item.kind === "metric" ? item.data.metric?.source : undefined,
5940
+ onMoveUp: () => handleMoveUp(index),
5941
+ onMoveDown: () => handleMoveDown(index),
5942
+ isFirst: index === 0,
5943
+ isLast: index === resolvedItems.length - 1
5944
+ }))));
5945
+ };
5946
+
5947
+ // Default numeral.js formats applied when a dimension/metric definition has a
5948
+ // known type but no explicit `format` set on the provider.
5949
+ const DEFAULT_FORMATS_BY_TYPE = {
5772
5950
  currency: '0,0.00',
5773
5951
  integer: '0,0'
5774
5952
  };
@@ -5780,11 +5958,107 @@ const formatValue = (value, def) => {
5780
5958
  return def?.prefix ? `${def.prefix} ${formatted}` : formatted;
5781
5959
  };
5782
5960
  const isNumericType = type => type === 'integer' || type === 'currency';
5961
+
5962
+ // Builds a single DataGrid column definition for one resolved column item
5963
+ // (a dimension, computed dimension, or metric). Field naming logic:
5964
+ // - Dimensions from base provider: baseAlias.field -> baseAlias_field
5965
+ // - Dimensions from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5966
+ // - Computed dimensions have no provider/relations chain, so the API returns
5967
+ // each row keyed by the computed dimension's own `name`.
5968
+ // - Metrics: API returns metric keys in different forms depending on path depth
5969
+ // (see comment inline below), normalised to match the row-key normalisation
5970
+ // done when building `rows`.
5971
+ const buildColumn = (item, titleOverrides, parameters) => {
5972
+ if (item.kind === 'computed') {
5973
+ const cd = item.data;
5974
+ return {
5975
+ field: cd.name,
5976
+ headerName: cd.name,
5977
+ flex: 1,
5978
+ minWidth: 150,
5979
+ type: 'string'
5980
+ };
5981
+ }
5982
+ if (item.kind === 'metric') {
5983
+ const metric = item.data;
5984
+ const metricDef = metric.metric;
5985
+ let fieldName;
5986
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
5987
+ if (dotCount >= 2) {
5988
+ // 3+-part namespaced path: API returns the full dotted path as the key.
5989
+ // Normalise dots to underscores to match the row key normalisation below.
5990
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5991
+ fieldName = metric.fullPath.replace(/\./g, '_');
5992
+ } else if (metric.relations && metric.relations.length > 0) {
5993
+ // 2-part path from a nested provider: API drops the base provider alias.
5994
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
5995
+ const parts = metric.fullPath.split('.');
5996
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
5997
+ const field = parts[1]; // e.g., "total_amount"
5998
+
5999
+ const pathParts = pathWithoutField.split('_');
6000
+ pathParts.shift(); // remove base provider alias
6001
+ const pathWithoutBase = pathParts.join('_');
6002
+ fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6003
+ } else {
6004
+ // 2-part path from the base provider: API returns just the metric name.
6005
+ // Example: "mc.record_count" -> "record_count"
6006
+ fieldName = metric.metricName;
6007
+ }
6008
+ const headerName = interpolateTitle(titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName, parameters);
6009
+ return {
6010
+ field: fieldName,
6011
+ headerName: headerName,
6012
+ flex: 1,
6013
+ minWidth: 150,
6014
+ type: isNumericType(metricDef?.type) ? 'number' : 'string',
6015
+ renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6016
+ sx: {
6017
+ display: 'flex',
6018
+ alignItems: 'center',
6019
+ gap: 0.5
6020
+ }
6021
+ }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
6022
+ source: metricDef?.source
6023
+ }), /*#__PURE__*/React__namespace.default.createElement("span", null, headerName)),
6024
+ valueFormatter: value => formatValue(value, metricDef)
6025
+ };
6026
+ }
6027
+
6028
+ // Dimension
6029
+ const dim = item.data;
6030
+ let fieldName;
6031
+ if (dim.relations && dim.relations.length > 0) {
6032
+ // From nested provider: drop the base provider alias
6033
+ // Example: ft_fa_db.currency -> fa_db_currency
6034
+ const parts = dim.fullPath.split('.');
6035
+ const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
6036
+ const field = parts[1]; // e.g., "currency"
6037
+
6038
+ const pathParts = pathWithoutField.split('_');
6039
+ pathParts.shift(); // Remove base provider alias
6040
+ const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
6041
+
6042
+ fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
6043
+ } else {
6044
+ // From base provider: keep the full path with underscore
6045
+ // Example: ba.created_at -> ba_created_at
6046
+ fieldName = dim.fullPath.replace('.', '_');
6047
+ }
6048
+ const headerName = interpolateTitle(titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName, parameters);
6049
+ const dimDef = dim.dimension;
6050
+ return {
6051
+ field: fieldName,
6052
+ headerName: headerName,
6053
+ flex: 1,
6054
+ minWidth: 150,
6055
+ type: isNumericType(dimDef?.type) ? 'number' : 'string',
6056
+ valueFormatter: value => formatValue(value, dimDef)
6057
+ };
6058
+ };
5783
6059
  const ReportDataGrid = ({
5784
6060
  reportData,
5785
- dimensions,
5786
- metrics,
5787
- computedDimensions = [],
6061
+ columnItems = [],
5788
6062
  loading,
5789
6063
  onPageChange,
5790
6064
  totalRows = 0,
@@ -5800,124 +6074,10 @@ const ReportDataGrid = ({
5800
6074
  pageSize: 50
5801
6075
  });
5802
6076
 
5803
- // Generate columns dynamically from dimensions and metrics
5804
- const columns = React__namespace.default.useMemo(() => {
5805
- const cols = [];
5806
-
5807
- // Add dimension columns
5808
- // Field naming logic:
5809
- // - If from base provider: baseAlias.field -> baseAlias_field
5810
- // - If from nested provider: baseAlias_rel1_rel2.field -> rel1_rel2_field (drops base alias)
5811
- dimensions.forEach(dim => {
5812
- let fieldName;
5813
- let headerName;
5814
-
5815
- // Check if there are relations (nested providers)
5816
- if (dim.relations && dim.relations.length > 0) {
5817
- // From nested provider: drop the base provider alias
5818
- // Example: ft_fa_db.currency -> fa_db_currency
5819
- const parts = dim.fullPath.split('.');
5820
- const pathWithoutField = parts[0]; // e.g., "ft_fa_db"
5821
- const field = parts[1]; // e.g., "currency"
5822
-
5823
- // Remove the base provider alias (first part before first underscore)
5824
- const pathParts = pathWithoutField.split('_');
5825
- pathParts.shift(); // Remove base provider alias
5826
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa_db"
5827
-
5828
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5829
- } else {
5830
- // From base provider: keep the full path with underscore
5831
- // Example: ba.created_at -> ba_created_at
5832
- fieldName = dim.fullPath.replace('.', '_');
5833
- }
5834
-
5835
- // Check for title override, otherwise use the friendly dimension title from provider
5836
- headerName = titleOverrides.dimensions[dim.fullPath] || dim.dimensionTitle || fieldName;
5837
- headerName = interpolateTitle(headerName, parameters);
5838
- const dimDef = dim.dimension;
5839
- cols.push({
5840
- field: fieldName,
5841
- headerName: headerName,
5842
- flex: 1,
5843
- minWidth: 150,
5844
- type: isNumericType(dimDef?.type) ? 'number' : 'string',
5845
- valueFormatter: value => formatValue(value, dimDef)
5846
- });
5847
- });
5848
-
5849
- // Add computed dimension columns. These have no provider/relations chain,
5850
- // so the API returns each row keyed by the computed dimension's own `name`.
5851
- computedDimensions.forEach(cd => {
5852
- cols.push({
5853
- field: cd.name,
5854
- headerName: cd.name,
5855
- flex: 1,
5856
- minWidth: 150,
5857
- type: 'string'
5858
- });
5859
- });
5860
-
5861
- // Add metric columns
5862
- // The API returns metric keys in two different forms depending on the path depth:
5863
- //
5864
- // • 2-part path "mc.record_count" → API key: "record_count"
5865
- // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
5866
- // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
5867
- //
5868
- // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
5869
- // in field names as nested-object accessors, so the rows useMemo normalises
5870
- // those keys (dots → underscores). The column field must match that normalised key.
5871
- metrics.forEach(metric => {
5872
- const metricDef = metric.metric;
5873
- let fieldName;
5874
- let headerName;
5875
- const dotCount = (metric.fullPath.match(/\./g) || []).length;
5876
- if (dotCount >= 2) {
5877
- // 3+-part namespaced path: API returns the full dotted path as the key.
5878
- // Normalise dots to underscores to match the row key normalisation below.
5879
- // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
5880
- fieldName = metric.fullPath.replace(/\./g, '_');
5881
- } else if (metric.relations && metric.relations.length > 0) {
5882
- // 2-part path from a nested provider: API drops the base provider alias.
5883
- // Example: "mc_fa.total_amount" -> "fa_total_amount"
5884
- const parts = metric.fullPath.split('.');
5885
- const pathWithoutField = parts[0]; // e.g., "mc_fa"
5886
- const field = parts[1]; // e.g., "total_amount"
5887
-
5888
- const pathParts = pathWithoutField.split('_');
5889
- pathParts.shift(); // remove base provider alias
5890
- const pathWithoutBase = pathParts.join('_');
5891
- fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
5892
- } else {
5893
- // 2-part path from the base provider: API returns just the metric name.
5894
- // Example: "mc.record_count" -> "record_count"
5895
- fieldName = metric.metricName;
5896
- }
5897
-
5898
- // Check for title override, otherwise use the friendly metric title from provider
5899
- headerName = titleOverrides.metrics[metric.fullPath] || metric.metricTitle || fieldName;
5900
- headerName = interpolateTitle(headerName, parameters);
5901
- cols.push({
5902
- field: fieldName,
5903
- headerName: headerName,
5904
- flex: 1,
5905
- minWidth: 150,
5906
- type: isNumericType(metricDef?.type) ? 'number' : 'string',
5907
- renderHeader: () => /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5908
- sx: {
5909
- display: 'flex',
5910
- alignItems: 'center',
5911
- gap: 0.5
5912
- }
5913
- }, /*#__PURE__*/React__namespace.default.createElement(MetricSourceIcon, {
5914
- source: metricDef?.source
5915
- }), /*#__PURE__*/React__namespace.default.createElement("span", null, headerName)),
5916
- valueFormatter: value => formatValue(value, metricDef)
5917
- });
5918
- });
5919
- return cols;
5920
- }, [dimensions, metrics, computedDimensions, titleOverrides, parameters]);
6077
+ // Generate columns dynamically, in the final resolved column order
6078
+ // (custom Column Order tab arrangement when defined, otherwise
6079
+ // dimensions/computed then metrics).
6080
+ const columns = React__namespace.default.useMemo(() => columnItems.map(item => buildColumn(item, titleOverrides, parameters)), [columnItems, titleOverrides, parameters]);
5921
6081
 
5922
6082
  // Transform report data to rows with unique IDs.
5923
6083
  // Keys are normalised by replacing dots with underscores so that MUI DataGrid
@@ -6024,7 +6184,15 @@ const ReportBuilder = ({
6024
6184
  dimensions: [],
6025
6185
  metrics: [],
6026
6186
  filters: {},
6027
- computedDimensions: []
6187
+ computedDimensions: [],
6188
+ // Unified display/execution order across dimensions + computedDimensions.
6189
+ // Entries: { type: 'dimension', key: fullPath } | { type: 'computed', key: name }
6190
+ dimensionOrder: [],
6191
+ // Optional unified display order across dimensions + computedDimensions +
6192
+ // metrics, set on the Column Order tab. Empty means "not defined" — the
6193
+ // results grid falls back to dimensionOrder followed by metrics order.
6194
+ // Entries: { type: 'dimension' | 'computed' | 'metric', key }
6195
+ columnOrder: []
6028
6196
  });
6029
6197
  const [titleOverrides, setTitleOverrides] = React.useState({
6030
6198
  dimensions: {},
@@ -6070,6 +6238,16 @@ const ReportBuilder = ({
6070
6238
  }
6071
6239
  }, [autoRun, reportLoaded]);
6072
6240
 
6241
+ // Re-run the currently displayed report when base_currency changes in context
6242
+ const currentBaseCurrency = reportingContext?.parameters?.base_currency;
6243
+ const prevBaseCurrencyRef = React.useRef(currentBaseCurrency);
6244
+ React.useEffect(() => {
6245
+ if (reportData && prevBaseCurrencyRef.current !== undefined && prevBaseCurrencyRef.current !== currentBaseCurrency) {
6246
+ runReportWithPagination(currentPage, pageSize);
6247
+ }
6248
+ prevBaseCurrencyRef.current = currentBaseCurrency;
6249
+ }, [currentBaseCurrency]);
6250
+
6073
6251
  // Utility function to reconstruct dimension object from fullPath
6074
6252
  const reconstructDimensionFromPath = (fullPath, providersData, rootProvider) => {
6075
6253
  try {
@@ -6199,6 +6377,92 @@ const ReportBuilder = ({
6199
6377
  return null;
6200
6378
  }
6201
6379
  };
6380
+
6381
+ // Build the unified display/execution order for dimensions + computed
6382
+ // dimensions. Prefers the persisted `ordered_dimensions` field; falls back
6383
+ // to "dimensions first, then computed dimensions" for report definitions
6384
+ // saved before this field existed.
6385
+ const buildDimensionOrder = (rawOrderedDimensions, dimensions, computedDimensions) => {
6386
+ const dimensionOrder = [];
6387
+ const seenDimensionKeys = new Set();
6388
+ const seenComputedKeys = new Set();
6389
+ if (Array.isArray(rawOrderedDimensions)) {
6390
+ rawOrderedDimensions.forEach(entry => {
6391
+ if (entry.type === "dimension" && dimensions.some(d => d.fullPath === entry.ref)) {
6392
+ dimensionOrder.push({
6393
+ type: "dimension",
6394
+ key: entry.ref
6395
+ });
6396
+ seenDimensionKeys.add(entry.ref);
6397
+ } else if (entry.type === "computed" && computedDimensions.some(cd => cd.name === entry.ref)) {
6398
+ dimensionOrder.push({
6399
+ type: "computed",
6400
+ key: entry.ref
6401
+ });
6402
+ seenComputedKeys.add(entry.ref);
6403
+ }
6404
+ });
6405
+ }
6406
+
6407
+ // Append anything not covered by ordered_dimensions (older reports, or drift)
6408
+ dimensions.forEach(d => {
6409
+ if (!seenDimensionKeys.has(d.fullPath)) {
6410
+ dimensionOrder.push({
6411
+ type: "dimension",
6412
+ key: d.fullPath
6413
+ });
6414
+ }
6415
+ });
6416
+ computedDimensions.forEach(cd => {
6417
+ if (!seenComputedKeys.has(cd.name)) {
6418
+ dimensionOrder.push({
6419
+ type: "computed",
6420
+ key: cd.name
6421
+ });
6422
+ }
6423
+ });
6424
+ return dimensionOrder;
6425
+ };
6426
+
6427
+ // Build the optional unified column order (dimensions + computed
6428
+ // dimensions + metrics) from the persisted `ordered_columns` field.
6429
+ // Unlike buildDimensionOrder, entries that no longer resolve are simply
6430
+ // dropped rather than backfilled — an empty/partial result just means "no
6431
+ // custom order defined (yet)", which resolveColumnOrder treats as
6432
+ // "fall back to the default order".
6433
+ const buildColumnOrder = (rawOrderedColumns, dimensions, computedDimensions, metrics) => {
6434
+ if (!Array.isArray(rawOrderedColumns)) return [];
6435
+ const dimensionPaths = new Set(dimensions.map(d => d.fullPath));
6436
+ const computedNames = new Set(computedDimensions.map(cd => cd.name));
6437
+ const metricPaths = new Set(metrics.map(m => m.fullPath));
6438
+ const columnOrder = [];
6439
+ rawOrderedColumns.forEach(entry => {
6440
+ if (entry.type === "dimension" && dimensionPaths.has(entry.ref)) {
6441
+ columnOrder.push({
6442
+ type: "dimension",
6443
+ key: entry.ref
6444
+ });
6445
+ } else if (entry.type === "computed" && computedNames.has(entry.ref)) {
6446
+ columnOrder.push({
6447
+ type: "computed",
6448
+ key: entry.ref
6449
+ });
6450
+ } else if (entry.type === "metric" && metricPaths.has(entry.ref)) {
6451
+ columnOrder.push({
6452
+ type: "metric",
6453
+ key: entry.ref
6454
+ });
6455
+ }
6456
+ });
6457
+ return columnOrder;
6458
+ };
6459
+
6460
+ // Auto-append a newly added dimension/computed dimension/metric to the end
6461
+ // of columnOrder — but only once a custom order actually exists. While
6462
+ // columnOrder is empty (no custom order defined) it's left untouched, so
6463
+ // the results grid keeps falling back to the default dimensions-then-
6464
+ // metrics order until the user visits the Column Order tab.
6465
+ const appendToColumnOrder = (columnOrder, entry) => columnOrder.length > 0 ? [...columnOrder, entry] : columnOrder;
6202
6466
  const loadReportDefinition = async id => {
6203
6467
  try {
6204
6468
  console.log("Loading report definition:", id);
@@ -6294,13 +6558,17 @@ const ReportBuilder = ({
6294
6558
  // Computed dimensions are self-contained { name, value } pairs with no
6295
6559
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6296
6560
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6561
+ const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6562
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6297
6563
 
6298
6564
  // Set the report state
6299
6565
  setReport({
6300
6566
  dimensions: reconstructedDimensions,
6301
6567
  metrics: reconstructedMetrics,
6302
6568
  filters: loadedFilters,
6303
- computedDimensions
6569
+ computedDimensions,
6570
+ dimensionOrder,
6571
+ columnOrder
6304
6572
  });
6305
6573
 
6306
6574
  // Set title overrides
@@ -6409,13 +6677,17 @@ const ReportBuilder = ({
6409
6677
  // Computed dimensions are self-contained { name, value } pairs with no
6410
6678
  // provider/relation chain, so they're loaded as-is (no reconstruction needed)
6411
6679
  const computedDimensions = reportDef.definition?.doc?.query?.computed_dimensions || [];
6680
+ const dimensionOrder = buildDimensionOrder(reportDef.definition?.doc?.query?.ordered_dimensions, reconstructedDimensions, computedDimensions);
6681
+ const columnOrder = buildColumnOrder(reportDef.definition?.doc?.query?.ordered_columns, reconstructedDimensions, computedDimensions, reconstructedMetrics);
6412
6682
 
6413
6683
  // Set the report state
6414
6684
  setReport({
6415
6685
  dimensions: reconstructedDimensions,
6416
6686
  metrics: reconstructedMetrics,
6417
6687
  filters: loadedFilters,
6418
- computedDimensions
6688
+ computedDimensions,
6689
+ dimensionOrder,
6690
+ columnOrder
6419
6691
  });
6420
6692
 
6421
6693
  // Set title overrides
@@ -6448,7 +6720,9 @@ const ReportBuilder = ({
6448
6720
  dimensions: [],
6449
6721
  metrics: [],
6450
6722
  filters: {},
6451
- computedDimensions: []
6723
+ computedDimensions: [],
6724
+ dimensionOrder: [],
6725
+ columnOrder: []
6452
6726
  });
6453
6727
  // Reset title overrides
6454
6728
  setTitleOverrides({
@@ -6525,58 +6799,93 @@ const ReportBuilder = ({
6525
6799
  setReport(prev => {
6526
6800
  const newReport = {
6527
6801
  ...prev,
6528
- dimensions: [...prev.dimensions, dimensionData]
6802
+ dimensions: [...prev.dimensions, dimensionData],
6803
+ dimensionOrder: [...prev.dimensionOrder, {
6804
+ type: "dimension",
6805
+ key: dimensionData.fullPath
6806
+ }],
6807
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6808
+ type: "dimension",
6809
+ key: dimensionData.fullPath
6810
+ })
6529
6811
  };
6530
6812
  console.log("Dimension saved:", dimensionData);
6531
6813
  console.log("Complete report:", newReport);
6532
6814
  return newReport;
6533
6815
  });
6534
6816
  };
6535
- const handleRemoveDimension = index => {
6817
+ const handleRemoveDimension = fullPath => {
6536
6818
  setReport(prev => ({
6537
6819
  ...prev,
6538
- dimensions: prev.dimensions.filter((_, i) => i !== index)
6820
+ dimensions: prev.dimensions.filter(d => d.fullPath !== fullPath),
6821
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath)),
6822
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "dimension" && entry.key === fullPath))
6539
6823
  }));
6540
6824
  };
6541
- const handleReorderDimensions = newOrder => {
6825
+ const handleUpdateDimensionSortOrder = (fullPath, sortOrder) => {
6542
6826
  setReport(prev => ({
6543
6827
  ...prev,
6544
- dimensions: newOrder
6828
+ dimensions: prev.dimensions.map(d => d.fullPath === fullPath ? {
6829
+ ...d,
6830
+ sortOrder
6831
+ } : d)
6545
6832
  }));
6546
6833
  };
6547
- const handleSaveComputedDimension = computedDimensionData => {
6834
+
6835
+ // Reorders the single unified dimensions + computed-dimensions list;
6836
+ // `dimensions`/`computedDimensions` arrays themselves are unordered from
6837
+ // this perspective, so only `dimensionOrder` needs to change.
6838
+ const handleReorderDimensionItems = newDimensionOrder => {
6548
6839
  setReport(prev => ({
6549
6840
  ...prev,
6550
- computedDimensions: [...prev.computedDimensions, computedDimensionData]
6841
+ dimensionOrder: newDimensionOrder
6551
6842
  }));
6552
6843
  };
6553
- const handleUpdateComputedDimension = (index, computedDimensionData) => {
6554
- setReport(prev => {
6555
- const newComputedDimensions = [...prev.computedDimensions];
6556
- newComputedDimensions[index] = computedDimensionData;
6557
- return {
6558
- ...prev,
6559
- computedDimensions: newComputedDimensions
6560
- };
6561
- });
6844
+ const handleSaveComputedDimension = computedDimensionData => {
6845
+ setReport(prev => ({
6846
+ ...prev,
6847
+ computedDimensions: [...prev.computedDimensions, computedDimensionData],
6848
+ dimensionOrder: [...prev.dimensionOrder, {
6849
+ type: "computed",
6850
+ key: computedDimensionData.name
6851
+ }],
6852
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6853
+ type: "computed",
6854
+ key: computedDimensionData.name
6855
+ })
6856
+ }));
6562
6857
  };
6563
- const handleRemoveComputedDimension = index => {
6858
+ const handleUpdateComputedDimension = (name, computedDimensionData) => {
6564
6859
  setReport(prev => ({
6565
6860
  ...prev,
6566
- computedDimensions: prev.computedDimensions.filter((_, i) => i !== index)
6861
+ computedDimensions: prev.computedDimensions.map(cd => cd.name === name ? computedDimensionData : cd),
6862
+ dimensionOrder: prev.dimensionOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6863
+ ...entry,
6864
+ key: computedDimensionData.name
6865
+ } : entry),
6866
+ columnOrder: prev.columnOrder.map(entry => entry.type === "computed" && entry.key === name ? {
6867
+ ...entry,
6868
+ key: computedDimensionData.name
6869
+ } : entry)
6567
6870
  }));
6568
6871
  };
6569
- const handleReorderComputedDimensions = newOrder => {
6872
+ const handleRemoveComputedDimension = name => {
6570
6873
  setReport(prev => ({
6571
6874
  ...prev,
6572
- computedDimensions: newOrder
6875
+ computedDimensions: prev.computedDimensions.filter(cd => cd.name !== name),
6876
+ dimensionOrder: prev.dimensionOrder.filter(entry => !(entry.type === "computed" && entry.key === name)),
6877
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "computed" && entry.key === name))
6573
6878
  }));
6574
6879
  };
6575
6880
  const handleSaveMetric = metricData => {
6576
6881
  setReport(prev => {
6577
6882
  const newReport = {
6578
6883
  ...prev,
6579
- metrics: [...prev.metrics, metricData]
6884
+ metrics: [...prev.metrics, metricData],
6885
+ columnOrder: appendToColumnOrder(prev.columnOrder, {
6886
+ type: "metric",
6887
+ key: metricData.fullPath
6888
+ })
6580
6889
  };
6581
6890
  console.log("Metric saved:", metricData);
6582
6891
  console.log("Complete report:", newReport);
@@ -6584,15 +6893,29 @@ const ReportBuilder = ({
6584
6893
  });
6585
6894
  };
6586
6895
  const handleRemoveMetric = index => {
6896
+ setReport(prev => {
6897
+ const fullPath = prev.metrics[index]?.fullPath;
6898
+ return {
6899
+ ...prev,
6900
+ metrics: prev.metrics.filter((_, i) => i !== index),
6901
+ columnOrder: prev.columnOrder.filter(entry => !(entry.type === "metric" && entry.key === fullPath))
6902
+ };
6903
+ });
6904
+ };
6905
+ const handleReorderMetrics = newOrder => {
6587
6906
  setReport(prev => ({
6588
6907
  ...prev,
6589
- metrics: prev.metrics.filter((_, i) => i !== index)
6908
+ metrics: newOrder
6590
6909
  }));
6591
6910
  };
6592
- const handleReorderMetrics = newOrder => {
6911
+
6912
+ // Sets the unified column order shown on the Column Order tab. An empty
6913
+ // array (e.g. via "Reset to Default") clears the customization, and the
6914
+ // results grid falls back to dimensionOrder followed by metrics order.
6915
+ const handleReorderColumnItems = newColumnOrder => {
6593
6916
  setReport(prev => ({
6594
6917
  ...prev,
6595
- metrics: newOrder
6918
+ columnOrder: newColumnOrder
6596
6919
  }));
6597
6920
  };
6598
6921
  const handleSaveFilter = (fullPath, filterData) => {
@@ -6625,10 +6948,20 @@ const ReportBuilder = ({
6625
6948
  });
6626
6949
  };
6627
6950
 
6628
- // Convert internal report structure to API format
6629
- const convertReportToApiFormat = (page = 0, size = 50) => {
6951
+ // Builds the query object shared between running (paginated) and saving
6952
+ // (unpaginated) a report. Dimension and computed-dimension order both
6953
+ // derive from the single unified `report.dimensionOrder` list, so the
6954
+ // saved `dimensions`/`computed_dimensions` arrays, `order_by`, and the
6955
+ // persisted `ordered_dimensions` field all stay consistent with whatever
6956
+ // order the user arranged on screen.
6957
+ const buildQueryObject = () => {
6958
+ const dimensionByPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
6959
+ const computedByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
6960
+ const orderedDimensionsOnly = report.dimensionOrder.filter(entry => entry.type === "dimension").map(entry => dimensionByPath[entry.key]).filter(Boolean);
6961
+ const orderedComputedOnly = report.dimensionOrder.filter(entry => entry.type === "computed").map(entry => computedByName[entry.key]).filter(Boolean);
6962
+
6630
6963
  // Build order_by array - include all dimensions, add desc property only when needed
6631
- const orderBy = report.dimensions.map(dim => {
6964
+ const orderBy = orderedDimensionsOnly.map(dim => {
6632
6965
  const orderItem = {
6633
6966
  name: dim.fullPath
6634
6967
  };
@@ -6684,21 +7017,40 @@ const ReportBuilder = ({
6684
7017
  titles.filters = titleOverrides.filters;
6685
7018
  }
6686
7019
  const queryObj = {
6687
- dimensions: report.dimensions.map(dim => dim.fullPath),
7020
+ dimensions: orderedDimensionsOnly.map(dim => dim.fullPath),
6688
7021
  metrics: report.metrics.map(metric => metric.fullPath),
6689
- order_by: orderBy,
6690
- limit: size,
6691
- offset: page * size
7022
+ order_by: orderBy
6692
7023
  };
6693
7024
 
6694
7025
  // Only add computed_dimensions if there are any
6695
- if (report.computedDimensions && report.computedDimensions.length > 0) {
6696
- queryObj.computed_dimensions = report.computedDimensions.map(cd => ({
7026
+ if (orderedComputedOnly.length > 0) {
7027
+ queryObj.computed_dimensions = orderedComputedOnly.map(cd => ({
6697
7028
  name: cd.name,
6698
7029
  value: cd.value
6699
7030
  }));
6700
7031
  }
6701
7032
 
7033
+ // Persist the unified dimension + computed-dimension order so it can be
7034
+ // reconstructed on reload and so executed reports render columns in the
7035
+ // same order they were arranged in the builder.
7036
+ if (report.dimensionOrder.length > 0) {
7037
+ queryObj.ordered_dimensions = report.dimensionOrder.map(entry => ({
7038
+ type: entry.type,
7039
+ ref: entry.key
7040
+ }));
7041
+ }
7042
+
7043
+ // Persist the optional unified column order (Column Order tab) so the
7044
+ // results grid can reproduce it on reload. Absent when no custom order
7045
+ // has been defined, so old/untouched reports keep their current
7046
+ // dimensions-then-metrics column order unchanged.
7047
+ if (report.columnOrder.length > 0) {
7048
+ queryObj.ordered_columns = report.columnOrder.map(entry => ({
7049
+ type: entry.type,
7050
+ ref: entry.key
7051
+ }));
7052
+ }
7053
+
6702
7054
  // Only add titles if there are any overrides
6703
7055
  if (Object.keys(titles).length > 0) {
6704
7056
  queryObj.titles = titles;
@@ -6708,6 +7060,14 @@ const ReportBuilder = ({
6708
7060
  if (filter) {
6709
7061
  queryObj.filter = filter;
6710
7062
  }
7063
+ return queryObj;
7064
+ };
7065
+
7066
+ // Convert internal report structure to API format
7067
+ const convertReportToApiFormat = (page = 0, size = 50) => {
7068
+ const queryObj = buildQueryObject();
7069
+ queryObj.limit = size;
7070
+ queryObj.offset = page * size;
6711
7071
 
6712
7072
  // Get parameters from context if available, otherwise use default
6713
7073
  const parameters = reportingContext?.parameters || {
@@ -6750,8 +7110,8 @@ const ReportBuilder = ({
6750
7110
  const handleRunReport = async () => {
6751
7111
  setCurrentPage(0);
6752
7112
  await runReportWithPagination(0, pageSize);
6753
- // Switch to Results tab after running report (now index 3 after adding Filters tab)
6754
- setActiveTab(3);
7113
+ // Switch to Results tab after running report (index 4: Dimensions, Metrics, Filters, Column Order, Results)
7114
+ setActiveTab(4);
6755
7115
  };
6756
7116
  const handleDownloadReport = async () => {
6757
7117
  try {
@@ -6801,77 +7161,7 @@ const ReportBuilder = ({
6801
7161
  }
6802
7162
  try {
6803
7163
  setSaving(true);
6804
-
6805
- // Build order_by array - include all dimensions, add desc property only when needed
6806
- const orderBy = report.dimensions.map(dim => {
6807
- const orderItem = {
6808
- name: dim.fullPath
6809
- };
6810
- if (dim.sortOrder === "desc") {
6811
- orderItem.desc = true;
6812
- }
6813
- // For 'asc' or null, just include { name: fullPath } without desc property
6814
- return orderItem;
6815
- });
6816
-
6817
- // Build titles object - only include overridden titles
6818
- const titles = {};
6819
- if (Object.keys(titleOverrides.dimensions).length > 0) {
6820
- titles.dimensions = titleOverrides.dimensions;
6821
- }
6822
- if (Object.keys(titleOverrides.metrics).length > 0) {
6823
- titles.metrics = titleOverrides.metrics;
6824
- }
6825
- if (Object.keys(titleOverrides.filters).length > 0) {
6826
- titles.filters = titleOverrides.filters;
6827
- }
6828
- const queryObj = {
6829
- dimensions: report.dimensions.map(dim => dim.fullPath),
6830
- metrics: report.metrics.map(metric => metric.fullPath),
6831
- order_by: orderBy
6832
- };
6833
-
6834
- // Only add computed_dimensions if there are any
6835
- if (report.computedDimensions && report.computedDimensions.length > 0) {
6836
- queryObj.computed_dimensions = report.computedDimensions.map(cd => ({
6837
- name: cd.name,
6838
- value: cd.value
6839
- }));
6840
- }
6841
-
6842
- // Only add titles if there are any overrides
6843
- if (Object.keys(titles).length > 0) {
6844
- queryObj.titles = titles;
6845
- }
6846
-
6847
- // Add filter if there are any - NEW API format with top-level 'and' operator
6848
- // NEW format: { and: [ { "ft.currency": ["USD", "EUR"] }, { "ft_ba.created_at": {gte: "2025-05-01", lte: "2025-12-31"} } ] }
6849
- if (Object.keys(report.filters).length > 0) {
6850
- const conditions = [];
6851
- Object.entries(report.filters).forEach(([fullPath, filterData]) => {
6852
- if (filterData.values) {
6853
- // Check if values is an object (date range) or array (regular filter)
6854
- if (Array.isArray(filterData.values)) {
6855
- // Regular filter - only add if array has values
6856
- if (filterData.values.length > 0) {
6857
- conditions.push({
6858
- [fullPath]: filterData.values
6859
- });
6860
- }
6861
- } else if (typeof filterData.values === "object") {
6862
- // Date range filter - add the object as-is
6863
- conditions.push({
6864
- [fullPath]: filterData.values
6865
- });
6866
- }
6867
- }
6868
- });
6869
- if (conditions.length > 0) {
6870
- queryObj.filter = {
6871
- and: conditions
6872
- };
6873
- }
6874
- }
7164
+ const queryObj = buildQueryObject();
6875
7165
 
6876
7166
  // Build the doc object
6877
7167
  const docObj = {
@@ -6916,6 +7206,23 @@ const ReportBuilder = ({
6916
7206
  }
6917
7207
  };
6918
7208
  const canSaveReport = selectedProvider && reportTitle.trim() && (report.dimensions.length > 0 || report.metrics.length > 0 || report.computedDimensions.length > 0);
7209
+
7210
+ // Resolve the unified dimensionOrder into actual dimension/computed-dimension
7211
+ // objects so the results grid can render columns in that same order.
7212
+ const dimensionByFullPath = Object.fromEntries(report.dimensions.map(d => [d.fullPath, d]));
7213
+ const computedDimensionByName = Object.fromEntries(report.computedDimensions.map(cd => [cd.name, cd]));
7214
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
7215
+ kind: "dimension",
7216
+ data: dimensionByFullPath[entry.key]
7217
+ } : {
7218
+ kind: "computed",
7219
+ data: computedDimensionByName[entry.key]
7220
+ }).filter(item => item.data);
7221
+
7222
+ // Final column list for the results grid: the explicit Column Order tab
7223
+ // arrangement when defined, otherwise dimensions/computed (in their own
7224
+ // order) followed by metrics (in their own order) — today's behavior.
7225
+ const columnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
6919
7226
  return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
6920
7227
  sx: {
6921
7228
  p: 3,
@@ -7170,9 +7477,36 @@ const ReportBuilder = ({
7170
7477
  }
7171
7478
  }
7172
7479
  }), /*#__PURE__*/React__namespace.default.createElement(material.Tab, {
7173
- label: reportData ? "Results" : "Results (Run report first)",
7480
+ label: /*#__PURE__*/React__namespace.default.createElement(material.Badge, {
7481
+ variant: "dot",
7482
+ invisible: report.columnOrder.length === 0,
7483
+ sx: {
7484
+ "& .MuiBadge-dot": {
7485
+ backgroundColor: "rgb(70, 134, 128)"
7486
+ }
7487
+ }
7488
+ }, /*#__PURE__*/React__namespace.default.createElement("span", {
7489
+ style: {
7490
+ marginRight: report.columnOrder.length > 0 ? "8px" : "0"
7491
+ }
7492
+ }, "Column Order")),
7174
7493
  id: "report-tab-3",
7175
7494
  "aria-controls": "report-tabpanel-3",
7495
+ sx: {
7496
+ height: "41px",
7497
+ fontFamily: "system-ui",
7498
+ borderRadius: "0.5rem",
7499
+ boxShadow: "none",
7500
+ textTransform: "none",
7501
+ color: "rgb(37, 37, 37)",
7502
+ "&.Mui-selected": {
7503
+ color: "rgb(70, 134, 128)"
7504
+ }
7505
+ }
7506
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Tab, {
7507
+ label: reportData ? "Results" : "Results (Run report first)",
7508
+ id: "report-tab-4",
7509
+ "aria-controls": "report-tabpanel-4",
7176
7510
  disabled: !reportData,
7177
7511
  sx: {
7178
7512
  height: "41px",
@@ -7194,7 +7528,7 @@ const ReportBuilder = ({
7194
7528
  savedDimensions: report.dimensions,
7195
7529
  onSaveDimension: handleSaveDimension,
7196
7530
  onRemoveDimension: handleRemoveDimension,
7197
- onReorderDimensions: handleReorderDimensions,
7531
+ onUpdateDimensionSortOrder: handleUpdateDimensionSortOrder,
7198
7532
  titleOverrides: titleOverrides.dimensions,
7199
7533
  onUpdateTitle: handleUpdateDimensionTitle,
7200
7534
  onResetTitle: handleResetDimensionTitle,
@@ -7204,7 +7538,8 @@ const ReportBuilder = ({
7204
7538
  onSaveComputedDimension: handleSaveComputedDimension,
7205
7539
  onUpdateComputedDimension: handleUpdateComputedDimension,
7206
7540
  onRemoveComputedDimension: handleRemoveComputedDimension,
7207
- onReorderComputedDimensions: handleReorderComputedDimensions
7541
+ dimensionOrder: report.dimensionOrder,
7542
+ onReorderDimensionItems: handleReorderDimensionItems
7208
7543
  })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7209
7544
  value: activeTab,
7210
7545
  index: 1
@@ -7238,11 +7573,18 @@ const ReportBuilder = ({
7238
7573
  })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7239
7574
  value: activeTab,
7240
7575
  index: 3
7576
+ }, /*#__PURE__*/React__namespace.default.createElement(ColumnOrder, {
7577
+ orderedDimensionItems: orderedDimensionItems,
7578
+ metrics: report.metrics,
7579
+ columnOrder: report.columnOrder,
7580
+ onReorderColumnItems: handleReorderColumnItems,
7581
+ titleOverrides: titleOverrides
7582
+ })), /*#__PURE__*/React__namespace.default.createElement(TabPanel, {
7583
+ value: activeTab,
7584
+ index: 4
7241
7585
  }, reportData && /*#__PURE__*/React__namespace.default.createElement(ReportDataGrid, {
7242
7586
  reportData: reportData,
7243
- dimensions: report.dimensions,
7244
- metrics: report.metrics,
7245
- computedDimensions: report.computedDimensions,
7587
+ columnItems: columnItems,
7246
7588
  loading: loading,
7247
7589
  onPageChange: handlePageChange,
7248
7590
  totalRows: totalRows,
@@ -7305,6 +7647,50 @@ const ReportDefinitionsManager = () => {
7305
7647
  });
7306
7648
  };
7307
7649
 
7650
+ // TODO: replace with currencies fetched from the API once that's available.
7651
+ const CURRENCY_OPTIONS = [{
7652
+ key: "USD",
7653
+ value: "USD"
7654
+ }, {
7655
+ key: "EUR",
7656
+ value: "EUR"
7657
+ }, {
7658
+ key: "SEK",
7659
+ value: "SEK"
7660
+ }, {
7661
+ key: "GBP",
7662
+ value: "GBP"
7663
+ }, {
7664
+ key: "DKK",
7665
+ value: "DKK"
7666
+ }, {
7667
+ key: "NOK",
7668
+ value: "NOK"
7669
+ }];
7670
+ const CurrencySelector = () => {
7671
+ const {
7672
+ parameters,
7673
+ setParameters
7674
+ } = useReportingContext();
7675
+
7676
+ // Hidden by default; only shown when a `cur` query parameter is present in the top-level URL.
7677
+ const showCurrencySelector = new URLSearchParams(window.location.search).has("cur");
7678
+ if (!showCurrencySelector) {
7679
+ return null;
7680
+ }
7681
+ return /*#__PURE__*/React__namespace.default.createElement(SingleSelect, {
7682
+ items: CURRENCY_OPTIONS,
7683
+ value: parameters.base_currency,
7684
+ label: "Currency",
7685
+ onChange: e => setParameters({
7686
+ ...parameters,
7687
+ base_currency: e.target.value
7688
+ }),
7689
+ sx: {
7690
+ width: 110
7691
+ }
7692
+ });
7693
+ };
7308
7694
  const ReportApp = ({
7309
7695
  params,
7310
7696
  api
@@ -7371,7 +7757,13 @@ const ReportApp = ({
7371
7757
  }
7372
7758
  }
7373
7759
  })
7374
- }, /*#__PURE__*/React__namespace.default.createElement(ReportDefinitionsManager, null))));
7760
+ }, /*#__PURE__*/React__namespace.default.createElement(Box__default.default, {
7761
+ sx: {
7762
+ display: "flex",
7763
+ justifyContent: "flex-end",
7764
+ p: 1
7765
+ }
7766
+ }, /*#__PURE__*/React__namespace.default.createElement(CurrencySelector, null)), /*#__PURE__*/React__namespace.default.createElement(ReportDefinitionsManager, null))));
7375
7767
  };
7376
7768
 
7377
7769
  var index = {