@xh/hoist 86.2.0 → 86.4.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 (75) hide show
  1. package/.gitattributes +4 -0
  2. package/CHANGELOG.md +75 -0
  3. package/admin/AppModel.ts +38 -11
  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/RoleModel.ts +11 -18
  9. package/admin/tabs/userData/roles/RolePanel.ts +2 -9
  10. package/appcontainer/RouterModel.ts +6 -6
  11. package/build/types/admin/AppModel.d.ts +7 -4
  12. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
  13. package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
  14. package/build/types/admin/tabs/userData/roles/RoleModel.d.ts +2 -2
  15. package/build/types/appcontainer/RouterModel.d.ts +5 -5
  16. package/build/types/cmp/grid/Grid.d.ts +2 -1
  17. package/build/types/cmp/grid/GridModel.d.ts +2 -2
  18. package/build/types/cmp/grid/Types.d.ts +8 -1
  19. package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
  20. package/build/types/core/HoistAppModel.d.ts +2 -3
  21. package/build/types/core/types/Types.d.ts +12 -0
  22. package/build/types/data/Store.d.ts +3 -2
  23. package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
  24. package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
  25. package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
  26. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
  27. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
  28. package/build/types/desktop/cmp/input/Select.d.ts +8 -0
  29. package/build/types/kit/react-dropzone/index.d.ts +1 -0
  30. package/build/types/kit/swiper/index.d.ts +4 -3
  31. package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
  32. package/build/types/mobile/cmp/input/Select.d.ts +8 -0
  33. package/build/types/svc/PrefService.d.ts +12 -1
  34. package/build/types/utils/datetime/LocalDate.d.ts +23 -1
  35. package/cmp/grid/Grid.ts +10 -2
  36. package/cmp/grid/GridModel.ts +4 -3
  37. package/cmp/grid/Types.ts +9 -1
  38. package/cmp/grid/impl/MenuSupport.ts +1 -1
  39. package/cmp/input/SegmentedControlOption.ts +14 -0
  40. package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
  41. package/core/HoistAppModel.ts +2 -3
  42. package/core/types/Types.ts +14 -5
  43. package/data/Store.ts +3 -2
  44. package/desktop/cmp/filechooser/FileChooser.ts +6 -3
  45. package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
  46. package/desktop/cmp/filter/FilterChooser.scss +42 -0
  47. package/desktop/cmp/filter/FilterChooser.ts +147 -7
  48. package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
  49. package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
  50. package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
  51. package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
  52. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
  53. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
  54. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
  55. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
  56. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
  57. package/desktop/cmp/input/SegmentedControl.ts +7 -3
  58. package/desktop/cmp/input/Select.ts +24 -16
  59. package/docs/routing.md +4 -2
  60. package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
  61. package/kit/react-dropzone/index.ts +1 -0
  62. package/mcp/README.md +12 -0
  63. package/mcp/data/ts-registry.spec.ts +156 -0
  64. package/mcp/data/ts-registry.ts +13 -6
  65. package/mcp/formatters/typescript.ts +7 -3
  66. package/mcp/util/paths.ts +21 -0
  67. package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
  68. package/mobile/cmp/input/SegmentedControl.ts +7 -3
  69. package/mobile/cmp/input/Select.ts +21 -15
  70. package/mobx/README.md +2 -1
  71. package/package.json +5 -5
  72. package/svc/PrefService.ts +80 -20
  73. package/svc/README.md +6 -0
  74. package/utils/datetime/LocalDate.ts +24 -1
  75. package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
@@ -9,6 +9,7 @@ import {LocalDate} from '@xh/hoist/utils/datetime';
9
9
  import {MomentInput} from 'moment';
10
10
  import {Component, FunctionComponent, ReactElement} from 'react';
11
11
  import {DebounceSettings} from 'lodash';
12
+ import {Route} from 'router5';
12
13
 
13
14
  /** Values available for intents. */
14
15
  export type Intent = 'primary' | 'success' | 'warning' | 'danger';
@@ -55,14 +56,22 @@ export type DebounceSpec = number | (DebounceSettings & {interval: number});
55
56
  * function that returns a ReactElement. In either case, the function will be called with no arguments.
56
57
  */
57
58
  export type Content =
58
- | ReactElement
59
- | FunctionComponent
60
- | Component
61
- | ElementFactory
62
- | (() => ReactElement);
59
+ ReactElement | FunctionComponent | Component | ElementFactory | (() => ReactElement);
63
60
 
64
61
  export type DateLike = Date | LocalDate | MomentInput;
65
62
 
63
+ /**
64
+ * A Router5 {@link Route} spec, extended with Hoist's `omit` support for declarative exclusion of
65
+ * routes at registration time (e.g. role-gated sections). Note `omit` is evaluated once when routes
66
+ * are added to the router during app startup - it is not reactive.
67
+ *
68
+ * @see HoistAppModel.getRoutes
69
+ */
70
+ export interface HoistRoute extends Omit<Route, 'children'> {
71
+ omit?: Thunkable<boolean>;
72
+ children?: HoistRoute[];
73
+ }
74
+
66
75
  /** Valid units for the {@link LocalDate} adjustment methods. */
67
76
  export type LocalDateUnit =
68
77
  | 'year'
package/data/Store.ts CHANGED
@@ -559,8 +559,9 @@ export class Store
559
559
  * Add new Records to this Store in a local, uncommitted state - i.e. with data that has yet to
560
560
  * be persisted back to, or sourced from, the server or other data source of record.
561
561
  *
562
- * Note that data objects passed to this method must include a unique ID - callers can generate
563
- * one with `XH.genId()` if no natural ID can be produced locally on the client.
562
+ * Note that data objects passed to this method must include a literal `id` property - this
563
+ * method does *not* run the Store's `idSpec` function. Callers can generate an id with
564
+ * `XH.genId()` if no natural ID can be produced locally on the client.
564
565
  *
565
566
  * For StoreRecord additions that originate from the server, call `updateData()` instead.
566
567
  *
@@ -12,7 +12,7 @@ import {dropzone} from '@xh/hoist/kit/react-dropzone';
12
12
  import {elementFromContent, getLayoutProps} from '@xh/hoist/utils/react';
13
13
  import {FileRejection} from 'react-dropzone';
14
14
  import {FileChooserModel} from './FileChooserModel';
15
- import {fromPairs, isEmpty} from 'lodash';
15
+ import {isEmpty} from 'lodash';
16
16
  import classNames from 'classnames';
17
17
  import {defaultEmptyDisplay} from './impl/DefaultEmptyDisplay';
18
18
  import {defaultFileDisplay} from './impl/DefaultFileDisplay';
@@ -88,8 +88,11 @@ export const [FileChooser, fileChooser] = hoistCmp.withFactory<FileChooserProps>
88
88
 
89
89
  return dropzone({
90
90
  ref: model.dropzoneRef,
91
- // react-dropzone wants a {type: [extensions]} map; extensions serve as their own keys.
92
- accept: accept ? fromPairs(accept.map(ext => [ext, [ext]])) : null,
91
+ // react-dropzone expects `accept` to be a {type: [extensions]} map. The FileChooserModel
92
+ // only accepts a list of extensions. Extensions alone are sufficient for validation and
93
+ // a MIME type is not required. Use `_/_` as a dummy MIME type to prevent console warnings
94
+ // from react-dropzone.
95
+ accept: accept ? {'_/_': accept} : null,
93
96
  // Disable interaction (drag/click/drop) at the limit; the target shows a clear message.
94
97
  disabled: disabled || atLimit,
95
98
  maxFiles,
@@ -7,7 +7,7 @@
7
7
  import {em, li, span, ul, vbox} from '@xh/hoist/cmp/layout';
8
8
  import {HoistModel, Some, ToastSpec, XH} from '@xh/hoist/core';
9
9
  import '@xh/hoist/desktop/register';
10
- import {FileRejection} from '@xh/hoist/kit/react-dropzone';
10
+ import {ErrorCode, FileRejection} from '@xh/hoist/kit/react-dropzone';
11
11
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
12
12
  import {pluralize, withDefault} from '@xh/hoist/utils/js';
13
13
  import {createObservableRef} from '@xh/hoist/utils/react';
@@ -187,10 +187,17 @@ export class FileChooserModel extends HoistModel {
187
187
  return true;
188
188
  }
189
189
 
190
- private defaultRejectMessage(rejections: FileRejection[]): ReactElement {
191
- // 1) Map rejected files to error messages
190
+ private defaultRejectMessage = (rejections: FileRejection[]): ReactElement => {
191
+ // 1) Map rejected files to error messages. Use custom message for invalid types to avoid displaying dummy MIME type.
192
192
  const errorsByFile = fromPairs(
193
- map(rejections, ({file, errors}) => [file.name, map(errors, 'message')])
193
+ map(rejections, ({file, errors}) => [
194
+ file.name,
195
+ map(errors, e =>
196
+ e.code === ErrorCode.FileInvalidType
197
+ ? `File type must be one of ${this.accept.join(', ')}`
198
+ : e.message
199
+ )
200
+ ])
194
201
  );
195
202
 
196
203
  // 2) List files with bulleted error messages
@@ -207,7 +214,7 @@ export class FileChooserModel extends HoistModel {
207
214
  });
208
215
 
209
216
  return vbox(rejectItems);
210
- }
217
+ };
211
218
 
212
219
  private getRejectToastSpec(params: Partial<ToastSpec> | boolean): Partial<ToastSpec> {
213
220
  if (params == false) return null;
@@ -15,6 +15,48 @@
15
15
  margin: 7px 4px;
16
16
  align-self: start;
17
17
  }
18
+
19
+ // Popover mode - collapsed trigger expands into an overlaid popover.
20
+ &--popover > .bp6-popover-target {
21
+ display: flex;
22
+ flex: 1;
23
+ }
24
+
25
+ &__trigger {
26
+ .xh-select__value-container--is-multi {
27
+ overflow-y: hidden !important;
28
+ }
29
+ }
30
+
31
+ // Extra class names required to override the default styles of the popover.
32
+ &__popover.bp6-popover.bp6-minimal {
33
+ box-shadow: none;
34
+
35
+ // Overlay the expanded input onto the collapsed 30px trigger. Offset follows Blueprint's
36
+ // resolved placement, which flips (bottom-anchored) near the viewport's bottom edge.
37
+ &.bp6-popover-placement-bottom {
38
+ margin-top: -30px !important;
39
+ }
40
+ &.bp6-popover-placement-top {
41
+ margin-bottom: -30px !important;
42
+ }
43
+
44
+ // Content is portaled out of .xh-filter-chooser - restate the flex rule so it fills the width.
45
+ .bp6-popover-target {
46
+ display: flex;
47
+ flex: 1;
48
+ }
49
+
50
+ // Opaque bg to occlude the trigger and page beneath (--xh-input-bg is translucent in dark mode).
51
+ .bp6-popover-content {
52
+ background: var(--xh-bg);
53
+ }
54
+
55
+ .xh-select__value-container--is-multi {
56
+ height: unset;
57
+ line-height: unset;
58
+ }
59
+ }
18
60
  }
19
61
 
20
62
  .xh-filter-chooser-option {
@@ -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(),