66 */
77
88import { Some } from '@xh/hoist/core' ;
9- import { FieldFilter } from '@xh/hoist/data' ;
9+ import { FieldFilter , FieldFilterOperator } from '@xh/hoist/data' ;
1010import { fmtNumber } from '@xh/hoist/format' ;
1111import {
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
0 commit comments