@xh/hoist 75.0.0-SNAPSHOT.1753369138152 → 75.0.0-SNAPSHOT.1753454795132

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
  /**
@@ -437,6 +437,10 @@ export declare class GridModel extends HoistModel {
437
437
  collapseAll(): void;
438
438
  /** Expand all parent rows in grouped or tree grid to the specified level. */
439
439
  expandToLevel(level: number): void;
440
+ /**
441
+ * Get the resolved level labels for the current state of the grid.
442
+ */
443
+ get resolvedLevelLabels(): string[];
440
444
  /**
441
445
  * Sort this grid.
442
446
  * This method is a no-op if provided any sorters without a corresponding column.
@@ -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
@@ -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,
@@ -92,7 +92,8 @@ import {
92
92
  min,
93
93
  omit,
94
94
  pick,
95
- pull
95
+ pull,
96
+ take
96
97
  } from 'lodash';
97
98
  import {ReactNode} from 'react';
98
99
  import {GridAutosizeOptions} from './GridAutosizeOptions';
@@ -552,7 +553,7 @@ export class GridModel extends HoistModel {
552
553
 
553
554
  this.xhImpl = xhImpl;
554
555
 
555
- this._defaultState = {columns, sortBy, groupBy};
556
+ this._defaultState = {columns, sortBy, groupBy, expandLevel};
556
557
 
557
558
  this.treeMode = treeMode;
558
559
  this.treeStyle = treeStyle;
@@ -679,10 +680,11 @@ export class GridModel extends HoistModel {
679
680
  if (!confirmed) return false;
680
681
  }
681
682
 
682
- const {columns, sortBy, groupBy, filter} = this._defaultState;
683
+ const {columns, sortBy, groupBy, filter, expandLevel} = this._defaultState;
683
684
  this.setColumns(columns);
684
685
  this.setSortBy(sortBy);
685
686
  this.setGroupBy(groupBy);
687
+ this.expandToLevel(expandLevel);
686
688
 
687
689
  this.filterModel?.setFilter(filter);
688
690
 
@@ -1043,6 +1045,19 @@ export class GridModel extends HoistModel {
1043
1045
  this.expandLevel = level;
1044
1046
  }
1045
1047
 
1048
+ /**
1049
+ * Get the resolved level labels for the current state of the grid.
1050
+ */
1051
+ get resolvedLevelLabels(): string[] {
1052
+ const {maxDepth, levelLabels} = this,
1053
+ ret = executeIfFunction(levelLabels);
1054
+ if (ret && ret.length < maxDepth + 1) {
1055
+ this.logError('Value produced by `GridModel.levelLabels` has insufficient length.');
1056
+ return null;
1057
+ }
1058
+ return ret ? take(ret, maxDepth + 1) : null;
1059
+ }
1060
+
1046
1061
  /**
1047
1062
  * Sort this grid.
1048
1063
  * This method is a no-op if provided any sorters without a corresponding column.
@@ -9,7 +9,6 @@ import {Column, GridModel} from '@xh/hoist/cmp/grid';
9
9
  import {RecordAction, Store, StoreRecord} from '@xh/hoist/data';
10
10
  import {convertIconToHtml, Icon} from '@xh/hoist/icon';
11
11
  import {filterConsecutiveMenuSeparators} from '@xh/hoist/utils/impl';
12
- import {executeIfFunction} from '@xh/hoist/utils/js';
13
12
  import copy from 'clipboard-copy';
14
13
  import {isEmpty, isFunction, isNil, isString, uniq} from 'lodash';
15
14
  import {isValidElement} from 'react';
@@ -282,26 +281,15 @@ function levelExpandAction(gridModel: GridModel): RecordAction {
282
281
  return new RecordAction({
283
282
  text: 'Expand to ...',
284
283
  displayFn: () => {
285
- // Don't show for degenerate shallow grid models, or if we don't have labels
286
- const {maxDepth, expandLevel} = gridModel;
287
- if (maxDepth <= 1) return {hidden: true};
284
+ const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
288
285
 
289
- // Validate level labels.
290
- const levelLabels = executeIfFunction(gridModel.levelLabels);
291
- if (!levelLabels) {
292
- return {hidden: true};
293
- }
294
- if (levelLabels.length < maxDepth + 1) {
295
- gridModel.logDebug(
296
- 'Value produced by `GridModel.levelLabels` has insufficient length. No menu items shown.'
297
- );
298
- return {hidden: true};
299
- }
286
+ // Don't show for flat grid models or if we don't have labels
287
+ if (!maxDepth || !resolvedLevelLabels) return {hidden: true};
300
288
 
301
- const items = levelLabels.map((label, idx) => {
289
+ const items = resolvedLevelLabels.map((label, idx) => {
302
290
  const isCurrLevel =
303
291
  expandLevel === idx ||
304
- (expandLevel > maxDepth && idx === levelLabels.length - 1);
292
+ (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
305
293
 
306
294
  return {
307
295
  icon: isCurrLevel ? Icon.check() : null,
@@ -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
@@ -11,7 +11,7 @@ import '@xh/hoist/desktop/register';
11
11
  import {Icon} from '@xh/hoist/icon';
12
12
  import {menu, popover, Position} from '@xh/hoist/kit/blueprint';
13
13
  import {parseMenuItems} from '@xh/hoist/utils/impl';
14
- import {executeIfFunction, logDebug, logError, withDefault} from '@xh/hoist/utils/js';
14
+ import {logError, withDefault} from '@xh/hoist/utils/js';
15
15
  import {ReactNode} from 'react';
16
16
  import {button, ButtonProps} from '../Button';
17
17
 
@@ -52,25 +52,14 @@ export const [ExpandToLevelButton, expandToLevelButton] =
52
52
  return disabledButton();
53
53
  }
54
54
 
55
- // Render a disabled button if requested or if we have a flat grid.
56
- const {maxDepth, expandLevel} = gridModel;
57
- if (disabled || !maxDepth) return disabledButton();
55
+ // Render a disabled button if requested, if we have a flat grid, or no level labels
56
+ const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
57
+ if (disabled || !maxDepth || !resolvedLevelLabels) return disabledButton();
58
58
 
59
- // Validate level labels - disable quietly if unspecified or w/log if mismatched to grid depth.
60
- const levelLabels = executeIfFunction(gridModel.levelLabels);
61
- if (!levelLabels) return disabledButton();
62
- if (levelLabels.length < maxDepth + 1) {
63
- logDebug(
64
- 'Value produced by `GridModel.levelLabels` has insufficient length - button will be disabled.',
65
- ExpandToLevelButton
66
- );
67
- return disabledButton();
68
- }
69
-
70
- const menuItems: MenuItemLike[] = levelLabels.map((label, idx) => {
59
+ const menuItems: MenuItemLike[] = resolvedLevelLabels.map((label, idx) => {
71
60
  const isCurrLevel =
72
61
  expandLevel === idx ||
73
- (expandLevel > maxDepth && idx === levelLabels.length - 1);
62
+ (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
74
63
 
75
64
  return {
76
65
  icon: isCurrLevel ? Icon.check() : Icon.placeholder(),
@@ -8,7 +8,7 @@ import {GridModel} from '@xh/hoist/cmp/grid';
8
8
  import {hoistCmp, MenuItemLike, useContextModel} from '@xh/hoist/core';
9
9
  import {Icon} from '@xh/hoist/icon';
10
10
  import '@xh/hoist/mobile/register';
11
- import {executeIfFunction, logDebug, logError, withDefault} from '@xh/hoist/utils/js';
11
+ import {logError, withDefault} from '@xh/hoist/utils/js';
12
12
  import {menuButton, MenuButtonProps} from '../../menu';
13
13
 
14
14
  export interface ExpandToLevelButtonProps extends MenuButtonProps {
@@ -29,7 +29,7 @@ export const [ExpandToLevelButton, expandToLevelButton] =
29
29
  className: 'xh-expand-to-level-button',
30
30
  model: false,
31
31
 
32
- render({gridModel, className, icon, ...rest}) {
32
+ render({gridModel, className, icon, disabled, ...rest}) {
33
33
  gridModel = withDefault(gridModel, useContextModel(GridModel));
34
34
  icon = withDefault(icon, Icon.treeList());
35
35
 
@@ -50,28 +50,14 @@ export const [ExpandToLevelButton, expandToLevelButton] =
50
50
  return disabledButton();
51
51
  }
52
52
 
53
- // Disable for flat grids. Still show for grids with a single grouping level, although
54
- // in this case we're effectively a less efficient way to expand/collapse all.
55
- const {maxDepth, expandLevel} = gridModel;
56
- if (!maxDepth) return disabledButton();
53
+ // Render a disabled button if requested or if we have a flat grid, or no level labels
54
+ const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
55
+ if (disabled || !maxDepth || !resolvedLevelLabels) return disabledButton();
57
56
 
58
- // Validate level labels - disable if unspecified or mismatched to grid depth.
59
- const levelLabels = executeIfFunction(gridModel.levelLabels);
60
- if (!levelLabels) {
61
- return disabledButton();
62
- }
63
- if (levelLabels.length < maxDepth + 1) {
64
- logDebug(
65
- 'Value produced by `GridModel.levelLabels` has insufficient length. No menu items shown.',
66
- ExpandToLevelButton
67
- );
68
- return disabledButton();
69
- }
70
-
71
- const menuItems: MenuItemLike[] = levelLabels.map((label, idx) => {
57
+ const menuItems: MenuItemLike[] = resolvedLevelLabels.map((label, idx) => {
72
58
  const isCurrLevel =
73
59
  expandLevel === idx ||
74
- (expandLevel > maxDepth && idx === levelLabels.length - 1);
60
+ (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
75
61
 
76
62
  return {
77
63
  icon: isCurrLevel ? Icon.check() : Icon.placeholder(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "75.0.0-SNAPSHOT.1753369138152",
3
+ "version": "75.0.0-SNAPSHOT.1753454795132",
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",