Skip to content

Commit 60358a8

Browse files
jskupsikclaude
andcommitted
Fixed FilterChooser / QueryEngine to better handle null values and errors (#4308)
* Added console logging to QueryEngine errors - these were completely invisible before * Adds the 'is' pseudo-operator to the list of options * Implemented the hasBlankValues() getter, and fixed QueryEngine to not throw if given a null value --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7052d6f commit 60358a8

3 files changed

Lines changed: 73 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
* Fixed `Store.getFieldValues()` to include `null` in its returned set when records contain
88
null/undefined values. Previously these were silently excluded, preventing grid column filters
99
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.
1015

1116
## 82.0.3 - 2026-03-02
1217

cmp/filter/impl/QueryEngine.ts

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import {Some} from '@xh/hoist/core';
9-
import {FieldFilter} from '@xh/hoist/data';
9+
import {FieldFilter, FieldFilterOperator} from '@xh/hoist/data';
1010
import {fmtNumber} from '@xh/hoist/format';
1111
import {
1212
castArray,
@@ -50,23 +50,28 @@ export class QueryEngine {
5050
// Returns a set of options appropriate for react-select to display.
5151
//-----------------------------------------------------------------
5252
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 [];
6874
}
69-
return [];
7075
}
7176

7277
//------------------------------------------------------------------------
@@ -93,16 +98,12 @@ export class QueryEngine {
9398
// Suggest matching *fields* for the user to select on their way to a more targeted query.
9499
let ret = this.getFieldOpts(q.field);
95100

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;
104104
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));
106107
});
107108

108109
ret = this.sortAndTruncate(ret);
@@ -152,7 +153,7 @@ export class QueryEngine {
152153
// Get suggestions if supported
153154
const supportsSuggestions = spec.supportsSuggestions(q.op);
154155
if (supportsSuggestions) {
155-
ret = this.getValueMatchesForField(q.op, q.value, spec);
156+
ret = this.getMatchesForField(q.op, q.value, spec);
156157
ret = this.sortAndTruncate(ret);
157158
}
158159

@@ -194,9 +195,7 @@ export class QueryEngine {
194195
// 5) We have an op and a value but no field-- look in *all* fields for matching candidates
195196
//-------------------------------------------------------------------------------------------
196197
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));
200199
ret = this.sortAndTruncate(ret);
201200

202201
return isEmpty(ret) ? msgOption('No matches found') : ret;
@@ -221,27 +220,59 @@ export class QueryEngine {
221220
return this.fieldSpecs.map(fieldSpec => minimalFieldOption({fieldSpec}));
222221
}
223222

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[] {
225233
if (!spec.supportsSuggestions(op)) return [];
226234

227235
const {values, field} = spec,
228-
value = spec.parseValue(queryStr, '='),
236+
parsedValue = spec.parseValue(queryStr, '='),
229237
testFn = createWordBoundaryTest(queryStr);
230238

231-
// assume spec will not produce dup values. React-select will de-dup identical opts as well
232239
const ret = [];
240+
241+
// Non-null value matches
233242
values.forEach(v => {
243+
if (isNil(v)) return;
234244
const formattedValue = spec.renderValue(v, '=');
235245
if (testFn(formattedValue)) {
236246
ret.push(
237247
fieldFilterOption({
238248
filter: new FieldFilter({field, op, value: v}),
239249
fieldSpec: spec,
240-
isExact: value === v || caselessEquals(formattedValue, queryStr)
250+
isExact: parsedValue === v || caselessEquals(formattedValue, queryStr)
241251
})
242252
);
243253
}
244254
});
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+
245276
return ret;
246277
}
247278

desktop/cmp/filter/FilterChooser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,14 @@ const fieldOption = hoistCmp.factory({
158158
observer: false,
159159
memo: false,
160160
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.
162163
return hframe({
163164
className: 'xh-filter-chooser-option__field',
164165
items: [
165166
div({className: 'prefix', item: 'e.g.'}),
166167
div({className: 'name', item: displayName}),
167-
div({className: 'operators', item: '[ ' + ops.join(', ') + ' ]'}),
168+
div({className: 'operators', item: '[ ' + displayOps.join(', ') + ' ]'}),
168169
div({className: 'example', item: example})
169170
]
170171
});

0 commit comments

Comments
 (0)