@xh/hoist 59.1.0 → 59.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 (117) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/admin/AppComponent.ts +1 -1
  3. package/admin/tabs/activity/ActivityTab.ts +1 -1
  4. package/admin/tabs/general/GeneralTab.ts +2 -2
  5. package/admin/tabs/general/alertBanner/AlertBannerModel.ts +2 -2
  6. package/admin/tabs/general/config/ConfigPanel.ts +1 -0
  7. package/admin/tabs/monitor/MonitorTab.ts +1 -1
  8. package/admin/tabs/server/ServerTab.ts +1 -1
  9. package/admin/tabs/userData/UserDataTab.ts +1 -1
  10. package/cmp/ag-grid/AgGrid.scss +60 -12
  11. package/cmp/ag-grid/AgGrid.ts +8 -2
  12. package/cmp/badge/Badge.ts +18 -5
  13. package/cmp/chart/Chart.ts +13 -11
  14. package/cmp/clock/Clock.ts +6 -5
  15. package/cmp/dataview/DataView.ts +5 -3
  16. package/cmp/form/Form.ts +25 -6
  17. package/cmp/grid/Grid.scss +10 -0
  18. package/cmp/grid/Grid.ts +41 -25
  19. package/cmp/grid/GridModel.ts +138 -34
  20. package/cmp/grid/Types.ts +1 -1
  21. package/cmp/grid/columns/Column.ts +45 -2
  22. package/cmp/grid/columns/ColumnGroup.ts +11 -0
  23. package/cmp/grid/impl/GridHScrollbar.ts +140 -0
  24. package/cmp/grid/renderers/MultiFieldRenderer.ts +1 -1
  25. package/cmp/input/HoistInputModel.ts +4 -4
  26. package/cmp/input/HoistInputProps.ts +3 -1
  27. package/cmp/layout/Box.ts +4 -2
  28. package/cmp/relativetimestamp/RelativeTimestamp.ts +106 -40
  29. package/cmp/store/StoreFilterField.ts +2 -2
  30. package/cmp/tab/TabContainer.ts +1 -1
  31. package/cmp/zoneGrid/Types.ts +47 -0
  32. package/cmp/zoneGrid/ZoneGrid.ts +62 -0
  33. package/cmp/zoneGrid/ZoneGridModel.ts +666 -0
  34. package/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts +143 -0
  35. package/cmp/zoneGrid/impl/ZoneMapperModel.ts +335 -0
  36. package/cmp/zoneGrid/index.ts +3 -0
  37. package/core/HoistComponent.ts +23 -10
  38. package/core/HoistProps.ts +25 -6
  39. package/core/XH.ts +49 -27
  40. package/core/elem.ts +11 -3
  41. package/core/impl/InstanceManager.ts +24 -1
  42. package/core/model/HoistModel.ts +4 -4
  43. package/data/RecordAction.ts +8 -5
  44. package/data/StoreRecord.ts +8 -1
  45. package/data/cube/Query.ts +37 -3
  46. package/data/cube/View.ts +2 -2
  47. package/data/cube/row/BaseRow.ts +2 -2
  48. package/desktop/appcontainer/AppContainer.ts +2 -0
  49. package/desktop/appcontainer/Banner.ts +2 -2
  50. package/desktop/cmp/appbar/AppBar.ts +8 -6
  51. package/desktop/cmp/button/Button.ts +14 -3
  52. package/desktop/cmp/button/ButtonGroup.ts +14 -3
  53. package/desktop/cmp/button/ZoneMapperButton.ts +82 -0
  54. package/desktop/cmp/button/index.ts +1 -0
  55. package/desktop/cmp/dash/canvas/DashCanvas.ts +14 -4
  56. package/desktop/cmp/dash/container/DashContainer.ts +11 -4
  57. package/desktop/cmp/dash/container/DashContainerModel.ts +3 -2
  58. package/desktop/cmp/dash/container/impl/DashContainerUtils.ts +4 -4
  59. package/desktop/cmp/dock/DockViewModel.ts +10 -8
  60. package/desktop/cmp/dock/impl/DockContainer.ts +1 -0
  61. package/desktop/cmp/error/ErrorMessage.ts +9 -8
  62. package/desktop/cmp/form/FormField.ts +34 -10
  63. package/desktop/cmp/grid/columns/Actions.ts +2 -1
  64. package/desktop/cmp/grid/impl/colchooser/ColChooser.ts +3 -2
  65. package/desktop/cmp/grouping/GroupingChooser.ts +29 -29
  66. package/desktop/cmp/input/ButtonGroupInput.ts +1 -1
  67. package/desktop/cmp/input/Checkbox.ts +3 -3
  68. package/desktop/cmp/input/CodeInput.ts +2 -1
  69. package/desktop/cmp/input/DateInput.ts +128 -123
  70. package/desktop/cmp/input/JsonInput.ts +1 -1
  71. package/desktop/cmp/input/NumberInput.ts +3 -2
  72. package/desktop/cmp/input/RadioInput.ts +3 -1
  73. package/desktop/cmp/input/Select.ts +31 -4
  74. package/desktop/cmp/input/SwitchInput.ts +2 -1
  75. package/desktop/cmp/input/TextArea.ts +3 -3
  76. package/desktop/cmp/input/TextInput.ts +51 -47
  77. package/desktop/cmp/panel/Panel.ts +21 -19
  78. package/desktop/cmp/panel/impl/ResizeContainer.ts +3 -2
  79. package/desktop/cmp/pinpad/impl/PinPad.ts +4 -3
  80. package/desktop/cmp/record/RecordActionBar.ts +12 -3
  81. package/desktop/cmp/record/impl/RecordActionButton.ts +1 -0
  82. package/desktop/cmp/rest/Actions.ts +10 -5
  83. package/desktop/cmp/rest/RestGrid.ts +20 -6
  84. package/desktop/cmp/rest/impl/RestForm.ts +5 -4
  85. package/desktop/cmp/rest/impl/RestGridToolbar.ts +3 -2
  86. package/desktop/cmp/tab/TabSwitcher.ts +8 -3
  87. package/desktop/cmp/tab/impl/Tab.ts +2 -1
  88. package/desktop/cmp/tab/impl/TabContainer.ts +18 -15
  89. package/desktop/cmp/toolbar/Toolbar.ts +3 -1
  90. package/desktop/cmp/treemap/SplitTreeMap.ts +2 -1
  91. package/desktop/cmp/treemap/TreeMap.ts +5 -3
  92. package/desktop/cmp/zoneGrid/impl/ZoneMapper.scss +71 -0
  93. package/desktop/cmp/zoneGrid/impl/ZoneMapper.ts +232 -0
  94. package/desktop/cmp/zoneGrid/impl/ZoneMapperDialog.ts +35 -0
  95. package/dynamics/desktop.ts +2 -0
  96. package/dynamics/mobile.ts +2 -0
  97. package/inspector/instances/InstancesModel.ts +3 -3
  98. package/mobile/appcontainer/AppContainer.ts +2 -0
  99. package/mobile/appcontainer/Banner.ts +2 -2
  100. package/mobile/cmp/button/ZoneMapperButton.ts +41 -0
  101. package/mobile/cmp/button/index.ts +1 -0
  102. package/mobile/cmp/error/ErrorMessage.ts +4 -4
  103. package/mobile/cmp/input/Select.scss +1 -0
  104. package/mobile/cmp/input/Select.ts +7 -0
  105. package/mobile/cmp/input/TextInput.ts +1 -0
  106. package/mobile/cmp/menu/impl/Menu.scss +7 -1
  107. package/mobile/cmp/panel/DialogPanel.scss +18 -6
  108. package/mobile/cmp/panel/DialogPanel.ts +3 -1
  109. package/mobile/cmp/zoneGrid/impl/ZoneMapper.scss +67 -0
  110. package/mobile/cmp/zoneGrid/impl/ZoneMapper.ts +236 -0
  111. package/package.json +4 -3
  112. package/styles/vars.scss +3 -3
  113. package/svc/InspectorService.ts +1 -1
  114. package/utils/js/DomUtils.ts +10 -0
  115. package/utils/js/LangUtils.ts +10 -0
  116. package/utils/js/TestUtils.ts +9 -0
  117. package/utils/js/index.ts +1 -0
@@ -7,10 +7,19 @@
7
7
  import {ContextMenu} from '@blueprintjs/core';
8
8
  import composeRefs from '@seznam/compose-react-refs';
9
9
  import {div, vbox, vspacer} from '@xh/hoist/cmp/layout';
10
- import {elementFactory, hoistCmp, HoistProps, refreshContextView, uses, XH} from '@xh/hoist/core';
10
+ import {
11
+ elementFactory,
12
+ hoistCmp,
13
+ HoistProps,
14
+ refreshContextView,
15
+ TestSupportProps,
16
+ uses,
17
+ XH
18
+ } from '@xh/hoist/core';
11
19
  import {dashCanvasAddViewButton} from '@xh/hoist/desktop/cmp/button/DashCanvasAddViewButton';
12
20
  import '@xh/hoist/desktop/register';
13
21
  import {Classes, overlay} from '@xh/hoist/kit/blueprint';
22
+ import {TEST_ID} from '@xh/hoist/utils/js';
14
23
  import {useOnVisibleChange} from '@xh/hoist/utils/react';
15
24
  import classNames from 'classnames';
16
25
  import ReactGridLayout, {WidthProvider} from 'react-grid-layout';
@@ -20,7 +29,7 @@ import {DashCanvasModel} from './DashCanvasModel';
20
29
  import {dashCanvasContextMenu} from './impl/DashCanvasContextMenu';
21
30
  import {dashCanvasView} from './impl/DashCanvasView';
22
31
 
23
- export type DashCanvasProps = HoistProps<DashCanvasModel>;
32
+ export type DashCanvasProps = HoistProps<DashCanvasModel> & TestSupportProps;
24
33
 
25
34
  /**
26
35
  * Dashboard-style container that allows users to drag-and-drop child widgets into flexible layouts.
@@ -38,7 +47,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory<DashCanvasProps>({
38
47
  className: 'xh-dash-canvas',
39
48
  model: uses(DashCanvasModel),
40
49
 
41
- render({className, model}, ref) {
50
+ render({className, model, testId}, ref) {
42
51
  const isDraggable = !model.layoutLocked,
43
52
  isResizable = !model.layoutLocked;
44
53
 
@@ -85,7 +94,8 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory<DashCanvasProps>({
85
94
  )
86
95
  }),
87
96
  emptyContainerOverlay()
88
- ]
97
+ ],
98
+ [TEST_ID]: testId
89
99
  })
90
100
  });
91
101
  }
@@ -6,7 +6,14 @@
6
6
  */
7
7
  import composeRefs from '@seznam/compose-react-refs';
8
8
  import {div, frame, vbox, vspacer} from '@xh/hoist/cmp/layout';
9
- import {hoistCmp, uses, ModelLookupContext, HoistProps, refreshContextView} from '@xh/hoist/core';
9
+ import {
10
+ hoistCmp,
11
+ HoistProps,
12
+ ModelLookupContext,
13
+ refreshContextView,
14
+ TestSupportProps,
15
+ uses
16
+ } from '@xh/hoist/core';
10
17
  import {mask} from '@xh/hoist/desktop/cmp/mask';
11
18
  import {Classes, overlay} from '@xh/hoist/kit/blueprint';
12
19
  import {useOnMount, useOnResize} from '@xh/hoist/utils/react';
@@ -15,7 +22,7 @@ import './DashContainer.scss';
15
22
  import {DashContainerModel} from './DashContainerModel';
16
23
  import {dashContainerAddViewButton} from './impl/DashContainerContextMenu';
17
24
 
18
- export type DashContainerProps = HoistProps<DashContainerModel>;
25
+ export type DashContainerProps = HoistProps<DashContainerModel> & TestSupportProps;
19
26
 
20
27
  /**
21
28
  * Display a set of child components in accordance with a DashContainerModel.
@@ -25,7 +32,7 @@ export const [DashContainer, dashContainer] = hoistCmp.withFactory<DashContainer
25
32
  model: uses(DashContainerModel),
26
33
  className: 'xh-dash-container',
27
34
 
28
- render({model, className}, ref) {
35
+ render({model, className, testId}, ref) {
29
36
  // Store current ModelLookupContext in model, to be applied in views later
30
37
  const context = useContext(ModelLookupContext);
31
38
  useOnMount(() => (model.modelLookupContext = context));
@@ -39,7 +46,7 @@ export const [DashContainer, dashContainer] = hoistCmp.withFactory<DashContainer
39
46
  return refreshContextView({
40
47
  model: model.refreshContextModel,
41
48
  item: frame(
42
- frame({className, ref}),
49
+ frame({className, ref, testId}),
43
50
  mask({spinner: true, bind: model.loadingStateTask}),
44
51
  emptyContainerOverlay()
45
52
  )
@@ -22,7 +22,7 @@ import {wait} from '@xh/hoist/promise';
22
22
  import {isOmitted} from '@xh/hoist/utils/impl';
23
23
  import {debounced, ensureUniqueBy, throwIf} from '@xh/hoist/utils/js';
24
24
  import {createObservableRef} from '@xh/hoist/utils/react';
25
- import {cloneDeep, defaultsDeep, find, isFinite, isNil, reject, startCase} from 'lodash';
25
+ import {cloneDeep, defaultsDeep, find, isFinite, isNil, last, reject, startCase} from 'lodash';
26
26
  import {createRoot} from 'react-dom/client';
27
27
  import {DashConfig, DashModel} from '../';
28
28
  import {DashViewModel, DashViewState} from '../DashViewModel';
@@ -290,7 +290,8 @@ export class DashContainerModel extends DashModel<
290
290
 
291
291
  if (!isFinite(index)) index = container.contentItems.length;
292
292
  container.addChild(goldenLayoutConfig(viewSpec), index);
293
- wait(1).then(() => this.onStackActiveItemChange(container));
293
+ const stack = container.isStack ? container : last(container.contentItems);
294
+ wait(1).then(() => this.onStackActiveItemChange(stack));
294
295
  }
295
296
 
296
297
  /**
@@ -46,8 +46,8 @@ function convertGLToStateInner(configItems = [], contentItems = [], dashContaine
46
46
 
47
47
  ret.push(view);
48
48
  } else {
49
- const {type, width, height, activeItemIndex, content} = configItem,
50
- container = {type} as PlainObject;
49
+ const {type, width, height, activeItemIndex, content, isClosable} = configItem,
50
+ container = {type, allowRemove: isClosable} as PlainObject;
51
51
 
52
52
  if (isFinite(width)) container.width = round(width, 2);
53
53
  if (isFinite(height)) container.height = round(height, 2);
@@ -138,12 +138,12 @@ function convertStateToGLInner(items = [], viewSpecs = [], containerSize, contai
138
138
  const content = convertStateToGLInner(item.content, viewSpecs, itemSize, item).filter(
139
139
  it => !isNil(it)
140
140
  );
141
- if (!content.length) return null;
141
+ if (!content.length && item.allowRemove) return null;
142
142
 
143
143
  // Below is a workaround for issue https://github.com/golden-layout/golden-layout/issues/418
144
144
  // GoldenLayouts can sometimes export its state with an out-of-bounds `activeItemIndex`.
145
145
  // If we encounter this, we overwrite `activeItemIndex` to point to the last item.
146
- const ret = {...item, content};
146
+ const ret = {...item, content, isClosable: item.allowRemove};
147
147
  if (
148
148
  type === 'stack' &&
149
149
  isFinite(ret.activeItemIndex) &&
@@ -13,7 +13,7 @@ import {
13
13
  RefreshContextModel,
14
14
  RefreshMode,
15
15
  RenderMode,
16
- XH
16
+ Awaitable
17
17
  } from '@xh/hoist/core';
18
18
  import {ModalSupportModel} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupportModel';
19
19
  import '@xh/hoist/desktop/register';
@@ -53,6 +53,8 @@ export interface DockViewConfig {
53
53
  allowClose?: boolean;
54
54
  /** true (default) to allow popping out of the dock and displaying in a modal Dialog. */
55
55
  allowDialog?: boolean;
56
+ /** Awaitable callback invoked on close. Return false to prevent close. */
57
+ onClose?: () => Awaitable<boolean | void>;
56
58
  }
57
59
 
58
60
  /**
@@ -75,6 +77,7 @@ export class DockViewModel extends HoistModel {
75
77
  collapsedWidth: number;
76
78
  allowClose: boolean;
77
79
  allowDialog: boolean;
80
+ onClose?: () => Awaitable<boolean | void>;
78
81
 
79
82
  containerModel: DockContainerModel;
80
83
  @managed refreshContextModel: RefreshContextModel;
@@ -109,7 +112,8 @@ export class DockViewModel extends HoistModel {
109
112
  docked = true,
110
113
  collapsed = false,
111
114
  allowClose = true,
112
- allowDialog = true
115
+ allowDialog = true,
116
+ onClose
113
117
  }: DockViewConfig) {
114
118
  super();
115
119
  makeObservable(this);
@@ -128,6 +132,7 @@ export class DockViewModel extends HoistModel {
128
132
  this.collapsed = collapsed;
129
133
  this.allowClose = allowClose;
130
134
  this.allowDialog = allowDialog;
135
+ this.onClose = onClose;
131
136
 
132
137
  this._renderMode = renderMode;
133
138
  this._refreshMode = refreshMode;
@@ -195,11 +200,8 @@ export class DockViewModel extends HoistModel {
195
200
  // Actions
196
201
  //-----------------------
197
202
  close() {
198
- this.containerModel.removeView(this.id);
199
- }
200
-
201
- override destroy() {
202
- XH.safeDestroy(this.content);
203
- super.destroy();
203
+ Promise.resolve(this.onClose?.()).then(v => {
204
+ if (v !== false) this.containerModel.removeView(this.id);
205
+ });
204
206
  }
205
207
  }
@@ -25,6 +25,7 @@ export function dockContainerImpl(
25
25
  className: classNames(className, `xh-dock-container--${model.direction}`),
26
26
  items: model.views.map(viewModel => {
27
27
  return dockView({
28
+ key: viewModel.xhId,
28
29
  model: viewModel,
29
30
  compactHeaders
30
31
  });
@@ -5,16 +5,15 @@
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {div, filler, frame, hbox, p} from '@xh/hoist/cmp/layout';
8
- import {hoistCmp, HoistProps} from '@xh/hoist/core';
8
+ import {BoxProps, hoistCmp, HoistProps} from '@xh/hoist/core';
9
9
  import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button';
10
10
  import '@xh/hoist/desktop/register';
11
11
  import {isNil, isString} from 'lodash';
12
12
  import {isValidElement, ReactNode} from 'react';
13
-
14
13
  import './ErrorMessage.scss';
15
14
  import {Icon} from '@xh/hoist/icon';
16
15
 
17
- export interface ErrorMessageProps extends HoistProps {
16
+ export interface ErrorMessageProps extends HoistProps, Omit<BoxProps, 'title'> {
18
17
  /**
19
18
  * If provided, will render a "Retry" button that calls this function.
20
19
  * Use `actionButtonProps` for further control over this button.
@@ -69,7 +68,8 @@ export const [ErrorMessage, errorMessage] = hoistCmp.withFactory<ErrorMessagePro
69
68
  actionFn,
70
69
  actionButtonProps,
71
70
  detailsFn,
72
- detailsButtonProps
71
+ detailsButtonProps,
72
+ ...rest
73
73
  } = props;
74
74
 
75
75
  if (isNil(error)) return null;
@@ -77,17 +77,17 @@ export const [ErrorMessage, errorMessage] = hoistCmp.withFactory<ErrorMessagePro
77
77
  if (!message) {
78
78
  if (isString(error)) {
79
79
  message = error;
80
- } else if (error.message) {
81
- message = error.message;
80
+ } else {
81
+ message = error.message || error.name || 'Unknown Error';
82
82
  }
83
83
  }
84
84
 
85
85
  if (actionFn) {
86
- actionButtonProps = {...actionButtonProps, onClick: error => actionFn(error)};
86
+ actionButtonProps = {...actionButtonProps, onClick: () => actionFn(error)};
87
87
  }
88
88
 
89
89
  if (detailsFn) {
90
- detailsButtonProps = {...detailsButtonProps, onClick: error => detailsFn(error)};
90
+ detailsButtonProps = {...detailsButtonProps, onClick: () => detailsFn(error)};
91
91
  }
92
92
 
93
93
  let buttons = [],
@@ -103,6 +103,7 @@ export const [ErrorMessage, errorMessage] = hoistCmp.withFactory<ErrorMessagePro
103
103
  return frame({
104
104
  ref,
105
105
  className,
106
+ ...rest,
106
107
  item: div({
107
108
  className: 'xh-error-message__inner',
108
109
  items: [titleCmp({title}), messageCmp({message, error}), buttonBar]
@@ -4,22 +4,23 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {PopoverPosition, PopperBoundary} from '@blueprintjs/core';
7
8
  import composeRefs from '@seznam/compose-react-refs/composeRefs';
8
- import {FieldModel, FormContext, FormContextType, BaseFormFieldProps} from '@xh/hoist/cmp/form';
9
+ import {BaseFormFieldProps, FieldModel, FormContext, FormContextType} from '@xh/hoist/cmp/form';
9
10
  import {box, div, label as labelEl, li, span, ul} from '@xh/hoist/cmp/layout';
10
11
  import {DefaultHoistProps, hoistCmp, HSide, uses, XH} from '@xh/hoist/core';
11
12
  import '@xh/hoist/desktop/register';
13
+ import {instanceManager} from '@xh/hoist/core/impl/InstanceManager';
12
14
  import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format';
13
15
  import {Icon} from '@xh/hoist/icon';
14
16
  import {tooltip} from '@xh/hoist/kit/blueprint';
15
17
  import {isLocalDate} from '@xh/hoist/utils/datetime';
16
- import {errorIf, throwIf, withDefault} from '@xh/hoist/utils/js';
17
- import {getLayoutProps, getReactElementName} from '@xh/hoist/utils/react';
18
+ import {errorIf, getTestId, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js';
19
+ import {getLayoutProps, getReactElementName, useOnMount, useOnUnmount} from '@xh/hoist/utils/react';
18
20
  import classNames from 'classnames';
19
21
  import {isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash';
20
22
  import {Children, cloneElement, ReactElement, ReactNode, useContext, useState} from 'react';
21
23
  import './FormField.scss';
22
- import {PopoverPosition, PopperBoundary} from '@blueprintjs/core';
23
24
 
24
25
  export interface FormFieldProps extends BaseFormFieldProps {
25
26
  /**
@@ -162,10 +163,18 @@ export const [FormField, formField] = hoistCmp.withFactory<FormFieldProps>({
162
163
  if (disabled) classes.push('xh-form-field-disabled');
163
164
  if (displayNotValid) classes.push('xh-form-field-invalid');
164
165
 
166
+ const testId = getFormFieldTestId(props, formContext, model.name);
167
+ useOnMount(() => instanceManager.registerModelWithTestId(testId, model));
168
+ useOnUnmount(() => instanceManager.unregisterModelWithTestId(testId));
169
+
165
170
  // generate actual element child to render
166
171
  let childEl: ReactElement =
167
172
  !child || readonly
168
- ? readonlyChild({model, readonlyRenderer})
173
+ ? readonlyChild({
174
+ model,
175
+ readonlyRenderer,
176
+ testId: getTestId(testId, 'readonly-display')
177
+ })
169
178
  : editableChild({
170
179
  model,
171
180
  child,
@@ -174,7 +183,8 @@ export const [FormField, formField] = hoistCmp.withFactory<FormFieldProps>({
174
183
  disabled,
175
184
  displayNotValid,
176
185
  leftErrorIcon,
177
- commitOnChange
186
+ commitOnChange,
187
+ testId: getTestId(testId, 'input')
178
188
  });
179
189
 
180
190
  if (minimal) {
@@ -195,6 +205,7 @@ export const [FormField, formField] = hoistCmp.withFactory<FormFieldProps>({
195
205
  key: model?.xhId,
196
206
  className: classNames(className, classes),
197
207
  ...getLayoutProps(props),
208
+ testId,
198
209
  items: [
199
210
  labelEl({
200
211
  omit: !label,
@@ -236,9 +247,13 @@ export const [FormField, formField] = hoistCmp.withFactory<FormFieldProps>({
236
247
  const readonlyChild = hoistCmp.factory({
237
248
  model: false,
238
249
 
239
- render({model, readonlyRenderer}) {
250
+ render({model, readonlyRenderer, testId}) {
240
251
  const value = model ? model['value'] : null;
241
- return div({className: 'xh-form-field-readonly-display', item: readonlyRenderer(value)});
252
+ return div({
253
+ className: 'xh-form-field-readonly-display',
254
+ [TEST_ID]: testId,
255
+ item: readonlyRenderer(value)
256
+ });
242
257
  }
243
258
  });
244
259
 
@@ -253,7 +268,8 @@ const editableChild = hoistCmp.factory<FieldModel>({
253
268
  disabled,
254
269
  displayNotValid,
255
270
  leftErrorIcon,
256
- commitOnChange
271
+ commitOnChange,
272
+ testId
257
273
  }) {
258
274
  const {props} = child;
259
275
 
@@ -263,7 +279,8 @@ const editableChild = hoistCmp.factory<FieldModel>({
263
279
  bind: 'value',
264
280
  id: childId,
265
281
  disabled: props.disabled || disabled,
266
- ref: composeRefs(model?.boundInputRef, child.ref)
282
+ ref: composeRefs(model?.boundInputRef, child.ref),
283
+ testId: props.testId ?? testId
267
284
  };
268
285
 
269
286
  // If a sizeable child input doesn't specify its own dimensions,
@@ -347,3 +364,10 @@ function defaultProp(
347
364
  const fieldDefault = formContext.fieldDefaults ? formContext.fieldDefaults[name] : null;
348
365
  return withDefault(props[name], fieldDefault, defaultVal);
349
366
  }
367
+ function getFormFieldTestId(
368
+ props: Partial<FormFieldProps>,
369
+ formContext: FormContextType,
370
+ fieldName: string
371
+ ): string {
372
+ return props.testId ?? (formContext.testId ? `${formContext.testId}-${fieldName}` : undefined);
373
+ }
@@ -8,7 +8,7 @@ import {ColumnSpec} from '@xh/hoist/cmp/grid/columns';
8
8
  import {RecordAction} from '@xh/hoist/data';
9
9
  import {button, buttonGroup} from '@xh/hoist/desktop/cmp/button';
10
10
  import '@xh/hoist/desktop/register';
11
- import {throwIf} from '@xh/hoist/utils/js';
11
+ import {getTestId, throwIf} from '@xh/hoist/utils/js';
12
12
  import classNames from 'classnames';
13
13
  import {isEmpty} from 'lodash';
14
14
 
@@ -64,6 +64,7 @@ export const actionCol: ColumnSpec = {
64
64
  if (hidden) return null;
65
65
 
66
66
  return button({
67
+ testId: getTestId(action.testId, `${record.id}`),
67
68
  icon,
68
69
  disabled,
69
70
  tooltip,
@@ -43,7 +43,7 @@ export const colChooser = hoistCmp.factory<ColChooserProps>({
43
43
  filler(),
44
44
  button({
45
45
  omit: !showRestoreDefaults,
46
- text: 'Restore Grid Defaults',
46
+ text: 'Restore Defaults',
47
47
  icon: Icon.undo({className: 'xh-red'}),
48
48
  onClick: () => model.restoreDefaultsAsync()
49
49
  }),
@@ -57,7 +57,8 @@ export const colChooser = hoistCmp.factory<ColChooserProps>({
57
57
  button({
58
58
  omit: commitOnChange,
59
59
  text: 'Save',
60
- icon: Icon.check({className: 'xh-green'}),
60
+ icon: Icon.check(),
61
+ intent: 'success',
61
62
  onClick: () => {
62
63
  model.commit();
63
64
  model.close();
@@ -8,12 +8,13 @@ import {GroupingChooserModel} from '@xh/hoist/cmp/grouping';
8
8
  import {box, div, filler, fragment, hbox, vbox} from '@xh/hoist/cmp/layout';
9
9
  import {hoistCmp, uses} from '@xh/hoist/core';
10
10
  import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button';
11
- import {select, MENU_PORTAL_ID} from '@xh/hoist/desktop/cmp/input';
11
+ import {select} from '@xh/hoist/desktop/cmp/input';
12
12
  import {panel} from '@xh/hoist/desktop/cmp/panel';
13
13
  import '@xh/hoist/desktop/register';
14
14
  import {Icon} from '@xh/hoist/icon';
15
15
  import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint';
16
16
  import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd';
17
+ import {elemWithin, getTestId, TEST_ID} from '@xh/hoist/utils/js';
17
18
  import {splitLayoutProps} from '@xh/hoist/utils/react';
18
19
  import classNames from 'classnames';
19
20
  import {compact, isEmpty, sortBy} from 'lodash';
@@ -58,6 +59,7 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
58
59
  popoverTitle = 'Group By',
59
60
  popoverPosition = 'bottom',
60
61
  styleButtonAsInput = true,
62
+ testId,
61
63
  ...rest
62
64
  },
63
65
  ref
@@ -65,7 +67,10 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
65
67
  const {editorIsOpen, favoritesIsOpen, persistFavorites, value, allowEmpty} = model,
66
68
  isOpen = editorIsOpen || favoritesIsOpen,
67
69
  label = isEmpty(value) && allowEmpty ? emptyText : model.getValueLabel(value),
68
- [layoutProps, buttonProps] = splitLayoutProps(rest);
70
+ [layoutProps, buttonProps] = splitLayoutProps(rest),
71
+ favoritesMenuTestId = getTestId(testId, 'favorites-menu'),
72
+ favoritesIconTestId = getTestId(testId, 'favorites-icon'),
73
+ editorTestId = getTestId(testId, 'editor');
69
74
 
70
75
  return box({
71
76
  ref,
@@ -91,21 +96,28 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
91
96
  ),
92
97
  minimal: styleButtonAsInput,
93
98
  ...buttonProps,
94
- onClick: () => model.toggleEditor()
99
+ onClick: () => model.toggleEditor(),
100
+ testId
95
101
  }),
96
- favoritesIcon()
102
+ favoritesIcon({testId: favoritesIconTestId})
97
103
  ),
98
104
  content: favoritesIsOpen
99
- ? favoritesMenu()
105
+ ? favoritesMenu({testId: favoritesMenuTestId})
100
106
  : editorIsOpen
101
- ? editor({popoverWidth, popoverMinHeight, popoverTitle, emptyText})
107
+ ? editor({
108
+ popoverWidth,
109
+ popoverMinHeight,
110
+ popoverTitle,
111
+ emptyText,
112
+ testId: editorTestId
113
+ })
102
114
  : null,
103
115
  onInteraction: (nextOpenState, e) => {
104
116
  if (
105
117
  isOpen &&
106
118
  nextOpenState === false &&
107
119
  e?.target &&
108
- !targetIsControlButtonOrPortal(e.target)
120
+ !elemWithin(e.target, 'xh-grouping-chooser-button--with-favorites')
109
121
  ) {
110
122
  model.commitPendingValueAndClose();
111
123
  }
@@ -119,7 +131,7 @@ export const [GroupingChooser, groupingChooser] = hoistCmp.withFactory<GroupingC
119
131
  // Editor
120
132
  //------------------
121
133
  const editor = hoistCmp.factory<GroupingChooserModel>({
122
- render({popoverWidth, popoverMinHeight, popoverTitle, emptyText}) {
134
+ render({popoverWidth, popoverMinHeight, popoverTitle, emptyText, testId}) {
123
135
  return panel({
124
136
  width: popoverWidth,
125
137
  minHeight: popoverMinHeight,
@@ -128,7 +140,8 @@ const editor = hoistCmp.factory<GroupingChooserModel>({
128
140
  dimensionList({emptyText}),
129
141
  addDimensionControl(),
130
142
  filler()
131
- ]
143
+ ],
144
+ testId
132
145
  });
133
146
  }
134
147
  });
@@ -292,32 +305,16 @@ function getDimOptions(dims, model) {
292
305
  return sortBy(ret, 'label');
293
306
  }
294
307
 
295
- function targetIsControlButtonOrPortal(target) {
296
- const selectPortal = document.getElementById(MENU_PORTAL_ID)?.contains(target),
297
- selectClick = targetWithin(target, 'xh-select__single-value'),
298
- editorClick = targetWithin(target, 'xh-grouping-chooser-button--with-favorites');
299
- return selectPortal || selectClick || editorClick;
300
- }
301
-
302
- /**
303
- * Determines whether any of the target's parents have a specific class name
304
- */
305
- function targetWithin(target, className): boolean {
306
- for (let elem = target; elem; elem = elem.parentElement) {
307
- if (elem.classList.contains(className)) return true;
308
- }
309
- return false;
310
- }
311
-
312
308
  //------------------
313
309
  // Favorites
314
310
  //------------------
315
311
  const favoritesIcon = hoistCmp.factory<GroupingChooserModel>({
316
- render({model}) {
312
+ render({model, testId}) {
317
313
  if (!model.persistFavorites) return null;
318
314
  return div({
319
315
  item: Icon.favorite(),
320
316
  className: 'xh-grouping-chooser__favorite-icon',
317
+ [TEST_ID]: testId,
321
318
  onClick: e => {
322
319
  model.toggleFavoritesMenu();
323
320
  e.stopPropagation();
@@ -327,7 +324,7 @@ const favoritesIcon = hoistCmp.factory<GroupingChooserModel>({
327
324
  });
328
325
 
329
326
  const favoritesMenu = hoistCmp.factory<GroupingChooserModel>({
330
- render({model}) {
327
+ render({model, testId}) {
331
328
  const options = model.favoritesOptions,
332
329
  isFavorite = model.isFavorite(model.value),
333
330
  omitAdd = isEmpty(model.value) || isFavorite,
@@ -349,7 +346,10 @@ const favoritesMenu = hoistCmp.factory<GroupingChooserModel>({
349
346
  })
350
347
  );
351
348
 
352
- return vbox(div({className: 'xh-popup__title', item: 'Favorites'}), menu({items}));
349
+ return vbox({
350
+ testId,
351
+ items: [div({className: 'xh-popup__title', item: 'Favorites'}), menu({items})]
352
+ });
353
353
  }
354
354
  });
355
355
 
@@ -10,7 +10,7 @@ import {Button, buttonGroup, ButtonGroupProps} from '@xh/hoist/desktop/cmp/butto
10
10
  import '@xh/hoist/desktop/register';
11
11
  import {throwIf, warnIf, withDefault} from '@xh/hoist/utils/js';
12
12
  import {getLayoutProps, getNonLayoutProps} from '@xh/hoist/utils/react';
13
- import {isEmpty, filter, without, castArray} from 'lodash';
13
+ import {castArray, filter, isEmpty, without} from 'lodash';
14
14
  import {Children, cloneElement, isValidElement} from 'react';
15
15
 
16
16
  export interface ButtonGroupInputProps
@@ -5,10 +5,10 @@
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
8
- import {hoistCmp, StyleProps, HoistProps, HSide} from '@xh/hoist/core';
8
+ import {hoistCmp, HoistProps, HSide, StyleProps} from '@xh/hoist/core';
9
9
  import '@xh/hoist/desktop/register';
10
10
  import {checkbox as bpCheckbox} from '@xh/hoist/kit/blueprint';
11
- import {withDefault} from '@xh/hoist/utils/js';
11
+ import {TEST_ID, withDefault} from '@xh/hoist/utils/js';
12
12
  import {isNil} from 'lodash';
13
13
  import {ReactNode} from 'react';
14
14
 
@@ -73,8 +73,8 @@ const cmp = hoistCmp.factory<CheckboxInputModel>(({model, className, ...props},
73
73
  inline: withDefault(props.inline, true),
74
74
  label: props.label,
75
75
  tabIndex: props.tabIndex,
76
-
77
76
  id: props.id,
77
+ [TEST_ID]: props.testId,
78
78
  className,
79
79
  style: props.style,
80
80
 
@@ -35,8 +35,8 @@ import 'codemirror/addon/selection/mark-selection.js';
35
35
  import 'codemirror/lib/codemirror.css';
36
36
  import 'codemirror/theme/dracula.css';
37
37
  import {compact, defaultsDeep, isEqual, isFunction} from 'lodash';
38
- import {findDOMNode} from 'react-dom';
39
38
  import {ReactElement} from 'react';
39
+ import {findDOMNode} from 'react-dom';
40
40
  import './CodeInput.scss';
41
41
 
42
42
  export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps {
@@ -455,6 +455,7 @@ const cmp = hoistCmp.factory<CodeInputModel>(({model, className, ...props}, ref)
455
455
  item: modalSupport({
456
456
  model: model.modalSupportModel,
457
457
  item: inputCmp({
458
+ testId: props.testId,
458
459
  width: '100%',
459
460
  height: '100%',
460
461
  className,