@bygd/nc-report-ui 0.1.43 → 0.1.44

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.
@@ -77759,6 +77759,16 @@ const SortableComputedChip = ({
77759
77759
  handleCancel();
77760
77760
  }
77761
77761
  };
77762
+
77763
+ // The value field is multiline, so Enter inserts a newline instead of
77764
+ // saving; only Escape (cancel) and Cmd/Ctrl+Enter (save) are handled here.
77765
+ const handleValueKeyDown = e => {
77766
+ if (e.key === "Escape") {
77767
+ handleCancel();
77768
+ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
77769
+ handleSave();
77770
+ }
77771
+ };
77762
77772
  return /*#__PURE__*/gn.createElement("div", _extends({
77763
77773
  ref: setNodeRef,
77764
77774
  style: style
@@ -77862,10 +77872,13 @@ const SortableComputedChip = ({
77862
77872
  }), /*#__PURE__*/gn.createElement(TextField, {
77863
77873
  value: editValue,
77864
77874
  onChange: e => setEditValue(e.target.value),
77865
- onKeyDown: handleKeyDown,
77875
+ onKeyDown: handleValueKeyDown,
77866
77876
  size: "small",
77867
77877
  label: "Value",
77868
- helperText: " ",
77878
+ helperText: "Cmd/Ctrl+Enter to save",
77879
+ multiline: true,
77880
+ minRows: 2,
77881
+ maxRows: 10,
77869
77882
  sx: {
77870
77883
  flex: 1,
77871
77884
  minWidth: "200px",
@@ -78275,6 +78288,9 @@ const Dimensions = ({
78275
78288
  value: computedValue,
78276
78289
  onChange: e => setComputedValue(e.target.value),
78277
78290
  size: "small",
78291
+ multiline: true,
78292
+ minRows: 2,
78293
+ maxRows: 10,
78278
78294
  sx: {
78279
78295
  width: "400px",
78280
78296
  "& .MuiInputBase-input": {
@@ -90517,6 +90533,50 @@ class AdapterDayjs {
90517
90533
  };
90518
90534
  }
90519
90535
 
90536
+ // Small text input + button for adding a filter value that isn't present in
90537
+ // the "Select Filter Values" dropdown (e.g. because no existing row has that
90538
+ // value). Kept as an uncontrolled local field so it clears itself after add.
90539
+ const CustomFilterValueInput = ({
90540
+ onAdd
90541
+ }) => {
90542
+ const [value, setValue] = d('');
90543
+ const handleAdd = () => {
90544
+ const trimmed = value.trim();
90545
+ if (!trimmed) return;
90546
+ onAdd(trimmed);
90547
+ setValue('');
90548
+ };
90549
+ return /*#__PURE__*/gn.createElement(Box, {
90550
+ sx: {
90551
+ display: 'flex',
90552
+ gap: 1,
90553
+ mt: 1
90554
+ }
90555
+ }, /*#__PURE__*/gn.createElement(TextField, {
90556
+ size: "small",
90557
+ fullWidth: true,
90558
+ placeholder: "Add a value not in the list above",
90559
+ value: value,
90560
+ onChange: e => setValue(e.target.value),
90561
+ onKeyDown: e => {
90562
+ if (e.key === 'Enter') {
90563
+ e.preventDefault();
90564
+ handleAdd();
90565
+ }
90566
+ }
90567
+ }), /*#__PURE__*/gn.createElement(Button, {
90568
+ variant: "outlined",
90569
+ size: "small",
90570
+ onClick: handleAdd,
90571
+ disabled: !value.trim(),
90572
+ sx: {
90573
+ textTransform: 'none',
90574
+ whiteSpace: 'nowrap',
90575
+ borderColor: "rgb(70, 134, 128)",
90576
+ color: "rgb(70, 134, 128)"
90577
+ }
90578
+ }, "Add"));
90579
+ };
90520
90580
  const Filters = ({
90521
90581
  providersData,
90522
90582
  rootProvider,
@@ -90656,8 +90716,9 @@ const Filters = ({
90656
90716
  }
90657
90717
  } else {
90658
90718
  // For regular filters, set selected values and fetch available values
90659
- setSelectedFilterValues(filter.values || []);
90660
- await fetchFilterValues(fullPath);
90719
+ const currentValues = filter.values || [];
90720
+ setSelectedFilterValues(currentValues);
90721
+ await fetchFilterValues(fullPath, '', currentValues);
90661
90722
  }
90662
90723
  };
90663
90724
  const handleSaveEditedFilter = fullPath => {
@@ -90752,7 +90813,11 @@ const Filters = ({
90752
90813
  };
90753
90814
 
90754
90815
  // Fetch distinct values for the selected dimension
90755
- const fetchFilterValues = async (fullPath, searchText = '') => {
90816
+ // preserveValues lets a caller pass the values that must survive the merge
90817
+ // explicitly, instead of relying on the selectedFilterValues closure - a
90818
+ // setSelectedFilterValues() call right before fetchFilterValues() hasn't
90819
+ // committed yet by the time this function reads that state.
90820
+ const fetchFilterValues = async (fullPath, searchText = '', preserveValues = null) => {
90756
90821
  setLoadingFilterValues(true);
90757
90822
  try {
90758
90823
  // Get parameters from context if available, otherwise use default
@@ -90825,7 +90890,8 @@ const Filters = ({
90825
90890
  // Merge with currently selected values to ensure they're always available
90826
90891
  // This is important when editing filters with search text - we don't want to lose
90827
90892
  // previously selected values that don't match the current search
90828
- const selectedValueItems = selectedFilterValues.map(key => ({
90893
+ const valuesToPreserve = preserveValues !== null ? preserveValues : selectedFilterValues;
90894
+ const selectedValueItems = valuesToPreserve.map(key => ({
90829
90895
  key: String(key),
90830
90896
  value: String(key)
90831
90897
  }));
@@ -91054,6 +91120,21 @@ const Filters = ({
91054
91120
  }
91055
91121
  };
91056
91122
 
91123
+ // Add a value typed in the "custom value" box directly into the selected
91124
+ // filter values, and inject it into availableFilterValues too, since
91125
+ // CheckboxMultiAutocomplete only renders a chip for keys it can resolve
91126
+ // against its items list.
91127
+ const handleAddCustomFilterValue = rawValue => {
91128
+ const value = rawValue.trim();
91129
+ if (!value) return;
91130
+ if (selectedFilterValues.includes(value)) return;
91131
+ setAvailableFilterValues(prev => prev.some(item => item.key === value) ? prev : [{
91132
+ key: value,
91133
+ value
91134
+ }, ...prev]);
91135
+ setSelectedFilterValues(prev => [...prev, value]);
91136
+ };
91137
+
91057
91138
  // Handler for search text input in filter values dropdown (debounced - triggers API call)
91058
91139
  const handleFilterSearchChange = searchText => {
91059
91140
  // Determine the fullPath based on current mode
@@ -91273,6 +91354,8 @@ const Filters = ({
91273
91354
  loading: loadingFilterValues,
91274
91355
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
91275
91356
  debounceMs: 1000
91357
+ }), /*#__PURE__*/gn.createElement(CustomFilterValueInput, {
91358
+ onAdd: handleAddCustomFilterValue
91276
91359
  })));
91277
91360
  })(), /*#__PURE__*/gn.createElement(Box, {
91278
91361
  sx: {
@@ -91424,6 +91507,8 @@ const Filters = ({
91424
91507
  loading: loadingFilterValues,
91425
91508
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
91426
91509
  debounceMs: 1000
91510
+ }), /*#__PURE__*/gn.createElement(CustomFilterValueInput, {
91511
+ onAdd: handleAddCustomFilterValue
91427
91512
  })));
91428
91513
  })(), /*#__PURE__*/gn.createElement(Box, {
91429
91514
  sx: {
@@ -91713,6 +91798,8 @@ const Filters = ({
91713
91798
  loading: loadingFilterValues,
91714
91799
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
91715
91800
  debounceMs: 1000
91801
+ }), /*#__PURE__*/gn.createElement(CustomFilterValueInput, {
91802
+ onAdd: handleAddCustomFilterValue
91716
91803
  })), /*#__PURE__*/gn.createElement(Box, {
91717
91804
  sx: {
91718
91805
  display: 'flex',
@@ -93104,14 +93191,24 @@ const ReportBuilder = ({
93104
93191
  queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
93105
93192
  }
93106
93193
 
93107
- // Persist the optional unified column order (Column Order tab) so the
93108
- // results grid can reproduce it on reload. Absent when no custom order
93109
- // has been defined, so old/untouched reports keep their current
93110
- // dimensions-then-metrics column order unchanged.
93111
- if (report.columnOrder.length > 0) {
93112
- queryObj.ordered_columns = report.columnOrder.map(entry => ({
93113
- type: entry.type,
93114
- ref: entry.key
93194
+ // Persist the full resolved column order - the explicit Column Order tab
93195
+ // arrangement when defined, otherwise dimensions/computed (in their own
93196
+ // order) followed by metrics (in their own order), same as what the
93197
+ // results grid renders. Always emitted (not just when a custom order
93198
+ // exists) so the API/results grid get an explicit order even for
93199
+ // untouched reports, rather than having to fall back to guessing it.
93200
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
93201
+ kind: "dimension",
93202
+ data: dimensionById[entry.key]
93203
+ } : {
93204
+ kind: "computed",
93205
+ data: computedByName[entry.key]
93206
+ }).filter(item => item.data);
93207
+ const resolvedColumnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
93208
+ if (resolvedColumnItems.length > 0) {
93209
+ queryObj.ordered_columns = resolvedColumnItems.map(item => ({
93210
+ type: item.kind,
93211
+ ref: item.key
93115
93212
  }));
93116
93213
  }
93117
93214
 
@@ -3172,6 +3172,16 @@ const SortableComputedChip = ({
3172
3172
  handleCancel();
3173
3173
  }
3174
3174
  };
3175
+
3176
+ // The value field is multiline, so Enter inserts a newline instead of
3177
+ // saving; only Escape (cancel) and Cmd/Ctrl+Enter (save) are handled here.
3178
+ const handleValueKeyDown = e => {
3179
+ if (e.key === "Escape") {
3180
+ handleCancel();
3181
+ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3182
+ handleSave();
3183
+ }
3184
+ };
3175
3185
  return /*#__PURE__*/React__namespace.default.createElement("div", _extends({
3176
3186
  ref: setNodeRef,
3177
3187
  style: style
@@ -3275,10 +3285,13 @@ const SortableComputedChip = ({
3275
3285
  }), /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
3276
3286
  value: editValue,
3277
3287
  onChange: e => setEditValue(e.target.value),
3278
- onKeyDown: handleKeyDown,
3288
+ onKeyDown: handleValueKeyDown,
3279
3289
  size: "small",
3280
3290
  label: "Value",
3281
- helperText: " ",
3291
+ helperText: "Cmd/Ctrl+Enter to save",
3292
+ multiline: true,
3293
+ minRows: 2,
3294
+ maxRows: 10,
3282
3295
  sx: {
3283
3296
  flex: 1,
3284
3297
  minWidth: "200px",
@@ -3688,6 +3701,9 @@ const Dimensions = ({
3688
3701
  value: computedValue,
3689
3702
  onChange: e => setComputedValue(e.target.value),
3690
3703
  size: "small",
3704
+ multiline: true,
3705
+ minRows: 2,
3706
+ maxRows: 10,
3691
3707
  sx: {
3692
3708
  width: "400px",
3693
3709
  "& .MuiInputBase-input": {
@@ -4582,6 +4598,50 @@ const Metrics = ({
4582
4598
  })))));
4583
4599
  };
4584
4600
 
4601
+ // Small text input + button for adding a filter value that isn't present in
4602
+ // the "Select Filter Values" dropdown (e.g. because no existing row has that
4603
+ // value). Kept as an uncontrolled local field so it clears itself after add.
4604
+ const CustomFilterValueInput = ({
4605
+ onAdd
4606
+ }) => {
4607
+ const [value, setValue] = React.useState('');
4608
+ const handleAdd = () => {
4609
+ const trimmed = value.trim();
4610
+ if (!trimmed) return;
4611
+ onAdd(trimmed);
4612
+ setValue('');
4613
+ };
4614
+ return /*#__PURE__*/React__namespace.default.createElement(material.Box, {
4615
+ sx: {
4616
+ display: 'flex',
4617
+ gap: 1,
4618
+ mt: 1
4619
+ }
4620
+ }, /*#__PURE__*/React__namespace.default.createElement(material.TextField, {
4621
+ size: "small",
4622
+ fullWidth: true,
4623
+ placeholder: "Add a value not in the list above",
4624
+ value: value,
4625
+ onChange: e => setValue(e.target.value),
4626
+ onKeyDown: e => {
4627
+ if (e.key === 'Enter') {
4628
+ e.preventDefault();
4629
+ handleAdd();
4630
+ }
4631
+ }
4632
+ }), /*#__PURE__*/React__namespace.default.createElement(material.Button, {
4633
+ variant: "outlined",
4634
+ size: "small",
4635
+ onClick: handleAdd,
4636
+ disabled: !value.trim(),
4637
+ sx: {
4638
+ textTransform: 'none',
4639
+ whiteSpace: 'nowrap',
4640
+ borderColor: "rgb(70, 134, 128)",
4641
+ color: "rgb(70, 134, 128)"
4642
+ }
4643
+ }, "Add"));
4644
+ };
4585
4645
  const Filters = ({
4586
4646
  providersData,
4587
4647
  rootProvider,
@@ -4721,8 +4781,9 @@ const Filters = ({
4721
4781
  }
4722
4782
  } else {
4723
4783
  // For regular filters, set selected values and fetch available values
4724
- setSelectedFilterValues(filter.values || []);
4725
- await fetchFilterValues(fullPath);
4784
+ const currentValues = filter.values || [];
4785
+ setSelectedFilterValues(currentValues);
4786
+ await fetchFilterValues(fullPath, '', currentValues);
4726
4787
  }
4727
4788
  };
4728
4789
  const handleSaveEditedFilter = fullPath => {
@@ -4817,7 +4878,11 @@ const Filters = ({
4817
4878
  };
4818
4879
 
4819
4880
  // Fetch distinct values for the selected dimension
4820
- const fetchFilterValues = async (fullPath, searchText = '') => {
4881
+ // preserveValues lets a caller pass the values that must survive the merge
4882
+ // explicitly, instead of relying on the selectedFilterValues closure - a
4883
+ // setSelectedFilterValues() call right before fetchFilterValues() hasn't
4884
+ // committed yet by the time this function reads that state.
4885
+ const fetchFilterValues = async (fullPath, searchText = '', preserveValues = null) => {
4821
4886
  setLoadingFilterValues(true);
4822
4887
  try {
4823
4888
  // Get parameters from context if available, otherwise use default
@@ -4890,7 +4955,8 @@ const Filters = ({
4890
4955
  // Merge with currently selected values to ensure they're always available
4891
4956
  // This is important when editing filters with search text - we don't want to lose
4892
4957
  // previously selected values that don't match the current search
4893
- const selectedValueItems = selectedFilterValues.map(key => ({
4958
+ const valuesToPreserve = preserveValues !== null ? preserveValues : selectedFilterValues;
4959
+ const selectedValueItems = valuesToPreserve.map(key => ({
4894
4960
  key: String(key),
4895
4961
  value: String(key)
4896
4962
  }));
@@ -5119,6 +5185,21 @@ const Filters = ({
5119
5185
  }
5120
5186
  };
5121
5187
 
5188
+ // Add a value typed in the "custom value" box directly into the selected
5189
+ // filter values, and inject it into availableFilterValues too, since
5190
+ // CheckboxMultiAutocomplete only renders a chip for keys it can resolve
5191
+ // against its items list.
5192
+ const handleAddCustomFilterValue = rawValue => {
5193
+ const value = rawValue.trim();
5194
+ if (!value) return;
5195
+ if (selectedFilterValues.includes(value)) return;
5196
+ setAvailableFilterValues(prev => prev.some(item => item.key === value) ? prev : [{
5197
+ key: value,
5198
+ value
5199
+ }, ...prev]);
5200
+ setSelectedFilterValues(prev => [...prev, value]);
5201
+ };
5202
+
5122
5203
  // Handler for search text input in filter values dropdown (debounced - triggers API call)
5123
5204
  const handleFilterSearchChange = searchText => {
5124
5205
  // Determine the fullPath based on current mode
@@ -5338,6 +5419,8 @@ const Filters = ({
5338
5419
  loading: loadingFilterValues,
5339
5420
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5340
5421
  debounceMs: 1000
5422
+ }), /*#__PURE__*/React__namespace.default.createElement(CustomFilterValueInput, {
5423
+ onAdd: handleAddCustomFilterValue
5341
5424
  })));
5342
5425
  })(), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5343
5426
  sx: {
@@ -5489,6 +5572,8 @@ const Filters = ({
5489
5572
  loading: loadingFilterValues,
5490
5573
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5491
5574
  debounceMs: 1000
5575
+ }), /*#__PURE__*/React__namespace.default.createElement(CustomFilterValueInput, {
5576
+ onAdd: handleAddCustomFilterValue
5492
5577
  })));
5493
5578
  })(), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5494
5579
  sx: {
@@ -5778,6 +5863,8 @@ const Filters = ({
5778
5863
  loading: loadingFilterValues,
5779
5864
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5780
5865
  debounceMs: 1000
5866
+ }), /*#__PURE__*/React__namespace.default.createElement(CustomFilterValueInput, {
5867
+ onAdd: handleAddCustomFilterValue
5781
5868
  })), /*#__PURE__*/React__namespace.default.createElement(material.Box, {
5782
5869
  sx: {
5783
5870
  display: 'flex',
@@ -7165,14 +7252,24 @@ const ReportBuilder = ({
7165
7252
  queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
7166
7253
  }
7167
7254
 
7168
- // Persist the optional unified column order (Column Order tab) so the
7169
- // results grid can reproduce it on reload. Absent when no custom order
7170
- // has been defined, so old/untouched reports keep their current
7171
- // dimensions-then-metrics column order unchanged.
7172
- if (report.columnOrder.length > 0) {
7173
- queryObj.ordered_columns = report.columnOrder.map(entry => ({
7174
- type: entry.type,
7175
- ref: entry.key
7255
+ // Persist the full resolved column order - the explicit Column Order tab
7256
+ // arrangement when defined, otherwise dimensions/computed (in their own
7257
+ // order) followed by metrics (in their own order), same as what the
7258
+ // results grid renders. Always emitted (not just when a custom order
7259
+ // exists) so the API/results grid get an explicit order even for
7260
+ // untouched reports, rather than having to fall back to guessing it.
7261
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
7262
+ kind: "dimension",
7263
+ data: dimensionById[entry.key]
7264
+ } : {
7265
+ kind: "computed",
7266
+ data: computedByName[entry.key]
7267
+ }).filter(item => item.data);
7268
+ const resolvedColumnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
7269
+ if (resolvedColumnItems.length > 0) {
7270
+ queryObj.ordered_columns = resolvedColumnItems.map(item => ({
7271
+ type: item.kind,
7272
+ ref: item.key
7176
7273
  }));
7177
7274
  }
7178
7275
 
@@ -3091,6 +3091,16 @@ const SortableComputedChip = ({
3091
3091
  handleCancel();
3092
3092
  }
3093
3093
  };
3094
+
3095
+ // The value field is multiline, so Enter inserts a newline instead of
3096
+ // saving; only Escape (cancel) and Cmd/Ctrl+Enter (save) are handled here.
3097
+ const handleValueKeyDown = e => {
3098
+ if (e.key === "Escape") {
3099
+ handleCancel();
3100
+ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3101
+ handleSave();
3102
+ }
3103
+ };
3094
3104
  return /*#__PURE__*/React__default.createElement("div", _extends({
3095
3105
  ref: setNodeRef,
3096
3106
  style: style
@@ -3194,10 +3204,13 @@ const SortableComputedChip = ({
3194
3204
  }), /*#__PURE__*/React__default.createElement(TextField, {
3195
3205
  value: editValue,
3196
3206
  onChange: e => setEditValue(e.target.value),
3197
- onKeyDown: handleKeyDown,
3207
+ onKeyDown: handleValueKeyDown,
3198
3208
  size: "small",
3199
3209
  label: "Value",
3200
- helperText: " ",
3210
+ helperText: "Cmd/Ctrl+Enter to save",
3211
+ multiline: true,
3212
+ minRows: 2,
3213
+ maxRows: 10,
3201
3214
  sx: {
3202
3215
  flex: 1,
3203
3216
  minWidth: "200px",
@@ -3607,6 +3620,9 @@ const Dimensions = ({
3607
3620
  value: computedValue,
3608
3621
  onChange: e => setComputedValue(e.target.value),
3609
3622
  size: "small",
3623
+ multiline: true,
3624
+ minRows: 2,
3625
+ maxRows: 10,
3610
3626
  sx: {
3611
3627
  width: "400px",
3612
3628
  "& .MuiInputBase-input": {
@@ -4501,6 +4517,50 @@ const Metrics = ({
4501
4517
  })))));
4502
4518
  };
4503
4519
 
4520
+ // Small text input + button for adding a filter value that isn't present in
4521
+ // the "Select Filter Values" dropdown (e.g. because no existing row has that
4522
+ // value). Kept as an uncontrolled local field so it clears itself after add.
4523
+ const CustomFilterValueInput = ({
4524
+ onAdd
4525
+ }) => {
4526
+ const [value, setValue] = useState('');
4527
+ const handleAdd = () => {
4528
+ const trimmed = value.trim();
4529
+ if (!trimmed) return;
4530
+ onAdd(trimmed);
4531
+ setValue('');
4532
+ };
4533
+ return /*#__PURE__*/React__default.createElement(Box$1, {
4534
+ sx: {
4535
+ display: 'flex',
4536
+ gap: 1,
4537
+ mt: 1
4538
+ }
4539
+ }, /*#__PURE__*/React__default.createElement(TextField, {
4540
+ size: "small",
4541
+ fullWidth: true,
4542
+ placeholder: "Add a value not in the list above",
4543
+ value: value,
4544
+ onChange: e => setValue(e.target.value),
4545
+ onKeyDown: e => {
4546
+ if (e.key === 'Enter') {
4547
+ e.preventDefault();
4548
+ handleAdd();
4549
+ }
4550
+ }
4551
+ }), /*#__PURE__*/React__default.createElement(Button, {
4552
+ variant: "outlined",
4553
+ size: "small",
4554
+ onClick: handleAdd,
4555
+ disabled: !value.trim(),
4556
+ sx: {
4557
+ textTransform: 'none',
4558
+ whiteSpace: 'nowrap',
4559
+ borderColor: "rgb(70, 134, 128)",
4560
+ color: "rgb(70, 134, 128)"
4561
+ }
4562
+ }, "Add"));
4563
+ };
4504
4564
  const Filters = ({
4505
4565
  providersData,
4506
4566
  rootProvider,
@@ -4640,8 +4700,9 @@ const Filters = ({
4640
4700
  }
4641
4701
  } else {
4642
4702
  // For regular filters, set selected values and fetch available values
4643
- setSelectedFilterValues(filter.values || []);
4644
- await fetchFilterValues(fullPath);
4703
+ const currentValues = filter.values || [];
4704
+ setSelectedFilterValues(currentValues);
4705
+ await fetchFilterValues(fullPath, '', currentValues);
4645
4706
  }
4646
4707
  };
4647
4708
  const handleSaveEditedFilter = fullPath => {
@@ -4736,7 +4797,11 @@ const Filters = ({
4736
4797
  };
4737
4798
 
4738
4799
  // Fetch distinct values for the selected dimension
4739
- const fetchFilterValues = async (fullPath, searchText = '') => {
4800
+ // preserveValues lets a caller pass the values that must survive the merge
4801
+ // explicitly, instead of relying on the selectedFilterValues closure - a
4802
+ // setSelectedFilterValues() call right before fetchFilterValues() hasn't
4803
+ // committed yet by the time this function reads that state.
4804
+ const fetchFilterValues = async (fullPath, searchText = '', preserveValues = null) => {
4740
4805
  setLoadingFilterValues(true);
4741
4806
  try {
4742
4807
  // Get parameters from context if available, otherwise use default
@@ -4809,7 +4874,8 @@ const Filters = ({
4809
4874
  // Merge with currently selected values to ensure they're always available
4810
4875
  // This is important when editing filters with search text - we don't want to lose
4811
4876
  // previously selected values that don't match the current search
4812
- const selectedValueItems = selectedFilterValues.map(key => ({
4877
+ const valuesToPreserve = preserveValues !== null ? preserveValues : selectedFilterValues;
4878
+ const selectedValueItems = valuesToPreserve.map(key => ({
4813
4879
  key: String(key),
4814
4880
  value: String(key)
4815
4881
  }));
@@ -5038,6 +5104,21 @@ const Filters = ({
5038
5104
  }
5039
5105
  };
5040
5106
 
5107
+ // Add a value typed in the "custom value" box directly into the selected
5108
+ // filter values, and inject it into availableFilterValues too, since
5109
+ // CheckboxMultiAutocomplete only renders a chip for keys it can resolve
5110
+ // against its items list.
5111
+ const handleAddCustomFilterValue = rawValue => {
5112
+ const value = rawValue.trim();
5113
+ if (!value) return;
5114
+ if (selectedFilterValues.includes(value)) return;
5115
+ setAvailableFilterValues(prev => prev.some(item => item.key === value) ? prev : [{
5116
+ key: value,
5117
+ value
5118
+ }, ...prev]);
5119
+ setSelectedFilterValues(prev => [...prev, value]);
5120
+ };
5121
+
5041
5122
  // Handler for search text input in filter values dropdown (debounced - triggers API call)
5042
5123
  const handleFilterSearchChange = searchText => {
5043
5124
  // Determine the fullPath based on current mode
@@ -5257,6 +5338,8 @@ const Filters = ({
5257
5338
  loading: loadingFilterValues,
5258
5339
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5259
5340
  debounceMs: 1000
5341
+ }), /*#__PURE__*/React__default.createElement(CustomFilterValueInput, {
5342
+ onAdd: handleAddCustomFilterValue
5260
5343
  })));
5261
5344
  })(), /*#__PURE__*/React__default.createElement(Box$1, {
5262
5345
  sx: {
@@ -5408,6 +5491,8 @@ const Filters = ({
5408
5491
  loading: loadingFilterValues,
5409
5492
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5410
5493
  debounceMs: 1000
5494
+ }), /*#__PURE__*/React__default.createElement(CustomFilterValueInput, {
5495
+ onAdd: handleAddCustomFilterValue
5411
5496
  })));
5412
5497
  })(), /*#__PURE__*/React__default.createElement(Box$1, {
5413
5498
  sx: {
@@ -5697,6 +5782,8 @@ const Filters = ({
5697
5782
  loading: loadingFilterValues,
5698
5783
  helperText: selectedFilterValues.length > 0 ? `${selectedFilterValues.length} value(s) selected` : 'Select at least one value',
5699
5784
  debounceMs: 1000
5785
+ }), /*#__PURE__*/React__default.createElement(CustomFilterValueInput, {
5786
+ onAdd: handleAddCustomFilterValue
5700
5787
  })), /*#__PURE__*/React__default.createElement(Box$1, {
5701
5788
  sx: {
5702
5789
  display: 'flex',
@@ -7084,14 +7171,24 @@ const ReportBuilder = ({
7084
7171
  queryObj.ordered_metrics = report.metrics.map(metric => metric.id);
7085
7172
  }
7086
7173
 
7087
- // Persist the optional unified column order (Column Order tab) so the
7088
- // results grid can reproduce it on reload. Absent when no custom order
7089
- // has been defined, so old/untouched reports keep their current
7090
- // dimensions-then-metrics column order unchanged.
7091
- if (report.columnOrder.length > 0) {
7092
- queryObj.ordered_columns = report.columnOrder.map(entry => ({
7093
- type: entry.type,
7094
- ref: entry.key
7174
+ // Persist the full resolved column order - the explicit Column Order tab
7175
+ // arrangement when defined, otherwise dimensions/computed (in their own
7176
+ // order) followed by metrics (in their own order), same as what the
7177
+ // results grid renders. Always emitted (not just when a custom order
7178
+ // exists) so the API/results grid get an explicit order even for
7179
+ // untouched reports, rather than having to fall back to guessing it.
7180
+ const orderedDimensionItems = report.dimensionOrder.map(entry => entry.type === "dimension" ? {
7181
+ kind: "dimension",
7182
+ data: dimensionById[entry.key]
7183
+ } : {
7184
+ kind: "computed",
7185
+ data: computedByName[entry.key]
7186
+ }).filter(item => item.data);
7187
+ const resolvedColumnItems = resolveColumnOrder(report.columnOrder, orderedDimensionItems, report.metrics);
7188
+ if (resolvedColumnItems.length > 0) {
7189
+ queryObj.ordered_columns = resolvedColumnItems.map(item => ({
7190
+ type: item.kind,
7191
+ ref: item.key
7095
7192
  }));
7096
7193
  }
7097
7194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygd/nc-report-ui",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "files": [
5
5
  "dist/*",
6
6
  "fnet/input.yaml",