@xh/hoist 59.0.3 → 59.2.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 (78) hide show
  1. package/CHANGELOG.md +59 -2
  2. package/admin/AdminUtils.ts +23 -0
  3. package/admin/tabs/activity/clienterrors/ClientErrorsModel.ts +2 -1
  4. package/admin/tabs/activity/feedback/FeedbackPanel.ts +3 -3
  5. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +2 -1
  6. package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +3 -2
  7. package/admin/tabs/general/alertBanner/AlertBannerModel.ts +2 -2
  8. package/admin/tabs/general/config/ConfigPanelModel.ts +2 -2
  9. package/admin/tabs/general/users/UserModel.ts +2 -2
  10. package/admin/tabs/monitor/MonitorResultsModel.ts +48 -11
  11. package/admin/tabs/monitor/MonitorResultsPanel.ts +71 -8
  12. package/admin/tabs/server/connectionpool/ConnPoolMonitorModel.ts +2 -2
  13. package/admin/tabs/server/ehcache/EhCacheModel.ts +2 -2
  14. package/admin/tabs/server/environment/ServerEnvModel.ts +3 -3
  15. package/admin/tabs/server/logLevel/LogLevelPanel.ts +3 -3
  16. package/admin/tabs/server/logViewer/LogViewerModel.ts +2 -2
  17. package/admin/tabs/server/memory/MemoryMonitorModel.ts +2 -2
  18. package/admin/tabs/server/services/ServiceModel.ts +3 -3
  19. package/admin/tabs/server/websocket/WebSocketModel.ts +3 -2
  20. package/admin/tabs/userData/jsonblob/JsonBlobModel.ts +2 -2
  21. package/admin/tabs/userData/prefs/PreferenceModel.ts +2 -2
  22. package/admin/tabs/userData/prefs/UserPreferencePanel.ts +3 -3
  23. package/appcontainer/ExceptionDialogModel.ts +6 -0
  24. package/cmp/ag-grid/AgGrid.scss +27 -5
  25. package/cmp/error/ErrorBoundary.ts +68 -0
  26. package/cmp/error/ErrorBoundaryModel.ts +77 -0
  27. package/cmp/grid/Grid.scss +10 -0
  28. package/cmp/grid/GridModel.ts +137 -33
  29. package/cmp/grid/columns/ColumnGroup.ts +11 -0
  30. package/cmp/markdown/Markdown.ts +32 -0
  31. package/cmp/markdown/index.ts +1 -0
  32. package/core/XH.ts +5 -9
  33. package/core/exception/ExceptionHandler.ts +15 -0
  34. package/data/RecordAction.ts +1 -1
  35. package/data/cube/Query.ts +37 -3
  36. package/data/cube/View.ts +16 -4
  37. package/data/cube/row/BaseRow.ts +2 -2
  38. package/desktop/appcontainer/AppContainer.ts +17 -3
  39. package/desktop/appcontainer/Banner.scss +25 -0
  40. package/desktop/appcontainer/Banner.ts +3 -2
  41. package/desktop/cmp/dash/canvas/impl/DashCanvasView.ts +4 -1
  42. package/desktop/cmp/dash/container/DashContainerModel.ts +3 -2
  43. package/desktop/cmp/dash/container/impl/DashContainerUtils.ts +4 -4
  44. package/desktop/cmp/dash/container/impl/DashContainerView.ts +2 -1
  45. package/desktop/cmp/dock/DockViewModel.ts +10 -8
  46. package/desktop/cmp/dock/impl/DockContainer.ts +1 -0
  47. package/desktop/cmp/dock/impl/DockView.ts +2 -1
  48. package/desktop/cmp/error/ErrorMessage.scss +1 -0
  49. package/desktop/cmp/error/ErrorMessage.ts +61 -23
  50. package/desktop/cmp/input/Checkbox.scss +13 -0
  51. package/desktop/cmp/input/Checkbox.ts +2 -0
  52. package/desktop/cmp/modalsupport/ModalSupport.scss +2 -0
  53. package/desktop/cmp/panel/Panel.ts +37 -14
  54. package/desktop/cmp/panel/PanelModel.ts +35 -7
  55. package/desktop/cmp/panel/impl/PanelHeader.scss +5 -0
  56. package/desktop/cmp/panel/impl/PanelHeader.ts +53 -38
  57. package/desktop/cmp/tab/impl/Tab.ts +2 -1
  58. package/dynamics/desktop.ts +15 -17
  59. package/dynamics/mobile.ts +10 -8
  60. package/inspector/Inspector.scss +5 -10
  61. package/inspector/InspectorPanel.ts +2 -0
  62. package/inspector/instances/InstancesModel.ts +1 -1
  63. package/kit/react-markdown/index.ts +11 -0
  64. package/mobile/appcontainer/AppContainer.ts +17 -3
  65. package/mobile/appcontainer/Banner.scss +25 -0
  66. package/mobile/appcontainer/Banner.ts +3 -2
  67. package/mobile/cmp/error/ErrorMessage.ts +58 -18
  68. package/mobile/cmp/menu/impl/Menu.scss +7 -1
  69. package/mobile/cmp/navigator/PageModel.ts +1 -0
  70. package/mobile/cmp/navigator/impl/Page.ts +2 -1
  71. package/mobile/cmp/panel/Panel.ts +5 -1
  72. package/mobile/cmp/tab/impl/Tab.ts +2 -1
  73. package/package.json +5 -3
  74. package/styles/vars.scss +2 -0
  75. package/svc/AlertBannerService.ts +2 -2
  76. package/svc/GridExportService.ts +1 -1
  77. package/admin/tabs/monitor/MonitorResultsToolbar.ts +0 -66
  78. package/appcontainer/ErrorBoundary.ts +0 -36
package/core/XH.ts CHANGED
@@ -56,6 +56,7 @@ import '../styles/XH.scss';
56
56
  import {ModelSelector, HoistModel, RefreshContextModel} from './model';
57
57
  import {HoistAppModel, BannerSpec, ToastSpec, MessageSpec, HoistUser, TaskObserver} from './';
58
58
  import {CancelFn} from 'router5/types/types/base';
59
+ import {apiDeprecated} from '@xh/hoist/utils/js';
59
60
 
60
61
  export const MIN_HOIST_CORE_VERSION = '16.0';
61
62
 
@@ -576,16 +577,11 @@ export class XHApi {
576
577
  this.exceptionHandler.handleException(exception, options);
577
578
  }
578
579
 
579
- /**
580
- * Show an exception. This method is an alias for {@link ExceptionHandler.showException}.
581
- *
582
- * Intended to be used for the deferred / user-initiated showing of exceptions that have
583
- * already been appropriately logged. Apps should typically prefer {@link handleException}.
584
- *
585
- * @param exception - thrown object, will be coerced into a {@link HoistException}.
586
- * @param options - provides further control over how the exception is shown and/or logged.
587
- */
588
580
  showException(exception: unknown, options?: ExceptionHandlerOptions) {
581
+ apiDeprecated('showException', {
582
+ msg: 'Use XH.exceptionHandler.showException instead',
583
+ v: '62'
584
+ });
589
585
  this.exceptionHandler.showException(exception, options);
590
586
  }
591
587
 
@@ -156,6 +156,21 @@ export class ExceptionHandler {
156
156
  XH.appContainerModel.exceptionDialogModel.show(e, opts);
157
157
  }
158
158
 
159
+ /**
160
+ * Show an exception in full detail, with ability to report and copy to clipboard.
161
+ * Intended to be used for the deferred / user-initiated showing of exceptions that have
162
+ * already been appropriately logged. Apps should typically prefer {@link handleException}.
163
+ *
164
+ * @param exception - thrown object, will be coerced into a {@link HoistException}.
165
+ * @param options - provides further control over how the exception is shown.
166
+ */
167
+ showExceptionDetails(exception: unknown, options?: ExceptionHandlerOptions) {
168
+ if (XH.pageState == 'terminated' || XH.pageState == 'frozen') return;
169
+
170
+ const {e, opts} = this.parseArgs(exception, options);
171
+ XH.appContainerModel.exceptionDialogModel.showDetails(e, opts);
172
+ }
173
+
159
174
  /**
160
175
  * Create a server-side exception entry. Client metadata will be set automatically.
161
176
  *
@@ -66,7 +66,7 @@ export interface ActionFnData {
66
66
  action?: RecordAction;
67
67
 
68
68
  /** Row data object (entire row, if any).*/
69
- record?: any;
69
+ record?: StoreRecord;
70
70
 
71
71
  /** All currently selected records (if any).*/
72
72
  selectedRecords?: StoreRecord[];
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import {Filter, parseFilter, StoreRecord} from '@xh/hoist/data';
8
+ import {BucketSpecFn, Filter, LockFn, OmitFn, parseFilter, StoreRecord} from '@xh/hoist/data';
9
9
  import {isEqual, find} from 'lodash';
10
10
  import {FilterLike, FilterTestFn} from '../filter/Types';
11
11
  import {CubeField} from './CubeField';
@@ -51,6 +51,25 @@ export interface QueryConfig {
51
51
 
52
52
  /** True to include leaf nodes in return.*/
53
53
  includeLeaves?: boolean;
54
+
55
+ /**
56
+ * Optional function to be called for each aggregate node to determine if it should be "locked",
57
+ * preventing drill-down into its children. Defaults to Cube.lockFn.
58
+ */
59
+ lockFn?: LockFn;
60
+
61
+ /**
62
+ * Optional function to be called for each dimension during row generation to determine if the
63
+ * children of that dimension should be bucketed into additional dynamic dimensions.
64
+ * Defaults to Cube.bucketSpecFn.
65
+ */
66
+ bucketSpecFn?: BucketSpecFn;
67
+
68
+ /**
69
+ * Optional function to be called on all single child rows during view processing.
70
+ * Return true to omit the row. Defaults to Cube.omitFn.
71
+ */
72
+ omitFn?: OmitFn;
54
73
  }
55
74
 
56
75
  /** {@inheritDoc QueryConfig} */
@@ -61,6 +80,9 @@ export class Query {
61
80
  readonly includeRoot: boolean;
62
81
  readonly includeLeaves: boolean;
63
82
  readonly cube: Cube;
83
+ readonly lockFn: LockFn;
84
+ readonly bucketSpecFn: BucketSpecFn;
85
+ readonly omitFn: OmitFn;
64
86
 
65
87
  private readonly _testFn: FilterTestFn;
66
88
 
@@ -70,7 +92,10 @@ export class Query {
70
92
  dimensions,
71
93
  filter = null,
72
94
  includeRoot = false,
73
- includeLeaves = false
95
+ includeLeaves = false,
96
+ lockFn = cube.lockFn,
97
+ bucketSpecFn = cube.bucketSpecFn,
98
+ omitFn = cube.omitFn
74
99
  }: QueryConfig) {
75
100
  this.cube = cube;
76
101
  this.fields = this.parseFields(fields);
@@ -78,6 +103,9 @@ export class Query {
78
103
  this.includeRoot = includeRoot;
79
104
  this.includeLeaves = includeLeaves;
80
105
  this.filter = parseFilter(filter);
106
+ this.lockFn = lockFn;
107
+ this.bucketSpecFn = bucketSpecFn;
108
+ this.omitFn = omitFn;
81
109
 
82
110
  this._testFn = this.filter?.getTestFn(this.cube.store) ?? null;
83
111
  }
@@ -89,6 +117,9 @@ export class Query {
89
117
  filter: this.filter,
90
118
  includeRoot: this.includeRoot,
91
119
  includeLeaves: this.includeLeaves,
120
+ lockFn: this.lockFn,
121
+ bucketSpecFn: this.bucketSpecFn,
122
+ omitFn: this.omitFn,
92
123
  cube: this.cube,
93
124
  ...overrides
94
125
  };
@@ -121,7 +152,10 @@ export class Query {
121
152
  isEqual(this.dimensions, other.dimensions) &&
122
153
  this.cube === other.cube &&
123
154
  this.includeRoot === other.includeRoot &&
124
- this.includeLeaves === other.includeLeaves
155
+ this.includeLeaves === other.includeLeaves &&
156
+ this.bucketSpecFn == other.bucketSpecFn &&
157
+ this.omitFn == other.omitFn &&
158
+ this.lockFn == other.lockFn
125
159
  );
126
160
  }
127
161
 
package/data/cube/View.ts CHANGED
@@ -99,7 +99,7 @@ export class View extends HoistBase {
99
99
  const {query, stores = [], connect = false} = config;
100
100
 
101
101
  this.query = query;
102
- this.stores = castArray(stores);
102
+ this.stores = this.parseStores(stores);
103
103
  this._rowCache = new Map();
104
104
  this.fullUpdate();
105
105
 
@@ -185,7 +185,7 @@ export class View extends HoistBase {
185
185
 
186
186
  /** Set stores to be loaded/reloaded with data from this view. */
187
187
  setStores(stores: Some<Store>) {
188
- this.stores = castArray(stores);
188
+ this.stores = this.parseStores(stores);
189
189
  this.loadStores();
190
190
  }
191
191
 
@@ -351,9 +351,9 @@ export class View extends HoistBase {
351
351
  parentId: string,
352
352
  appliedDimensions: PlainObject
353
353
  ): BaseRow[] {
354
- if (!this.cube.bucketSpecFn) return rows;
354
+ if (!this.query.bucketSpecFn) return rows;
355
355
 
356
- const bucketSpec = this.cube.bucketSpecFn(rows);
356
+ const bucketSpec = this.query.bucketSpecFn(rows);
357
357
  if (!bucketSpec) return rows;
358
358
 
359
359
  if (!this.query.includeLeaves && rows[0]?.isLeaf) return rows;
@@ -463,6 +463,18 @@ export class View extends HoistBase {
463
463
  return this.fields.every(({aggregator}) => !aggregator || aggregator.dependsOnChildrenOnly);
464
464
  }
465
465
 
466
+ private parseStores(stores: Some<Store>): Store[] {
467
+ const ret = castArray(stores);
468
+
469
+ // Views mutate the rows they feed to connected stores -- `reuseRecords` not appropriate
470
+ throwIf(
471
+ ret.some(s => s.reuseRecords),
472
+ 'Store.reuseRecords cannot be used on a Store that is connected to a Cube View'
473
+ );
474
+
475
+ return ret;
476
+ }
477
+
466
478
  override destroy() {
467
479
  this.disconnect();
468
480
  super.destroy();
@@ -59,7 +59,7 @@ export abstract class BaseRow {
59
59
  let dataChildren = this.getVisibleChildrenDatas();
60
60
 
61
61
  // 2) If omitting ourselves, we are done, return visible children.
62
- if (!isLeaf && view.cube.omitFn?.(this as any)) return dataChildren;
62
+ if (!isLeaf && view.query.omitFn?.(this as any)) return dataChildren;
63
63
 
64
64
  // 3) Otherwise, we can attach this data to the children data and return.
65
65
 
@@ -89,7 +89,7 @@ export abstract class BaseRow {
89
89
  }
90
90
 
91
91
  // Skip all children in a locked node
92
- if (view.cube.lockFn?.(this as any)) {
92
+ if (view.query.lockFn?.(this as any)) {
93
93
  this.locked = true;
94
94
  return null;
95
95
  }
@@ -7,7 +7,7 @@
7
7
  import {AppContainerModel} from '@xh/hoist/appcontainer/AppContainerModel';
8
8
  import {fragment, frame, vframe, viewport} from '@xh/hoist/cmp/layout';
9
9
  import {createElement, hoistCmp, refreshContextView, uses, XH} from '@xh/hoist/core';
10
- import {errorBoundary} from '@xh/hoist/appcontainer/ErrorBoundary';
10
+ import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
11
11
  import {changelogDialog} from '@xh/hoist/desktop/appcontainer/ChangelogDialog';
12
12
  import {suspendPanel} from '@xh/hoist/desktop/appcontainer/SuspendPanel';
13
13
  import {dockContainerImpl} from '@xh/hoist/desktop/cmp/dock/impl/DockContainer';
@@ -40,6 +40,7 @@ import {optionsDialog} from './OptionsDialog';
40
40
  import {toastSource} from './ToastSource';
41
41
  import {versionBar} from './VersionBar';
42
42
  import {ReactElement} from 'react';
43
+ import {errorMessage} from '../cmp/error/ErrorMessage';
43
44
 
44
45
  installDesktopImpls({
45
46
  tabContainerImpl,
@@ -52,7 +53,8 @@ installDesktopImpls({
52
53
  ColChooserModel,
53
54
  ColumnHeaderFilterModel,
54
55
  useContextMenu,
55
- ModalSupportModel
56
+ ModalSupportModel,
57
+ errorMessage
56
58
  });
57
59
  /**
58
60
  * Top-level wrapper for Desktop applications.
@@ -73,7 +75,19 @@ export const AppContainer = hoistCmp({
73
75
 
74
76
  return fragment(
75
77
  hotkeysProvider(
76
- errorBoundary(viewForState()),
78
+ errorBoundary({
79
+ modelConfig: {
80
+ errorHandler: {
81
+ title: 'Critical Error',
82
+ message:
83
+ XH.clientAppName +
84
+ ' encountered a critical error and cannot be displayed.',
85
+ requireReload: true
86
+ },
87
+ errorRenderer: () => null
88
+ },
89
+ item: viewForState()
90
+ }),
77
91
  // Modal component helpers rendered here at top-level to support display of messages
78
92
  // and exceptions at any point during the app lifecycle.
79
93
  exceptionDialog(),
@@ -21,6 +21,31 @@ body.xh-app .xh-banner {
21
21
  overflow: hidden;
22
22
  white-space: nowrap;
23
23
  text-overflow: ellipsis;
24
+
25
+ // Disallow most markdown styling except **bold**, *italics* and (links)
26
+ h1,
27
+ h2,
28
+ h3,
29
+ h4,
30
+ p,
31
+ ul,
32
+ li,
33
+ a,
34
+ code {
35
+ display: inline;
36
+ margin: unset;
37
+ padding: unset;
38
+ color: unset;
39
+ font-weight: unset;
40
+ font-size: unset;
41
+ font-family: unset;
42
+ list-style: none;
43
+ }
44
+
45
+ // Render links with an underline
46
+ a {
47
+ text-decoration: underline;
48
+ }
24
49
  }
25
50
 
26
51
  &__action-button,
@@ -10,7 +10,8 @@ import {hframe, div} from '@xh/hoist/cmp/layout';
10
10
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
11
11
  import {button} from '@xh/hoist/desktop/cmp/button';
12
12
  import {Icon} from '@xh/hoist/icon';
13
- import {isFunction, isEmpty} from 'lodash';
13
+ import {markdown} from '@xh/hoist/cmp/markdown';
14
+ import {isFunction, isEmpty, isString} from 'lodash';
14
15
  import classNames from 'classnames';
15
16
 
16
17
  import './Banner.scss';
@@ -43,7 +44,7 @@ export const banner = hoistCmp.factory({
43
44
  icon,
44
45
  div({
45
46
  className: 'xh-banner__message',
46
- item: message,
47
+ item: isString(message) ? markdown({content: message}) : message,
47
48
  onClick
48
49
  })
49
50
  ]
@@ -14,6 +14,7 @@ import {popover, Position} from '@xh/hoist/kit/blueprint';
14
14
  import {button} from '../../../button';
15
15
  import {panel} from '../../../panel';
16
16
  import {DashCanvasViewModel} from '../DashCanvasViewModel';
17
+ import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
17
18
 
18
19
  /**
19
20
  * Implementation component to show an item within a DashCanvas. This component
@@ -46,7 +47,9 @@ export const dashCanvasView = hoistCmp.factory({
46
47
  ...headerProps,
47
48
  item: box({
48
49
  ref: useOnResize(dims => model.onContentsResized(dims), {debounce: 100}),
49
- item: elementFromContent(viewSpec.content, {flex: 1, viewModel: model}),
50
+ item: errorBoundary(
51
+ elementFromContent(viewSpec.content, {flex: 1, viewModel: model})
52
+ ),
50
53
  flex: autoHeight ? 'none' : 'auto'
51
54
  })
52
55
  });
@@ -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) &&
@@ -9,6 +9,7 @@ import {hoistCmp, refreshContextView, uses} from '@xh/hoist/core';
9
9
  import {elementFromContent} from '@xh/hoist/utils/react';
10
10
  import {useRef} from 'react';
11
11
  import {DashViewModel} from '../../DashViewModel';
12
+ import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
12
13
 
13
14
  /**
14
15
  * Implementation component to show an item within a DashContainer. This component
@@ -44,7 +45,7 @@ export const dashContainerView = hoistCmp.factory({
44
45
  className,
45
46
  item: refreshContextView({
46
47
  model: refreshContextModel,
47
- item: elementFromContent(viewSpec.content, {flex: 1})
48
+ item: errorBoundary(elementFromContent(viewSpec.content, {flex: 1}))
48
49
  })
49
50
  });
50
51
  }
@@ -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
  });
@@ -15,6 +15,7 @@ import {elementFromContent} from '@xh/hoist/utils/react';
15
15
  import classNames from 'classnames';
16
16
  import {useRef} from 'react';
17
17
  import './Dock.scss';
18
+ import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
18
19
 
19
20
  interface DockViewProps extends HoistProps<DockViewModel> {
20
21
  /** True to style docked headers with reduced padding and font-size. */
@@ -57,7 +58,7 @@ export const dockView = hoistCmp.factory<DockViewProps>({
57
58
  model: refreshContextModel,
58
59
  item: div({
59
60
  className: 'xh-dock-view__body',
60
- item: elementFromContent(model.content)
61
+ item: errorBoundary(elementFromContent(model.content))
61
62
  })
62
63
  });
63
64
 
@@ -1,6 +1,7 @@
1
1
  .xh-error-message {
2
2
  align-items: center;
3
3
  justify-content: center;
4
+ padding: 10px;
4
5
 
5
6
  &__inner {
6
7
  padding: var(--xh-pad-px);
@@ -4,36 +4,52 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
- import {div, frame, p} from '@xh/hoist/cmp/layout';
8
- import {hoistCmp, HoistProps, PlainObject} from '@xh/hoist/core';
7
+ import {div, filler, frame, hbox, p} from '@xh/hoist/cmp/layout';
8
+ import {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
- import {isEmpty, isNil, isString} from 'lodash';
12
- import {isValidElement, ReactNode, MouseEvent} from 'react';
11
+ import {isNil, isString} from 'lodash';
12
+ import {isValidElement, ReactNode} from 'react';
13
13
 
14
14
  import './ErrorMessage.scss';
15
+ import {Icon} from '@xh/hoist/icon';
15
16
 
16
17
  export interface ErrorMessageProps extends HoistProps {
17
18
  /**
18
19
  * If provided, will render a "Retry" button that calls this function.
19
20
  * Use `actionButtonProps` for further control over this button.
20
21
  */
21
- actionFn?: (e: MouseEvent) => void;
22
+ actionFn?: (error: unknown) => void;
23
+
22
24
  /**
23
25
  * If provided, component will render an inline action button - prompting to user to take some
24
26
  * action that might resolve the error, such as retrying a failed data load.
25
27
  */
26
28
  actionButtonProps?: ButtonProps;
29
+
30
+ /**
31
+ * If provided, will render a "Details" button that calls this function.
32
+ * Use `detailsButtonProps` for further control over this button. Default false.
33
+ */
34
+ detailsFn?: (error: unknown) => void;
35
+
36
+ /**
37
+ * If provided, component will render an inline details button.
38
+ */
39
+ detailsButtonProps?: ButtonProps;
40
+
27
41
  /**
28
42
  * Error to display. If undefined, this component will look for an error property on its model.
29
43
  * If no error is found, this component will not be displayed.
30
44
  */
31
- error?: Error | string | PlainObject;
45
+ error?: unknown;
46
+
32
47
  /**
33
48
  * Message to display for the error.
34
49
  * Defaults to the error, or any 'message' property contained within it.
35
50
  */
36
51
  message?: ReactNode;
52
+
37
53
  /** Optional title to display above the message. */
38
54
  title?: ReactNode;
39
55
  }
@@ -43,38 +59,53 @@ export interface ErrorMessageProps extends HoistProps {
43
59
  */
44
60
  export const [ErrorMessage, errorMessage] = hoistCmp.withFactory<ErrorMessageProps>({
45
61
  className: 'xh-error-message',
46
- render(
47
- {
62
+ render(props, ref) {
63
+ let {
48
64
  className,
49
65
  model,
50
- error = (model as any)?.error,
66
+ error = model?.['error'],
51
67
  message,
52
68
  title,
53
69
  actionFn,
54
- actionButtonProps
55
- },
56
- ref
57
- ) {
58
- if (actionFn) {
59
- actionButtonProps = {...actionButtonProps, onClick: actionFn};
60
- }
70
+ actionButtonProps,
71
+ detailsFn,
72
+ detailsButtonProps
73
+ } = props;
61
74
 
62
75
  if (isNil(error)) return null;
63
76
 
64
77
  if (!message) {
65
78
  if (isString(error)) {
66
- message = error as any;
79
+ message = error;
67
80
  } else if (error.message) {
68
81
  message = error.message;
69
82
  }
70
83
  }
71
84
 
85
+ if (actionFn) {
86
+ actionButtonProps = {...actionButtonProps, onClick: error => actionFn(error)};
87
+ }
88
+
89
+ if (detailsFn) {
90
+ detailsButtonProps = {...detailsButtonProps, onClick: error => detailsFn(error)};
91
+ }
92
+
93
+ let buttons = [],
94
+ buttonBar = null;
95
+ if (detailsButtonProps) buttons.push(detailsButton(detailsButtonProps));
96
+ if (actionButtonProps) buttons.push(actionButton(actionButtonProps));
97
+ if (buttons.length == 1) {
98
+ buttonBar = buttons[0];
99
+ } else if (buttons.length == 2) {
100
+ buttonBar = hbox(buttons[0], filler(), buttons[1]);
101
+ }
102
+
72
103
  return frame({
104
+ ref,
73
105
  className,
74
106
  item: div({
75
- ref,
76
107
  className: 'xh-error-message__inner',
77
- items: [titleCmp({title}), messageCmp({message}), actionButton({actionButtonProps})]
108
+ items: [titleCmp({title}), messageCmp({message, error}), buttonBar]
78
109
  })
79
110
  });
80
111
  }
@@ -92,11 +123,18 @@ const messageCmp = hoistCmp.factory(({message}) => {
92
123
  return null;
93
124
  });
94
125
 
95
- const actionButton = hoistCmp.factory(({actionButtonProps}) => {
96
- if (isEmpty(actionButtonProps)) return null;
126
+ const actionButton = hoistCmp.factory<ButtonProps>(props => {
97
127
  return button({
98
128
  text: 'Retry',
99
- minimal: false,
100
- ...actionButtonProps
129
+ icon: Icon.refresh(),
130
+ ...props
131
+ });
132
+ });
133
+
134
+ const detailsButton = hoistCmp.factory<ButtonProps>(props => {
135
+ return button({
136
+ text: 'Details',
137
+ icon: Icon.detail(),
138
+ ...props
101
139
  });
102
140
  });
@@ -0,0 +1,13 @@
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 © 2023 Extremely Heavy Industries Inc.
6
+ */
7
+
8
+ .xh-check-box.xh-input-disabled input:checked ~ .bp4-control-indicator {
9
+ background-color: var(--xh-input-disabled-bg) !important;
10
+ &::before {
11
+ background-image: var(--xh-input-disabled-checkmark-svg) !important;
12
+ }
13
+ }
@@ -12,6 +12,8 @@ import {withDefault} from '@xh/hoist/utils/js';
12
12
  import {isNil} from 'lodash';
13
13
  import {ReactNode} from 'react';
14
14
 
15
+ import './Checkbox.scss';
16
+
15
17
  export interface CheckboxProps extends HoistProps, HoistInputProps, StyleProps {
16
18
  value?: boolean;
17
19