@xh/hoist 86.0.1 → 86.1.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 (59) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/admin/tabs/userData/roles/RoleModel.ts +1 -19
  3. package/appcontainer/RouterModel.ts +2 -0
  4. package/appcontainer/ThemeModel.ts +41 -0
  5. package/appcontainer/login/LoginPanelModel.ts +6 -2
  6. package/build/types/appcontainer/RouterModel.d.ts +1 -0
  7. package/build/types/appcontainer/ThemeModel.d.ts +9 -0
  8. package/build/types/cmp/input/SegmentedControlOption.d.ts +44 -0
  9. package/build/types/cmp/input/index.d.ts +1 -0
  10. package/build/types/core/AppSpec.d.ts +8 -2
  11. package/build/types/core/HoistAuthModel.d.ts +6 -3
  12. package/build/types/data/filter/BaseFilterFieldSpec.d.ts +7 -1
  13. package/build/types/data/filter/FieldFilter.d.ts +2 -0
  14. package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +1 -1
  15. package/build/types/desktop/cmp/input/SegmentedControl.d.ts +1 -41
  16. package/build/types/icon/Icon.d.ts +1 -0
  17. package/build/types/mobile/cmp/appOption/SizingModeAppOption.d.ts +3 -3
  18. package/build/types/mobile/cmp/appOption/ThemeAppOption.d.ts +3 -3
  19. package/build/types/mobile/cmp/input/SegmentedControl.d.ts +47 -0
  20. package/build/types/mobile/cmp/input/SwitchInput.d.ts +1 -1
  21. package/build/types/mobile/cmp/input/TextInput.d.ts +3 -0
  22. package/build/types/mobile/cmp/input/index.d.ts +1 -0
  23. package/build/types/svc/JsonBlobService.d.ts +7 -7
  24. package/cmp/error/ErrorMessage.scss +2 -0
  25. package/cmp/grid/filter/GridFilterFieldSpec.ts +5 -2
  26. package/cmp/grid/impl/GridHScrollbar.ts +10 -3
  27. package/cmp/input/SegmentedControlOption.ts +61 -0
  28. package/cmp/input/index.ts +1 -0
  29. package/core/AppSpec.ts +9 -1
  30. package/core/HoistAuthModel.ts +13 -6
  31. package/data/Field.ts +3 -1
  32. package/data/filter/BaseFilterFieldSpec.ts +16 -2
  33. package/data/filter/FieldFilter.ts +90 -21
  34. package/desktop/appcontainer/LoginPanel.ts +5 -3
  35. package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
  36. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
  37. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
  38. package/desktop/cmp/input/SegmentedControl.ts +8 -51
  39. package/desktop/cmp/tab/Tabs.scss +6 -2
  40. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
  41. package/docs/error-handling.md +9 -0
  42. package/icon/Icon.ts +3 -0
  43. package/icon/index.ts +8 -0
  44. package/mobile/appcontainer/LoginPanel.scss +53 -11
  45. package/mobile/appcontainer/LoginPanel.ts +69 -44
  46. package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
  47. package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
  48. package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
  49. package/mobile/cmp/input/SegmentedControl.scss +126 -0
  50. package/mobile/cmp/input/SegmentedControl.ts +210 -0
  51. package/mobile/cmp/input/SwitchInput.ts +1 -1
  52. package/mobile/cmp/input/TextInput.scss +7 -0
  53. package/mobile/cmp/input/TextInput.ts +11 -1
  54. package/mobile/cmp/input/index.ts +1 -0
  55. package/mobile/cmp/tab/impl/Tabs.scss +8 -0
  56. package/mobile/cmp/toolbar/Toolbar.scss +8 -0
  57. package/package.json +2 -2
  58. package/styles/vars.scss +6 -1
  59. package/svc/JsonBlobService.ts +21 -20
@@ -79,6 +79,10 @@ class GridHScrollbarModel extends HoistModel {
79
79
  return this.viewRef.current.querySelector('.ag-center-cols-viewport');
80
80
  }
81
81
 
82
+ private get agHeaderViewport(): HTMLDivElement {
83
+ return this.viewRef.current.querySelector('.ag-header-viewport');
84
+ }
85
+
82
86
  private get agVerticalScrollContainer(): HTMLDivElement {
83
87
  return this.viewRef.current.querySelector('.ag-body-vertical-scroll-container');
84
88
  }
@@ -102,6 +106,7 @@ class GridHScrollbarModel extends HoistModel {
102
106
 
103
107
  scrollViewport(left: number) {
104
108
  this.agViewport.scrollLeft = left;
109
+ this.agHeaderViewport.scrollLeft = left;
105
110
  }
106
111
 
107
112
  override afterLinked() {
@@ -110,9 +115,11 @@ class GridHScrollbarModel extends HoistModel {
110
115
  run: () => {
111
116
  const {agViewport, viewRef} = this;
112
117
  this.viewWidth = viewRef.current.clientWidth;
113
- agViewport.addEventListener('scroll', e =>
114
- this.scrollScroller((e.target as HTMLDivElement).scrollLeft)
115
- );
118
+ agViewport.addEventListener('scroll', e => {
119
+ const left = (e.target as HTMLDivElement).scrollLeft;
120
+ this.scrollScroller(left);
121
+ this.agHeaderViewport.scrollLeft = left;
122
+ });
116
123
  this.agViewportResizeObserver = observeResize(
117
124
  () => this.onAgViewportResized(),
118
125
  agViewport,
@@ -0,0 +1,61 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2026 Extremely Heavy Industries Inc.
6
+ */
7
+ import {Intent} from '@xh/hoist/core';
8
+ import {LocalDate} from '@xh/hoist/utils/datetime';
9
+ import {ReactElement} from 'react';
10
+
11
+ /** Primitive value types supported as a SegmentedControl option value/label. */
12
+ export type OptionPrimitive = string | number | boolean | LocalDate;
13
+
14
+ /**
15
+ * Option for a SegmentedControl, shared by the desktop and mobile implementations.
16
+ */
17
+ export interface SegmentedControlOption {
18
+ /** Value for this option. */
19
+ value: OptionPrimitive;
20
+
21
+ /** Display label. Defaults to `value.toString()` if omitted. */
22
+ label?: string;
23
+
24
+ /** Icon element, displayed before the label. */
25
+ icon?: ReactElement;
26
+
27
+ /** True to disable this individual option. */
28
+ disabled?: boolean;
29
+
30
+ /**
31
+ * Visual intent for this option - rendered as a solid fill when selected and as a subtle
32
+ * text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
33
+ * `intent` default. Defaults to the control's `intent`.
34
+ */
35
+ intent?: Intent;
36
+ }
37
+
38
+ /**
39
+ * Variant of SegmentedControlOption for representing a null/"no value" selection.
40
+ * Label is required to force use case to override default js 'null' toString rendering.
41
+ */
42
+ export interface SegmentedControlNullOption {
43
+ /** Null value for this option. */
44
+ value: null;
45
+
46
+ /** Display label - required for null options. */
47
+ label: string;
48
+
49
+ /** Icon element, displayed before the label. */
50
+ icon?: ReactElement;
51
+
52
+ /** True to disable this individual option. */
53
+ disabled?: boolean;
54
+
55
+ /**
56
+ * Visual intent for this option - rendered as a solid fill when selected and as a subtle
57
+ * text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
58
+ * `intent` default. Defaults to the control's `intent`.
59
+ */
60
+ intent?: Intent;
61
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './HoistInputModel';
2
2
  export * from './HoistInputProps';
3
+ export * from './SegmentedControlOption';
package/core/AppSpec.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import {ElementFactory, HoistAppModel, HoistAuthModel, HoistProps, XH} from '@xh/hoist/core';
8
8
  import {throwIf} from '@xh/hoist/utils/js';
9
9
  import {isFunction, isNil, isString} from 'lodash';
10
- import {Component, ComponentClass, FunctionComponent} from 'react';
10
+ import {Component, ComponentClass, FunctionComponent, ReactElement} from 'react';
11
11
 
12
12
  /**
13
13
  * Spec for a client-side Hoist application. A config matching this class's shape is provided
@@ -118,6 +118,12 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
118
118
  /** Optional message to show users when denied access to app. */
119
119
  lockoutMessage?: string;
120
120
 
121
+ /**
122
+ * Icon to display on the form-based login page, if active via `enableLoginForm: true`.
123
+ * Defaults to `Icon.shieldHalved()`.
124
+ */
125
+ loginPanelIcon?: ReactElement;
126
+
121
127
  /** Optional message to show on login form, if `showLoginForm: true`. */
122
128
  loginMessage?: string;
123
129
 
@@ -156,6 +162,7 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
156
162
  lockoutMessage = null,
157
163
  lockoutPanel = null,
158
164
  loginMessage = null,
165
+ loginPanelIcon = null,
159
166
  modelClass,
160
167
  showBrowserContextMenu = false,
161
168
  trackAppLoad = true
@@ -191,6 +198,7 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
191
198
  this.lockoutMessage = lockoutMessage;
192
199
  this.lockoutPanel = lockoutPanel;
193
200
  this.loginMessage = loginMessage;
201
+ this.loginPanelIcon = loginPanelIcon;
194
202
  this.modelClass = modelClass;
195
203
  this.showBrowserContextMenu = showBrowserContextMenu;
196
204
  this.trackAppLoad = trackAppLoad;
@@ -66,8 +66,12 @@ export class HoistAuthModel extends HoistModel {
66
66
  * Process a manual login, submitted by user via form.
67
67
  * @returns identity of the user authenticated with the server; null if not yet authenticated.
68
68
  */
69
- async loginWithCredentialsAsync(username: string, password: string): Promise<IdentityInfo> {
70
- return this.runner()
69
+ async loginWithCredentialsAsync(
70
+ username: string,
71
+ password: string,
72
+ ctx?: CallContextLike
73
+ ): Promise<IdentityInfo> {
74
+ return this.runner(ctx)
71
75
  .span('login')
72
76
  .run(async ctx => {
73
77
  const {success, identity} = await XH.fetchJson(
@@ -87,8 +91,8 @@ export class HoistAuthModel extends HoistModel {
87
91
  * The default implementation will call the 'logout' endpoint on the Grails server, clearing
88
92
  * any server-side session state there. Override to manage any client-side or third-party state.
89
93
  */
90
- async logoutAsync(): Promise<void> {
91
- await this.runner().span('logout').fetchJson({url: 'xh/logout'});
94
+ async logoutAsync(ctx?: CallContextLike): Promise<void> {
95
+ await this.runner(ctx).span('logout').fetchJson({url: 'xh/logout'});
92
96
  }
93
97
 
94
98
  /**
@@ -96,9 +100,12 @@ export class HoistAuthModel extends HoistModel {
96
100
  * whitelisted by Hoist to allow access prior to user authentication. For use in bootstrapping
97
101
  * client-side auth solutions that require configs such as OAuth endpoint URLs and client IDs.
98
102
  * See `BaseAuthenticationService.getClientConfig()` in hoist-core.
103
+ *
104
+ * @param ctx - tracing/load context. When called from within a `completeAuthAsync` override,
105
+ * forward the supplied context so this fetch nests under the bootstrap trace.
99
106
  */
100
- async loadConfigAsync(): Promise<PlainObject> {
101
- return this.runner().span('config').fetchJson({url: 'xh/authConfig'});
107
+ async loadConfigAsync(ctx?: CallContextLike): Promise<PlainObject> {
108
+ return this.runner(ctx).span('config').fetchJson({url: 'xh/authConfig'});
102
109
  }
103
110
 
104
111
  /**
package/data/Field.ts CHANGED
@@ -166,7 +166,9 @@ export function parseFieldValue(
166
166
  case 'date':
167
167
  return isLocalDate(val) ? val.date : isDate(val) ? val : new Date(val);
168
168
  case 'localDate':
169
- return isLocalDate(val) ? val : LocalDate.get(val);
169
+ if (isLocalDate(val)) return val;
170
+ // `get` parses strict 'YYYYMMDD'/'YYYY-MM-DD' strings; `from` coerces Date/number/moment.
171
+ return isString(val) ? LocalDate.get(val) : LocalDate.from(val);
170
172
  }
171
173
 
172
174
  throw XH.exception(`Unknown field type '${type}'`);
@@ -21,7 +21,12 @@ import {FieldFilterOperator} from './Types';
21
21
  export interface BaseFilterFieldSpecConfig {
22
22
  /** Identifying field name to filter on. */
23
23
  field: string;
24
- /** Type of field, will default from related field on source if provided, or 'auto'. */
24
+ /**
25
+ * Type of field, will default from related field on source if provided, or 'auto'. A `date`
26
+ * (timestamp) source defaults to 'localDate' so filtering compares by calendar day (range and
27
+ * equality operators use full-day bounds). Set explicitly to 'date' to filter by exact
28
+ * timestamp instead.
29
+ */
25
30
  fieldType?: FieldType;
26
31
  /** DisplayName, will default from related field on source if provided */
27
32
  displayName?: string;
@@ -77,7 +82,11 @@ export abstract class BaseFilterFieldSpec extends HoistBase {
77
82
  this.source = source;
78
83
 
79
84
  const sourceField = this.sourceField;
80
- this.fieldType = fieldType ?? sourceField?.type ?? 'auto';
85
+ // Default a `date` (timestamp) source to `localDate` so filtering compares by calendar day
86
+ // rather than against midnight (#3338). Apps wanting exact-timestamp filtering can set
87
+ // `fieldType: 'date'` explicitly.
88
+ this.fieldType =
89
+ fieldType ?? (sourceField?.type === 'date' ? 'localDate' : sourceField?.type) ?? 'auto';
81
90
  this.displayName = displayName ?? sourceField?.displayName ?? genDisplayName(field);
82
91
  this.ops = this.parseOperators(ops);
83
92
  this.forceSelection = forceSelection ?? false;
@@ -183,7 +192,12 @@ export abstract class BaseFilterFieldSpec extends HoistBase {
183
192
  : ['>', '>=', '<', '<=', '=', '!='];
184
193
  }
185
194
 
195
+ private get isLocalDateFilteringTimestamp(): boolean {
196
+ return this.fieldType === 'localDate' && this.sourceField?.type === 'date';
197
+ }
198
+
186
199
  private get isEnumerableByDefault(): boolean {
200
+ if (this.isLocalDateFilteringTimestamp) return false;
187
201
  switch (this.fieldType) {
188
202
  case 'int':
189
203
  case 'number':
@@ -14,6 +14,7 @@ import {
14
14
  escapeRegExp,
15
15
  first,
16
16
  isArray,
17
+ isEmpty,
17
18
  isEqual,
18
19
  isNil,
19
20
  isString,
@@ -112,9 +113,9 @@ export class FieldFilter extends Filter {
112
113
  // Overrides
113
114
  //-----------------
114
115
  override getTestFn(store?: Store): FilterTestFn {
115
- let {field, op, value} = this,
116
- regExps;
116
+ const {field, op, value} = this;
117
117
 
118
+ let storeFieldType: FieldType;
118
119
  if (store) {
119
120
  const storeField = store.getField(field);
120
121
  if (!storeField) {
@@ -127,29 +128,72 @@ export class FieldFilter extends Filter {
127
128
  }
128
129
  return () => true;
129
130
  }
131
+ storeFieldType = storeField.type;
132
+ }
133
+
134
+ const opFn = this.getOpFn(op, value, storeFieldType);
135
+
136
+ if (!store) return r => opFn(r[field]);
137
+
138
+ return (r: StoreRecord) => {
139
+ const val = r.get(field);
140
+ if (opFn(val)) return true;
141
+
142
+ // Maximize chances of matching. Always pass adds ...
143
+ if (r.isAdd) return true;
130
144
 
131
- const fieldType = storeField.type === 'tags' ? 'string' : storeField.type;
145
+ // ... and check any differing original value as well
146
+ const committedVal = r.committedData[field];
147
+ return committedVal !== val && opFn(committedVal);
148
+ };
149
+ }
150
+
151
+ private getOpFn(
152
+ op: FieldFilterOperator,
153
+ value: any,
154
+ storeFieldType?: FieldType
155
+ ): (v: any) => boolean {
156
+ const RANGE_OPS = FieldFilter.RANGE_LIKE_OPERATORS,
157
+ ARR_OPS = FieldFilter.ARRAY_OPERATORS;
158
+
159
+ // Day-aware filtering for LocalDate value(s) over a timestamp field - compares against
160
+ // full calendar-day bounds for range and equality operators (#3338).
161
+ const firstVal = isArray(value) ? value[0] : value;
162
+ if (
163
+ storeFieldType === 'date' &&
164
+ LocalDate.isLocalDate(firstVal) &&
165
+ (RANGE_OPS.includes(op) || op === '=' || op === '!=')
166
+ ) {
167
+ return this.getDayBoundedOpFn(op, value);
168
+ }
169
+
170
+ // Coerce candidate value(s) to the store field's type when filtering against a store.
171
+ if (storeFieldType) {
172
+ const fieldType = storeFieldType === 'tags' ? 'string' : storeFieldType;
132
173
  value = isArray(value)
133
174
  ? value.map(v => parseFieldValue(v, fieldType))
134
175
  : parseFieldValue(value, fieldType);
135
176
  }
136
-
137
- if (FieldFilter.ARRAY_OPERATORS.includes(op)) {
177
+ if (ARR_OPS.includes(op)) {
138
178
  value = castArray(value);
139
179
  }
140
180
 
141
- let opFn: (v: any) => boolean;
181
+ // Treat null, empty string, and empty array (blank `tags`) alike as "blank".
182
+ const isBlank = (v: any) => isNil(v) || v === '' || (isArray(v) && isEmpty(v));
183
+
184
+ let regExps, opFn: (v: any) => boolean;
142
185
  switch (op) {
143
186
  case '=':
144
187
  opFn = v => {
145
- if (isNil(v) || v === '') v = null;
146
- return value.some(it => isEqual(v, it));
188
+ if (isBlank(v)) v = null;
189
+ // A blank filter (empty `value`) matches only blank record values.
190
+ return (v == null && isEmpty(value)) || value.some(it => isEqual(v, it));
147
191
  };
148
192
  break;
149
193
  case '!=':
150
194
  opFn = v => {
151
- if (isNil(v) || v === '') v = null;
152
- return !value.some(it => isEqual(v, it));
195
+ if (isBlank(v)) v = null;
196
+ return (v != null || !isEmpty(value)) && !value.some(it => isEqual(v, it));
153
197
  };
154
198
  break;
155
199
  case '>':
@@ -197,20 +241,45 @@ export class FieldFilter extends Filter {
197
241
  default:
198
242
  throw XH.exception(`Unknown operator: ${op}`);
199
243
  }
244
+ return opFn;
245
+ }
200
246
 
201
- if (!store) return r => opFn(r[field]);
247
+ // Compare a timestamp against full calendar-day bounds `[dayStart, nextDayStart)` so a
248
+ // LocalDate value filters by date part. Supports range and equality operators (#3338).
249
+ private getDayBoundedOpFn(
250
+ op: FieldFilterOperator,
251
+ value: LocalDate | LocalDate[]
252
+ ): (v: any) => boolean {
253
+ const toMillis = (v: any) => (v instanceof Date ? v.getTime() : v);
202
254
 
203
- return (r: StoreRecord) => {
204
- const val = r.get(field);
205
- if (opFn(val)) return true;
206
-
207
- // Maximize chances of matching. Always pass adds ...
208
- if (r.isAdd) return true;
255
+ // Equality ops test the date part against any of the candidate day(s).
256
+ if (op === '=' || op === '!=') {
257
+ const ranges = castArray(value).map(d => [
258
+ d.date.getTime(),
259
+ d.nextDay().date.getTime()
260
+ ]),
261
+ inAnyDay = (t: number) => ranges.some(([start, next]) => t >= start && t < next);
262
+ return op === '='
263
+ ? v => !isNil(v) && inAnyDay(toMillis(v))
264
+ : v => isNil(v) || !inAnyDay(toMillis(v));
265
+ }
209
266
 
210
- // ... and check any differing original value as well
211
- const committedVal = r.committedData[field];
212
- return committedVal !== val && opFn(committedVal);
213
- };
267
+ // Range ops compare against the bounds of a single day.
268
+ const day = value as LocalDate,
269
+ dayStart = day.date.getTime(),
270
+ nextDayStart = day.nextDay().date.getTime();
271
+ switch (op) {
272
+ case '>':
273
+ return v => !isNil(v) && toMillis(v) >= nextDayStart;
274
+ case '>=':
275
+ return v => !isNil(v) && toMillis(v) >= dayStart;
276
+ case '<':
277
+ return v => !isNil(v) && toMillis(v) < dayStart;
278
+ case '<=':
279
+ return v => !isNil(v) && toMillis(v) < nextDayStart;
280
+ default:
281
+ throw XH.exception(`Unsupported calendar-day operator: ${op}`);
282
+ }
214
283
  }
215
284
 
216
285
  override equals(other: Filter): boolean {
@@ -24,7 +24,7 @@ export const loginPanel = hoistCmp.factory({
24
24
  model: creates(LoginPanelModel),
25
25
 
26
26
  render({model}) {
27
- const {loginMessage} = XH.appSpec,
27
+ const {loginMessage, loginPanelIcon} = XH.appSpec,
28
28
  {loadObserver, warning, isValid, loginInProgress} = model;
29
29
 
30
30
  const onKeyDown = ev => {
@@ -36,8 +36,8 @@ export const loginPanel = hoistCmp.factory({
36
36
  justifyContent: 'center',
37
37
  flexDirection: 'column',
38
38
  item: panel({
39
- title: XH.clientAppName,
40
- icon: Icon.login(),
39
+ title: `Login to ${XH.clientAppName}`,
40
+ icon: loginPanelIcon ?? Icon.shieldHalved({prefix: 'fas'}),
41
41
  className: 'xh-login',
42
42
  testId: 'xh-login',
43
43
  width: 300,
@@ -47,6 +47,7 @@ export const loginPanel = hoistCmp.factory({
47
47
  form(
48
48
  textInput({
49
49
  bind: 'username',
50
+ leftIcon: Icon.user(),
50
51
  placeholder: 'Username',
51
52
  autoComplete: 'username',
52
53
  autoFocus: true,
@@ -57,6 +58,7 @@ export const loginPanel = hoistCmp.factory({
57
58
  }),
58
59
  textInput({
59
60
  bind: 'password',
61
+ leftIcon: Icon.lock(),
60
62
  placeholder: 'Password...',
61
63
  autoComplete: 'current-password',
62
64
  type: 'password',
@@ -96,7 +96,7 @@ export class FileChooserModel extends HoistModel {
96
96
  private readonly rejectToastMessage: (rejectedFiles: FileRejection[]) => ReactNode;
97
97
  private readonly rejectToastSpec: Partial<ToastSpec>;
98
98
 
99
- constructor(config: FileChooserConfig) {
99
+ constructor(config: FileChooserConfig = {}) {
100
100
  super();
101
101
  makeObservable(this);
102
102
 
@@ -98,10 +98,15 @@ export class HeaderFilterModel extends HoistModel {
98
98
 
99
99
  @computed
100
100
  get isCustomFilter() {
101
- const {columnCompoundFilter, columnFilters} = this;
101
+ const {columnCompoundFilter, columnFilters, fieldType} = this;
102
102
  if (columnCompoundFilter) return true;
103
103
  if (isEmpty(columnFilters)) return false;
104
- return columnFilters.some(it => !['=', '!=', 'includes'].includes(it.op));
104
+ return columnFilters.some(it => {
105
+ const isValuesTabOp = ['=', '!=', 'includes'].includes(it.op),
106
+ isTagsBlank =
107
+ fieldType === 'tags' && ['=', '!='].includes(it.op) && it.value == null;
108
+ return !isValuesTabOp || isTagsBlank;
109
+ });
105
110
  }
106
111
 
107
112
  get commitOnChange() {
@@ -170,8 +175,8 @@ export class HeaderFilterModel extends HoistModel {
170
175
  if (close) {
171
176
  this.parent.close();
172
177
  } else {
173
- // We must wait before resetting as GridFilterModel.setFilter() is async
174
- wait().then(() => this.resetTabModels());
178
+ // Wait as setFilter is async.
179
+ wait().then(() => this.syncWithFilter());
175
180
  }
176
181
  }
177
182
 
@@ -8,7 +8,7 @@ import {HoistModel} from '@xh/hoist/core';
8
8
  import {FieldFilterOperator, FieldFilterSpec} from '@xh/hoist/data';
9
9
  import {HeaderFilterModel} from '../HeaderFilterModel';
10
10
  import {bindable, computed, makeObservable} from '@xh/hoist/mobx';
11
- import {isArray, isNil} from 'lodash';
11
+ import {isArray, isEmpty, isNil} from 'lodash';
12
12
  import {CustomTabModel} from './CustomTabModel';
13
13
 
14
14
  type OperatorOptionValue = 'blank' | 'not blank' | FieldFilterOperator;
@@ -39,7 +39,7 @@ export class CustomRowModel extends HoistModel {
39
39
  } else if (op === 'not blank') {
40
40
  op = '!=';
41
41
  value = null;
42
- } else if (isNil(value)) {
42
+ } else if (isNil(value) || (isArray(value) && isEmpty(value))) {
43
43
  return null;
44
44
  }
45
45
 
@@ -4,22 +4,25 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
7
+ import {
8
+ HoistInputModel,
9
+ HoistInputProps,
10
+ OptionPrimitive,
11
+ SegmentedControlNullOption,
12
+ SegmentedControlOption,
13
+ useHoistInputModel
14
+ } from '@xh/hoist/cmp/input';
8
15
  import {div} from '@xh/hoist/cmp/layout';
9
16
  import {hoistCmp, HoistProps, Intent} from '@xh/hoist/core';
10
17
  import '@xh/hoist/desktop/register';
11
18
  import {bpSegmentedControl} from '@xh/hoist/kit/blueprint';
12
19
  import {computed, makeObservable} from '@xh/hoist/mobx';
13
- import {LocalDate} from '@xh/hoist/utils/datetime';
14
20
  import {getLayoutProps, getNonLayoutProps} from '@xh/hoist/utils/react';
15
21
  import {TEST_ID} from '@xh/hoist/utils/js';
16
22
  import classNames from 'classnames';
17
23
  import {filter, isObject} from 'lodash';
18
- import {ReactElement} from 'react';
19
24
  import './SegmentedControl.scss';
20
25
 
21
- type OptionPrimitive = string | number | boolean | LocalDate;
22
-
23
26
  export interface SegmentedControlProps extends HoistProps, HoistInputProps {
24
27
  /** True to render in a compact mode with reduced sizing for space-constrained contexts. */
25
28
  compact?: boolean;
@@ -51,52 +54,6 @@ export interface SegmentedControlProps extends HoistProps, HoistInputProps {
51
54
  outlined?: boolean;
52
55
  }
53
56
 
54
- export interface SegmentedControlOption {
55
- /** Value for this option. */
56
- value: OptionPrimitive;
57
-
58
- /** Display label. Defaults to `value.toString()` if omitted. */
59
- label?: string;
60
-
61
- /** Icon element, displayed before the label. */
62
- icon?: ReactElement;
63
-
64
- /** True to disable this individual option. */
65
- disabled?: boolean;
66
-
67
- /**
68
- * Visual intent for this option - rendered as a solid fill when selected and as a subtle
69
- * text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
70
- * `intent` default. Defaults to the control's `intent`.
71
- */
72
- intent?: Intent;
73
- }
74
-
75
- /**
76
- * Variant of SegmentedControlOption for representing a null/"no value" selection.
77
- * Label is required to force use case to override default js 'null' toString rendering.
78
- */
79
- export interface SegmentedControlNullOption {
80
- /** Null value for this option. */
81
- value: null;
82
-
83
- /** Display label - required for null options. */
84
- label: string;
85
-
86
- /** Icon element, displayed before the label. */
87
- icon?: ReactElement;
88
-
89
- /** True to disable this individual option. */
90
- disabled?: boolean;
91
-
92
- /**
93
- * Visual intent for this option - rendered as a solid fill when selected and as a subtle
94
- * text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
95
- * `intent` default. Defaults to the control's `intent`.
96
- */
97
- intent?: Intent;
98
- }
99
-
100
57
  /**
101
58
  * An input for selecting a single value from a small set of mutually exclusive options,
102
59
  * rendered as a group of toggle buttons with clear visual indication of the active
@@ -10,13 +10,17 @@
10
10
  max-width: 100%;
11
11
  max-height: 100%;
12
12
  flex-direction: column;
13
- flex: 1 1 0;
13
+ //noinspection CssRedundantUnit
14
+ flex: 1 1 0%;
15
+ // % unit used to avoid browser fallback to px,
16
+ // which prevents container expansion
14
17
 
15
18
  .xh-tab-wrapper {
16
19
  display: flex;
17
20
  min-width: 0;
18
21
  min-height: 0;
19
- flex: 1 1 0;
22
+ //noinspection CssRedundantUnit
23
+ flex: 1 1 0%;
20
24
  }
21
25
  }
22
26
 
@@ -24,7 +24,7 @@ import {Icon} from '@xh/hoist/icon';
24
24
  import {tooltip as bpTooltip, showContextMenu} from '@xh/hoist/kit/blueprint';
25
25
  import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd';
26
26
  import {wait} from '@xh/hoist/promise';
27
- import {consumeEvent} from '@xh/hoist/utils/js';
27
+ import {consumeEvent, getTestId} from '@xh/hoist/utils/js';
28
28
  import {composeRefs, getLayoutProps} from '@xh/hoist/utils/react';
29
29
  import classNames from 'classnames';
30
30
  import {first, isFinite, last} from 'lodash';
@@ -41,13 +41,14 @@ export const [DynamicTabSwitcher, dynamicTabSwitcher] = hoistCmp.withFactory<Tab
41
41
  className: 'xh-dynamic-tab-switcher',
42
42
  displayName: 'DynamicTabSwitcher',
43
43
  model: uses(TabContainerModel),
44
- render({className, orientation, ...props}) {
44
+ render({className, orientation, testId, ...props}) {
45
45
  const impl = useLocalModel(DynamicTabSwitcherLocalModel);
46
46
  return scroller({
47
47
  className: classNames(className, impl.isVertical && `${className}--vertical`),
48
48
  content: tabs,
49
49
  contentProps: {localModel: impl},
50
50
  orientation: ['left', 'right'].includes(orientation) ? 'vertical' : 'horizontal',
51
+ testId,
51
52
  ...getLayoutProps(props)
52
53
  });
53
54
  }
@@ -127,7 +128,8 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
127
128
  {disabled, icon, tooltip} = tab,
128
129
  isFavorite = model.isTabFavorite(tab.id),
129
130
  {isVertical, props} = localModel,
130
- {tabWidth, tabMinWidth, tabMaxWidth} = props;
131
+ {tabWidth, tabMinWidth, tabMaxWidth} = props,
132
+ tabTestId = getTestId(props, tab.id);
131
133
 
132
134
  // Handle tab sizing props
133
135
  const tabStyle: CSSProperties = {};
@@ -172,6 +174,7 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
172
174
  );
173
175
  },
174
176
  ref: composeRefs(provided.innerRef, tabRef),
177
+ testId: tabTestId,
175
178
  ...provided.draggableProps,
176
179
  ...provided.dragHandleProps,
177
180
  style: getStyles(isVertical, provided.draggableProps.style),
@@ -199,6 +202,7 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
199
202
  button({
200
203
  className:
201
204
  'xh-dynamic-tab-switcher__tabs__tab__close-button',
205
+ testId: getTestId(tabTestId, 'remove-btn'),
202
206
  icon: Icon.x({size: 'sm'}),
203
207
  title: 'Remove Tab',
204
208
  minimal: true,
@@ -490,6 +490,15 @@ The logged payload includes:
490
490
  - Whether the user was shown an alert
491
491
  - An optional user-provided message (via the "Report" dialog)
492
492
 
493
+ > **Routing client errors to a chat system:** Because reported errors land server-side as
494
+ > `'Client Error'` track entries, a Grails service can forward them to a realtime chat system
495
+ > (Slack, Teams, etc.) by subscribing to the `xhTrackReceived` cluster topic - no client changes
496
+ > required. XH's [Toolbox](https://github.com/xh/toolbox) demo app
497
+ > [includes a `SlackAlertService` that does exactly this](https://github.com/xh/toolbox/blob/develop/grails-app/services/io/xh/toolbox/SlackAlertService.groovy),
498
+ > posting client errors (alongside monitor alerts and user feedback) to Slack. See the hoist-core
499
+ > [activity tracking](https://github.com/xh/hoist-core/blob/develop/docs/activity-tracking.md) docs
500
+ > for the server-side topic mechanism.
501
+
493
502
  ### Sensitive Data Redaction
494
503
 
495
504
  The handler automatically redacts values at paths listed in