@xh/hoist 75.0.0-SNAPSHOT.1753294066799 → 75.0.0-SNAPSHOT.1753376908830

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
@@ -16,6 +16,12 @@
16
16
  which `dimensions` are provided to the model.
17
17
  * The usage of the `RelativeTimestamp` component has been streamlined by deprecating the `options`
18
18
  prop. All `RelativeTimestampOptions` are now supported by this component as top-level props.
19
+ * `FilterChooserModel` has been enhanced to better handle multiple simultaneous filters with
20
+ different `op`s on the same field. "Inclusive" ops (e.g. `=`, `like`) will be OR'ed together,
21
+ "Exclusive" ops (e.g. `!=`, `not like`) will be AND'ed together and range ops (e.g. `<`, `>` )
22
+ will use a heuristic to create a meaningful query that will actually return results. This
23
+ behavior is consistent with current behavior and user intuition, and should maximize the ability
24
+ to create useful queries.
19
25
 
20
26
  ### 🐞 Bug Fixes
21
27
 
@@ -95,7 +95,6 @@ export declare class FilterChooserModel extends HoistModel {
95
95
  */
96
96
  setValue(rawValue: FilterLike): void;
97
97
  setSelectValue(selectValue: string[]): void;
98
- toDisplayFilters(filter: Filter): any[];
99
98
  queryAsync(query: string): Promise<FilterChooserOption[]>;
100
99
  autoComplete(value: any): void;
101
100
  createFilterOption(filter: Filter): FilterChooserOption;
@@ -114,6 +113,9 @@ export declare class FilterChooserModel extends HoistModel {
114
113
  getFieldSpec(fieldName: string): FilterChooserFieldSpec;
115
114
  validateFilter(f: Filter): f is FilterChooserFilter;
116
115
  getDefaultIntroHelpText(): string;
116
+ private toValueFilter;
117
+ private toDisplayFilters;
118
+ private implicitOrFieldFilters;
117
119
  private initPersist;
118
120
  private setValueInternal;
119
121
  /**
@@ -17,6 +17,9 @@ export declare class FieldFilter extends Filter {
17
17
  readonly value: any;
18
18
  static OPERATORS: string[];
19
19
  static ARRAY_OPERATORS: string[];
20
+ static INCLUDE_LIKE_OPERATORS: string[];
21
+ static EXCLUDE_LIKE_OPERATORS: string[];
22
+ static RANGE_LIKE_OPERATORS: string[];
20
23
  /**
21
24
  * Constructor - not typically called by apps - create via {@link parseFilter} instead.
22
25
  * @internal
@@ -9,7 +9,7 @@ export interface ExpandToLevelButtonProps extends Omit<ButtonProps, 'title'> {
9
9
  /** Position for menu, as per Blueprint docs. */
10
10
  popoverPosition?: Position;
11
11
  /** Title for the menu popover - defaults to "Expand To Level". */
12
- title: ReactNode;
12
+ title?: ReactNode;
13
13
  }
14
14
  /**
15
15
  * A menu button to expand a multi-level grouped or tree grid out to a desired level.
@@ -15,7 +15,6 @@ import {
15
15
  XH
16
16
  } from '@xh/hoist/core';
17
17
  import {
18
- combineValueFilters,
19
18
  CompoundFilter,
20
19
  FieldFilter,
21
20
  Filter,
@@ -32,6 +31,8 @@ import {createObservableRef} from '@xh/hoist/utils/react';
32
31
  import {
33
32
  cloneDeep,
34
33
  compact,
34
+ every,
35
+ first,
35
36
  flatMap,
36
37
  flatten,
37
38
  forEach,
@@ -41,6 +42,7 @@ import {
41
42
  isFinite,
42
43
  isObject,
43
44
  isString,
45
+ map,
44
46
  partition,
45
47
  sortBy,
46
48
  uniq,
@@ -222,48 +224,12 @@ export class FilterChooserModel extends HoistModel {
222
224
  const [filters, suggestions] = partition(parsedValues, 'op');
223
225
 
224
226
  // Round-trip actual filters through main value setter above.
225
- this.setValue(combineValueFilters(filters));
227
+ this.setValue(this.toValueFilter(filters));
226
228
 
227
229
  // And then programmatically re-enter any suggestion
228
230
  if (suggestions.length === 1) this.autoComplete(suggestions[0]);
229
231
  }
230
232
 
231
- // Transfer the value filter to the canonical set of individual filters for display.
232
- // Filters with arrays values will be split.
233
- toDisplayFilters(filter: Filter) {
234
- if (!filter) return [];
235
-
236
- let ret;
237
- const unsupported = s => {
238
- throw XH.exception(`Unsupported Filter in FilterChooserModel: ${s}`);
239
- };
240
-
241
- // 1) Flatten CompoundFilters across disparate fields to FieldFilters.
242
- if (filter instanceof CompoundFilter && !filter.field) {
243
- ret = filter.filters;
244
- } else {
245
- ret = [filter];
246
- }
247
- if (ret.some(f => !(f instanceof FieldFilter) && !(f instanceof CompoundFilter))) {
248
- unsupported('Filters must be FieldFilters or CompoundFilters.');
249
- }
250
-
251
- // 2) Recognize unsupported multiple filters for array-based filters.
252
- const groupMap = groupBy(ret, ({op, field}) => `${op}|${field}`);
253
- forEach(groupMap, filters => {
254
- const {op} = filters[0];
255
- if (filters.length > 1 && FieldFilter.ARRAY_OPERATORS.includes(op)) {
256
- unsupported(`Multiple filters cannot be provided with ${op} operator`);
257
- }
258
- });
259
-
260
- // 3) Finally unroll multi-value filters to one value per filter.
261
- // The multiple values will later be restored.
262
- return flatMap(ret, f => {
263
- return isArray(f.value) ? f.value.map(value => new FieldFilter({...f, value})) : f;
264
- });
265
- }
266
-
267
233
  //-------------
268
234
  // Querying
269
235
  //---------------
@@ -411,6 +377,108 @@ export class FilterChooserModel extends HoistModel {
411
377
  // -------------------------------
412
378
  // Implementation
413
379
  // -------------------------------
380
+
381
+ // Take the raw flat displayed FieldFilter specs and combine them with appropriate semantics
382
+ // into a proper Filter for the value of ths model. Field Filters on the same field are going
383
+ // to be combined into a FieldFilter with array values as well as potentially appropriate
384
+ // compound filters to combine different ops. See toDisplayFilters() for the inverse of this
385
+ // operation.
386
+ private toValueFilter(specs: FieldFilterSpec[] = []): FilterLike {
387
+ const ret: FilterLike[] = [];
388
+
389
+ // group filters by field -- we'll produce up to two ANDable filters per field
390
+ const fieldMap = groupBy(specs, 'field');
391
+ forEach(fieldMap, specs => {
392
+ // a) combine filters with SAME operator in to a single FieldFilter
393
+ const opMap = groupBy(specs, 'op');
394
+ specs = flatMap(opMap, specs => {
395
+ const firstSpec = first(specs);
396
+ return specs.length > 1 && FieldFilter.ARRAY_OPERATORS.includes(firstSpec.op)
397
+ ? {...firstSpec, value: map(specs, 'value')}
398
+ : specs;
399
+ });
400
+
401
+ // Process like operators together, potentially creating sub-OR clauses.
402
+ [
403
+ FieldFilter.INCLUDE_LIKE_OPERATORS,
404
+ FieldFilter.EXCLUDE_LIKE_OPERATORS,
405
+ FieldFilter.RANGE_LIKE_OPERATORS
406
+ ].forEach(type => {
407
+ const filters = specs.filter(s => type.includes(s.op));
408
+ if (this.implicitOrFieldFilters(filters)) {
409
+ ret.push([{op: 'OR', filters}]);
410
+ } else {
411
+ ret.push(...filters);
412
+ }
413
+ });
414
+ });
415
+
416
+ return ret;
417
+ }
418
+
419
+ // Transfer the value filter to the canonical set of individual filters for display.
420
+ // See toValueFilter() for the inverse of this operation.
421
+ private toDisplayFilters(filter: Filter): Filter[] {
422
+ if (!filter) return [];
423
+ const unsupported = s => {
424
+ throw XH.exception(`Unsupported Filter in FilterChooserModel: ${s}`);
425
+ };
426
+
427
+ let ret: Filter[] = [filter];
428
+
429
+ // 0) Can always unwind the top level AND -- its implicit.
430
+ if (filter instanceof CompoundFilter && filter.op == 'AND') {
431
+ ret = filter.filters;
432
+ }
433
+
434
+ // 1) Further flatten 2nd-Level CompoundFilters to FieldFilters.
435
+ // OR'ed filters on the same field can be decomposed if they will later be re-combined
436
+ ret = ret.flatMap(f => {
437
+ return f instanceof CompoundFilter &&
438
+ (f.op == 'AND' ||
439
+ (f.field && this.implicitOrFieldFilters(f.filters as FieldFilter[])))
440
+ ? f.filters
441
+ : f;
442
+ });
443
+
444
+ // 2) Recognize misc unsupported filters.
445
+ if (!ret.every(f => f instanceof FieldFilter || f instanceof CompoundFilter)) {
446
+ unsupported('Filters must be FieldFilters or CompoundFilters.');
447
+ }
448
+ const fieldFilters = ret.filter(it => it instanceof FieldFilter) as FieldFilter[];
449
+ const groupMap = groupBy(fieldFilters, ({op, field}) => `${op}|${field}`);
450
+ forEach(groupMap, filters => {
451
+ const {op} = filters[0];
452
+ if (filters.length > 1 && FieldFilter.ARRAY_OPERATORS.includes(op)) {
453
+ unsupported(`Multiple filters cannot be provided with ${op} operator`);
454
+ }
455
+ });
456
+
457
+ // 3) Finally unroll multi-value filters to one filter per value.
458
+ return flatMap(ret, f => {
459
+ return f instanceof FieldFilter && isArray(f.value)
460
+ ? f.value.map(value => new FieldFilter({...f, value}))
461
+ : f;
462
+ });
463
+ }
464
+
465
+ // Should Field Filters on a particular Field be implicitly OR'ed together?
466
+ private implicitOrFieldFilters(filters: Array<FieldFilterSpec | FieldFilter>): boolean {
467
+ const {INCLUDE_LIKE_OPERATORS, RANGE_LIKE_OPERATORS} = FieldFilter;
468
+ if (filters.length < 2) return false;
469
+
470
+ // For INCLUDE_LIKE, treat them like "equals" and OR them
471
+ if (every(filters, f => INCLUDE_LIKE_OPERATORS.includes(f.op))) return true;
472
+
473
+ // For RANGE_LIKE, recognize simple "exterior" bifurcated range as an OR, otherwise AND
474
+ if (filters.length == 2 && every(filters, f => RANGE_LIKE_OPERATORS.includes(f.op))) {
475
+ const [a, b] = sortBy(filters, 'op');
476
+ return a.op.startsWith('<') && b.op.startsWith('>') && a.value <= b.value;
477
+ }
478
+
479
+ return false;
480
+ }
481
+
414
482
  private initPersist({
415
483
  persistValue = true,
416
484
  persistFavorites = true,
@@ -69,6 +69,10 @@ export class FieldFilter extends Filter {
69
69
  'excludes'
70
70
  ];
71
71
 
72
+ static INCLUDE_LIKE_OPERATORS = ['=', 'like', 'begins', 'ends', 'includes'];
73
+ static EXCLUDE_LIKE_OPERATORS = ['!=', 'not like', 'excludes'];
74
+ static RANGE_LIKE_OPERATORS = ['>', '>=', '<', '<='];
75
+
72
76
  /**
73
77
  * Constructor - not typically called by apps - create via {@link parseFilter} instead.
74
78
  * @internal
@@ -23,7 +23,7 @@ export interface ExpandToLevelButtonProps extends Omit<ButtonProps, 'title'> {
23
23
  popoverPosition?: Position;
24
24
 
25
25
  /** Title for the menu popover - defaults to "Expand To Level". */
26
- title: ReactNode;
26
+ title?: ReactNode;
27
27
  }
28
28
 
29
29
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "75.0.0-SNAPSHOT.1753294066799",
3
+ "version": "75.0.0-SNAPSHOT.1753376908830",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",