@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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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,
|
|
97
|
-
//
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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: '[ ' +
|
|
168
|
+
div({className: 'operators', item: '[ ' + displayOps.join(', ') + ' ]'}),
|
|
168
169
|
div({className: 'example', item: example})
|
|
169
170
|
]
|
|
170
171
|
});
|