@xh/hoist 86.1.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 (104) hide show
  1. package/.gitattributes +4 -0
  2. package/CHANGELOG.md +104 -0
  3. package/admin/AdminJsonDisplay.ts +31 -0
  4. package/admin/App.scss +3 -11
  5. package/admin/AppModel.ts +3 -4
  6. package/admin/jsonsearch/impl/JsonSearchImplModel.ts +4 -4
  7. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
  8. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
  9. package/admin/tabs/clients/ClientsModel.ts +4 -1
  10. package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
  11. package/admin/tabs/cluster/instances/connpool/ConnPoolMonitorPanel.ts +3 -11
  12. package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -13
  13. package/admin/tabs/cluster/objects/DetailPanel.ts +3 -12
  14. package/admin/tabs/userData/roles/RolePanel.ts +2 -2
  15. package/appcontainer/RouterModel.ts +6 -6
  16. package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
  17. package/build/types/admin/AppModel.d.ts +3 -4
  18. package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
  19. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
  20. package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
  21. package/build/types/appcontainer/RouterModel.d.ts +5 -5
  22. package/build/types/cmp/grid/Grid.d.ts +8 -4
  23. package/build/types/cmp/grid/GridModel.d.ts +13 -2
  24. package/build/types/cmp/grid/Types.d.ts +8 -1
  25. package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
  26. package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
  27. package/build/types/core/HoistAppModel.d.ts +2 -3
  28. package/build/types/core/types/Types.d.ts +12 -0
  29. package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
  30. package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
  31. package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
  32. package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
  33. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
  34. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
  35. package/build/types/desktop/cmp/input/CodeInput.d.ts +7 -0
  36. package/build/types/desktop/cmp/input/NumberInput.d.ts +1 -1
  37. package/build/types/desktop/cmp/input/Select.d.ts +8 -0
  38. package/build/types/desktop/cmp/input/impl/CalcWindowedMenuWidth.d.ts +20 -0
  39. package/build/types/desktop/cmp/rest/impl/RestFormModel.d.ts +1 -1
  40. package/build/types/format/FormatNumber.d.ts +11 -3
  41. package/build/types/icon/Icon.d.ts +4 -3
  42. package/build/types/kit/react-dropzone/index.d.ts +1 -0
  43. package/build/types/kit/swiper/index.d.ts +4 -3
  44. package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
  45. package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
  46. package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
  47. package/build/types/mobile/cmp/input/Select.d.ts +8 -0
  48. package/cmp/grid/Grid.ts +27 -2
  49. package/cmp/grid/GridModel.ts +21 -8
  50. package/cmp/grid/Types.ts +9 -1
  51. package/cmp/grid/filter/GridFilterFieldSpec.ts +28 -5
  52. package/cmp/grid/impl/MenuSupport.ts +3 -5
  53. package/cmp/input/SegmentedControlOption.ts +14 -0
  54. package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
  55. package/core/HoistAppModel.ts +2 -3
  56. package/core/types/Types.ts +14 -5
  57. package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
  58. package/desktop/cmp/filechooser/FileChooser.ts +6 -3
  59. package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
  60. package/desktop/cmp/filter/FilterChooser.scss +41 -0
  61. package/desktop/cmp/filter/FilterChooser.ts +147 -7
  62. package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
  63. package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
  64. package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
  65. package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
  66. package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
  67. package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
  68. package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
  69. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
  70. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
  71. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
  72. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
  73. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
  74. package/desktop/cmp/grid/impl/filter/headerfilter/values/ValuesTabModel.ts +46 -7
  75. package/desktop/cmp/input/CheckboxButton.ts +21 -1
  76. package/desktop/cmp/input/CodeInput.ts +20 -3
  77. package/desktop/cmp/input/NumberInput.ts +1 -1
  78. package/desktop/cmp/input/SegmentedControl.ts +7 -3
  79. package/desktop/cmp/input/Select.ts +37 -18
  80. package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
  81. package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
  82. package/desktop/cmp/tab/Tabs.scss +72 -25
  83. package/docs/routing.md +4 -2
  84. package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
  85. package/format/FormatNumber.ts +69 -32
  86. package/icon/Icon.scss +13 -0
  87. package/icon/Icon.ts +4 -3
  88. package/icon/impl/IconHtml.ts +1 -1
  89. package/kit/react-dropzone/index.ts +1 -0
  90. package/mcp/README.md +12 -0
  91. package/mcp/data/ts-registry.spec.ts +156 -0
  92. package/mcp/data/ts-registry.ts +14 -7
  93. package/mcp/formatters/typescript.ts +7 -3
  94. package/mcp/util/paths.ts +21 -0
  95. package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
  96. package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
  97. package/mobile/cmp/input/CheckboxButton.ts +18 -1
  98. package/mobile/cmp/input/NumberInput.ts +1 -1
  99. package/mobile/cmp/input/SegmentedControl.ts +7 -3
  100. package/mobile/cmp/input/Select.ts +21 -15
  101. package/mobx/README.md +2 -1
  102. package/package.json +10 -10
  103. package/styles/vars.scss +15 -0
  104. 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
- }
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import {withDefault} from '@xh/hoist/utils/js';
8
8
  import {isNil} from 'lodash';
9
- import {useCallback, useEffect} from 'react';
10
- import {CustomCellEditorProps, useGridCellEditor} from '@xh/hoist/kit/ag-grid';
9
+ import {useEffect} from 'react';
10
+ import {CustomCellEditorProps} from '@xh/hoist/kit/ag-grid';
11
11
  import {hoistCmp} from '@xh/hoist/core';
12
12
  import {numberInput, NumberInputProps} from '@xh/hoist/desktop/cmp/input';
13
13
  import '@xh/hoist/desktop/register';
@@ -39,20 +39,19 @@ export const [NumberEditor, numberEditor] = hoistCmp.withFactory<NumberEditorPro
39
39
  }
40
40
  });
41
41
 
42
- const useNumberGuard = ({onValueChange, eventKey}: CustomCellEditorProps) => {
43
- // Needed (strangely) by agGrid to trigger call of isCancelBeforeStart
44
- useEffect(() => {
45
- if (eventKey?.length === 1) onValueChange(eventKey);
46
- }, [eventKey, onValueChange]);
47
-
48
- // Gets called before editor component is rendered, to give agGrid a chance to
49
- // cancel the editing before it even starts if char is not a number.
50
- const isCancelBeforeStart = useCallback(
51
- () => eventKey?.length === 1 && '1234567890'.indexOf(eventKey) < 0,
52
- [eventKey]
53
- );
42
+ // Characters that can validly begin a numeric entry - digits, sign, and decimal point.
43
+ const NUMBER_START_RE = /[0-9.+-]/;
54
44
 
55
- useGridCellEditor({
56
- isCancelBeforeStart
57
- });
45
+ const useNumberGuard = ({onValueChange, eventKey, stopEditing}: CustomCellEditorProps) => {
46
+ // When editing is started by typing a printable character, seed the editor with it if it can
47
+ // legally begin a number (digit, `-`, `+`, or `.`), otherwise stop editing to reject the
48
+ // keystroke - reverting to the original value, as the editor was never seeded.
49
+ useEffect(() => {
50
+ if (eventKey?.length !== 1) return;
51
+ if (NUMBER_START_RE.test(eventKey)) {
52
+ onValueChange(eventKey);
53
+ } else {
54
+ stopEditing(true);
55
+ }
56
+ }, [eventKey, onValueChange, stopEditing]);
58
57
  };
@@ -30,16 +30,21 @@ export const [SelectEditor, selectEditor] = hoistCmp.withFactory<SelectEditorPro
30
30
  onCommit: flushOnCommit
31
31
  ? () => wait().then(() => props.agParams.stopEditing())
32
32
  : null,
33
- rsOptions: {
34
- styles: {
35
- menu: styles => ({
36
- ...styles,
37
- whiteSpace: 'nowrap',
38
- width: 'auto',
39
- minWidth: '100%'
40
- })
41
- }
42
- },
33
+ // Auto-size the menu to content, not the narrow cell - but skip when width is already
34
+ // set, via windowed measurement (#4325) or an explicit `menuWidth` (#4057).
35
+ rsOptions:
36
+ props.inputProps?.enableWindowed || props.inputProps?.menuWidth != null
37
+ ? {}
38
+ : {
39
+ styles: {
40
+ menu: styles => ({
41
+ ...styles,
42
+ whiteSpace: 'nowrap',
43
+ width: 'auto',
44
+ minWidth: '100%'
45
+ })
46
+ }
47
+ },
43
48
  ...props.inputProps
44
49
  }
45
50
  };
@@ -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;
@@ -55,7 +55,7 @@ const filterForm = hoistCmp.factory(({impl}) => {
55
55
  fieldDefaults: {label: null, minimal: true},
56
56
  item: formField({
57
57
  field: 'filter',
58
- item: jsonInput()
58
+ item: jsonInput({autoFormat: true})
59
59
  })
60
60
  });
61
61
  });
@@ -147,7 +147,7 @@ class GridFilterDialogLocalModel extends HoistModel {
147
147
  loadForm() {
148
148
  const filter = this.model.filter?.removeFunctionFilters();
149
149
  this.formModel.init({
150
- filter: JSON.stringify(filter?.toJSON() ?? null, undefined, 2)
150
+ filter: JSON.stringify(filter ?? null)
151
151
  });
152
152
  }
153
153
 
@@ -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(),