@xh/hoist 46.0.0 → 46.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.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## v46.1.0 - 2022-02-07
4
+
5
+ ### Technical
6
+ * This release modifies our workaround to handle the ag-Grid v26 changes to cast all of their node
7
+ ids to strings. The initial approach in v46.0.0 -- matching the ag-Grid behavior by casting all
8
+ `StoreRecord` ids to strings -- was deemed too problematic for applications and has been reverted.
9
+ Numerical ids in Store are once again fully supported.
10
+
11
+ In order to accommodate the ag-Grid changes, applications that are using ag-Grid APIs
12
+ (e.g. `agApi.getNode()` ) with `StoreRecord` should be sure to use the new property `StoreRecord.agId`
13
+ to locate and compare records. We expect such usages to be rare in application code.
14
+
15
+ ### 🎁 New Features
16
+
17
+ * `XH.showFeedbackDialog()` now takes an optional message to pre-populate within the dialog.
18
+ * Admins can now force suspension of individual client apps from the Server > WebSockets tab.
19
+ Intended to e.g. force an app to stop refreshing an expensive query or polling an endpoint removed
20
+ in a new release. Requires websockets to be enabled on both server and client.
21
+ * `FormField`s no longer need to specify a child input, and will simply render their readonly version
22
+ if no child is specified. This simplifies the common use-case of fields/forms that are always
23
+ readonly.
24
+
25
+ ### 🐞 Bug Fixes
26
+ * `FormField` would previously throw if given a child that did not have `propTypes`. This has
27
+ been fixed.
28
+
29
+ [Commit Log](https://github.com/xh/hoist-react/compare/v46.0.0...v46.1.0)
30
+
3
31
  ## v46.0.0 - 2022-01-25
4
32
 
5
33
  ### 🎁 New Features
6
34
 
7
- * `ExceptionHandler` provides a collection of overwritable static properties, allowing you to set
35
+ * `ExceptionHandler` provides a collection of overridable static properties, allowing you to set
8
36
  app-wide default behaviour for exception handling.
9
37
  * `XH.handleException()` takes new `alertType` option to render error alerts via the familiar
10
38
  `dialog` or new `toast` UI.
@@ -8,7 +8,7 @@ import {form} from '@xh/hoist/cmp/form';
8
8
  import {a, div, h3, hframe, span, vbox} from '@xh/hoist/cmp/layout';
9
9
  import {hoistCmp} from '@xh/hoist/core';
10
10
  import {formField} from '@xh/hoist/desktop/cmp/form';
11
- import {jsonInput, switchInput, textInput} from '@xh/hoist/desktop/cmp/input';
11
+ import {jsonInput} from '@xh/hoist/desktop/cmp/input';
12
12
  import {panel} from '@xh/hoist/desktop/cmp/panel';
13
13
  import {fmtDateTimeSec} from '@xh/hoist/format';
14
14
  import {Icon} from '@xh/hoist/icon';
@@ -35,46 +35,25 @@ export const clientErrorDetail = hoistCmp.factory(
35
35
  style: {width: '400px'},
36
36
  items: [
37
37
  h3(Icon.info(), 'Error Info'),
38
- formField({
39
- field: 'username',
40
- item: textInput()
41
- }),
38
+ formField({field: 'username'}),
42
39
  formField({
43
40
  field: 'dateCreated',
44
- item: textInput(),
45
41
  readonlyRenderer: fmtDateTimeSec
46
42
  }),
47
- formField({
48
- field: 'appVersion',
49
- item: textInput()
50
- }),
43
+ formField({field: 'appVersion'}),
51
44
  formField({
52
45
  field: 'userAlerted',
53
- label: 'User Alerted?',
54
- item: switchInput()
55
- }),
56
- formField({
57
- field: 'id',
58
- item: textInput()
46
+ label: 'User Alerted?'
59
47
  }),
48
+ formField({field: 'id'}),
60
49
  formField({
61
50
  field: 'url',
62
- item: textInput(),
63
51
  readonlyRenderer: hyperlinkVal
64
52
  }),
65
53
  h3(Icon.desktop(), 'Device / Browser'),
66
- formField({
67
- field: 'device',
68
- item: textInput()
69
- }),
70
- formField({
71
- field: 'browser',
72
- item: textInput()
73
- }),
74
- formField({
75
- field: 'userAgent',
76
- item: textInput()
77
- })
54
+ formField({field: 'device'}),
55
+ formField({field: 'browser'}),
56
+ formField({field: 'userAgent'})
78
57
  ]
79
58
  }),
80
59
  vbox({
@@ -5,7 +5,7 @@ import {storeFilterField} from '@xh/hoist/cmp/store';
5
5
  import {hoistCmp, uses} from '@xh/hoist/core';
6
6
  import {colChooserButton, exportButton} from '@xh/hoist/desktop/cmp/button';
7
7
  import {formField} from '@xh/hoist/desktop/cmp/form';
8
- import {jsonInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input';
8
+ import {jsonInput} from '@xh/hoist/desktop/cmp/input';
9
9
  import {panel} from '@xh/hoist/desktop/cmp/panel';
10
10
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
11
11
  import {dateTimeSecRenderer, numberRenderer} from '@xh/hoist/format';
@@ -63,7 +63,6 @@ const detailRecForm = hoistCmp.factory(
63
63
  h3(Icon.info(), 'Activity'),
64
64
  formField({
65
65
  field: 'username',
66
- item: textInput(),
67
66
  readonlyRenderer: (username) => {
68
67
  if (!username) return naSpan();
69
68
  const {impersonating} = formModel.values,
@@ -71,22 +70,14 @@ const detailRecForm = hoistCmp.factory(
71
70
  return span(username, impSpan);
72
71
  }
73
72
  }),
74
- formField({
75
- field: 'category',
76
- item: textInput()
77
- }),
78
- formField({
79
- field: 'msg',
80
- item: textArea()
81
- }),
73
+ formField({field: 'category'}),
74
+ formField({field: 'msg'}),
82
75
  formField({
83
76
  field: 'dateCreated',
84
- item: textInput(),
85
77
  readonlyRenderer: dateTimeSecRenderer({})
86
78
  }),
87
79
  formField({
88
80
  field: 'elapsed',
89
- item: textInput(),
90
81
  readonlyRenderer: numberRenderer({
91
82
  label: 'ms',
92
83
  nullDisplay: '-',
@@ -94,23 +85,11 @@ const detailRecForm = hoistCmp.factory(
94
85
  formatConfig: {thousandSeparated: false, mantissa: 0}
95
86
  })
96
87
  }),
97
- formField({
98
- field: 'id',
99
- item: textInput()
100
- }),
88
+ formField({field: 'id'}),
101
89
  h3(Icon.desktop(), 'Device / Browser'),
102
- formField({
103
- field: 'device',
104
- item: textInput()
105
- }),
106
- formField({
107
- field: 'browser',
108
- item: textInput()
109
- }),
110
- formField({
111
- field: 'userAgent',
112
- item: textInput()
113
- })
90
+ formField({field: 'device'}),
91
+ formField({field: 'browser'}),
92
+ formField({field: 'userAgent'})
114
93
  ]
115
94
  }),
116
95
  panel({
@@ -15,8 +15,7 @@ import {
15
15
  buttonGroupInput,
16
16
  dateInput,
17
17
  switchInput,
18
- textArea,
19
- textInput
18
+ textArea
20
19
  } from '@xh/hoist/desktop/cmp/input';
21
20
  import {panel} from '@xh/hoist/desktop/cmp/panel';
22
21
  import {dateTimeRenderer} from '@xh/hoist/format';
@@ -137,14 +136,12 @@ const formPanel = hoistCmp.factory(
137
136
  omit: !formModel.values.updated,
138
137
  field: 'updated',
139
138
  className: 'xh-alert-banner-panel__form-panel__fields--ro',
140
- item: textInput(),
141
139
  readonlyRenderer: dateTimeRenderer({})
142
140
  }),
143
141
  formField({
144
142
  omit: !formModel.values.updatedBy,
145
143
  field: 'updatedBy',
146
- className: 'xh-alert-banner-panel__form-panel__fields--ro',
147
- item: textInput()
144
+ className: 'xh-alert-banner-panel__form-panel__fields--ro'
148
145
  })
149
146
  ]
150
147
  })
@@ -4,16 +4,17 @@
4
4
  *
5
5
  * Copyright © 2021 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {div, p} from '@xh/hoist/cmp/layout';
7
8
  import {HoistModel, managed, XH} from '@xh/hoist/core';
8
9
  import {GridModel} from '@xh/hoist/cmp/grid';
9
10
  import {textInput} from '@xh/hoist/desktop/cmp/input';
10
- import {required} from '@xh/hoist/data';
11
11
  import {Icon} from '@xh/hoist/icon';
12
12
  import {action, observable, makeObservable} from '@xh/hoist/mobx';
13
13
  import {Timer} from '@xh/hoist/utils/async';
14
14
  import {SECONDS} from '@xh/hoist/utils/datetime';
15
15
  import {isDisplayed} from '@xh/hoist/utils/js';
16
16
  import * as Col from '@xh/hoist/admin/columns';
17
+ import {isEmpty} from 'lodash';
17
18
  import {createRef} from 'react';
18
19
  import * as WSCol from './WebSocketColumns';
19
20
 
@@ -37,12 +38,13 @@ export class WebSocketModel extends HoistModel {
37
38
  this.gridModel = new GridModel({
38
39
  emptyText: 'No clients connected.',
39
40
  enableExport: true,
41
+ selModel: 'multiple',
40
42
  store: {
41
43
  idSpec: 'key',
42
44
  processRawData: row => {
43
45
  const authUser = row.authUser.username,
44
46
  apparentUser = row.apparentUser.username,
45
- impersonating = authUser != apparentUser;
47
+ impersonating = authUser !== apparentUser;
46
48
 
47
49
  return {
48
50
  ...row,
@@ -91,29 +93,41 @@ export class WebSocketModel extends HoistModel {
91
93
  this.lastRefresh = Date.now();
92
94
  }
93
95
 
94
- async sendAlertToSelectedAsync() {
95
- const {selectedRecord} = this.gridModel;
96
- if (!selectedRecord) return;
96
+ async forceSuspendOnSelectedAsync() {
97
+ const {selectedRecords} = this.gridModel;
98
+ if (isEmpty(selectedRecords)) return;
97
99
 
98
100
  const message = await XH.prompt({
99
- title: 'Send test alert',
100
- icon: Icon.bullhorn(),
101
- confirmProps: {text: 'Send'},
102
- message: `Send an in-app alert to ${selectedRecord.data.authUser} with the text below.`,
101
+ title: 'Force suspend',
102
+ icon: Icon.stopCircle(),
103
+ confirmProps: {text: 'Force Suspend', icon: Icon.stopCircle(), intent: 'danger'},
104
+ cancelProps: {autoFocus: true},
105
+ message: div(
106
+ p(`This action will force ${selectedRecords.length} connected client(s) into suspended mode, halting all background refreshes and other activity, masking the UI, and requiring users to reload the app to continue.`),
107
+ p('If desired, you can enter a message below to display within the suspended app.')
108
+ ),
103
109
  input: {
104
- item: textInput({autoFocus: true, selectOnFocus: true}),
105
- initialValue: 'This is a test alert',
106
- rules: [required]
110
+ item: textInput({placeholder: 'User-facing message (optional)'}),
111
+ initialValue: null
107
112
  }
108
113
  });
109
114
 
110
- XH.fetchJson({
111
- url: 'webSocketAdmin/pushToChannel',
112
- params: {
113
- channelKey: selectedRecord.data.key,
114
- topic: XH.webSocketService.TEST_MSG_TOPIC,
115
- message
116
- }
117
- });
115
+ if (message !== false) {
116
+ const tasks = selectedRecords
117
+ .map((rec) => XH.fetchJson({
118
+ url: 'webSocketAdmin/pushToChannel',
119
+ params: {
120
+ channelKey: rec.data.key,
121
+ topic: XH.webSocketService.FORCE_APP_SUSPEND_TOPIC,
122
+ message
123
+ }
124
+ }));
125
+
126
+ await Promise.allSettled(tasks).track({
127
+ category: 'Audit',
128
+ message: 'Suspended clients via WebSocket',
129
+ data: {users: selectedRecords.map(it => it.data.user).sort()}
130
+ });
131
+ }
118
132
  }
119
133
  }
@@ -6,28 +6,30 @@
6
6
  */
7
7
  import {WebSocketModel} from '@xh/hoist/admin/tabs/server/websocket/WebSocketModel';
8
8
  import {grid, gridCountLabel} from '@xh/hoist/cmp/grid';
9
- import {filler} from '@xh/hoist/cmp/layout';
9
+ import {filler, box, fragment, p} from '@xh/hoist/cmp/layout';
10
10
  import {relativeTimestamp} from '@xh/hoist/cmp/relativetimestamp';
11
11
  import {storeFilterField} from '@xh/hoist/cmp/store';
12
- import {creates, hoistCmp} from '@xh/hoist/core';
12
+ import {XH, creates, hoistCmp} from '@xh/hoist/core';
13
13
  import {button, exportButton} from '@xh/hoist/desktop/cmp/button';
14
14
  import {panel} from '@xh/hoist/desktop/cmp/panel';
15
15
  import {toolbarSep} from '@xh/hoist/desktop/cmp/toolbar';
16
16
  import {Icon} from '@xh/hoist/icon';
17
+ import {errorMessage} from '@xh/hoist/desktop/cmp/error';
17
18
 
18
19
  export const webSocketPanel = hoistCmp.factory({
19
20
 
20
21
  model: creates(WebSocketModel),
21
22
 
22
23
  render({model}) {
24
+ if (!XH.webSocketService.enabled) return notPresentMessage();
23
25
  return panel({
24
26
  tbar: [
25
27
  button({
26
- text: 'Send test alert',
27
- icon: Icon.bullhorn(),
28
- intent: 'primary',
29
- disabled: !model.gridModel.selectedRecord,
30
- onClick: () => model.sendAlertToSelectedAsync()
28
+ text: 'Force suspend',
29
+ icon: Icon.stopCircle(),
30
+ intent: 'danger',
31
+ disabled: !model.gridModel.hasSelection,
32
+ onClick: () => model.forceSuspendOnSelectedAsync()
31
33
  }),
32
34
  filler(),
33
35
  relativeTimestamp({bind: 'lastRefresh'}),
@@ -43,3 +45,21 @@ export const webSocketPanel = hoistCmp.factory({
43
45
  });
44
46
  }
45
47
  });
48
+
49
+
50
+ const notPresentMessage = hoistCmp.factory(
51
+ () => box({
52
+ height: 200,
53
+ width: 1000,
54
+ items: [
55
+ errorMessage({
56
+ error: {
57
+ message: fragment(
58
+ p('WebSockets are not enabled in this application.'),
59
+ p('Please ensure that you have enabled web sockets in your server and client application configuration.')
60
+ )
61
+ }
62
+ })
63
+ ]
64
+ })
65
+ );
@@ -30,8 +30,8 @@ export class FeedbackDialogModel extends HoistModel {
30
30
  }
31
31
 
32
32
  @action
33
- show() {
34
- this.message = null;
33
+ show({message = null} = {}) {
34
+ this.message = message;
35
35
  this.isOpen = true;
36
36
  }
37
37
 
@@ -463,17 +463,16 @@ export class AgGridModel extends HoistModel {
463
463
  }
464
464
  }
465
465
 
466
- /** @returns {(string[]|number[])} - list of selected row node ids */
466
+ /** @returns {(string[])} - list of selected row node ids */
467
467
  getSelectedRowNodeIds() {
468
468
  this.throwIfNotReady();
469
-
470
- return this.agApi.getSelectedRows().map(it => it.id);
469
+ return this.agApi.getSelectedNodes().map(it => it.id);
471
470
  }
472
471
 
473
472
  /**
474
473
  * Sets the selected row node ids. Any rows currently selected which are not in the list will be
475
474
  * deselected.
476
- * @param ids {(string[]|number[])} - row node ids to mark as selected
475
+ * @param ids {(string[])} - row node ids to mark as selected
477
476
  */
478
477
  setSelectedRowNodeIds(ids) {
479
478
  this.throwIfNotReady();
@@ -487,19 +486,27 @@ export class AgGridModel extends HoistModel {
487
486
  }
488
487
 
489
488
  /**
490
- * @returns {number} - the id of the first row in the grid, after sorting and filtering, which
489
+ * @returns {string} - the id of the first row in the grid, after sorting and filtering, which
491
490
  * has data associated with it (i.e. not a group or other synthetic row).
492
491
  */
493
492
  getFirstSelectableRowNodeId() {
493
+ return this.getFirstSelectableRowNode()?.id;
494
+ }
495
+
496
+ /**
497
+ * @returns {{Object}} - the first row in the grid, after sorting and filtering, which
498
+ * has data associated with it (i.e. not a group or other synthetic row).
499
+ */
500
+ getFirstSelectableRowNode() {
494
501
  this.throwIfNotReady();
495
502
 
496
- let id = null;
503
+ let ret = null;
497
504
  this.agApi.forEachNodeAfterFilterAndSort(node => {
498
- if (isNil(id) && node.data) {
499
- id = node.id;
505
+ if (!ret && node.data) {
506
+ ret = node;
500
507
  }
501
508
  });
502
- return id;
509
+ return ret;
503
510
  }
504
511
 
505
512
  /**
package/cmp/grid/Grid.js CHANGED
@@ -193,7 +193,7 @@ class GridLocalModel extends HoistModel {
193
193
  immutableData: true,
194
194
  rowDataChangeDetectionStrategy: 'IdentityCheck',
195
195
  suppressColumnVirtualisation: !model.useVirtualColumns,
196
- getRowNodeId: (data) => data.id,
196
+ getRowNodeId: (record) => record.agId,
197
197
  defaultColDef: {
198
198
  sortable: true,
199
199
  resizable: true,
@@ -292,7 +292,7 @@ class GridLocalModel extends HoistModel {
292
292
 
293
293
  getContextMenuItems = (params) => {
294
294
  const {model, agOptions} = this,
295
- {store, selModel, contextMenu} = model;
295
+ {selModel, contextMenu} = model;
296
296
  if (!contextMenu || XH.isMobileApp) return null;
297
297
 
298
298
  let menu = null;
@@ -303,10 +303,9 @@ class GridLocalModel extends HoistModel {
303
303
  }
304
304
  if (!menu) return null;
305
305
 
306
- const recId = params.node?.id,
306
+ const record = params.node?.data,
307
307
  colId = params.column?.colId,
308
- record = isNil(recId) ? null : store.getById(recId, true),
309
- column = isNil(colId) ? null : model.getColumn(colId),
308
+ column = !isNil(colId) ? model.getColumn(colId) : null,
310
309
  {selectedRecords} = model;
311
310
 
312
311
 
@@ -637,7 +636,7 @@ class GridLocalModel extends HoistModel {
637
636
  // Refresh cells in columns with complex renderers
638
637
  const refreshCols = visibleCols.filter(c => c.rendererIsComplex);
639
638
  if (!isEmpty(refreshCols)) {
640
- const rowNodes = compact(transaction.update.map(r => agApi.getRowNode(r.id))),
639
+ const rowNodes = compact(transaction.update.map(r => agApi.getRowNode(r.agId))),
641
640
  columns = refreshCols.map(c => c.colId);
642
641
  agApi.refreshCells({rowNodes, columns, force: true});
643
642
  }
@@ -667,8 +666,8 @@ class GridLocalModel extends HoistModel {
667
666
  }
668
667
 
669
668
  syncSelection() {
670
- const {agGridModel, selModel, isReady} = this.model,
671
- {selectedIds} = selModel;
669
+ const {agGridModel, selModel, isReady} = this.model;
670
+ const selectedIds = selModel.selectedRecords.map(r => r.agId);
672
671
  if (isReady && !isEqual(selectedIds, agGridModel.getSelectedRowNodeIds())) {
673
672
  agGridModel.setSelectedRowNodeIds(selectedIds);
674
673
  }
@@ -685,8 +684,8 @@ class GridLocalModel extends HoistModel {
685
684
  //------------------------
686
685
  // Event Handlers on AG Grid.
687
686
  //------------------------
688
- getDataPath = (data) => {
689
- return data.treePath;
687
+ getDataPath = (record) => {
688
+ return record.treePath;
690
689
  };
691
690
 
692
691
  // We debounce this handler because the implementation of `AgGridModel.setSelectedRowNodeIds()`
@@ -762,11 +761,10 @@ class GridLocalModel extends HoistModel {
762
761
  }
763
762
 
764
763
  processCellForClipboard = ({value, node, column}) => {
765
- const {model} = this,
766
- recId = node.id,
767
- colId = column.colId,
768
- record = isNil(recId) ? null : model.store.getById(recId, true),
769
- xhColumn = isNil(colId) ? null : model.getColumn(colId);
764
+ const record = node.data,
765
+ {model} = this,
766
+ {colId} = column,
767
+ xhColumn = !isNil(colId) ? model.getColumn(colId) : null;
770
768
 
771
769
  if (!record || !xhColumn) return value;
772
770
 
@@ -221,7 +221,7 @@ export class GridModel extends HoistModel {
221
221
  * @param {?ReactNode} [c.restoreDefaultsWarning] - Confirmation warning to be presented to
222
222
  * user before restoring default grid state. Set to null to skip user confirmation.
223
223
  * @param {GridModelPersistOptions} [c.persistWith] - options governing persistence.
224
- * @param {?string} [c.emptyText] - text/HTML to display if grid has no records.
224
+ * @param {?ReactNode} [c.emptyText] - text/element to display if grid has no records.
225
225
  * Defaults to null, in which case no empty text will be shown.
226
226
  * @param {boolean} [c.hideEmptyTextBeforeLoad] - true (default) to hide empty text until
227
227
  * after the Store has been loaded at least once.
@@ -559,7 +559,7 @@ export class GridModel extends HoistModel {
559
559
 
560
560
  // Get first displayed row with data - i.e. backed by a record, not a full-width group row.
561
561
  const {selModel} = this,
562
- id = this.agGridModel.getFirstSelectableRowNodeId();
562
+ id = this.agGridModel.getFirstSelectableRowNode()?.data.id;
563
563
 
564
564
  if (id != null) {
565
565
  selModel.select(id);
@@ -603,8 +603,8 @@ export class GridModel extends HoistModel {
603
603
  indices = [];
604
604
 
605
605
  // 1) Expand any selected nodes that are collapsed
606
- selectedRecords.forEach(({id}) => {
607
- for (let row = agApi.getRowNode(id)?.parent; row; row = row.parent) {
606
+ selectedRecords.forEach(({agId}) => {
607
+ for (let row = agApi.getRowNode(agId)?.parent; row; row = row.parent) {
608
608
  if (!row.expanded) {
609
609
  agApi.setRowNodeExpanded(row, true);
610
610
  }
@@ -614,8 +614,8 @@ export class GridModel extends HoistModel {
614
614
  await wait();
615
615
 
616
616
  // 2) Scroll to all selected nodes
617
- selectedRecords.forEach(({id}) => {
618
- const rowIndex = agApi.getRowNode(id)?.rowIndex;
617
+ selectedRecords.forEach(({agId}) => {
618
+ const rowIndex = agApi.getRowNode(agId)?.rowIndex;
619
619
  if (!isNil(rowIndex)) indices.push(rowIndex);
620
620
  });
621
621
 
@@ -736,7 +736,7 @@ export class GridModel extends HoistModel {
736
736
 
737
737
  /**
738
738
  * Set the text displayed when the grid is empty.
739
- * @param {?string} emptyText - text/HTML to display if grid has no records.
739
+ * @param {?ReactNode} emptyText - text/element to display if grid has no records.
740
740
  */
741
741
  @action
742
742
  setEmptyText(emptyText) {
@@ -857,7 +857,7 @@ export class GridModel extends HoistModel {
857
857
 
858
858
  // Check required as we may be receiving stale message after unmounting
859
859
  if (isReady) {
860
- selModel.select(agGridModel.getSelectedRowNodeIds());
860
+ selModel.select(agGridModel.agApi.getSelectedRows().map(r => r.id));
861
861
  }
862
862
  }
863
863
 
@@ -1120,12 +1120,12 @@ export class GridModel extends HoistModel {
1120
1120
  recToEdit = selectedRecords[0];
1121
1121
  } else {
1122
1122
  // Or use the first record overall.
1123
- const firstRowId = agGridModel.getFirstSelectableRowNodeId();
1123
+ const firstRowId = agGridModel.getFirstSelectableRowNode()?.data.id;
1124
1124
  recToEdit = store.getById(firstRowId);
1125
1125
  }
1126
1126
  }
1127
1127
 
1128
- const rowIndex = agApi.getRowNode(recToEdit?.id)?.rowIndex;
1128
+ const rowIndex = agApi.getRowNode(recToEdit?.agId)?.rowIndex;
1129
1129
  if (isNil(rowIndex) || rowIndex < 0) {
1130
1130
  console.warn(
1131
1131
  'Unable to start editing - ' +
@@ -174,7 +174,7 @@ export class Column {
174
174
  /** @member {number} */
175
175
  autosizeMaxWidth;
176
176
  /** @member {number} */
177
- autosizeBufferWidth;
177
+ autosizeBufferPx;
178
178
 
179
179
  /** @member {boolean} */
180
180
  autoHeight;
package/core/XH.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  TrackService,
28
28
  WebSocketService
29
29
  } from '@xh/hoist/svc';
30
+ import {Timer} from '@xh/hoist/utils/async';
30
31
  import {MINUTES} from '@xh/hoist/utils/datetime';
31
32
  import {checkMinVersion, getClientDeviceInfo, throwIf, withDebug} from '@xh/hoist/utils/js';
32
33
  import {camelCase, compact, flatten, isBoolean, isString, uniqueId} from 'lodash';
@@ -188,6 +189,7 @@ class XHClass extends HoistBase {
188
189
  //---------------------------
189
190
  // Other State
190
191
  //---------------------------
192
+ suspendData = null;
191
193
  accessDeniedMessage = null;
192
194
  exceptionHandler = new ExceptionHandler();
193
195
 
@@ -609,9 +611,12 @@ class XHClass extends HoistBase {
609
611
  this.acm.changelogDialogModel.show();
610
612
  }
611
613
 
612
- /** Show a dialog to elicit feedback from the user. */
613
- showFeedbackDialog() {
614
- this.acm.feedbackDialogModel.show();
614
+ /**
615
+ * Show a dialog to elicit feedback from the user.
616
+ * @param {string} [message] - optional message to preset within the feedback dialog.
617
+ */
618
+ showFeedbackDialog({message} = {}) {
619
+ this.acm.feedbackDialogModel.show({message});
615
620
  }
616
621
 
617
622
  /** Show the impersonation bar to allow switching users. */
@@ -805,6 +810,21 @@ class XHClass extends HoistBase {
805
810
  }
806
811
  }
807
812
 
813
+ /**
814
+ * Suspend all app activity and display, including timers and web sockets.
815
+ *
816
+ * Suspension is a terminal state, requiring user to reload the app.
817
+ * Used for idling, forced version upgrades, and ad-hoc killing of problematic clients.
818
+ * @package - not intended for application use.
819
+ */
820
+ suspendApp(suspendData) {
821
+ if (XH.appState === AppState.SUSPENDED) return;
822
+ this.suspendData = suspendData;
823
+ XH.setAppState(AppState.SUSPENDED);
824
+ XH.webSocketService.shutdown();
825
+ Timer.cancelAll();
826
+ }
827
+
808
828
  //------------------------
809
829
  // Implementation
810
830
  //------------------------