@xh/hoist 74.1.0 → 74.1.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## v74.1.2 - 2025-07-03
4
+
5
+ ### 🐞 Bug Fixes
6
+
7
+ * Fixed `GroupingChooser` layout issue, visible only when favorites are disabled.
8
+
9
+ ## v74.1.1 - 2025-07-02
10
+
11
+ ### 🎁 New Features
12
+
13
+ * Further refinements to the `GroupingChooser` desktop UI.
14
+ * Added new props `favoritesSide` and `favoritesTitle`.
15
+ * Deprecated `popoverTitle` prop - use `editorTitle` instead.
16
+ * Moved "Save as Favorite" button to a new compact toolbar within the popover.
17
+
18
+ ### 🐞 Bug Fixes
19
+
20
+ * Fixed a bug where `TrackService` was not properly verifying that tracked `data` was below the
21
+ configured `maxDataLength` limit.
22
+
3
23
  ## v74.1.0 - 2025-06-30
4
24
 
5
25
  ### 🎁 New Features
@@ -76,6 +76,7 @@ export declare class GroupingChooserModel extends HoistModel {
76
76
  value: string[];
77
77
  label: string;
78
78
  }[];
79
+ get hasFavorites(): boolean;
79
80
  setFavorites(favorites: string[][]): void;
80
81
  addFavorite(value: string[]): void;
81
82
  addPendingAsFavorite(): void;
@@ -177,7 +177,7 @@ export interface TrackOptions {
177
177
  /** Correlation ID to save along with track log. */
178
178
  correlationId?: string;
179
179
  /** App-supplied data to save along with track log.*/
180
- data?: PlainObject | PlainObject[];
180
+ data?: PlainObject | Array<unknown>;
181
181
  /**
182
182
  * Set true to log on the server all primitive values in the 'data' property.
183
183
  * May also be specified as list of specific property keys that should be logged.
@@ -1,19 +1,30 @@
1
1
  import { GroupingChooserModel } from '@xh/hoist/cmp/grouping';
2
+ import { Side } from '@xh/hoist/core';
2
3
  import { ButtonProps } from '@xh/hoist/desktop/cmp/button';
3
4
  import '@xh/hoist/desktop/register';
4
5
  import './GroupingChooser.scss';
6
+ import { ReactNode } from 'react';
5
7
  export interface GroupingChooserProps extends ButtonProps<GroupingChooserModel> {
8
+ /** Title for value-editing portion of popover, or null to suppress. */
9
+ editorTitle?: ReactNode;
6
10
  /** Text to represent empty state (i.e. value = null or []) */
7
11
  emptyText?: string;
12
+ /**
13
+ * Side of the popover, relative to the value-editing controls, on which the Favorites list
14
+ * should be rendered, if enabled.
15
+ */
16
+ favoritesSide?: Side;
17
+ /** Title for favorites-list portion of popover, or null to suppress. */
18
+ favoritesTitle?: ReactNode;
8
19
  /** Min height in pixels of the popover menu itself. */
9
20
  popoverMinHeight?: number;
10
21
  /** Position of popover relative to target button. */
11
22
  popoverPosition?: 'bottom' | 'top';
12
- /** Title for popover (default "GROUP BY") or null to suppress. */
13
- popoverTitle?: string;
23
+ /** @deprecated - use `editorTitle` instead */
24
+ popoverTitle?: ReactNode;
14
25
  /**
15
26
  * Width in pixels of the popover menu itself.
16
- * If unspecified, will default based on whether favorites are enabled.
27
+ * If unspecified, will default based on favorites enabled status + side.
17
28
  */
18
29
  popoverWidth?: number;
19
30
  /** True (default) to style target button as an input field - blends better in toolbars. */
@@ -44,7 +44,7 @@ export interface PanelProps extends HoistProps<PanelModel>, Omit<BoxProps, 'titl
44
44
  */
45
45
  tbar?: Some<ReactNode>;
46
46
  /**
47
- * A toolbar to be docked at the top of the panel.
47
+ * A toolbar to be docked at the bottom of the panel.
48
48
  * If specified as an array, items will be passed as children to a Toolbar component.
49
49
  */
50
50
  bbar?: Some<ReactNode>;
@@ -266,6 +266,11 @@ export class GroupingChooserModel extends HoistModel {
266
266
  );
267
267
  }
268
268
 
269
+ @computed
270
+ get hasFavorites() {
271
+ return !isEmpty(this.favorites);
272
+ }
273
+
269
274
  @action
270
275
  setFavorites(favorites: string[][]) {
271
276
  this.favorites = favorites.filter(v => this.validateValue(v));
@@ -202,7 +202,7 @@ export class ExceptionHandler {
202
202
  XH.track({
203
203
  category: 'Client Error',
204
204
  severity: exception.isRoutine ? 'INFO' : 'ERROR',
205
- message: exception.message ?? 'Client Error',
205
+ message: exception.message || 'Client Error',
206
206
  correlationId: exception.correlationId,
207
207
  data,
208
208
  logData: ['userAlerted']
@@ -6,8 +6,8 @@
6
6
  */
7
7
 
8
8
  import {RuleLike} from '@xh/hoist/data';
9
- import {MouseEvent, ReactElement, ReactNode, isValidElement} from 'react';
10
9
  import {isString} from 'lodash';
10
+ import {isValidElement, MouseEvent, ReactElement, ReactNode} from 'react';
11
11
  import {LoadSpec} from '../load';
12
12
  import {Intent, PlainObject, Thunkable} from './Types';
13
13
 
@@ -224,7 +224,7 @@ export interface TrackOptions {
224
224
  correlationId?: string;
225
225
 
226
226
  /** App-supplied data to save along with track log.*/
227
- data?: PlainObject | PlainObject[];
227
+ data?: PlainObject | Array<unknown>;
228
228
 
229
229
  /**
230
230
  * Set true to log on the server all primitive values in the 'data' property.
@@ -34,8 +34,20 @@
34
34
  position: relative;
35
35
  }
36
36
 
37
- &__editor {
38
- flex: 1;
37
+ &-popover {
38
+ // 60/40 split in favor of favorites in left/right orientation
39
+ &--faves-right,
40
+ &--faves-left {
41
+ .xh-grouping-chooser__editor {
42
+ width: 40%;
43
+ }
44
+ }
45
+
46
+ &--faves-disabled {
47
+ .xh-grouping-chooser__editor {
48
+ flex: 1;
49
+ }
50
+ }
39
51
  }
40
52
 
41
53
  &__list {
@@ -115,14 +127,24 @@
115
127
  }
116
128
  }
117
129
 
118
- &__btn-row {
119
- min-height: unset !important;
120
- padding: 2px var(--xh-tbar-item-pad-px);
121
- }
122
-
123
130
  &__favorites {
124
- flex: 1;
125
- border-left: 1px solid var(--xh-popup-border-color);
131
+ &--top {
132
+ border-bottom: 1px solid var(--xh-popup-border-color);
133
+ }
134
+
135
+ &--right {
136
+ flex: 1;
137
+ border-left: 1px solid var(--xh-popup-border-color);
138
+ }
139
+
140
+ &--bottom {
141
+ border-top: 1px solid var(--xh-popup-border-color);
142
+ }
143
+
144
+ &--left {
145
+ flex: 1;
146
+ border-right: 1px solid var(--xh-popup-border-color);
147
+ }
126
148
 
127
149
  --xh-menu-border: none;
128
150
 
@@ -130,14 +152,8 @@
130
152
  padding: 0;
131
153
  }
132
154
 
133
- &__add-btn {
134
- flex: none;
135
- margin: var(--xh-pad-px) auto;
136
- }
137
-
138
155
  &__favorite {
139
156
  align-items: center;
140
- max-width: 50vw;
141
157
 
142
158
  .xh-button {
143
159
  padding: 0 !important;
@@ -5,37 +5,62 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {GroupingChooserModel} from '@xh/hoist/cmp/grouping';
8
- import {box, div, filler, fragment, hbox, hframe, vbox} from '@xh/hoist/cmp/layout';
9
- import {hoistCmp, uses} from '@xh/hoist/core';
8
+ import {
9
+ box,
10
+ div,
11
+ filler,
12
+ fragment,
13
+ frame,
14
+ hbox,
15
+ hframe,
16
+ placeholder,
17
+ vbox,
18
+ vframe
19
+ } from '@xh/hoist/cmp/layout';
20
+ import {hoistCmp, Side, uses} from '@xh/hoist/core';
10
21
  import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button';
11
22
  import {select} from '@xh/hoist/desktop/cmp/input';
12
23
  import {panel} from '@xh/hoist/desktop/cmp/panel';
13
24
  import '@xh/hoist/desktop/register';
25
+ import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
14
26
  import {Icon} from '@xh/hoist/icon';
15
27
  import {menu, menuItem, popover} from '@xh/hoist/kit/blueprint';
16
28
  import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd';
17
- import {elemWithin, getTestId} from '@xh/hoist/utils/js';
29
+ import {apiDeprecated, elemWithin, getTestId} from '@xh/hoist/utils/js';
18
30
  import {splitLayoutProps} from '@xh/hoist/utils/react';
19
31
  import classNames from 'classnames';
20
- import {compact, isEmpty, sortBy} from 'lodash';
32
+ import {compact, isEmpty, isNil, isUndefined, sortBy} from 'lodash';
21
33
  import './GroupingChooser.scss';
34
+ import {ReactNode} from 'react';
22
35
 
23
36
  export interface GroupingChooserProps extends ButtonProps<GroupingChooserModel> {
37
+ /** Title for value-editing portion of popover, or null to suppress. */
38
+ editorTitle?: ReactNode;
39
+
24
40
  /** Text to represent empty state (i.e. value = null or []) */
25
41
  emptyText?: string;
26
42
 
43
+ /**
44
+ * Side of the popover, relative to the value-editing controls, on which the Favorites list
45
+ * should be rendered, if enabled.
46
+ */
47
+ favoritesSide?: Side;
48
+
49
+ /** Title for favorites-list portion of popover, or null to suppress. */
50
+ favoritesTitle?: ReactNode;
51
+
27
52
  /** Min height in pixels of the popover menu itself. */
28
53
  popoverMinHeight?: number;
29
54
 
30
55
  /** Position of popover relative to target button. */
31
56
  popoverPosition?: 'bottom' | 'top';
32
57
 
33
- /** Title for popover (default "GROUP BY") or null to suppress. */
34
- popoverTitle?: string;
58
+ /** @deprecated - use `editorTitle` instead */
59
+ popoverTitle?: ReactNode;
35
60
 
36
61
  /**
37
62
  * Width in pixels of the popover menu itself.
38
- * If unspecified, will default based on whether favorites are enabled.
63
+ * If unspecified, will default based on favorites enabled status + side.
39
64
  */
40
65
  popoverWidth?: number;
41
66
 
@@ -58,10 +83,13 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
58
83
  {
59
84
  model,
60
85
  className,
86
+ editorTitle = 'Group By',
61
87
  emptyText = 'Ungrouped',
88
+ favoritesSide = 'right',
89
+ favoritesTitle = 'Favorites',
62
90
  popoverWidth,
63
91
  popoverMinHeight,
64
- popoverTitle = 'Group By',
92
+ popoverTitle,
65
93
  popoverPosition = 'bottom',
66
94
  styleButtonAsInput = true,
67
95
  testId,
@@ -72,9 +100,18 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
72
100
  const {editorIsOpen, value, allowEmpty, persistFavorites} = model,
73
101
  isOpen = editorIsOpen,
74
102
  label = isEmpty(value) && allowEmpty ? emptyText : model.getValueLabel(value),
75
- [layoutProps, buttonProps] = splitLayoutProps(rest);
103
+ [layoutProps, buttonProps] = splitLayoutProps(rest),
104
+ favesClassNameMod = `faves-${persistFavorites ? favoritesSide : 'disabled'}`,
105
+ favesTB = isTB(favoritesSide);
106
+
107
+ if (!isUndefined(popoverTitle)) {
108
+ apiDeprecated('GroupingChooser.popoverTitle', {
109
+ msg: `Update to use 'editorTitle' instead`
110
+ });
111
+ editorTitle = popoverTitle;
112
+ }
76
113
 
77
- popoverWidth = popoverWidth || (persistFavorites ? 500 : 250);
114
+ popoverWidth = popoverWidth || (persistFavorites && !favesTB ? 500 : 250);
78
115
 
79
116
  return box({
80
117
  ref,
@@ -83,7 +120,7 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
83
120
  item: popover({
84
121
  isOpen,
85
122
  popoverRef: model.popoverRef,
86
- popoverClassName: 'xh-grouping-chooser-popover xh-popup--framed',
123
+ popoverClassName: `xh-grouping-chooser-popover xh-grouping-chooser-popover--${favesClassNameMod} xh-popup--framed`,
87
124
  // Left align editor to keep in place when button changing size when commitOnChange: true
88
125
  position: `${popoverPosition}-left`,
89
126
  minimal: false,
@@ -103,10 +140,12 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
103
140
  })
104
141
  ),
105
142
  content: popoverCmp({
143
+ editorTitle,
144
+ emptyText,
145
+ favoritesSide,
146
+ favoritesTitle,
106
147
  popoverWidth,
107
148
  popoverMinHeight,
108
- popoverTitle,
109
- emptyText,
110
149
  testId
111
150
  }),
112
151
  onInteraction: (nextOpenState, e) => {
@@ -127,35 +166,63 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
127
166
  //------------------
128
167
  // Editor
129
168
  //------------------
130
- const popoverCmp = hoistCmp.factory<GroupingChooserModel>({
131
- render({model, popoverWidth, popoverMinHeight, popoverTitle, emptyText, testId}) {
169
+ const popoverCmp = hoistCmp.factory<Partial<GroupingChooserProps>>({
170
+ render({
171
+ model,
172
+ editorTitle,
173
+ emptyText,
174
+ favoritesSide,
175
+ favoritesTitle,
176
+ popoverWidth,
177
+ popoverMinHeight,
178
+ testId
179
+ }) {
180
+ const {persistFavorites} = model,
181
+ favesTB = isTB(favoritesSide),
182
+ isFavesFirst = favoritesSide === 'left' || favoritesSide === 'top',
183
+ items = [
184
+ editor({
185
+ editorTitle,
186
+ emptyText,
187
+ testId: getTestId(testId, 'editor')
188
+ }),
189
+ favoritesChooser({
190
+ // Omit if favorites generally disabled, or if none saved yet AND in top/bottom
191
+ // orientation - the empty state looks clumsy in that case. Show when empty in
192
+ // left/right orientation to avoid large jump in popover width.
193
+ omit: !model.persistFavorites || (!model.hasFavorites && favesTB),
194
+ favoritesSide,
195
+ favoritesTitle,
196
+ testId: getTestId(testId, 'favorites')
197
+ })
198
+ ],
199
+ itemsContainer = !persistFavorites ? frame : favesTB ? vframe : hframe;
200
+
201
+ if (isFavesFirst) {
202
+ items.reverse();
203
+ }
204
+
132
205
  return panel({
206
+ className: 'xh-grouping-chooser-popover__inner',
133
207
  width: popoverWidth,
134
208
  minHeight: popoverMinHeight,
135
- items: hframe({
136
- items: [
137
- editor({
138
- popoverTitle,
139
- emptyText,
140
- testId: getTestId(testId, 'editor')
141
- }),
142
- favoritesChooser({
143
- omit: !model.persistFavorites,
144
- testId: getTestId(testId, 'favorites')
145
- })
146
- ]
209
+ items: itemsContainer({items}),
210
+ bbar: toolbar({
211
+ compact: true,
212
+ omit: !model.persistFavorites,
213
+ items: [filler(), favoritesAddBtn({testId})]
147
214
  })
148
215
  });
149
216
  }
150
217
  });
151
218
 
152
219
  const editor = hoistCmp.factory<GroupingChooserModel>({
153
- render({popoverTitle, emptyText, testId}) {
220
+ render({editorTitle, emptyText, testId}) {
154
221
  return vbox({
155
222
  className: 'xh-grouping-chooser__editor',
156
223
  testId,
157
224
  items: [
158
- div({className: 'xh-popup__title', item: popoverTitle, omit: !popoverTitle}),
225
+ div({className: 'xh-popup__title', item: editorTitle, omit: isNil(editorTitle)}),
159
226
  dimensionList({emptyText}),
160
227
  addDimensionControl()
161
228
  ]
@@ -330,26 +397,23 @@ function getDimOptions(dims, model) {
330
397
  // Favorites
331
398
  //------------------
332
399
  const favoritesChooser = hoistCmp.factory<GroupingChooserModel>({
333
- render({model, testId}) {
334
- const {favoritesOptions: options, isAddFavoriteEnabled} = model,
335
- items = isEmpty(options)
336
- ? [menuItem({text: 'No favorites saved.', disabled: true})]
337
- : options.map(it => favoriteMenuItem(it));
400
+ render({model, favoritesSide, favoritesTitle, testId}) {
401
+ const {favoritesOptions: options, hasFavorites} = model;
338
402
 
339
403
  return vbox({
340
- className: 'xh-grouping-chooser__favorites',
404
+ className: `xh-grouping-chooser__favorites xh-grouping-chooser__favorites--${favoritesSide}`,
341
405
  testId,
342
406
  items: [
343
- div({className: 'xh-popup__title', item: 'Favorites'}),
344
- menu({items}),
345
- button({
346
- text: 'Add current',
347
- icon: Icon.add({intent: 'success'}),
348
- className: 'xh-grouping-chooser__favorites__add-btn',
349
- outlined: true,
350
- omit: !isAddFavoriteEnabled,
351
- onClick: () => model.addPendingAsFavorite()
352
- })
407
+ div({
408
+ className: 'xh-popup__title',
409
+ item: favoritesTitle,
410
+ omit: isNil(favoritesTitle)
411
+ }),
412
+ hasFavorites
413
+ ? menu({
414
+ items: options.map(it => favoriteMenuItem(it))
415
+ })
416
+ : placeholder('No favorites saved.')
353
417
  ]
354
418
  });
355
419
  }
@@ -372,3 +436,19 @@ const favoriteMenuItem = hoistCmp.factory<GroupingChooserModel>({
372
436
  });
373
437
  }
374
438
  });
439
+
440
+ const favoritesAddBtn = hoistCmp.factory<GroupingChooserModel>({
441
+ render({model, testId}) {
442
+ return button({
443
+ text: 'Save as Favorite',
444
+ icon: Icon.favorite(),
445
+ className: 'xh-grouping-chooser__favorites__add-btn',
446
+ testId: getTestId(testId, 'favorites-add-btn'),
447
+ omit: !model.persistFavorites,
448
+ disabled: !model.isAddFavoriteEnabled,
449
+ onClick: () => model.addPendingAsFavorite()
450
+ });
451
+ }
452
+ });
453
+
454
+ const isTB = (favoritesSide: Side) => favoritesSide === 'top' || favoritesSide === 'bottom';
@@ -84,7 +84,7 @@ export interface PanelProps extends HoistProps<PanelModel>, Omit<BoxProps, 'titl
84
84
  tbar?: Some<ReactNode>;
85
85
 
86
86
  /**
87
- * A toolbar to be docked at the top of the panel.
87
+ * A toolbar to be docked at the bottom of the panel.
88
88
  * If specified as an array, items will be passed as children to a Toolbar component.
89
89
  */
90
90
  bbar?: Some<ReactNode>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "74.1.0",
3
+ "version": "74.1.2",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -132,10 +132,11 @@ export class TrackService extends HoistService {
132
132
  if (options.logData !== undefined) ret.logData = options.logData;
133
133
  if (options.elapsed !== undefined) ret.elapsed = options.elapsed;
134
134
 
135
- const {maxDataLength} = this.conf;
136
- if (ret.data?.length > maxDataLength) {
135
+ const {maxDataLength} = this.conf,
136
+ dataLength = JSON.stringify(ret.data)?.length ?? 0;
137
+ if (dataLength > maxDataLength) {
137
138
  this.logWarn(
138
- `Track log includes ${ret.data.length} chars of JSON data`,
139
+ `Track log includes ${dataLength} chars of JSON data`,
139
140
  `exceeds limit of ${maxDataLength}`,
140
141
  'data will not be persisted',
141
142
  options.data