@bygd/nc-report-ui 0.1.28 → 0.1.30

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.
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import React__default, { useEffect, useMemo, useState, useRef, createContext, useContext } from 'react';
2
+ import React__default, { useEffect, useMemo, useState, useRef, useContext, createContext } from 'react';
3
3
  import Paper from '@material-ui/core/Paper';
4
4
  import { makeStyles } from '@material-ui/core/styles';
5
5
  import { CircularProgress } from '@material-ui/core';
@@ -43,6 +43,9 @@ import CheckIcon from '@mui/icons-material/Check';
43
43
  import CloseIcon from '@mui/icons-material/Close';
44
44
  import RestartAltIcon from '@mui/icons-material/RestartAlt';
45
45
  import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
46
+ import StorageIcon from '@mui/icons-material/Storage';
47
+ import TrendingUpIcon from '@mui/icons-material/TrendingUp';
48
+ import BoltIcon from '@mui/icons-material/Bolt';
46
49
  import FilterAltIcon from '@mui/icons-material/FilterAlt';
47
50
  import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
48
51
  import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
@@ -606,6 +609,7 @@ function Internal(name, props) {
606
609
  * - inputValue?: string (controlled input)
607
610
  * - defaultInputValue?: string (uncontrolled input)
608
611
  * - onInputChange?: (text: string) => void (debounced by debounceMs)
612
+ * - onInputChangeImmediate?: (text: string) => void (called immediately without debounce)
609
613
  * - debounceMs?: number (default 300)
610
614
  * - label?, placeholder?, loading?, disabled?, size? = 'small', error?, helperText?,
611
615
  * limitTags? = 3, disableClearable?, id?, textFieldProps?
@@ -640,6 +644,7 @@ function CheckboxMultiAutocomplete({
640
644
  inputValue,
641
645
  defaultInputValue,
642
646
  onInputChange,
647
+ onInputChangeImmediate,
643
648
  debounceMs = 300,
644
649
  label,
645
650
  placeholder,
@@ -703,6 +708,14 @@ function CheckboxMultiAutocomplete({
703
708
  },
704
709
  onInputChange: (event, newInput, reason) => {
705
710
  if (!isInputControlled) setInnerInput(newInput ?? "");
711
+
712
+ // Call immediate handler if provided
713
+ if (reason === "input" || reason === "clear") {
714
+ const text = reason === "clear" ? "" : newInput ?? "";
715
+ onInputChangeImmediate && onInputChangeImmediate(text);
716
+ }
717
+
718
+ // Call debounced handler
706
719
  if (reason === "input") {
707
720
  debouncedInput(newInput ?? "");
708
721
  } else if (reason === "clear") {
@@ -866,7 +879,8 @@ function SingleSelect({
866
879
  const {
867
880
  key,
868
881
  value,
869
- disabled
882
+ disabled,
883
+ icon
870
884
  } = itm;
871
885
  return /*#__PURE__*/React__default.createElement(MenuItem, {
872
886
  key: key,
@@ -874,9 +888,18 @@ function SingleSelect({
874
888
  disabled: disabled,
875
889
  sx: {
876
890
  fontFamily: "system-ui",
877
- minHeight: "36px"
891
+ minHeight: "36px",
892
+ display: "flex",
893
+ alignItems: "center",
894
+ gap: "6px"
878
895
  }
879
- }, formatLabel(value));
896
+ }, icon && /*#__PURE__*/React__default.createElement(Box, {
897
+ component: "span",
898
+ sx: {
899
+ display: "inline-flex",
900
+ alignItems: "center"
901
+ }
902
+ }, icon), formatLabel(value));
880
903
  }))));
881
904
  }
882
905
 
@@ -2878,6 +2901,51 @@ const Dimensions = ({
2878
2901
  })))))));
2879
2902
  };
2880
2903
 
2904
+ const MetricSourceIcon = ({
2905
+ source
2906
+ }) => {
2907
+ const iconSx = {
2908
+ fontSize: '15px'
2909
+ };
2910
+ if (source === 'kpi') {
2911
+ return /*#__PURE__*/React__default.createElement(Tooltip, {
2912
+ title: "KPI Metric",
2913
+ arrow: true,
2914
+ placement: "top"
2915
+ }, /*#__PURE__*/React__default.createElement(TrendingUpIcon, {
2916
+ sx: {
2917
+ ...iconSx,
2918
+ color: 'rgb(70, 134, 128)'
2919
+ }
2920
+ }));
2921
+ }
2922
+ if (source === 'dynamic') {
2923
+ return /*#__PURE__*/React__default.createElement(Tooltip, {
2924
+ title: "Dynamic Metric",
2925
+ arrow: true,
2926
+ placement: "top"
2927
+ }, /*#__PURE__*/React__default.createElement(BoltIcon, {
2928
+ sx: {
2929
+ ...iconSx,
2930
+ color: '#e65100'
2931
+ }
2932
+ }));
2933
+ }
2934
+ if (source === 'provider') {
2935
+ return /*#__PURE__*/React__default.createElement(Tooltip, {
2936
+ title: "Provider Metric",
2937
+ arrow: true,
2938
+ placement: "top"
2939
+ }, /*#__PURE__*/React__default.createElement(StorageIcon, {
2940
+ sx: {
2941
+ ...iconSx,
2942
+ color: '#546e7a'
2943
+ }
2944
+ }));
2945
+ }
2946
+ return null;
2947
+ };
2948
+
2881
2949
  // Sortable Chip Component
2882
2950
  const SortableChip = ({
2883
2951
  id,
@@ -2892,7 +2960,8 @@ const SortableChip = ({
2892
2960
  defaultTitle,
2893
2961
  customTitle,
2894
2962
  onUpdateTitle,
2895
- onResetTitle
2963
+ onResetTitle,
2964
+ source
2896
2965
  }) => {
2897
2966
  const [isEditing, setIsEditing] = useState(false);
2898
2967
  const [editValue, setEditValue] = useState('');
@@ -3005,7 +3074,15 @@ const SortableChip = ({
3005
3074
  cursor: "grab",
3006
3075
  color: "rgba(110, 110, 110, 0.62)"
3007
3076
  }
3008
- })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Box$1, {
3077
+ })), !isEditing ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, source && /*#__PURE__*/React__default.createElement(Box$1, {
3078
+ sx: {
3079
+ display: 'flex',
3080
+ alignItems: 'center',
3081
+ flexShrink: 0
3082
+ }
3083
+ }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
3084
+ source: source
3085
+ })), /*#__PURE__*/React__default.createElement(Box$1, {
3009
3086
  sx: {
3010
3087
  minWidth: 0
3011
3088
  }
@@ -3237,7 +3314,10 @@ const Metrics = ({
3237
3314
  value: metric.title || metric.name,
3238
3315
  metricName: metric.name,
3239
3316
  metric: metric,
3240
- disabled: isAlreadySelected
3317
+ disabled: isAlreadySelected,
3318
+ icon: metric.source ? /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
3319
+ source: metric.source
3320
+ }) : undefined
3241
3321
  };
3242
3322
  });
3243
3323
  return items;
@@ -3546,7 +3626,8 @@ const Metrics = ({
3546
3626
  defaultTitle: metric.metricTitle,
3547
3627
  customTitle: titleOverrides[metric.fullPath],
3548
3628
  onUpdateTitle: onUpdateTitle,
3549
- onResetTitle: onResetTitle
3629
+ onResetTitle: onResetTitle,
3630
+ source: metric.metric?.source
3550
3631
  })))))));
3551
3632
  };
3552
3633
 
@@ -3573,6 +3654,7 @@ const Filters = ({
3573
3654
  const [selectedFilterValues, setSelectedFilterValues] = useState([]);
3574
3655
  const [loadingFilterValues, setLoadingFilterValues] = useState(false);
3575
3656
  const [editingFilterPath, setEditingFilterPath] = useState(null); // Track which filter is being edited
3657
+ const [filterSearchText, setFilterSearchText] = useState(''); // Track search text for server-side filtering
3576
3658
 
3577
3659
  // Date range state for date/timestamp filters
3578
3660
  const [dateRangeFrom, setDateRangeFrom] = useState(null);
@@ -3590,6 +3672,7 @@ const Filters = ({
3590
3672
  setSelectedFilterValues([]);
3591
3673
  setDateRangeFrom(null);
3592
3674
  setDateRangeTo(null);
3675
+ setFilterSearchText('');
3593
3676
  }, [dimensionSelectionChain]);
3594
3677
 
3595
3678
  // Get the current provider based on selection chain
@@ -3667,6 +3750,7 @@ const Filters = ({
3667
3750
  setSelectedFilterValues([]);
3668
3751
  setDateRangeFrom(null);
3669
3752
  setDateRangeTo(null);
3753
+ setFilterSearchText('');
3670
3754
  setEditingFilterPath(null); // Close any editing
3671
3755
  };
3672
3756
  const handleEditFilter = async (fullPath, filter) => {
@@ -3744,6 +3828,7 @@ const Filters = ({
3744
3828
  setSelectedFilterValues([]);
3745
3829
  setDateRangeFrom(null);
3746
3830
  setDateRangeTo(null);
3831
+ setFilterSearchText('');
3747
3832
  };
3748
3833
 
3749
3834
  // Title editing handlers
@@ -3780,7 +3865,7 @@ const Filters = ({
3780
3865
  };
3781
3866
 
3782
3867
  // Fetch distinct values for the selected dimension
3783
- const fetchFilterValues = async fullPath => {
3868
+ const fetchFilterValues = async (fullPath, searchText = '') => {
3784
3869
  setLoadingFilterValues(true);
3785
3870
  try {
3786
3871
  // Get parameters from context if available, otherwise use default
@@ -3788,17 +3873,31 @@ const Filters = ({
3788
3873
  base_currency: "EUR"
3789
3874
  };
3790
3875
 
3876
+ // Build query object
3877
+ const queryObj = {
3878
+ dimensions: [fullPath],
3879
+ metrics: [],
3880
+ order_by: [{
3881
+ "name": fullPath
3882
+ }]
3883
+ };
3884
+
3885
+ // Add filter if search text is provided
3886
+ if (searchText && searchText.trim() !== '') {
3887
+ queryObj.filter = {
3888
+ and: [{
3889
+ [fullPath]: {
3890
+ "ilike": `${searchText.trim()}%`
3891
+ }
3892
+ }]
3893
+ };
3894
+ }
3895
+
3791
3896
  // Build a temporary report to fetch distinct values
3792
3897
  const reportPayload = {
3793
3898
  provider: rootProvider,
3794
3899
  doc: {
3795
- query: {
3796
- dimensions: [fullPath],
3797
- metrics: [],
3798
- order_by: [{
3799
- "name": fullPath
3800
- }]
3801
- }
3900
+ query: queryObj
3802
3901
  },
3803
3902
  parameters
3804
3903
  };
@@ -3835,8 +3934,31 @@ const Filters = ({
3835
3934
  value: String(value)
3836
3935
  };
3837
3936
  });
3937
+
3938
+ // Merge with currently selected values to ensure they're always available
3939
+ // This is important when editing filters with search text - we don't want to lose
3940
+ // previously selected values that don't match the current search
3941
+ const selectedValueItems = selectedFilterValues.map(key => ({
3942
+ key: String(key),
3943
+ value: String(key)
3944
+ }));
3945
+
3946
+ // Create a map to avoid duplicates
3947
+ const valueMap = new Map();
3948
+
3949
+ // Add selected values first (they should appear at the top or be preserved)
3950
+ selectedValueItems.forEach(item => {
3951
+ valueMap.set(item.key, item);
3952
+ });
3953
+
3954
+ // Add fetched values
3955
+ distinctValues.forEach(item => {
3956
+ valueMap.set(item.key, item);
3957
+ });
3958
+ const mergedValues = Array.from(valueMap.values());
3838
3959
  console.log('Transformed distinct values:', distinctValues);
3839
- setAvailableFilterValues(distinctValues);
3960
+ console.log('Merged with selected values:', mergedValues);
3961
+ setAvailableFilterValues(mergedValues);
3840
3962
  } catch (error) {
3841
3963
  console.error('Error fetching filter values:', error);
3842
3964
  setAvailableFilterValues([]);
@@ -4026,6 +4148,8 @@ const Filters = ({
4026
4148
  setSelectedFilterValues([]);
4027
4149
  setDateRangeFrom(null);
4028
4150
  setDateRangeTo(null);
4151
+ setFilterSearchText(''); // Reset search text when changing dimension
4152
+
4029
4153
  if (fullPath) {
4030
4154
  // Find the dimension data from existingDimensions
4031
4155
  const dimensionData = existingDimensions.find(dim => dim.fullPath === fullPath);
@@ -4042,6 +4166,56 @@ const Filters = ({
4042
4166
  setAvailableFilterValues([]);
4043
4167
  }
4044
4168
  };
4169
+
4170
+ // Handler for search text input in filter values dropdown (debounced - triggers API call)
4171
+ const handleFilterSearchChange = searchText => {
4172
+ // Determine the fullPath based on current mode
4173
+ let fullPath = null;
4174
+ if (isAddingFromDimension && selectedExistingDimension) {
4175
+ // Mode: Adding filter from existing dimension
4176
+ fullPath = selectedExistingDimension;
4177
+ } else if (isAdding && selectedDimension) {
4178
+ // Mode: Adding new filter with provider selection
4179
+ const dimensionItems = getDimensionItems();
4180
+ const selectedItem = dimensionItems.find(item => item.key === selectedDimension);
4181
+ if (selectedItem) {
4182
+ // Build the complete relation objects array
4183
+ const relations = [];
4184
+ let currentProviderKey = rootProvider;
4185
+ dimensionSelectionChain.forEach(selection => {
4186
+ const provider = providersData[currentProviderKey];
4187
+ if (provider && provider.relations) {
4188
+ const relationObj = provider.relations.find(rel => rel.name === selection.relationName);
4189
+ if (relationObj) {
4190
+ relations.push(relationObj);
4191
+ }
4192
+ }
4193
+ currentProviderKey = selection.targetKey;
4194
+ });
4195
+
4196
+ // Build the alias path
4197
+ const rootProviderData = providersData[rootProvider];
4198
+ const aliasPath = [rootProviderData.default_alias];
4199
+ relations.forEach(rel => {
4200
+ aliasPath.push(rel.alias);
4201
+ });
4202
+ fullPath = `${aliasPath.join('_')}.${selectedItem.dimensionKey}`;
4203
+ }
4204
+ } else if (editingFilterPath) {
4205
+ // Mode: Editing existing filter
4206
+ fullPath = editingFilterPath;
4207
+ }
4208
+
4209
+ // Fetch filter values with search text if we have a fullPath
4210
+ if (fullPath) {
4211
+ fetchFilterValues(fullPath, searchText);
4212
+ }
4213
+ };
4214
+
4215
+ // Immediate handler for input changes (not debounced - updates state immediately)
4216
+ const handleFilterSearchInputChange = searchText => {
4217
+ setFilterSearchText(searchText);
4218
+ };
4045
4219
  const formatProviderPath = filter => {
4046
4220
  // Build path using root provider + relation names
4047
4221
  const pathParts = [rootProvider];
@@ -4201,10 +4375,14 @@ const Filters = ({
4201
4375
  items: availableFilterValues,
4202
4376
  selectedKeys: selectedFilterValues,
4203
4377
  onChange: keys => setSelectedFilterValues(keys),
4378
+ inputValue: filterSearchText,
4379
+ onInputChange: handleFilterSearchChange,
4380
+ onInputChangeImmediate: handleFilterSearchInputChange,
4204
4381
  label: "Choose Values",
4205
- placeholder: "Select one or more values...",
4382
+ placeholder: "Type to search...",
4206
4383
  loading: loadingFilterValues,
4207
- helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value'
4384
+ helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
4385
+ debounceMs: 1000
4208
4386
  })));
4209
4387
  })(), /*#__PURE__*/React__default.createElement(Box$1, {
4210
4388
  sx: {
@@ -4343,10 +4521,14 @@ const Filters = ({
4343
4521
  items: availableFilterValues,
4344
4522
  selectedKeys: selectedFilterValues,
4345
4523
  onChange: keys => setSelectedFilterValues(keys),
4524
+ inputValue: filterSearchText,
4525
+ onInputChange: handleFilterSearchChange,
4526
+ onInputChangeImmediate: handleFilterSearchInputChange,
4346
4527
  label: "Choose Values",
4347
- placeholder: "Select one or more values...",
4528
+ placeholder: "Type to search...",
4348
4529
  loading: loadingFilterValues,
4349
- helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value'
4530
+ helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
4531
+ debounceMs: 1000
4350
4532
  })));
4351
4533
  })(), /*#__PURE__*/React__default.createElement(Box$1, {
4352
4534
  sx: {
@@ -4626,10 +4808,14 @@ const Filters = ({
4626
4808
  items: availableFilterValues,
4627
4809
  selectedKeys: selectedFilterValues,
4628
4810
  onChange: keys => setSelectedFilterValues(keys),
4811
+ inputValue: filterSearchText,
4812
+ onInputChange: handleFilterSearchChange,
4813
+ onInputChangeImmediate: handleFilterSearchInputChange,
4629
4814
  label: "Choose Values",
4630
- placeholder: "Select one or more values...",
4815
+ placeholder: "Type to search...",
4631
4816
  loading: loadingFilterValues,
4632
- helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value'
4817
+ helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
4818
+ debounceMs: 1000
4633
4819
  })), /*#__PURE__*/React__default.createElement(Box$1, {
4634
4820
  sx: {
4635
4821
  display: 'flex',
@@ -4723,31 +4909,39 @@ const ReportDataGrid = ({
4723
4909
  });
4724
4910
 
4725
4911
  // Add metric columns
4726
- // Metric field naming logic:
4727
- // - If from base provider: baseAlias.metric -> metric (no prefix at all)
4728
- // - If from nested provider: baseAlias_rel1_rel2.metric -> rel1_rel2_metric (drops base alias)
4912
+ // The API returns metric keys in two different forms depending on the path depth:
4913
+ //
4914
+ // 2-part path "mc.record_count" → API key: "record_count"
4915
+ // • 2-part path "mc_fa.total_amount" (nested) → API key: "fa_total_amount"
4916
+ // • 3+-part path "mc.dkpi.activeAgreementsCount"→ API key: "mc.dkpi.activeAgreementsCount" (dots)
4917
+ //
4918
+ // For 3+-part paths the API key contains dots. MUI DataGrid interprets dots
4919
+ // in field names as nested-object accessors, so the rows useMemo normalises
4920
+ // those keys (dots → underscores). The column field must match that normalised key.
4729
4921
  metrics.forEach(metric => {
4730
4922
  const metricDef = metric.metric;
4731
4923
  let fieldName;
4732
4924
  let headerName;
4733
-
4734
- // Check if there are relations (nested providers)
4735
- if (metric.relations && metric.relations.length > 0) {
4736
- // From nested provider: drop the base provider alias
4737
- // Example: ft_fa.total_amount -> fa_total_amount
4925
+ const dotCount = (metric.fullPath.match(/\./g) || []).length;
4926
+ if (dotCount >= 2) {
4927
+ // 3+-part namespaced path: API returns the full dotted path as the key.
4928
+ // Normalise dots to underscores to match the row key normalisation below.
4929
+ // Example: "mc.dkpi.activeAgreementsCount" -> "mc_dkpi_activeAgreementsCount"
4930
+ fieldName = metric.fullPath.replace(/\./g, '_');
4931
+ } else if (metric.relations && metric.relations.length > 0) {
4932
+ // 2-part path from a nested provider: API drops the base provider alias.
4933
+ // Example: "mc_fa.total_amount" -> "fa_total_amount"
4738
4934
  const parts = metric.fullPath.split('.');
4739
- const pathWithoutField = parts[0]; // e.g., "ft_fa"
4935
+ const pathWithoutField = parts[0]; // e.g., "mc_fa"
4740
4936
  const field = parts[1]; // e.g., "total_amount"
4741
4937
 
4742
- // Remove the base provider alias (first part before first underscore)
4743
4938
  const pathParts = pathWithoutField.split('_');
4744
- pathParts.shift(); // Remove base provider alias
4745
- const pathWithoutBase = pathParts.join('_'); // e.g., "fa"
4746
-
4939
+ pathParts.shift(); // remove base provider alias
4940
+ const pathWithoutBase = pathParts.join('_');
4747
4941
  fieldName = pathWithoutBase ? `${pathWithoutBase}_${field}` : field;
4748
4942
  } else {
4749
- // From base provider: no prefix at all, just the metric name
4750
- // Example: fo.total_amount -> total_amount
4943
+ // 2-part path from the base provider: API returns just the metric name.
4944
+ // Example: "mc.record_count" -> "record_count"
4751
4945
  fieldName = metric.metricName;
4752
4946
  }
4753
4947
 
@@ -4759,6 +4953,15 @@ const ReportDataGrid = ({
4759
4953
  flex: 1,
4760
4954
  minWidth: 150,
4761
4955
  type: metricDef?.type === 'integer' || metricDef?.type === 'currency' ? 'number' : 'string',
4956
+ renderHeader: () => /*#__PURE__*/React__default.createElement(Box$1, {
4957
+ sx: {
4958
+ display: 'flex',
4959
+ alignItems: 'center',
4960
+ gap: 0.5
4961
+ }
4962
+ }, /*#__PURE__*/React__default.createElement(MetricSourceIcon, {
4963
+ source: metricDef?.source
4964
+ }), /*#__PURE__*/React__default.createElement("span", null, headerName)),
4762
4965
  valueFormatter: value => {
4763
4966
  if (value == null) return '';
4764
4967
 
@@ -4777,14 +4980,22 @@ const ReportDataGrid = ({
4777
4980
  return cols;
4778
4981
  }, [dimensions, metrics, titleOverrides]);
4779
4982
 
4780
- // Transform report data to rows with unique IDs
4983
+ // Transform report data to rows with unique IDs.
4984
+ // Keys are normalised by replacing dots with underscores so that MUI DataGrid
4985
+ // does not misinterpret dotted keys (e.g. "mc.dkpi.exposure") as nested
4986
+ // object paths. Dimension keys returned by the API are already underscore-
4987
+ // based, so normalisation is a no-op for them.
4781
4988
  const rows = React__default.useMemo(() => {
4782
4989
  if (!reportData || !Array.isArray(reportData)) return [];
4783
- return reportData.map((row, index) => ({
4784
- id: index,
4785
- // DataGrid requires unique id for each row
4786
- ...row
4787
- }));
4990
+ return reportData.map((row, index) => {
4991
+ const normalizedRow = {
4992
+ id: index
4993
+ };
4994
+ Object.entries(row).forEach(([key, value]) => {
4995
+ normalizedRow[key.replace(/\./g, '_')] = value;
4996
+ });
4997
+ return normalizedRow;
4998
+ });
4788
4999
  }, [reportData]);
4789
5000
 
4790
5001
  // Handle pagination change
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygd/nc-report-ui",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "files": [
5
5
  "dist/*",
6
6
  "fnet/input.yaml",