@xh/hoist 82.0.3 → 82.0.4

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 82.0.4 - 2026-03-23
4
+
5
+ ### 🐞 Bug Fixes
6
+
7
+ * Fixed `Store.getFieldValues()` to include `null` in its returned set when records contain
8
+ null/undefined values. Previously these were silently excluded, preventing grid column filters
9
+ from offering a [blank] option.
10
+ * Fixed `FilterChooser` `QueryEngine` to handle null values in suggestion generation without
11
+ throwing. Added error logging so failures in `queryAsync` surface in the console rather than
12
+ silently killing the dropdown. The 'is' pseudo-operator is now listed in the e.g. operator
13
+ hints, and 'is blank' / 'is not blank' suggestions are offered when a field contains null
14
+ values.
15
+
3
16
  ## 82.0.3 - 2026-03-02
4
17
 
5
18
  ### 🐞 Bug Fixes
@@ -1,4 +1,5 @@
1
1
  import { Some } from '@xh/hoist/core';
2
+ import { FieldFilterOperator } from '@xh/hoist/data';
2
3
  import { FilterChooserOption } from './Option';
3
4
  import { FilterChooserModel } from '../FilterChooserModel';
4
5
  import { FilterChooserFieldSpec } from '../FilterChooserFieldSpec';
@@ -21,7 +22,12 @@ export declare class QueryEngine {
21
22
  valueSearchingOnAll(q: any): Some<FilterChooserOption>;
22
23
  getFieldOpts(queryStr: any): FilterChooserOption[];
23
24
  getMinimalFieldOpts(): FilterChooserOption[];
24
- getValueMatchesForField(op: any, queryStr: any, spec: any): FilterChooserOption[];
25
+ /**
26
+ * Get all matching value suggestions for a field, including 'is blank' / 'is not blank'
27
+ * options when the field contains null values. Both blank options are always included
28
+ * regardless of the specified op, filtered only by the query text.
29
+ */
30
+ getMatchesForField(op: FieldFilterOperator, queryStr: string, spec: FilterChooserFieldSpec): FilterChooserOption[];
25
31
  get fieldSpecs(): FilterChooserFieldSpec[];
26
32
  getDecomposedQuery(raw: string): any;
27
33
  sortAndTruncate(results: FilterChooserOption[]): FilterChooserOption[];
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import {Some} from '@xh/hoist/core';
9
- import {FieldFilter} from '@xh/hoist/data';
9
+ import {FieldFilter, FieldFilterOperator} from '@xh/hoist/data';
10
10
  import {fmtNumber} from '@xh/hoist/format';
11
11
  import {
12
12
  castArray,
@@ -50,23 +50,28 @@ export class QueryEngine {
50
50
  // Returns a set of options appropriate for react-select to display.
51
51
  //-----------------------------------------------------------------
52
52
  async queryAsync(query: string): Promise<FilterChooserOption[]> {
53
- const q = this.getDecomposedQuery(query);
54
-
55
- //-----------------------------------------------------------------------
56
- // We respond in five primary states, described and implemented below.
57
- //-----------------------------------------------------------------------
58
- if (!q) {
59
- return this.whenNoQuery();
60
- } else if (q.field && !q.op) {
61
- return castArray(this.openSearching(q));
62
- } else if (q.field && q.op === 'is') {
63
- return castArray(this.withIsSearchingOnField(q));
64
- } else if (q.field && q.op) {
65
- return castArray(this.valueSearchingOnField(q));
66
- } else if (!q.field && q.op && q.value) {
67
- return castArray(this.valueSearchingOnAll(q));
53
+ try {
54
+ const q = this.getDecomposedQuery(query);
55
+
56
+ //-----------------------------------------------------------------------
57
+ // We respond in five primary states, described and implemented below.
58
+ //-----------------------------------------------------------------------
59
+ if (!q) {
60
+ return this.whenNoQuery();
61
+ } else if (q.field && !q.op) {
62
+ return castArray(this.openSearching(q));
63
+ } else if (q.field && q.op === 'is') {
64
+ return castArray(this.withIsSearchingOnField(q));
65
+ } else if (q.field && q.op) {
66
+ return castArray(this.valueSearchingOnField(q));
67
+ } else if (!q.field && q.op && q.value) {
68
+ return castArray(this.valueSearchingOnAll(q));
69
+ }
70
+ return [];
71
+ } catch (e) {
72
+ this.model.logError('Error generating suggestions', e);
73
+ return [];
68
74
  }
69
- return [];
70
75
  }
71
76
 
72
77
  //------------------------------------------------------------------------
@@ -93,16 +98,12 @@ export class QueryEngine {
93
98
  // Suggest matching *fields* for the user to select on their way to a more targeted query.
94
99
  let ret = this.getFieldOpts(q.field);
95
100
 
96
- // If a single field matches, reasonable to assume user is looking to search on it.
97
- // Suggest *all values from that field* for immediate selection with the = operator.
98
- if (ret.length === 1) {
99
- ret.push(...this.getValueMatchesForField('=', '', ret[0].fieldSpec));
100
- }
101
-
102
- // Also suggest *matching values* across all suggest-enabled fields to support the user
103
- // searching for a value directly, without them needing to type or select a field name.
101
+ // If a single field matches, show *all* its values for immediate selection (empty
102
+ // queryStr). Otherwise, filter each field's values against the user's query text.
103
+ const singleMatchSpec = ret.length === 1 ? ret[0].fieldSpec : null;
104
104
  this.fieldSpecs.forEach(spec => {
105
- ret.push(...this.getValueMatchesForField('=', q.field, spec));
105
+ const queryStr = spec === singleMatchSpec ? '' : q.field;
106
+ ret.push(...this.getMatchesForField('=', queryStr, spec));
106
107
  });
107
108
 
108
109
  ret = this.sortAndTruncate(ret);
@@ -152,7 +153,7 @@ export class QueryEngine {
152
153
  // Get suggestions if supported
153
154
  const supportsSuggestions = spec.supportsSuggestions(q.op);
154
155
  if (supportsSuggestions) {
155
- ret = this.getValueMatchesForField(q.op, q.value, spec);
156
+ ret = this.getMatchesForField(q.op, q.value, spec);
156
157
  ret = this.sortAndTruncate(ret);
157
158
  }
158
159
 
@@ -194,9 +195,7 @@ export class QueryEngine {
194
195
  // 5) We have an op and a value but no field-- look in *all* fields for matching candidates
195
196
  //-------------------------------------------------------------------------------------------
196
197
  valueSearchingOnAll(q): Some<FilterChooserOption> {
197
- let ret = flatMap(this.fieldSpecs, spec =>
198
- this.getValueMatchesForField(q.op, q.value, spec)
199
- );
198
+ let ret = flatMap(this.fieldSpecs, spec => this.getMatchesForField(q.op, q.value, spec));
200
199
  ret = this.sortAndTruncate(ret);
201
200
 
202
201
  return isEmpty(ret) ? msgOption('No matches found') : ret;
@@ -221,27 +220,59 @@ export class QueryEngine {
221
220
  return this.fieldSpecs.map(fieldSpec => minimalFieldOption({fieldSpec}));
222
221
  }
223
222
 
224
- getValueMatchesForField(op, queryStr, spec): FilterChooserOption[] {
223
+ /**
224
+ * Get all matching value suggestions for a field, including 'is blank' / 'is not blank'
225
+ * options when the field contains null values. Both blank options are always included
226
+ * regardless of the specified op, filtered only by the query text.
227
+ */
228
+ getMatchesForField(
229
+ op: FieldFilterOperator,
230
+ queryStr: string,
231
+ spec: FilterChooserFieldSpec
232
+ ): FilterChooserOption[] {
225
233
  if (!spec.supportsSuggestions(op)) return [];
226
234
 
227
235
  const {values, field} = spec,
228
- value = spec.parseValue(queryStr, '='),
236
+ parsedValue = spec.parseValue(queryStr, '='),
229
237
  testFn = createWordBoundaryTest(queryStr);
230
238
 
231
- // assume spec will not produce dup values. React-select will de-dup identical opts as well
232
239
  const ret = [];
240
+
241
+ // Non-null value matches
233
242
  values.forEach(v => {
243
+ if (isNil(v)) return;
234
244
  const formattedValue = spec.renderValue(v, '=');
235
245
  if (testFn(formattedValue)) {
236
246
  ret.push(
237
247
  fieldFilterOption({
238
248
  filter: new FieldFilter({field, op, value: v}),
239
249
  fieldSpec: spec,
240
- isExact: value === v || caselessEquals(formattedValue, queryStr)
250
+ isExact: parsedValue === v || caselessEquals(formattedValue, queryStr)
241
251
  })
242
252
  );
243
253
  }
244
254
  });
255
+
256
+ // Blank/not-blank options for fields with null values.
257
+ if (values.some(v => v == null)) {
258
+ const blankTestFn = queryStr ? testFn : null,
259
+ blankEntries: Array<{label: string; op: FieldFilterOperator}> = [
260
+ {label: 'blank', op: '='},
261
+ {label: 'not blank', op: '!='}
262
+ ];
263
+ blankEntries
264
+ .filter(e => !blankTestFn || blankTestFn(e.label))
265
+ .forEach(e =>
266
+ ret.push(
267
+ fieldFilterOption({
268
+ filter: new FieldFilter({field, op: e.op, value: null}),
269
+ fieldSpec: spec,
270
+ isExact: caselessEquals(e.label, queryStr)
271
+ })
272
+ )
273
+ );
274
+ }
275
+
245
276
  return ret;
246
277
  }
247
278
 
package/data/Store.ts CHANGED
@@ -849,12 +849,12 @@ export class Store
849
849
  const ret = new Set();
850
850
  recs.forEach(rec => {
851
851
  const val = rec.get(fieldName);
852
- if (!isNil(val)) {
853
- if (field.type === 'tags') {
854
- val.forEach(it => ret.add(it));
855
- } else {
856
- ret.add(val);
857
- }
852
+ if (isNil(val)) {
853
+ ret.add(null);
854
+ } else if (field.type === 'tags') {
855
+ val.forEach(it => ret.add(it));
856
+ } else {
857
+ ret.add(val);
858
858
  }
859
859
  });
860
860
 
@@ -158,13 +158,14 @@ const fieldOption = hoistCmp.factory({
158
158
  observer: false,
159
159
  memo: false,
160
160
  render({fieldSpec}) {
161
- const {displayName, ops, example} = fieldSpec;
161
+ const {displayName, ops, example} = fieldSpec,
162
+ displayOps = [...ops, 'is']; // Always include the 'is' pseudo-operator so users know to try to use it.
162
163
  return hframe({
163
164
  className: 'xh-filter-chooser-option__field',
164
165
  items: [
165
166
  div({className: 'prefix', item: 'e.g.'}),
166
167
  div({className: 'name', item: displayName}),
167
- div({className: 'operators', item: '[ ' + ops.join(', ') + ' ]'}),
168
+ div({className: 'operators', item: '[ ' + displayOps.join(', ') + ' ]'}),
168
169
  div({className: 'example', item: example})
169
170
  ]
170
171
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "82.0.3",
3
+ "version": "82.0.4",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",