@xh/hoist 86.2.0 → 86.3.0

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.
Files changed (66) hide show
  1. package/.gitattributes +4 -0
  2. package/CHANGELOG.md +58 -0
  3. package/admin/AppModel.ts +3 -4
  4. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
  5. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
  6. package/admin/tabs/clients/ClientsModel.ts +4 -1
  7. package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
  8. package/admin/tabs/userData/roles/RolePanel.ts +2 -2
  9. package/appcontainer/RouterModel.ts +6 -6
  10. package/build/types/admin/AppModel.d.ts +3 -4
  11. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
  12. package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
  13. package/build/types/appcontainer/RouterModel.d.ts +5 -5
  14. package/build/types/cmp/grid/Grid.d.ts +2 -1
  15. package/build/types/cmp/grid/GridModel.d.ts +2 -2
  16. package/build/types/cmp/grid/Types.d.ts +8 -1
  17. package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
  18. package/build/types/core/HoistAppModel.d.ts +2 -3
  19. package/build/types/core/types/Types.d.ts +12 -0
  20. package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
  21. package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
  22. package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
  23. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
  24. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
  25. package/build/types/desktop/cmp/input/Select.d.ts +8 -0
  26. package/build/types/kit/react-dropzone/index.d.ts +1 -0
  27. package/build/types/kit/swiper/index.d.ts +4 -3
  28. package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
  29. package/build/types/mobile/cmp/input/Select.d.ts +8 -0
  30. package/cmp/grid/Grid.ts +10 -2
  31. package/cmp/grid/GridModel.ts +4 -3
  32. package/cmp/grid/Types.ts +9 -1
  33. package/cmp/grid/impl/MenuSupport.ts +1 -1
  34. package/cmp/input/SegmentedControlOption.ts +14 -0
  35. package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
  36. package/core/HoistAppModel.ts +2 -3
  37. package/core/types/Types.ts +14 -5
  38. package/desktop/cmp/filechooser/FileChooser.ts +6 -3
  39. package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
  40. package/desktop/cmp/filter/FilterChooser.scss +41 -0
  41. package/desktop/cmp/filter/FilterChooser.ts +147 -7
  42. package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
  43. package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
  44. package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
  45. package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
  46. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
  47. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
  48. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
  49. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
  50. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
  51. package/desktop/cmp/input/SegmentedControl.ts +7 -3
  52. package/desktop/cmp/input/Select.ts +24 -16
  53. package/docs/routing.md +4 -2
  54. package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
  55. package/kit/react-dropzone/index.ts +1 -0
  56. package/mcp/README.md +12 -0
  57. package/mcp/data/ts-registry.spec.ts +156 -0
  58. package/mcp/data/ts-registry.ts +13 -6
  59. package/mcp/formatters/typescript.ts +7 -3
  60. package/mcp/util/paths.ts +21 -0
  61. package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
  62. package/mobile/cmp/input/SegmentedControl.ts +7 -3
  63. package/mobile/cmp/input/Select.ts +21 -15
  64. package/mobx/README.md +2 -1
  65. package/package.json +5 -5
  66. package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
@@ -6,14 +6,23 @@
6
6
  */
7
7
  import {FilterChooserFilter, FilterChooserModel} from '@xh/hoist/cmp/filter';
8
8
  import {box, div, hbox, hframe, vbox} from '@xh/hoist/cmp/layout';
9
- import {hoistCmp, HoistProps, LayoutProps, uses} from '@xh/hoist/core';
9
+ import {
10
+ hoistCmp,
11
+ HoistModel,
12
+ HoistProps,
13
+ LayoutProps,
14
+ lookup,
15
+ useLocalModel,
16
+ uses
17
+ } from '@xh/hoist/core';
18
+ import {bindable, makeObservable} from '@xh/hoist/mobx';
10
19
  import {button} from '@xh/hoist/desktop/cmp/button';
11
20
  import {select} from '@xh/hoist/desktop/cmp/input';
12
21
  import '@xh/hoist/desktop/register';
13
22
  import {Icon} from '@xh/hoist/icon';
14
23
  import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint';
15
- import {withDefault} from '@xh/hoist/utils/js';
16
- import {splitLayoutProps} from '@xh/hoist/utils/react';
24
+ import {elemWithin, withDefault} from '@xh/hoist/utils/js';
25
+ import {getLayoutProps, splitLayoutProps} from '@xh/hoist/utils/react';
17
26
  import classNames from 'classnames';
18
27
  import {isEmpty, sortBy} from 'lodash';
19
28
  import {badge} from '@xh/hoist/cmp/badge';
@@ -41,6 +50,11 @@ export interface FilterChooserProps extends HoistProps<FilterChooserModel>, Layo
41
50
  placeholder?: string;
42
51
  /** Icon clicked to launch favorites menu. (Defaults to Icon.favorite()) */
43
52
  favoritesIcon?: ReactElement;
53
+ /**
54
+ * True to render collapsed in-place, expanding into a popover when opened - useful in toolbars
55
+ * and other height-constrained containers. Opens in the direction set by `menuPlacement`.
56
+ */
57
+ popover?: boolean;
44
58
  }
45
59
 
46
60
  /**
@@ -50,6 +64,30 @@ export interface FilterChooserProps extends HoistProps<FilterChooserModel>, Layo
50
64
  export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooserProps>({
51
65
  model: uses(FilterChooserModel),
52
66
  className: 'xh-filter-chooser',
67
+ render({model, className, ...props}, ref) {
68
+ return props.popover
69
+ ? popoverFilterChooser({model, className, ...props, ref})
70
+ : filterChooserControl({model, className, ...props, ref});
71
+ }
72
+ });
73
+
74
+ //------------------
75
+ // Implementation
76
+ //------------------
77
+ interface FilterChooserControlProps extends FilterChooserProps {
78
+ /** Internal - false to render a non-interactive display (the collapsed popover trigger). */
79
+ xhInteractive?: boolean;
80
+ /** Internal - override for the favorites menu open state. */
81
+ xhFavoritesOpen?: boolean;
82
+ }
83
+
84
+ /**
85
+ * The Select-based control shared by the inline FilterChooser and both faces (collapsed trigger and
86
+ * expanded content) of the popover variant. Only the interactive instance binds `model.inputRef`,
87
+ * so the popover can mount trigger and content simultaneously without contention.
88
+ */
89
+ const filterChooserControl = hoistCmp.factory<FilterChooserControlProps>({
90
+ model: uses(FilterChooserModel),
53
91
  render({model, className, ...props}, ref) {
54
92
  const [layoutProps, chooserProps] = splitLayoutProps(props),
55
93
  {
@@ -68,7 +106,9 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
68
106
  maxMenuHeight,
69
107
  menuPlacement,
70
108
  menuWidth,
71
- favoritesIcon
109
+ favoritesIcon,
110
+ xhInteractive = true,
111
+ xhFavoritesOpen
72
112
  } = chooserProps,
73
113
  disabled = unsupportedFilter || chooserProps.disabled,
74
114
  placeholder = unsupportedFilter
@@ -90,7 +130,8 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
90
130
  flex: 1,
91
131
  height: layoutProps?.height,
92
132
  bind: 'selectValue',
93
- ref: inputRef,
133
+ // Only the interactive instance owns the shared inputRef.
134
+ ref: xhInteractive ? inputRef : undefined,
94
135
 
95
136
  autoFocus,
96
137
  disabled,
@@ -119,12 +160,15 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
119
160
  },
120
161
  components: {
121
162
  DropdownIndicator: () => favoritesIconCmp(model, favoritesIcon)
122
- }
163
+ },
164
+ // Display-only trigger: suppress menu + typing, but leave clear and
165
+ // favorites affordances live for single-click access.
166
+ ...(xhInteractive ? {} : {menuIsOpen: false, isSearchable: false})
123
167
  }
124
168
  })
125
169
  ),
126
170
  content: favoritesMenu(),
127
- isOpen: favoritesIsOpen,
171
+ isOpen: xhFavoritesOpen ?? favoritesIsOpen,
128
172
  position: 'bottom-right',
129
173
  minimal: true,
130
174
  onInteraction: willOpen => {
@@ -136,6 +180,102 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
136
180
  }
137
181
  });
138
182
 
183
+ /**
184
+ * Wraps a FilterChooser so it renders collapsed in-place and expands into a popover when opened,
185
+ * allowing it to grow vertically beyond the height of a toolbar. The collapsed trigger always
186
+ * occupies its space (so surrounding layout never shifts) and routes single clicks on its clear
187
+ * and favorites controls directly, rather than first opening the popover.
188
+ */
189
+ const popoverFilterChooser = hoistCmp.factory<FilterChooserProps>({
190
+ model: uses(FilterChooserModel),
191
+ render({model, className, ...props}, ref) {
192
+ const impl = useLocalModel(FilterChooserLocalModel),
193
+ {popoverIsOpen} = impl,
194
+ {popover: _popover, ...rest} = props,
195
+ layoutProps = getLayoutProps(rest);
196
+
197
+ return box({
198
+ ref,
199
+ className: classNames(className, 'xh-filter-chooser--popover'),
200
+ ...layoutProps,
201
+ item: popover({
202
+ isOpen: popoverIsOpen,
203
+ popoverClassName: 'xh-filter-chooser__popover',
204
+ matchTargetWidth: true,
205
+ minimal: true,
206
+ position: 'bottom',
207
+ item: filterChooserControl({
208
+ model,
209
+ flex: 1,
210
+ className: 'xh-filter-chooser__trigger',
211
+ ...rest,
212
+ displayCount: true,
213
+ xhInteractive: false,
214
+ // Trigger hosts the favorites menu only while collapsed; once open, the
215
+ // expanded content takes over.
216
+ xhFavoritesOpen: model.favoritesIsOpen && !popoverIsOpen
217
+ }),
218
+ content: filterChooserControl({
219
+ model,
220
+ flex: 1,
221
+ className: 'xh-filter-chooser__content',
222
+ ...rest,
223
+ displayCount: true
224
+ }),
225
+ onInteraction: (willOpen, e) => {
226
+ if (willOpen) {
227
+ // Let clicks on the inline clear / favorites controls act directly.
228
+ const target = e?.target as HTMLElement;
229
+ if (
230
+ target &&
231
+ (elemWithin(target, 'xh-select__clear-indicator') ||
232
+ elemWithin(target, 'xh-filter-chooser-favorite-icon'))
233
+ ) {
234
+ return;
235
+ }
236
+ impl.open();
237
+ } else {
238
+ impl.close();
239
+ }
240
+ }
241
+ })
242
+ });
243
+ }
244
+ });
245
+
246
+ class FilterChooserLocalModel extends HoistModel {
247
+ override xhImpl = true;
248
+
249
+ @lookup(FilterChooserModel)
250
+ model: FilterChooserModel;
251
+
252
+ @bindable
253
+ popoverIsOpen: boolean = false;
254
+
255
+ constructor() {
256
+ super();
257
+ makeObservable(this);
258
+ }
259
+
260
+ open() {
261
+ this.popoverIsOpen = true;
262
+
263
+ // Focus the (now-mounted) interactive input and open its menu once available.
264
+ this.addReaction({
265
+ when: () => !!this.model.inputRef.current,
266
+ run: () => {
267
+ const inputRef = this.model.inputRef.current;
268
+ inputRef.focus();
269
+ (inputRef as any).reactSelectRef.current?.openMenu('first');
270
+ }
271
+ });
272
+ }
273
+
274
+ close() {
275
+ this.popoverIsOpen = false;
276
+ }
277
+ }
278
+
139
279
  //-----------------
140
280
  // Options
141
281
  //------------------
@@ -5,100 +5,23 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {hoistCmp, HoistModel, lookup, useLocalModel, uses} from '@xh/hoist/core';
9
- import {bindable, makeObservable} from '@xh/hoist/mobx';
10
- import {box, hframe} from '@xh/hoist/cmp/layout';
8
+ import {hoistCmp, uses} from '@xh/hoist/core';
11
9
  import '@xh/hoist/desktop/register';
12
- import {popover} from '@xh/hoist/kit/blueprint';
13
- import {getLayoutProps} from '@xh/hoist/utils/react';
14
- import './PopoverFilterChooser.scss';
15
- import {filterChooser, FilterChooserProps} from './FilterChooser';
16
10
  import {FilterChooserModel} from '@xh/hoist/cmp/filter';
11
+ import {filterChooser, FilterChooserProps} from './FilterChooser';
17
12
 
18
13
  /**
19
14
  * A wrapper around a FilterChooser that renders in a popover when opened, allowing it to expand
20
15
  * vertically beyond the height of a toolbar.
16
+ *
17
+ * @deprecated Use `filterChooser({popover: true})` instead - the popover behavior is now a built-in
18
+ * mode of `FilterChooser`. This alias will be removed in a future major release.
21
19
  * @see FilterChooser
22
20
  */
23
21
  export const [PopoverFilterChooser, popoverFilterChooser] =
24
22
  hoistCmp.withFactory<FilterChooserProps>({
25
23
  model: uses(FilterChooserModel),
26
- className: 'xh-popover-filter-chooser',
27
- render({model, className, ...props}, ref) {
28
- const layoutProps = getLayoutProps(props),
29
- impl = useLocalModel(PopoverFilterChooserLocalModel);
30
-
31
- return box({
32
- ref,
33
- className,
34
- ...layoutProps,
35
- item: popover({
36
- isOpen: impl.popoverIsOpen,
37
- popoverClassName: 'xh-popover-filter-chooser__popover',
38
- item: hframe(
39
- filterChooser({
40
- model,
41
- // Omit when popover is open to force update the inputRef
42
- omit: impl.popoverIsOpen,
43
- className: 'xh-popover-filter-chooser__filter-chooser',
44
- displayCount: true,
45
- ...props,
46
- disabled: true
47
- })
48
- ),
49
- content: filterChooser({
50
- model,
51
- displayCount: true,
52
- ...props
53
- }),
54
- matchTargetWidth: true,
55
- minimal: true,
56
- position: 'bottom',
57
- onInteraction: open => {
58
- if (open) {
59
- impl.open();
60
- } else {
61
- impl.close();
62
- }
63
- }
64
- })
65
- });
24
+ render(props) {
25
+ return filterChooser({popover: true, ...props});
66
26
  }
67
27
  });
68
-
69
- class PopoverFilterChooserLocalModel extends HoistModel {
70
- override xhImpl = true;
71
-
72
- @lookup(FilterChooserModel)
73
- model: FilterChooserModel;
74
-
75
- @bindable
76
- popoverIsOpen: boolean = false;
77
-
78
- get displaySelectValue() {
79
- return this.model.selectValue[0];
80
- }
81
-
82
- constructor() {
83
- super();
84
- makeObservable(this);
85
- }
86
-
87
- open() {
88
- this.popoverIsOpen = true;
89
-
90
- // Focus and open the menu when rendered
91
- this.addReaction({
92
- when: () => !!this.model.inputRef.current,
93
- run: () => {
94
- const inputRef = this.model.inputRef.current;
95
- inputRef.focus();
96
- (inputRef as any).reactSelectRef.current?.openMenu('first');
97
- }
98
- });
99
- }
100
-
101
- close() {
102
- this.popoverIsOpen = false;
103
- }
104
- }
@@ -35,7 +35,12 @@ export function useInlineEditorModel(
35
35
 
36
36
  useGridCellEditor({
37
37
  // This is called in full-row editing when the user tabs into the cell
38
- focusIn: useCallback(() => impl.focus(), [impl])
38
+ focusIn: useCallback(() => impl.focus(), [impl]),
39
+ // Backstop to flush any pending edit when editing ends, in case no blur fired (#4134).
40
+ isCancelAfterEnd: useCallback(() => {
41
+ impl.ref.current?.doCommit();
42
+ return false;
43
+ }, [impl])
39
44
  });
40
45
 
41
46
  return component({
@@ -268,7 +268,6 @@ export class GridFindFieldImplModel extends HoistModel {
268
268
 
269
269
  private getActiveFields(): string[] {
270
270
  const {gridModel, includeFields, excludeFields} = this,
271
- groupBy = gridModel.groupBy,
272
271
  visibleCols = gridModel.getVisibleLeafColumns();
273
272
 
274
273
  let ret = ['id', ...gridModel.store.fieldNames];
@@ -293,12 +292,11 @@ export class GridFindFieldImplModel extends HoistModel {
293
292
  // Run exclude once more to support explicitly excluding a dot-sep field added above.
294
293
  if (excludeFields) ret = without(ret, ...excludeFields);
295
294
 
296
- // Final filter for column visibility, or explicit request for inclusion.
295
+ // Final filter for column visibility, or explicit request for inclusion. Deliberately not
296
+ // keyed to groupBy, so query results stay stable across regrouping (see #4070).
297
297
  ret = ret.filter(f => {
298
298
  return (
299
- (includeFields && includeFields.includes(f)) ||
300
- visibleCols.find(c => c.field === f) ||
301
- groupBy.includes(f)
299
+ (includeFields && includeFields.includes(f)) || visibleCols.find(c => c.field === f)
302
300
  );
303
301
  });
304
302
 
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {ColChooserConfig, GridModel} from '@xh/hoist/cmp/grid';
7
+ import {ColChooserConfig, GridModel, IColChooserModel} from '@xh/hoist/cmp/grid';
8
8
  import {HoistModel, managed} from '@xh/hoist/core';
9
9
  import type {FilterMatchMode} from '@xh/hoist/data';
10
10
  import {LeftRightChooserModel} from '@xh/hoist/desktop/cmp/leftrightchooser';
@@ -15,7 +15,7 @@ import {sortBy} from 'lodash';
15
15
  * State management for the ColChooser component.
16
16
  * @internal
17
17
  */
18
- export class ColChooserModel extends HoistModel {
18
+ export class ColChooserModel extends HoistModel implements IColChooserModel {
19
19
  override xhImpl = true;
20
20
 
21
21
  gridModel: GridModel;
@@ -12,7 +12,7 @@ import {dateInput, numberInput, select, textInput} from '@xh/hoist/desktop/cmp/i
12
12
  import {Icon} from '@xh/hoist/icon';
13
13
  import {kebabCase} from 'lodash';
14
14
 
15
- import {CustomRowModel} from './CustomRowModel';
15
+ import {CustomRowModel, usesMultiValueInput} from './CustomRowModel';
16
16
 
17
17
  /**
18
18
  * Row with operator and value combination for CustomTab.
@@ -74,17 +74,7 @@ const inputField = hoistCmp.factory<CustomRowModel>(({model}) => {
74
74
  ...fieldSpec.inputProps
75
75
  };
76
76
 
77
- if (fieldSpec.isNumericFieldType) {
78
- return numberInput({
79
- ...props,
80
- enableShorthandUnits: true
81
- });
82
- } else if (fieldSpec.isDateBasedFieldType) {
83
- return dateInput({
84
- ...props,
85
- valueType: fieldSpec.fieldType as 'localDate' | 'date'
86
- });
87
- } else if (fieldSpec.supportsSuggestions(op as FieldFilterOperator)) {
77
+ if (usesMultiValueInput(fieldSpec, op as FieldFilterOperator)) {
88
78
  return select({
89
79
  ...props,
90
80
  options: fieldSpec.values,
@@ -94,6 +84,16 @@ const inputField = hoistCmp.factory<CustomRowModel>(({model}) => {
94
84
  hideSelectedOptionCheck: true,
95
85
  enableClear: false
96
86
  });
87
+ } else if (fieldSpec.isNumericFieldType) {
88
+ return numberInput({
89
+ ...props,
90
+ enableShorthandUnits: true
91
+ });
92
+ } else if (fieldSpec.isDateBasedFieldType) {
93
+ return dateInput({
94
+ ...props,
95
+ valueType: fieldSpec.fieldType as 'localDate' | 'date'
96
+ });
97
97
  } else {
98
98
  return textInput(props);
99
99
  }
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
+ import type {GridFilterFieldSpec} from '@xh/hoist/cmp/grid';
7
8
  import {HoistModel} from '@xh/hoist/core';
8
9
  import {FieldFilterOperator, FieldFilterSpec} from '@xh/hoist/data';
9
10
  import {HeaderFilterModel} from '../HeaderFilterModel';
@@ -13,6 +14,23 @@ import {CustomTabModel} from './CustomTabModel';
13
14
 
14
15
  type OperatorOptionValue = 'blank' | 'not blank' | FieldFilterOperator;
15
16
 
17
+ /**
18
+ * Whether a row for the given op renders the multi-value `select` input - the only custom-tab input
19
+ * that holds an array of values directly. The single-value inputs (number, date, text) are used
20
+ * otherwise, so array filter values bound for them must be expanded into one row each (see
21
+ * `CustomTabModel.doSyncWithFilter`). Mirrors the input choice made in `CustomRow`.
22
+ */
23
+ export function usesMultiValueInput(
24
+ fieldSpec: GridFilterFieldSpec,
25
+ op: FieldFilterOperator
26
+ ): boolean {
27
+ return (
28
+ !fieldSpec.isNumericFieldType &&
29
+ !fieldSpec.isDateBasedFieldType &&
30
+ fieldSpec.supportsSuggestions(op)
31
+ );
32
+ }
33
+
16
34
  /**
17
35
  * @internal
18
36
  */
@@ -9,6 +9,11 @@
9
9
  }
10
10
  }
11
11
 
12
+ &__unsupported {
13
+ padding: var(--xh-pad-px);
14
+ color: var(--xh-text-color-muted);
15
+ }
16
+
12
17
  &__tbar {
13
18
  background-color: transparent;
14
19
  .xh-button {
@@ -23,13 +23,24 @@ import {CustomTabModel} from './CustomTabModel';
23
23
  export const customTab = hoistCmp.factory({
24
24
  model: uses(CustomTabModel),
25
25
  render({model}) {
26
+ const {rowModels} = model;
27
+ if (!rowModels) {
28
+ return panel({
29
+ className: 'xh-custom-filter-tab',
30
+ item: div({
31
+ className: 'xh-custom-filter-tab__unsupported',
32
+ item: 'This filter is too complex to edit here.'
33
+ })
34
+ });
35
+ }
36
+
26
37
  return panel({
27
38
  className: 'xh-custom-filter-tab',
28
39
  tbar: tbar(),
29
40
  items: div({
30
41
  className: 'xh-custom-filter-tab__list',
31
42
  items: [
32
- ...model.rowModels.map(m => customRow({model: m})),
43
+ ...rowModels.map(m => customRow({model: m})),
33
44
  div({
34
45
  className: 'xh-custom-filter-tab__list__add-btn-row',
35
46
  items: [
@@ -52,7 +63,7 @@ export const customTab = hoistCmp.factory({
52
63
  const tbar = hoistCmp.factory<CustomTabModel>(({model}) => {
53
64
  return toolbar({
54
65
  className: 'xh-custom-filter-tab__tbar',
55
- omit: model.rowModels.length < 2,
66
+ omit: !model.rowModels || model.rowModels.length < 2,
56
67
  compact: true,
57
68
  items: [
58
69
  filler(),
@@ -5,12 +5,18 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistModel, XH} from '@xh/hoist/core';
8
- import {CompoundFilterOperator, FilterLike} from '@xh/hoist/data';
8
+ import {
9
+ CompoundFilterOperator,
10
+ FieldFilter,
11
+ FieldFilterOperator,
12
+ FieldFilterSpec,
13
+ FilterLike
14
+ } from '@xh/hoist/data';
9
15
  import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
10
- import {compact, isEmpty} from 'lodash';
16
+ import {compact, first, flatMap, forEach, groupBy, isArray, isEmpty, uniq} from 'lodash';
11
17
  import {HeaderFilterModel} from '../HeaderFilterModel';
12
18
 
13
- import {CustomRowModel} from './CustomRowModel';
19
+ import {CustomRowModel, usesMultiValueInput} from './CustomRowModel';
14
20
 
15
21
  export class CustomTabModel extends HoistModel {
16
22
  override xhImpl = true;
@@ -23,11 +29,16 @@ export class CustomTabModel extends HoistModel {
23
29
  /** Filter config output by this model. */
24
30
  @computed.struct
25
31
  get filter(): FilterLike {
26
- const {op, rowModels} = this,
27
- filters = compact(rowModels.map(it => it.value));
28
- if (isEmpty(filters)) return null;
29
- if (filters.length > 1) return {filters, op};
30
- return filters;
32
+ const {op, rowModels} = this;
33
+
34
+ // Null rowModels flags an unrepresentable filter - emit it unchanged so commit is a no-op.
35
+ if (!rowModels) return this.columnCompoundFilter ?? this.columnFilters;
36
+
37
+ const specs = compact(rowModels.map(it => it.value));
38
+ if (isEmpty(specs)) return null;
39
+
40
+ const filters = this.collapseToArrayFilters(specs, op);
41
+ return filters.length > 1 ? {filters, op} : first(filters);
31
42
  }
32
43
 
33
44
  get fieldSpec() {
@@ -42,6 +53,10 @@ export class CustomTabModel extends HoistModel {
42
53
  return this.headerFilterModel.columnFilters;
43
54
  }
44
55
 
56
+ get columnCompoundFilter() {
57
+ return this.headerFilterModel.columnCompoundFilter;
58
+ }
59
+
45
60
  constructor(headerFilterModel: HeaderFilterModel) {
46
61
  super();
47
62
  makeObservable(this);
@@ -74,13 +89,27 @@ export class CustomTabModel extends HoistModel {
74
89
  //-------------------
75
90
  @action
76
91
  private doSyncWithFilter() {
77
- const {columnFilters} = this,
78
- rowModels = [];
92
+ const {columnCompoundFilter} = this,
93
+ op = this.deriveOp();
94
+ this.op = op;
95
+
96
+ if (!this.isRepresentable) {
97
+ this.logWarn('Filter cannot be edited in the custom tab; leaving it unchanged');
98
+ this.rowModels = null;
99
+ return;
100
+ }
79
101
 
80
- // Create rows based on filter.
81
- columnFilters.forEach(filter => {
82
- const {op, value} = filter;
83
- rowModels.push(new CustomRowModel(this, op, value));
102
+ // Expand a multi-value clause destined for a single-value input into one row per value
103
+ // (joined under the tab op); the multi-select input holds the array directly as one row.
104
+ const rowModels = [],
105
+ children = (columnCompoundFilter?.filters ?? this.columnFilters) as FieldFilter[];
106
+ children.forEach(filter => {
107
+ const {op: fieldOp, value} = filter;
108
+ if (this.needsExpansion(filter)) {
109
+ value.forEach(v => rowModels.push(new CustomRowModel(this, fieldOp, v)));
110
+ } else {
111
+ rowModels.push(new CustomRowModel(this, fieldOp, value));
112
+ }
84
113
  });
85
114
 
86
115
  // Add an empty pending row
@@ -90,4 +119,58 @@ export class CustomTabModel extends HoistModel {
90
119
 
91
120
  this.rowModels = rowModels;
92
121
  }
122
+
123
+ // Whether a multi-value clause must be expanded to one row per value (vs held by a multi-select).
124
+ private needsExpansion({op, value}: FieldFilter): boolean {
125
+ return isArray(value) && value.length > 1 && !usesMultiValueInput(this.fieldSpec, op);
126
+ }
127
+
128
+ // The op a multi-value clause joins under: `=`-family => OR, negated => AND.
129
+ private mergeOpFor(op: FieldFilterOperator): CompoundFilterOperator {
130
+ return FieldFilter.INCLUDE_LIKE_OPERATORS.includes(op) ? 'OR' : 'AND';
131
+ }
132
+
133
+ // Op joining the tab's rows
134
+ private deriveOp(): CompoundFilterOperator {
135
+ const {columnCompoundFilter, columnFilters} = this;
136
+ if (columnCompoundFilter) return columnCompoundFilter.op;
137
+
138
+ const arrayFilter = columnFilters.find(f => this.needsExpansion(f));
139
+ return arrayFilter ? this.mergeOpFor(arrayFilter.op) : 'AND';
140
+ }
141
+
142
+ // Filter must be representable as a flat set of rows with a single op
143
+ private get isRepresentable(): boolean {
144
+ const {columnCompoundFilter} = this,
145
+ op = this.deriveOp(),
146
+ children = columnCompoundFilter?.filters ?? this.columnFilters;
147
+ return children.every(
148
+ f =>
149
+ FieldFilter.isFieldFilter(f) &&
150
+ (!this.needsExpansion(f) || op === this.mergeOpFor(f.op))
151
+ );
152
+ }
153
+
154
+ // Inverse of the expand in `doSyncWithFilter`: collapse same-field/op rows into one multi-value FieldFilter
155
+ private collapseToArrayFilters(
156
+ specs: FieldFilterSpec[],
157
+ op: CompoundFilterOperator
158
+ ): FieldFilterSpec[] {
159
+ const ret: FieldFilterSpec[] = [];
160
+ forEach(groupBy(specs, 'op'), (groupSpecs, groupOp: FieldFilterOperator) => {
161
+ const canMerge =
162
+ groupSpecs.length > 1 &&
163
+ FieldFilter.ARRAY_OPERATORS.includes(groupOp) &&
164
+ op === this.mergeOpFor(groupOp);
165
+
166
+ if (canMerge) {
167
+ const {field} = groupSpecs[0],
168
+ value = uniq(flatMap(groupSpecs, it => it.value));
169
+ ret.push({field, op: groupOp, value});
170
+ } else {
171
+ ret.push(...groupSpecs);
172
+ }
173
+ });
174
+ return ret;
175
+ }
93
176
  }