@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
@@ -26,7 +26,7 @@ export interface NumberInputProps extends HoistProps, HoistInputProps, StyleProp
26
26
  modifier?: string;
27
27
  /** Text to display when control is empty. */
28
28
  placeholder?: string;
29
- /** Max decimal precision of the value, defaults to 4. */
29
+ /** Max decimal precision of the value, defaults to 4. Set to null for full, unrestricted precision. */
30
30
  precision?: NumericPrecision;
31
31
  /**
32
32
  * Scale factor to apply when converting between the internal and external value. Useful for
@@ -109,6 +109,14 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
109
109
  title?: string;
110
110
  /** Field on provided options for sourcing each option's value (default `value`). */
111
111
  valueField?: string;
112
+ /**
113
+ * Function to generate a `SelectOption` for a (non-null) selected value not present in the
114
+ * current options list. Return null to fall back to the default value-as-label behavior.
115
+ *
116
+ * Useful with queryFn-based selects, readonly forms, or any case where options may not be
117
+ * loaded when a value is set, ensuring the value renders with its proper label.
118
+ */
119
+ generateOptionFn?: (value: any) => SelectOption;
112
120
  }
113
121
  /**
114
122
  * A managed wrapper around the React-Select combobox/dropdown component.
package/cmp/grid/Grid.ts CHANGED
@@ -48,6 +48,8 @@ import {consumeEvent, isDisplayed, logWithDebug} from '@xh/hoist/utils/js';
48
48
  import {composeRefs, createObservableRef, getLayoutProps} from '@xh/hoist/utils/react';
49
49
  import classNames from 'classnames';
50
50
  import {compact, debounce, isBoolean, isEmpty, isEqual, isNil, max, maxBy, merge} from 'lodash';
51
+ import {type MouseEvent} from 'react';
52
+ import {PartialDeep} from 'type-fest';
51
53
  import './Grid.scss';
52
54
  import {GridModel} from './GridModel';
53
55
  import {columnGroupHeader} from './impl/ColumnGroupHeader';
@@ -65,7 +67,7 @@ export interface GridProps<M extends GridModel = GridModel>
65
67
  *
66
68
  * Note that changes to these options after the component's initial render will be ignored.
67
69
  */
68
- agOptions?: GridOptions;
70
+ agOptions?: PartialDeep<GridOptions>;
69
71
 
70
72
  /**
71
73
  * Callback when the grid has initialized. The component will call this with the ag-Grid
@@ -135,6 +137,7 @@ export const [Grid, grid] = hoistCmp.withFactory<GridProps>({
135
137
  ],
136
138
  testId,
137
139
  onKeyDown: impl.onKeyDown,
140
+ onMouseDown: impl.onViewMouseDown,
138
141
  ref: composeRefs(impl.viewRef, model.viewRef, ref)
139
142
  }),
140
143
  colChooserModel ? platformColChooser({model: colChooserModel}) : null,
@@ -151,6 +154,10 @@ export const [Grid, grid] = hoistCmp.withFactory<GridProps>({
151
154
  export class GridLocalModel extends HoistModel {
152
155
  override xhImpl = true;
153
156
 
157
+ // Structural "empty" grid space.
158
+ private static EMPTY_SPACE_SELECTOR =
159
+ '.ag-body-viewport, .ag-center-cols-viewport, .ag-center-cols-container, .ag-row';
160
+
154
161
  @lookup(GridModel)
155
162
  private model: GridModel;
156
163
  agOptions: GridOptions;
@@ -386,7 +393,14 @@ export class GridLocalModel extends HoistModel {
386
393
  return {
387
394
  track: () => [model.agApi, model.groupBy],
388
395
  run: ([agApi, groupBy]) => {
389
- if (agApi) agApi.setRowGroupColumns(groupBy);
396
+ if (!agApi) return;
397
+ agApi.setRowGroupColumns(groupBy);
398
+
399
+ // Re-assert configured visibility - AG Grid re-shows a column when ungrouped (#4473).
400
+ const state = model.columnState
401
+ .filter(({colId}) => !groupBy.includes(colId))
402
+ .map(({colId, hidden}) => ({colId, hide: hidden}));
403
+ agApi.applyColumnState({state});
390
404
  }
391
405
  };
392
406
  }
@@ -844,6 +858,17 @@ export class GridLocalModel extends HoistModel {
844
858
  return this.rowKeyNavSupport?.navigateToNextCell(agParams);
845
859
  };
846
860
 
861
+ // `stopEditingWhenCellsLoseFocus` doesn't fire on clicks in empty grid space (focus stays in
862
+ // the grid), so commit the active edit on those clicks ourselves. Require exact match on empty
863
+ // space to avoid interfering with cell editors.
864
+ onViewMouseDown = (evt: MouseEvent) => {
865
+ const {model} = this,
866
+ target = evt.target as HTMLElement;
867
+ if (model.isEditing && target.matches(GridLocalModel.EMPTY_SPACE_SELECTOR)) {
868
+ model.agApi?.stopEditing();
869
+ }
870
+ };
871
+
847
872
  onCellMouseDown = evt => {
848
873
  const {model} = this;
849
874
  if (model.highlightRowOnClick) {
@@ -16,6 +16,7 @@ import {
16
16
  GridAutosizeMode,
17
17
  GridFilterModelConfig,
18
18
  GridGroupSortFn,
19
+ IColChooserModel,
19
20
  isColumnSpec,
20
21
  TreeStyle
21
22
  } from '@xh/hoist/cmp/grid';
@@ -305,6 +306,10 @@ export interface GridConfig {
305
306
  * expand/collapse options in the default context menu will be enhanced to allow users to
306
307
  * expand/collapse to a specific level. See {@link GroupingChooserModel.valueDisplayNames}
307
308
  * for a convenient getter that will satisfy this API when a GroupingChooser is in play.
309
+ *
310
+ * Labels are matched to levels top-down and need not cover the full depth of the grid - provide
311
+ * a partial array to label only the top levels (e.g. when deeper levels should not be
312
+ * expand-to targets). Deeper, unlabelled levels are omitted from the menu.
308
313
  */
309
314
  levelLabels?: Thunkable<string[]>;
310
315
 
@@ -494,7 +499,7 @@ export class GridModel extends HoistModel {
494
499
  store: Store;
495
500
  selModel: StoreSelectionModel;
496
501
  treeMode: boolean;
497
- colChooserModel: HoistModel;
502
+ colChooserModel: IColChooserModel;
498
503
  rowClassFn: RowClassFn;
499
504
  rowClassRules: Record<string, RowClassRuleFn>;
500
505
  contextMenu: GridContextMenuSpec;
@@ -1174,15 +1179,23 @@ export class GridModel extends HoistModel {
1174
1179
 
1175
1180
  /**
1176
1181
  * Get the resolved level labels for the current state of the grid.
1182
+ * An over-long array is truncated to the current `maxDepth`.
1177
1183
  */
1178
1184
  get resolvedLevelLabels(): string[] {
1179
1185
  const {maxDepth, levelLabels} = this,
1180
1186
  ret = executeIfFunction(levelLabels);
1181
- if (ret && ret.length < maxDepth + 1) {
1182
- this.logDebug('Value produced by `GridModel.levelLabels` has insufficient length.');
1183
- return null;
1184
- }
1185
- return ret ? take(ret, maxDepth + 1) : null;
1187
+ return !isEmpty(ret) ? take(ret, maxDepth + 1) : null;
1188
+ }
1189
+
1190
+ /**
1191
+ * True if the given `resolvedLevelLabels` index is the grid's current expand level - used to
1192
+ * mark the active item in the "Expand to..." menu. The deepest labelled level counts as current
1193
+ * whenever the grid is expanded to or beyond it.
1194
+ */
1195
+ isCurrentExpandLevel(idx: number): boolean {
1196
+ const {expandLevel, resolvedLevelLabels} = this,
1197
+ lastIdx = resolvedLevelLabels?.length - 1;
1198
+ return expandLevel === idx || (idx === lastIdx && expandLevel > lastIdx);
1186
1199
  }
1187
1200
 
1188
1201
  /**
@@ -1247,7 +1260,7 @@ export class GridModel extends HoistModel {
1247
1260
  }
1248
1261
 
1249
1262
  showColChooser() {
1250
- (this.colChooserModel as any)?.open();
1263
+ this.colChooserModel?.open();
1251
1264
  }
1252
1265
 
1253
1266
  noteAgColumnStateChanged(agColState: AgColumnState[]) {
@@ -1974,7 +1987,7 @@ export class GridModel extends HoistModel {
1974
1987
  };
1975
1988
  }
1976
1989
 
1977
- private parseChooserModel(chooserModel: GridConfig['colChooserModel']): HoistModel {
1990
+ private parseChooserModel(chooserModel: GridConfig['colChooserModel']): IColChooserModel {
1978
1991
  if (!chooserModel) return null;
1979
1992
 
1980
1993
  const modelClass = XH.isMobileApp ? MobileColChooserModel : DesktopColChooserModel;
package/cmp/grid/Types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import type {HSide, PersistOptions, Some} from '@xh/hoist/core';
8
+ import type {HoistModel, HSide, PersistOptions, Some} from '@xh/hoist/core';
9
9
  import type {
10
10
  FilterBindTarget,
11
11
  FilterMatchMode,
@@ -142,6 +142,14 @@ export interface GridFilterBindTarget extends FilterBindTarget, FilterValueSourc
142
142
  */
143
143
  export type GroupRowRenderer = (context: ICellRendererParams) => ReactNode;
144
144
 
145
+ /** Cross-platform interface for desktop and mobile ColChooserModels. */
146
+ export interface IColChooserModel extends HoistModel {
147
+ readonly gridModel: GridModel;
148
+ readonly isOpen: boolean;
149
+ open(): void;
150
+ close(): void;
151
+ }
152
+
145
153
  /**
146
154
  * Configuration for a {@link ColChooserModel} - the model backing the grid column chooser UI.
147
155
  * Passed via the `colChooserModel` config on {@link GridConfig}, or set app-wide via
@@ -4,7 +4,6 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {ColumnRenderer} from '@xh/hoist/cmp/grid';
8
7
  import {HoistInputProps} from '@xh/hoist/cmp/input';
9
8
  import {PlainObject} from '@xh/hoist/core';
10
9
  import {
@@ -19,17 +18,38 @@ import {
19
18
  BaseFilterFieldSpecConfig
20
19
  } from '@xh/hoist/data/filter/BaseFilterFieldSpec';
21
20
  import {castArray, compact, flatMap, isDate, isEmpty, uniqBy} from 'lodash';
21
+ import {ReactNode} from 'react';
22
22
  import {GridFilterModel} from './GridFilterModel';
23
23
 
24
+ /**
25
+ * Produces the display content for an entry in the Values tab of a column filter. Must be a pure
26
+ * transform of the value - the values list carries no source record, and there is no column or
27
+ * grid context to reach for (unlike a Column's `renderer`, which runs against the source grid).
28
+ */
29
+ export type GridFilterRenderer = (value: any) => ReactNode;
30
+
31
+ /**
32
+ * Produces the value to sort by for an entry in the Values tab of a column filter. Must be a pure
33
+ * transform of the value - the values list carries no source record, and there is no column or
34
+ * grid context to reach for (unlike a Column's `sortValue`, which runs against the source grid).
35
+ */
36
+ export type GridFilterSortValueFn = (value: any) => any;
37
+
24
38
  export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig {
25
39
  /** GridFilterModel instance owning this fieldSpec. */
26
40
  filterModel?: GridFilterModel;
27
41
 
28
42
  /**
29
- * Function returning a formatted string for each value in this values filter display.
30
- * If not provided, the Column's renderer will be used.
43
+ * Pure function producing the display content for each entry in the values filter display. If
44
+ * not provided, the Column's renderer is used where it can be applied to a bare value.
45
+ */
46
+ renderer?: GridFilterRenderer;
47
+
48
+ /**
49
+ * Pure function producing the value to sort each entry of the values filter display by. If not
50
+ * provided, the Column's sortValue is used where it can be applied to a bare value.
31
51
  */
32
- renderer?: ColumnRenderer;
52
+ sortValue?: GridFilterSortValueFn;
33
53
 
34
54
  /**
35
55
  * Props to pass through to the HoistInput components used on the custom filter tab.
@@ -47,7 +67,8 @@ export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig {
47
67
  */
48
68
  export class GridFilterFieldSpec extends BaseFilterFieldSpec {
49
69
  filterModel: GridFilterModel;
50
- renderer: ColumnRenderer;
70
+ renderer: GridFilterRenderer;
71
+ sortValue: GridFilterSortValueFn;
51
72
  inputProps: PlainObject;
52
73
  defaultOp: FieldFilterOperator;
53
74
 
@@ -57,6 +78,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
57
78
  constructor({
58
79
  filterModel,
59
80
  renderer,
81
+ sortValue,
60
82
  inputProps,
61
83
  defaultOp,
62
84
  ...rest
@@ -65,6 +87,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
65
87
 
66
88
  this.filterModel = filterModel;
67
89
  this.renderer = renderer;
90
+ this.sortValue = sortValue;
68
91
  this.inputProps = inputProps;
69
92
  this.defaultOp = this.ops.includes(defaultOp) ? defaultOp : this.ops[0];
70
93
  }
@@ -138,7 +138,7 @@ function replaceHoistToken(token: string, gridModel: GridModel): Some<RecordActi
138
138
  text: 'Columns...',
139
139
  icon: Icon.gridPanel(),
140
140
  hidden: !gridModel?.colChooserModel,
141
- actionFn: () => (gridModel.colChooserModel as any)?.open()
141
+ actionFn: () => gridModel.colChooserModel?.open()
142
142
  });
143
143
  case 'expandCollapseAll': // For backward compatibility
144
144
  case 'expandCollapse':
@@ -280,15 +280,13 @@ function levelExpandAction(gridModel: GridModel): RecordAction {
280
280
  return new RecordAction({
281
281
  text: 'Expand to...',
282
282
  displayFn: () => {
283
- const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
283
+ const {maxDepth, resolvedLevelLabels} = gridModel;
284
284
 
285
285
  // Don't show for flat grid models or if we don't have labels
286
286
  if (!maxDepth || !resolvedLevelLabels) return {hidden: true};
287
287
 
288
288
  const items = resolvedLevelLabels.map((label, idx) => {
289
- const isCurrLevel =
290
- expandLevel === idx ||
291
- (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
289
+ const isCurrLevel = gridModel.isCurrentExpandLevel(idx);
292
290
 
293
291
  return {
294
292
  icon: isCurrLevel ? Icon.check() : null,
@@ -33,6 +33,13 @@ export interface SegmentedControlOption {
33
33
  * `intent` default. Defaults to the control's `intent`.
34
34
  */
35
35
  intent?: Intent;
36
+
37
+ /**
38
+ * Optional stable identifier emitted on this option's rendered button as `data-testid`, for
39
+ * use by E2E tests. If omitted and the control itself has a `testId`, one is auto-derived as
40
+ * `${controlTestId}-${value}`. If neither is set, no attribute is emitted.
41
+ */
42
+ testId?: string;
36
43
  }
37
44
 
38
45
  /**
@@ -58,4 +65,11 @@ export interface SegmentedControlNullOption {
58
65
  * `intent` default. Defaults to the control's `intent`.
59
66
  */
60
67
  intent?: Intent;
68
+
69
+ /**
70
+ * Optional stable identifier emitted on this option's rendered button as `data-testid`, for
71
+ * use by E2E tests. If omitted and the control itself has a `testId`, one is auto-derived as
72
+ * `${controlTestId}-null`. If neither is set, no attribute is emitted.
73
+ */
74
+ testId?: string;
61
75
  }
@@ -66,7 +66,7 @@ export class StoreFilterFieldImplModel extends HoistModel {
66
66
 
67
67
  this.addReaction(
68
68
  {
69
- track: () => [this.filterText, gridModel?.columns, gridModel?.groupBy],
69
+ track: () => [this.filterText, gridModel?.columns],
70
70
  run: () => this.regenerateFilter(),
71
71
  fireImmediately: true
72
72
  },
@@ -161,8 +161,7 @@ export class StoreFilterFieldImplModel extends HoistModel {
161
161
  if (excludeFields) ret = without(ret, ...excludeFields);
162
162
 
163
163
  if (gridModel) {
164
- const groupBy = gridModel.groupBy,
165
- visibleCols = gridModel.getVisibleLeafColumns();
164
+ const visibleCols = gridModel.getVisibleLeafColumns();
166
165
 
167
166
  // Push on dot-delimited grid column fields. These are supported by Grid and traverse
168
167
  // sub-objects in StoreRecord.data to display nested properties. Given that Grid treats these
@@ -182,12 +181,12 @@ export class StoreFilterFieldImplModel extends HoistModel {
182
181
  // Run exclude once more to support explicitly excluding a dot-sep field added above.
183
182
  if (excludeFields) ret = without(ret, ...excludeFields);
184
183
 
185
- // Final filter for column visibility, or explicit request for inclusion.
184
+ // Final filter for column visibility, or explicit request for inclusion. Deliberately
185
+ // not keyed to groupBy, so filter results stay stable across regrouping (see #4070).
186
186
  ret = ret.filter(f => {
187
187
  return (
188
188
  (includeFields && includeFields.includes(f)) ||
189
- visibleCols.find(c => c.field === f) ||
190
- groupBy.includes(f)
189
+ visibleCols.find(c => c.field === f)
191
190
  );
192
191
  });
193
192
  }
@@ -5,8 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {webSocketIndicator} from '@xh/hoist/cmp/websocket';
8
- import {AppOptionSpec, HoistModel, InitContext, Thunkable} from './';
9
- import {Route} from 'router5';
8
+ import {AppOptionSpec, HoistModel, HoistRoute, InitContext, Thunkable} from './';
10
9
  import {ReactNode} from 'react';
11
10
  /**
12
11
  * Specialized base class for defining the central model for a Hoist app as specified by its
@@ -52,7 +51,7 @@ export class HoistAppModel extends HoistModel {
52
51
  /**
53
52
  * Provide the initial set of Router5 Routes to be used by this application.
54
53
  */
55
- getRoutes(): Route[] {
54
+ getRoutes(): HoistRoute[] {
56
55
  return [];
57
56
  }
58
57
 
@@ -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'
@@ -28,7 +28,8 @@ export interface ExpandToLevelButtonProps extends Omit<ButtonProps, 'title'> {
28
28
 
29
29
  /**
30
30
  * A menu button to expand a multi-level grouped or tree grid out to a desired level.
31
- * Requires {@link GridConfig.levelLabels} to be configured on the bound GridModel.
31
+ * Requires {@link GridConfig.levelLabels} to be configured on the bound GridModel - the menu offers
32
+ * one entry per labelled level, so a partial array will limit the available expand-to targets.
32
33
  */
33
34
  export const [ExpandToLevelButton, expandToLevelButton] =
34
35
  hoistCmp.withFactory<ExpandToLevelButtonProps>({
@@ -53,13 +54,11 @@ export const [ExpandToLevelButton, expandToLevelButton] =
53
54
  }
54
55
 
55
56
  // Render a disabled button if requested, if we have a flat grid, or no level labels
56
- const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
57
+ const {maxDepth, resolvedLevelLabels} = gridModel;
57
58
  if (disabled || !maxDepth || !resolvedLevelLabels) return disabledButton();
58
59
 
59
60
  const menuItems: MenuItemLike[] = resolvedLevelLabels.map((label, idx) => {
60
- const isCurrLevel =
61
- expandLevel === idx ||
62
- (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
61
+ const isCurrLevel = gridModel.isCurrentExpandLevel(idx);
63
62
 
64
63
  return {
65
64
  icon: isCurrLevel ? Icon.check() : Icon.placeholder(),
@@ -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,47 @@
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
+ .bp6-popover-content {
51
+ background: transparent;
52
+ }
53
+
54
+ .xh-select__value-container--is-multi {
55
+ height: unset;
56
+ line-height: unset;
57
+ }
58
+ }
18
59
  }
19
60
 
20
61
  .xh-filter-chooser-option {