@xh/hoist 44.1.0 → 44.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.
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ lts/*
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## v44.2.0 - 2021-12-07
4
+
5
+ ### 🎁 New Features
6
+
7
+ * Desktop inline grid editor `Select` now commits the value immediately on selection.
8
+ * `DashContainerModel` now supports an observable `showMenuButton` config which will display a
9
+ button in the stack header for showing the context menu
10
+ * Added `GridAutosizeMode.MANAGED` to autosize Grid columns on data or `sizingMode` changes, unless
11
+ the user has manually modified their column widths.
12
+ * Copying from Grids to the clipboard will now use the value provided by the `exportValue`
13
+ property on the column.
14
+ * Refresh application hotkey is now built into hoist's global hotkeys (shift + r).
15
+ * Non-SSO applications will now automatically reload when a request fails due to session timeout.
16
+ * New utility methods `withInfo` and `logInfo` provide variants of the existing `withDebug` and
17
+ `logDebug` methods, but log at the more verbose `console.log` level.
18
+
19
+ ### 🐞 Bug Fixes
20
+
21
+ * Desktop panel splitter can now be dragged over an `iframe` and reliably resize the panel.
22
+ * Ensure scrollbar does not appear on multi-select in toolbar when not needed.
23
+ * `XH.isPortrait` property fixed so that it no longer changes due to the appearance of the
24
+ mobile keyboard.
25
+
26
+ [Commit Log](https://github.com/xh/hoist-react/compare/v44.1.0...v44.2.0)
27
+
3
28
  ## v44.1.0 - 2021-11-08
4
29
 
5
30
  ### 🎁 New Features
@@ -43,10 +43,12 @@ export class LogDisplayModel extends HoistModel {
43
43
  }
44
44
 
45
45
  syncTail() {
46
- const {tail} = this.parent,
47
- rowElem = this[tail ? 'lastRowRef' : 'firstRowRef'].current;
46
+ const {tail} = this.parent;
48
47
 
49
- if (rowElem) rowElem.scrollIntoView();
48
+ if (tail) {
49
+ const rowElem = this.lastRowRef.current;
50
+ if (rowElem) rowElem.scrollIntoView();
51
+ }
50
52
  }
51
53
 
52
54
  async doLoadAsync(loadSpec) {
@@ -21,7 +21,14 @@ export class ViewportSizeModel extends HoistModel {
21
21
  /** @returns {boolean} */
22
22
  @computed
23
23
  get isPortrait() {
24
- return this.size.width < this.size.height;
24
+ const {size} = this; // Force triggering observation of size.
25
+
26
+ // Check Modern API and legacy API (for safari, BB Access)
27
+ let orientation = this.getOrientation() ?? this.getLegacyOrientation();
28
+ if (orientation !== null) return orientation === 0 || orientation === 180;
29
+
30
+ // Default to aspect ratio
31
+ return size.width < size.height;
25
32
  }
26
33
 
27
34
  /** @returns {boolean} */
@@ -46,4 +53,15 @@ export class ViewportSizeModel extends HoistModel {
46
53
  height: window.innerHeight
47
54
  };
48
55
  }
56
+
57
+ getOrientation() {
58
+ const {orientation} = window.screen;
59
+ return orientation ? orientation.angle : null;
60
+ }
61
+
62
+ getLegacyOrientation() {
63
+ const {orientation} = window;
64
+ return isFinite(orientation) ? orientation: null;
65
+ }
66
+
49
67
  }
@@ -349,14 +349,24 @@
349
349
  .ag-set-filter-select-all {
350
350
  padding: var(--xh-pad-half-px) 0;
351
351
  }
352
- }
353
352
 
354
- // Ensure ag-grid popups (context menus, tooltips, and column filter controls) with default z-index of 5
355
- // appear over bp dialogs with z-index of 20.
356
- .ag-theme-balham.ag-popup,
357
- .ag-theme-balham-dark.ag-popup {
358
- .ag-popup-child {
359
- z-index: 30;
353
+ //------------------------
354
+ // Overlay masks
355
+ //------------------------
356
+ .ag-overlay {
357
+ // Match loading overlay color to mask, suppress display of text in center.
358
+ .ag-overlay-loading-wrapper {
359
+ background-color: var(--xh-mask-bg);
360
+
361
+ .ag-overlay-loading-center {
362
+ display: none;
363
+ }
364
+ }
365
+
366
+ // Reduce default contrast of emptyText.
367
+ .ag-overlay-no-rows-wrapper {
368
+ color: var(--xh-grid-empty-text-color);
369
+ }
360
370
  }
361
371
  }
362
372
 
@@ -371,58 +381,50 @@
371
381
  }
372
382
  }
373
383
 
374
- //------------------------
375
- // Overlay masks
376
- //------------------------
377
- .ag-overlay {
378
- // Match loading overlay color to mask, suppress display of text in center.
379
- .ag-overlay-loading-wrapper {
380
- background-color: var(--xh-mask-bg);
381
-
382
- .ag-overlay-loading-center {
383
- display: none;
384
+ body.xh-app {
385
+ // Ensure ag-grid popups (context menus, tooltips, and column filter controls) with default z-index of 5
386
+ // appear over bp dialogs with z-index of 20.
387
+ .ag-theme-balham.ag-popup,
388
+ .ag-theme-balham-dark.ag-popup {
389
+ .ag-popup-child {
390
+ z-index: 30;
384
391
  }
385
392
  }
386
393
 
387
- // Reduce default contrast of emptyText.
388
- .ag-overlay-no-rows-wrapper {
389
- color: var(--xh-grid-empty-text-color);
390
- }
391
- }
392
-
393
- //------------------------
394
- // Context Menu
395
- //------------------------
396
- .ag-theme-balham .ag-menu,
397
- .ag-theme-balham-dark .ag-menu {
398
- font-family: var(--xh-font-family);
399
- font-size: var(--xh-menu-item-font-size-px);
400
- color: var(--xh-menu-item-text-color);
394
+ //------------------------
395
+ // Context Menu
396
+ //------------------------
397
+ .ag-theme-balham .ag-menu,
398
+ .ag-theme-balham-dark .ag-menu {
399
+ font-family: var(--xh-font-family);
400
+ font-size: var(--xh-menu-item-font-size-px);
401
+ color: var(--xh-menu-item-text-color);
401
402
 
402
- // Minimal/high-contrast bg
403
- background-color: var(--xh-menu-bg);
404
- border: var(--xh-menu-border);
403
+ // Minimal/high-contrast bg
404
+ background-color: var(--xh-menu-bg);
405
+ border: var(--xh-menu-border);
405
406
 
406
- // Matching box-shadow of Blueprint context menu popover
407
- box-shadow: 0 0 0 1px rgba(16, 22, 26, 0.2), 0 2px 4px rgba(16, 22, 26, 0.4), 0 8px 24px rgba(16, 22, 26, 0.4);
407
+ // Matching box-shadow of Blueprint context menu popover
408
+ box-shadow: 0 0 0 1px rgba(16, 22, 26, 0.2), 0 2px 4px rgba(16, 22, 26, 0.4), 0 8px 24px rgba(16, 22, 26, 0.4);
408
409
 
409
- .ag-menu-option.ag-menu-option-active {
410
- background-color: var(--xh-menu-item-highlight-bg);
411
- }
410
+ .ag-menu-option.ag-menu-option-active {
411
+ background-color: var(--xh-menu-item-highlight-bg);
412
+ }
412
413
 
413
- .ag-menu-option-icon {
414
- text-align: center;
415
- padding-left: 8px;
416
- padding-right: 8px;
417
- }
414
+ .ag-menu-option-icon {
415
+ text-align: center;
416
+ padding-left: 8px;
417
+ padding-right: 8px;
418
+ }
418
419
 
419
- // Mute the shortcut text to keep focus on the actual option text
420
- .ag-menu-option-shortcut {
421
- color: var(--xh-text-color-muted);
422
- }
420
+ // Mute the shortcut text to keep focus on the actual option text
421
+ .ag-menu-option-shortcut {
422
+ color: var(--xh-text-color-muted);
423
+ }
423
424
 
424
- // Keep the submenu caret in the standard text color
425
- .ag-menu-option-popup-pointer {
426
- color: var(--xh-menu-item-text-color);
425
+ // Keep the submenu caret in the standard text color
426
+ .ag-menu-option-popup-pointer {
427
+ color: var(--xh-menu-item-text-color);
428
+ }
427
429
  }
428
- }
430
+ }
@@ -103,7 +103,7 @@ export class BaseFieldModel extends HoistModel {
103
103
  super();
104
104
  makeObservable(this);
105
105
  this.name = name;
106
- this.displayName = withDefault(displayName, genDisplayName(name));
106
+ this.displayName = displayName ?? genDisplayName(name);
107
107
  this._origInitialValue = initialValue;
108
108
  this.value = this.initialValue = executeIfFunction(initialValue);
109
109
  this._disabled = disabled;
package/cmp/grid/Grid.js CHANGED
@@ -530,12 +530,15 @@ class GridLocalModel extends HoistModel {
530
530
  }
531
531
 
532
532
  sizingModeReaction() {
533
- const {model} = this;
533
+ const {model} = this,
534
+ {mode} = model.autosizeOptions;
535
+
534
536
  return {
535
537
  track: () => model.sizingMode,
536
538
  run: () => {
537
- if (model.autosizeOptions.mode !== GridAutosizeMode.ON_SIZING_MODE_CHANGE) return;
538
- model.autosizeAsync({showMask: true});
539
+ if (mode === GridAutosizeMode.MANAGED || mode === GridAutosizeMode.ON_SIZING_MODE_CHANGE) {
540
+ model.autosizeAsync({showMask: true});
541
+ }
539
542
  }
540
543
  };
541
544
  }
@@ -657,6 +660,20 @@ class GridLocalModel extends HoistModel {
657
660
  wait().then(() => this.syncSelection());
658
661
  }
659
662
 
663
+ if (model.autosizeOptions.mode === GridAutosizeMode.MANAGED) {
664
+ // If sizingMode different to autosizeState, autosize all columns...
665
+ if (model.autosizeState.sizingMode !== model.sizingMode) {
666
+ model.autosizeAsync();
667
+ } else {
668
+ // ...otherwise, only autosize columns that are not manually sized
669
+ const columns = model.getLeafColumnIds().filter(colId => {
670
+ const state = model.findColumn(model.columnState, colId);
671
+ return state && !state.manuallySized;
672
+ });
673
+ model.autosizeAsync({columns});
674
+ }
675
+ }
676
+
660
677
  model.noteAgExpandStateChange();
661
678
 
662
679
  this._prevRs = newRs;
@@ -700,7 +717,10 @@ class GridLocalModel extends HoistModel {
700
717
 
701
718
  // Catches column resizing on call to autoSize API.
702
719
  onColumnResized = (ev) => {
703
- if (isDisplayed(this.viewRef.current) && ev.finished && ev.source === 'autosizeColumns') {
720
+ if (!isDisplayed(this.viewRef.current) || !ev.finished) return;
721
+ if (ev.source === 'uiColumnDragged') {
722
+ this.model.noteColumnsManuallySized(ev.column.colId);
723
+ } else if (ev.source === 'autosizeColumns') {
704
724
  this.model.noteAgColumnStateChanged(ev.columnApi.getColumnState());
705
725
  }
706
726
  ev.api.resetRowHeights();
@@ -754,10 +774,21 @@ class GridLocalModel extends HoistModel {
754
774
  this.model.agGridModel.agApi.setFilterModel(filterState);
755
775
  }
756
776
 
757
- // Underlying value for treeColumns is actually the record ID due to getDataPath() impl.
758
- // Special handling here, similar to that in Column class, to extract the desired value.
759
- processCellForClipboard({value, node, column}) {
760
- return column.isTreeColumn ? node.data[column.field] : value;
777
+ processCellForClipboard = ({value, node, column}) => {
778
+ const {model} = this,
779
+ recId = node.id,
780
+ colId = column.colId,
781
+ record = isNil(recId) ? null : model.store.getById(recId, true),
782
+ xhColumn = isNil(colId) ? null : model.getColumn(colId);
783
+
784
+ if (!record || !xhColumn) return value;
785
+
786
+ return XH.gridExportService.getExportableValueForCell({
787
+ gridModel: model,
788
+ record,
789
+ column: xhColumn,
790
+ node
791
+ });
761
792
  }
762
793
 
763
794
  navigateToNextCell = (agParams) => {
@@ -21,16 +21,16 @@ export const GridAutosizeMode = Object.freeze({
21
21
  ON_DEMAND: 'onDemand',
22
22
 
23
23
  /**
24
- * In addition to the affordances provided by ON_DEMAND, Grid will autosize columns when
25
- * the GridModel's sizingMode changes.
24
+ * Grid will autosize columns when the GridModel's sizingMode changes.
25
+ * Also offers the affordances provided by ON_DEMAND.
26
26
  */
27
- ON_SIZING_MODE_CHANGE: 'onSizingModeChange'
27
+ ON_SIZING_MODE_CHANGE: 'onSizingModeChange',
28
28
 
29
- // COMING SOON
30
- // /**
31
- // * Grid will autosize columns when data is *first* loaded into a grid. Persisted grids
32
- // * will only resize again if GridModel's sizingMode subsequently changes.
33
- // */
34
- // ON_FIRST_LOAD: 'onFirstLoad'
29
+ /**
30
+ * Grid will autosize columns when the GridModel's sizingMode changes or data is loaded,
31
+ * unless the user has manually modified their column widths.
32
+ * Also offers the affordances provided by ON_DEMAND.
33
+ */
34
+ MANAGED: 'managed'
35
35
 
36
36
  });
@@ -71,6 +71,8 @@ export class GridModel extends HoistModel {
71
71
  'OK to proceed?'
72
72
  );
73
73
 
74
+ static DEFAULT_AUTOSIZE_MODE = GridAutosizeMode.ON_SIZING_MODE_CHANGE;
75
+
74
76
  //------------------------
75
77
  // Immutable public properties
76
78
  //------------------------
@@ -129,6 +131,8 @@ export class GridModel extends HoistModel {
129
131
  @observable.ref columnState = [];
130
132
  /** @member {Object} */
131
133
  @observable.ref expandState = {};
134
+ /** @member {AutosizeState} */
135
+ @observable.ref autosizeState = {};
132
136
  /** @member {GridSorter[]} */
133
137
  @observable.ref sortBy = [];
134
138
  /** @member {string[]} */
@@ -367,7 +371,7 @@ export class GridModel extends HoistModel {
367
371
  this.autosizeOptions = defaults(
368
372
  {...autosizeOptions},
369
373
  {
370
- mode: GridAutosizeMode.ON_SIZING_MODE_CHANGE,
374
+ mode: GridModel.DEFAULT_AUTOSIZE_MODE,
371
375
  includeCollapsedChildren: false,
372
376
  showMask: false,
373
377
  // Larger buffer on mobile (perhaps counterintuitively) to minimize clipping due to
@@ -768,7 +772,10 @@ export class GridModel extends HoistModel {
768
772
 
769
773
  this.columns = columns;
770
774
  this.columnState = this.getLeafColumns()
771
- .map(({colId, width, hidden, pinned}) => ({colId, width, hidden, pinned}));
775
+ .map(it => {
776
+ const {colId, width, hidden, pinned} = it;
777
+ return {colId, width, hidden, pinned};
778
+ });
772
779
  }
773
780
 
774
781
  /** @param {ColumnState[]} colState */
@@ -824,6 +831,24 @@ export class GridModel extends HoistModel {
824
831
  }
825
832
  }
826
833
 
834
+ @action
835
+ setAutosizeState(autosizeState) {
836
+ if (!equal(this.autosizeState, autosizeState)) {
837
+ this.autosizeState = deepFreeze(autosizeState);
838
+ }
839
+ }
840
+
841
+ noteColumnsManuallySized(colIds) {
842
+ const colStateChanges = castArray(colIds).map(colId => ({colId, manuallySized: true}));
843
+ this.applyColumnStateChanges(colStateChanges);
844
+ }
845
+
846
+ noteColumnsAutosized(colIds) {
847
+ const colStateChanges = castArray(colIds).map(colId => ({colId, manuallySized: false}));
848
+ this.applyColumnStateChanges(colStateChanges);
849
+ this.setAutosizeState({sizingMode: this.sizingMode});
850
+ }
851
+
827
852
  /**
828
853
  * This method will update the current column definition if it has changed.
829
854
  * Throws an exception if any of the columns provided in colStateChanges are not
@@ -852,6 +877,7 @@ export class GridModel extends HoistModel {
852
877
  if (!isNil(change.width)) col.width = change.width;
853
878
  if (!isNil(change.hidden)) col.hidden = change.hidden;
854
879
  if (!isUndefined(change.pinned)) col.pinned = change.pinned;
880
+ if (!isNil(change.manuallySized)) col.manuallySized = change.manuallySized;
855
881
  });
856
882
 
857
883
  // 2) If the changes provided is a full list of leaf columns, synchronize the sort order
@@ -1038,7 +1064,6 @@ export class GridModel extends HoistModel {
1038
1064
  .linkTo(this.autosizeTask);
1039
1065
  }
1040
1066
 
1041
-
1042
1067
  /**
1043
1068
  * Begin an inline editing session.
1044
1069
  * @param {RecordOrId} [recOrId] - Record/ID to edit. If unspecified, the first selected Record
@@ -1178,6 +1203,7 @@ export class GridModel extends HoistModel {
1178
1203
 
1179
1204
  try {
1180
1205
  await XH.gridAutosizeService.autosizeAsync(this, colIds, options);
1206
+ this.noteColumnsAutosized(colIds);
1181
1207
  } finally {
1182
1208
  if (showMask) {
1183
1209
  await wait();
@@ -1307,10 +1333,10 @@ export class GridModel extends HoistModel {
1307
1333
  // conflict with any code-level updates to their widths.
1308
1334
  removeTransientWidths(columnState) {
1309
1335
  const gridCols = this.getLeafColumns();
1310
-
1311
1336
  return columnState.map(state => {
1312
1337
  const col = this.findColumn(gridCols, state.colId);
1313
- return col.resizable ? state : omit(state, 'width');
1338
+ if (!col.resizable) state = omit(state, 'width');
1339
+ return state;
1314
1340
  });
1315
1341
  }
1316
1342
 
@@ -1506,6 +1532,12 @@ export class GridModel extends HoistModel {
1506
1532
  * @property {number} [width] - new width to set for the column
1507
1533
  * @property {boolean} [hidden] - visibility of the column
1508
1534
  * @property {string} [pinned] - 'left'|'right' if pinned, null if not
1535
+ * @property {boolean} [manuallySized] - has this column been resized manually?
1536
+ */
1537
+
1538
+ /**
1539
+ * @typedef {Object} AutosizeState
1540
+ * @property {SizingMode} sizingMode - sizing mode used last time the columns were autosized.
1509
1541
  */
1510
1542
 
1511
1543
  /**
@@ -89,27 +89,28 @@ export class GridPersistenceModel extends HoistModel {
89
89
  columnReaction() {
90
90
  const {gridModel} = this;
91
91
  return {
92
- track: () => gridModel.columnState,
93
- run: (columnState) => {
94
- this.patchState({columns: gridModel.removeTransientWidths(columnState)});
92
+ track: () => [gridModel.columnState, gridModel.autosizeState],
93
+ run: ([columnState, autosizeState]) => {
94
+ this.patchState({
95
+ columns: gridModel.removeTransientWidths(columnState),
96
+ autosize: autosizeState
97
+ });
95
98
  }
96
99
  };
97
100
  }
98
101
 
99
102
  updateGridColumns() {
100
- const {gridModel, state} = this;
101
- if (!state.columns) return;
102
-
103
- gridModel.setColumnState(state.columns);
103
+ const {columns, autosize} = this.state;
104
+ if (!isUndefined(columns)) this.gridModel.setColumnState(columns);
105
+ if (!isUndefined(autosize)) this.gridModel.setAutosizeState(autosize);
104
106
  }
105
107
 
106
108
  //--------------------------
107
109
  // Sort
108
110
  //--------------------------
109
111
  sortReaction() {
110
- const {gridModel} = this;
111
112
  return {
112
- track: () => gridModel.sortBy,
113
+ track: () => this.gridModel.sortBy,
113
114
  run: (sortBy) => {
114
115
  this.patchState({sortBy: sortBy.map(it => it.toString())});
115
116
  }
package/cmp/grid/index.js CHANGED
@@ -9,10 +9,10 @@ export * from './columns';
9
9
 
10
10
  export * from './enums/TreeStyle';
11
11
 
12
+ export * from './GridAutosizeMode';
12
13
  export * from './helpers/GridCountLabel';
13
14
 
14
15
  export * from './renderers/MultiFieldRenderer';
15
16
 
16
- export * from './GridAutosizeMode';
17
17
  export * from './Grid';
18
18
  export * from './GridModel';
package/core/XH.js CHANGED
@@ -828,12 +828,12 @@ class XHClass extends HoistBase {
828
828
  return await this.fetchService
829
829
  .fetchJson({
830
830
  url: 'xh/authStatus',
831
- timeout: 3 * MINUTES // Accomodate delay for user at a credentials prompt
831
+ timeout: 3 * MINUTES // Accommodate delay for user at a credentials prompt
832
832
  })
833
833
  .then(r => r.authenticated)
834
834
  .catch(e => {
835
835
  // 401s normal / expected for non-SSO apps when user not yet logged in.
836
- if (e.httpStatus == 401) return false;
836
+ if (e.httpStatus === 401) return false;
837
837
  // Other exceptions indicate e.g. connectivity issue, server down - raise to user.
838
838
  throw e;
839
839
  });
@@ -150,7 +150,14 @@ const bannerList = hoistCmp.factory({
150
150
 
151
151
  function globalHotKeys(model) {
152
152
  const {impersonationBarModel, optionsDialogModel} = model,
153
- ret = [];
153
+ ret = [
154
+ {
155
+ global: true,
156
+ combo: 'shift + r',
157
+ label: 'Refresh application data',
158
+ onKeyDown: () => XH.refreshAppAsync()
159
+ }
160
+ ];
154
161
 
155
162
  if (XH.identityService.canAuthUserImpersonate) {
156
163
  ret.push({
@@ -96,5 +96,5 @@ export const dismissButton = hoistCmp.factory(
96
96
  );
97
97
 
98
98
  function isSessionExpired(e) {
99
- return e && e.httpStatus === 401;
99
+ return e?.httpStatus === 401;
100
100
  }
@@ -10,8 +10,9 @@
10
10
  border: var(--xh-border-solid);
11
11
 
12
12
  img {
13
+ display: block;
14
+ margin: 0 auto var(--xh-pad-px);
13
15
  border-bottom: var(--xh-border-solid);
14
- margin-bottom: var(--xh-pad-px);
15
16
  }
16
17
 
17
18
  p {
@@ -155,14 +155,13 @@ export class StoreContextMenu {
155
155
  recordsRequired: true,
156
156
  actionFn: ({record, column}) => {
157
157
  if (record && column) {
158
- const value = column.getValueFn({
159
- record,
160
- column,
161
- field: column.field,
162
- store: record.store,
163
- gridModel
164
- });
165
-
158
+ const node = gridModel.agApi?.getRowNode(record.id),
159
+ value = XH.gridExportService.getExportableValueForCell({
160
+ gridModel,
161
+ record,
162
+ column,
163
+ node
164
+ });
166
165
  copy(value);
167
166
  }
168
167
  }
@@ -10,3 +10,12 @@
10
10
  }
11
11
  }
12
12
  }
13
+
14
+ .xh-dash-container-menu-btn {
15
+ margin: 2px 0;
16
+ padding: 0 !important;
17
+ height: 22px;
18
+ width: 22px;
19
+ min-height: 22px;
20
+ min-width: 22px;
21
+ }
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Copyright © 2021 Extremely Heavy Industries Inc.
6
6
  */
7
+ import ReactDOM from 'react-dom';
7
8
  import {HoistModel, managed, RefreshMode, RenderMode, XH, PersistenceProvider, TaskObserver} from '@xh/hoist/core';
8
9
  import {convertIconToHtml, deserializeIcon} from '@xh/hoist/icon';
9
10
  import {ContextMenu} from '@xh/hoist/kit/blueprint';
@@ -15,6 +16,7 @@ import {createObservableRef} from '@xh/hoist/utils/react';
15
16
  import {cloneDeep, defaultsDeep, find, isFinite, reject} from 'lodash';
16
17
  import {DashViewModel} from './DashViewModel';
17
18
  import {DashViewSpec} from './DashViewSpec';
19
+ import {dashContainerMenuButton} from './impl/DashContainerMenuButton';
18
20
  import {dashContainerContextMenu} from './impl/DashContainerContextMenu';
19
21
  import {convertGLToState, convertStateToGL, getViewModelId} from './impl/DashContainerUtils';
20
22
  import {dashView} from './impl/DashView';
@@ -96,6 +98,8 @@ export class DashContainerModel extends HoistModel {
96
98
  @bindable contentLocked;
97
99
  /** @member {boolean} */
98
100
  @bindable renameLocked;
101
+ /** @member {boolean} */
102
+ @bindable showMenuButton;
99
103
 
100
104
  //------------------------
101
105
  // Immutable public properties
@@ -131,6 +135,8 @@ export class DashContainerModel extends HoistModel {
131
135
  * @param {boolean} [c.layoutLocked] - prevent re-arranging views by dragging and dropping.
132
136
  * @param {boolean} [c.contentLocked] - prevent adding and removing views.
133
137
  * @param {boolean} [c.renameLocked] - prevent renaming views.
138
+ * @param {boolean} [c.showMenuButton] - true to include a button in each stack header showing
139
+ * the dash context menu.
134
140
  * @param {Object} [c.goldenLayoutSettings] - custom settings to be passed to the GoldenLayout instance.
135
141
  * @see http://golden-layout.com/docs/Config.html
136
142
  * @param {PersistOptions} [c.persistWith] - options governing persistence
@@ -149,6 +155,7 @@ export class DashContainerModel extends HoistModel {
149
155
  layoutLocked = false,
150
156
  contentLocked = false,
151
157
  renameLocked = false,
158
+ showMenuButton = false,
152
159
  goldenLayoutSettings,
153
160
  persistWith = null,
154
161
  emptyText = 'No views have been added to the container.',
@@ -169,6 +176,7 @@ export class DashContainerModel extends HoistModel {
169
176
  this.layoutLocked = layoutLocked;
170
177
  this.contentLocked = contentLocked;
171
178
  this.renameLocked = renameLocked;
179
+ this.showMenuButton = showMenuButton;
172
180
  this.goldenLayoutSettings = goldenLayoutSettings;
173
181
  this.emptyText = emptyText;
174
182
  this.addViewButtonText = addViewButtonText;
@@ -380,6 +388,10 @@ export class DashContainerModel extends HoistModel {
380
388
  // Listen to active item change to support RenderMode
381
389
  stack.on('activeContentItemChanged', () => this.onStackActiveItemChange(stack));
382
390
 
391
+ // Add menu button to stack header
392
+ const containerEl = stack.header.controlsContainer[0];
393
+ ReactDOM.render(dashContainerMenuButton({dashContainerModel: this, stack}), containerEl);
394
+
383
395
  // Add context menu listener for adding components
384
396
  const $el = stack.header.element;
385
397
  $el.off('contextmenu').contextmenu(e => {
@@ -19,7 +19,7 @@ import {isEmpty} from 'lodash';
19
19
  * @private
20
20
  */
21
21
  export const dashContainerContextMenu = hoistCmp.factory({
22
- model: null, observable: null,
22
+ model: null, observer: null,
23
23
  render(props) {
24
24
  const menuItems = createMenuItems(props);
25
25
  return contextMenu({menuItems});
@@ -0,0 +1,34 @@
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 © 2021 Extremely Heavy Industries Inc.
6
+ */
7
+ import {hoistCmp} from '@xh/hoist/core';
8
+ import {button} from '@xh/hoist/desktop/cmp/button';
9
+ import {Icon} from '@xh/hoist/icon';
10
+ import {popover, Position} from '@xh/hoist/kit/blueprint';
11
+ import {dashContainerContextMenu} from './DashContainerContextMenu';
12
+
13
+ /**
14
+ * Button and popover for displaying the context menu. Apps can control whether this button is
15
+ * displayed via DashContainerModel's `showMenuButton` config.
16
+ *
17
+ * @see DashContainerModel
18
+ * @private
19
+ */
20
+ export const dashContainerMenuButton = hoistCmp.factory({
21
+ model: null,
22
+ render({stack, dashContainerModel}) {
23
+ if (dashContainerModel.contentLocked || !dashContainerModel.showMenuButton) return null;
24
+
25
+ return popover({
26
+ position: Position.BOTTOM,
27
+ target: button({
28
+ icon: Icon.ellipsisVertical(),
29
+ className: 'xh-dash-container-menu-btn'
30
+ }),
31
+ content: dashContainerContextMenu({stack, dashContainerModel})
32
+ });
33
+ }
34
+ });
@@ -23,6 +23,11 @@ export const [SelectEditor, selectEditor] = hoistCmp.withFactory({
23
23
  hideDropdownIndicator: true,
24
24
  hideSelectedOptionCheck: true,
25
25
  selectOnFocus: false,
26
+ onCommit: () => {
27
+ // When not full-row editing we end editing after commit to avoid extra clicks
28
+ const {gridModel} = props;
29
+ if (!gridModel.fullRowEditing) gridModel.endEditAsync();
30
+ },
26
31
  rsOptions: {
27
32
  styles: {
28
33
  menu: styles => ({
@@ -182,6 +182,7 @@
182
182
  &__value-container--is-multi {
183
183
  height: 28px;
184
184
  overflow-y: auto !important;
185
+ line-height: 1.2; // Ensure the line-height does not cause the scrollbar to appear
185
186
  }
186
187
  }
187
188
  }
@@ -59,6 +59,8 @@ export class DraggerModel extends HoistModel {
59
59
  'Resizable panel has no sibling panel against which to resize.'
60
60
  );
61
61
 
62
+ if (XH.isDesktop) this.setIframePointerEvents('none');
63
+
62
64
  e.stopPropagation();
63
65
 
64
66
  const {clientX, clientY} = this.parseEventPositions(e);
@@ -113,6 +115,8 @@ export class DraggerModel extends HoistModel {
113
115
  };
114
116
 
115
117
  onDragEnd = () => {
118
+ if (XH.isDesktop) this.setIframePointerEvents('auto');
119
+
116
120
  const {panelModel} = this;
117
121
  if (!panelModel.isResizing) return;
118
122
 
@@ -216,4 +220,14 @@ export class DraggerModel extends HoistModel {
216
220
  isValidTouchEvent(e) {
217
221
  return e.touches && e.touches.length > 0;
218
222
  }
223
+
224
+ /**
225
+ * @param {('none'|'auto')} v - Workaround to allow dragging over iframe, which is its own
226
+ * separate document and cannot listen for events from main document.
227
+ */
228
+ setIframePointerEvents(v) {
229
+ for (const el of document.getElementsByTagName('iframe')) {
230
+ el.style['pointer-events'] = v;
231
+ }
232
+ }
219
233
  }
@@ -79,6 +79,6 @@ export const dismissButton = hoistCmp.factory(
79
79
  );
80
80
 
81
81
  function sessionExpired(e) {
82
- return e && e.httpStatus === 401;
82
+ return e?.httpStatus === 401;
83
83
  }
84
84
 
@@ -66,9 +66,8 @@ export const [AppBar, appBar] = hoistCmp.withFactory({
66
66
  icon: icon,
67
67
  omit: !icon,
68
68
  onClick: () => {
69
- if (XH.routerModel.hasRoute('default')) {
70
- XH.navigate('default');
71
- }
69
+ // Navigate to root-level route
70
+ XH.navigate(XH.appModel.getRoutes()[0].name);
72
71
  }
73
72
  }),
74
73
  div({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "44.1.0",
3
+ "version": "44.2.0",
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",
@@ -4,14 +4,14 @@
4
4
  *
5
5
  * Copyright © 2021 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistService, XH} from '@xh/hoist/core';
7
+ import {HoistService, XH, AppState} from '@xh/hoist/core';
8
8
  import {Exception} from '@xh/hoist/exception';
9
- import {isLocalDate} from '@xh/hoist/utils/datetime';
9
+ import {isLocalDate, SECONDS, ONE_MINUTE, olderThan} from '@xh/hoist/utils/datetime';
10
10
  import {throwIf} from '@xh/hoist/utils/js';
11
11
  import {StatusCodes} from 'http-status-codes';
12
12
  import {isDate, isFunction, isNil, omitBy} from 'lodash';
13
13
  import {stringify} from 'qs';
14
- import {SECONDS} from '@xh/hoist/utils/datetime';
14
+ import {never} from '@xh/hoist/promise';
15
15
 
16
16
  /**
17
17
  * Service for making managed HTTP requests, both to the app's own Hoist server and to remote APIs.
@@ -27,6 +27,11 @@ import {SECONDS} from '@xh/hoist/utils/datetime';
27
27
  *
28
28
  * Note that the convenience methods `fetchJson`, `postJson`, `putJson` all accept the same options
29
29
  * as the main entry point `fetch`, as they delegate to fetch after setting additional defaults.
30
+ *
31
+ * Note: For non-SSO apps, FetchService will automatically trigger a reload of the app if a
32
+ * 401 is encountered from a local (relative) request. This default behavior is designed to allow
33
+ * more seamless re-establishment of timed out authentication sessions, but can be turned off
34
+ * via config if needed.
30
35
  */
31
36
  export class FetchService extends HoistService {
32
37
 
@@ -164,7 +169,7 @@ export class FetchService extends HoistService {
164
169
  }
165
170
 
166
171
  if (e.isHoistException) throw e;
167
-
172
+
168
173
  // Just two other cases where we expect this to throw -- Typically we get a failed response)
169
174
  throw (e.name === 'AbortError') ? Exception.fetchAborted(opts, e) : Exception.serverUnavailable(opts, e);
170
175
  } finally {
@@ -185,8 +190,8 @@ export class FetchService extends HoistService {
185
190
  if (!method) {
186
191
  method = (params ? 'POST' : 'GET');
187
192
  }
188
-
189
- if (!url.startsWith('/') && !url.includes('//')) {
193
+ const isRelativeUrl = !url.startsWith('/') && !url.includes('//');
194
+ if (isRelativeUrl) {
190
195
  url = XH.baseUrl + url;
191
196
  }
192
197
 
@@ -232,12 +237,31 @@ export class FetchService extends HoistService {
232
237
 
233
238
  if (!ret.ok) {
234
239
  ret.responseText = await this.safeResponseTextAsync(ret);
235
- throw Exception.fetchError(opts, ret);
240
+ const e = Exception.fetchError(opts, ret);
241
+ if (!XH.isSSO && isRelativeUrl && e.httpStatus === 401) {
242
+ await this.maybeReloadForAuthAsync();
243
+ }
244
+ throw e;
236
245
  }
237
246
 
238
247
  return ret;
239
248
  }
240
249
 
250
+ async maybeReloadForAuthAsync() {
251
+ const {appState, configService, localStorageService} = XH;
252
+
253
+ // Don't interfere with initialization, avoid tight loops, and provide kill switch
254
+ if (
255
+ appState === AppState.RUNNING &&
256
+ configService.get('xhReloadOnFailedAuth', true) &&
257
+ olderThan(localStorageService.get('xhLastFailedAuthReload', null), ONE_MINUTE)
258
+ ) {
259
+ localStorageService.set('xhLastFailedAuthReload', Date.now());
260
+ XH.reloadApp();
261
+ await never();
262
+ }
263
+ }
264
+
241
265
  async sendJsonInternalAsync(opts) {
242
266
  return this.fetchJson({
243
267
  ...opts,
@@ -12,7 +12,18 @@ import {SECONDS} from '@xh/hoist/utils/datetime';
12
12
  import {throwIf, withDefault} from '@xh/hoist/utils/js';
13
13
  import download from 'downloadjs';
14
14
  import {StatusCodes} from 'http-status-codes';
15
- import {castArray, isArray, isFunction, isNil, isString, sortBy, uniq, compact, findIndex} from 'lodash';
15
+ import {
16
+ castArray,
17
+ isArray,
18
+ isEmpty,
19
+ isFunction,
20
+ isNil,
21
+ isString,
22
+ sortBy,
23
+ uniq,
24
+ compact,
25
+ findIndex
26
+ } from 'lodash';
16
27
  import {span, a} from '@xh/hoist/cmp/layout';
17
28
  import {wait} from '@xh/hoist/promise';
18
29
 
@@ -131,6 +142,61 @@ export class GridExportService extends HoistService {
131
142
  }
132
143
  }
133
144
 
145
+ /**
146
+ * Get the exportable value for a given cell.
147
+ *
148
+ * This method is used internally by this service, but also made available
149
+ * publicly for use by grid clipboard functionality.
150
+ *
151
+ * @param {Object} c
152
+ * @param {GridModel} c.gridModel
153
+ * @param {Record} c.record
154
+ * @param {Column} c.column
155
+ * @param {Object} [c.node] - rendered ag-Grid row, if available. Necessary for
156
+ * exporting agGrid aggregates.
157
+ * @param {boolean} [c.forServer] - for posting to server, default false.
158
+ * @return {String} - value suitable for export to excel, csv, or clipboard.
159
+ */
160
+ getExportableValueForCell({gridModel, record, column, node, forServer = false}) {
161
+ const {field, exportValue, getValueFn} = column,
162
+ aggData = node && gridModel.treeMode && !isEmpty(record.children) ? node.aggData : null;
163
+
164
+ // 0) Main processing
165
+ let value = getValueFn({record, field, column, gridModel});
166
+ // Modify value using exportValue
167
+ if (isString(exportValue) && record.data[exportValue] !== null) {
168
+ // If exportValue points to a different field
169
+ value = record.data[exportValue];
170
+ } else if (isFunction(exportValue)) {
171
+ // If export value is a function that transforms the value
172
+ value = exportValue(value, {record, column, gridModel});
173
+ } else if (aggData && !isNil(aggData[field])) {
174
+ // If we found aggregate data calculated by agGrid
175
+ value = aggData[field];
176
+ }
177
+
178
+ if (isNil(value)) return null;
179
+
180
+ // 1) Support per-cell exportFormat
181
+ let {exportFormat} = column,
182
+ cellHasExportFormat = isFunction(exportFormat);
183
+
184
+ if (cellHasExportFormat) {
185
+ exportFormat = exportFormat(value, {record, column, gridModel});
186
+ }
187
+
188
+ // 2) Dates: Provide date data expected by server endpoint.
189
+ // Also, approximate formats for CSV and clipboard.
190
+ if (exportFormat === ExportFormat.DATE_FMT) value = fmtDate(value);
191
+ if (exportFormat === ExportFormat.DATETIME_FMT) value = fmtDate(value, 'YYYY-MM-DD HH:mm:ss');
192
+
193
+ value = value.toString();
194
+
195
+ return forServer && cellHasExportFormat ?
196
+ {value, format: exportFormat} :
197
+ value;
198
+ }
199
+
134
200
  //-----------------------
135
201
  // Implementation
136
202
  //-----------------------
@@ -255,48 +321,13 @@ export class GridExportService extends HoistService {
255
321
  }
256
322
 
257
323
  getRecordRow(gridModel, record, columns, depth) {
258
- let aggData = null;
259
- if (gridModel.treeMode && record.children.length) {
260
- aggData = gridModel.agApi.getRowNode(record.id).aggData;
261
- }
262
- const data = columns.map(it => this.getCellData(gridModel, record, it, aggData));
324
+ const node = gridModel.agApi?.getRowNode(record.id),
325
+ data = columns.map(column => {
326
+ return this.getExportableValueForCell({gridModel, record, column, node, forServer: true});
327
+ });
263
328
  return {data, depth};
264
329
  }
265
330
 
266
- getCellData(gridModel, record, column, aggData) {
267
- const {field, exportValue, getValueFn} = column;
268
-
269
- let value = getValueFn({record, field, column, gridModel});
270
- // Modify value using exportValue
271
- if (isString(exportValue) && record.data[exportValue] !== null) {
272
- // If exportValue points to a different field
273
- value = record.data[exportValue];
274
- } else if (isFunction(exportValue)) {
275
- // If export value is a function that transforms the value
276
- value = exportValue(value, {record, column, gridModel});
277
- } else if (aggData && !isNil(aggData[field])) {
278
- // If we found aggregate data calculated by agGrid
279
- value = aggData[field];
280
- }
281
-
282
- if (isNil(value)) return null;
283
-
284
- // Get cell-level format if function form provided
285
- let {exportFormat} = column,
286
- cellHasExportFormat = isFunction(exportFormat);
287
-
288
- if (cellHasExportFormat) {
289
- exportFormat = exportFormat(value, {record, column, gridModel});
290
- }
291
-
292
- // Enforce date formats expected by server
293
- if (exportFormat === ExportFormat.DATE_FMT) value = fmtDate(value);
294
- if (exportFormat === ExportFormat.DATETIME_FMT) value = fmtDate(value, 'YYYY-MM-DD HH:mm:ss');
295
-
296
- value = value.toString();
297
- return cellHasExportFormat ? {value, format: exportFormat} : value;
298
- }
299
-
300
331
  getContentType(type) {
301
332
  switch (type) {
302
333
  case 'excelTable':
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import {debounce, isFunction} from 'lodash';
8
8
  import {throwIf, getOrCreate} from './LangUtils';
9
- import {withDebug} from './WithDebug';
9
+ import {withDebug} from './LogUtils';
10
10
 
11
11
  /**
12
12
  * Decorates a class method so that it is debounced by the specified duration.
@@ -30,7 +30,7 @@ export function debounced(duration) {
30
30
  }
31
31
 
32
32
  /**
33
- * Modify a method or getter so that it will compute once lazily, and then cache the results.
33
+ * Modify a method or getter so that it will compute once lazily and then cache the results.
34
34
  * Not appropriate for methods that take arguments. Typically useful on immutable objects.
35
35
  */
36
36
  export function computeOnce(target, key, descriptor) {
@@ -51,8 +51,8 @@ export function computeOnce(target, key, descriptor) {
51
51
  }
52
52
 
53
53
  /**
54
- * Modify a method so that it execution is tracked and timed with a debug message
55
- * @see {withDebug}
54
+ * Modify a method so that its execution is tracked and timed with a debug message.
55
+ * @see withDebug
56
56
  */
57
57
  export function logWithDebug(target, key, descriptor) {
58
58
  const {value} = descriptor;
@@ -0,0 +1,119 @@
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 © 2021 Extremely Heavy Industries Inc.
6
+ */
7
+ import {castArray, isString} from 'lodash';
8
+ import {apiDeprecated} from './LangUtils';
9
+
10
+ /**
11
+ * Track a function execution with console.log.
12
+ *
13
+ * This method will log the provided message(s) with timing information in a single message *after*
14
+ * the tracked function returns.
15
+ *
16
+ * If the function passed to this util returns a Promise, it will wait until the Promise resolves
17
+ * or completes to finish its logging. The actual object returned by the tracked function will
18
+ * always be returned directly to the caller.
19
+ *
20
+ * @param {(string[]|string)} msgs
21
+ * @param {function} fn
22
+ * @param {(Object|string)} [source] - class, function or string to label the source of the message
23
+ */
24
+ export function withInfo(msgs, fn, source) {
25
+ return loggedDo(msgs, fn, source, 'info');
26
+ }
27
+
28
+ /**
29
+ * Track a function execution with console.debug.
30
+ * @see withInfo
31
+ *
32
+ * @param {(string[]|string)} msgs
33
+ * @param {function} fn
34
+ * @param {(Object|string)} [source] - class, function or string to label the source of the message
35
+ */
36
+ export function withDebug(msgs, fn, source) {
37
+ return loggedDo(msgs, fn, source, 'debug');
38
+ }
39
+
40
+ /**
41
+ * Log a message with console.log.
42
+ *
43
+ * @param {(string[]|string)} msgs
44
+ * @param {(Object|string)} [source] - class, function or string to label the source of the message
45
+ */
46
+ export function logInfo(msgs, source) {
47
+ return loggedDo(msgs, null, source, 'info');
48
+ }
49
+
50
+ /**
51
+ * Log a message with console.debug.
52
+ *
53
+ * @param {(string[]|string)} msgs
54
+ * @param {(Object|string)} [source] - class, function or string to label the source of the message
55
+ */
56
+ export function logDebug(msgs, source) {
57
+ return loggedDo(msgs, null, source, 'debug');
58
+ }
59
+
60
+ /** @deprecated */
61
+ export function withShortDebug(msgs, fn, source) {
62
+ apiDeprecated('withShortDebug', {msg: 'Use withDebug() instead', v: 'v45'});
63
+ return withDebug(msgs, fn, source);
64
+ }
65
+
66
+
67
+ //----------------------------------
68
+ // Implementation
69
+ //----------------------------------
70
+ function loggedDo(msgs, fn, source, level) {
71
+ source = parseSource(source);
72
+ msgs = castArray(msgs);
73
+ const msg = msgs.join(' | ');
74
+
75
+ // Support simple message only.
76
+ if (!fn) {
77
+ writeLog(msg, source, level);
78
+ return;
79
+ }
80
+
81
+ // Otherwise, wrap the call to the provided fn.
82
+ let start, ret;
83
+ const logCompletion = () => {
84
+ const elapsed = Date.now() - start;
85
+ writeLog(`${msg} | ${elapsed}ms`, source, level);
86
+ },
87
+ logException = (e) => {
88
+ const elapsed = Date.now() - start;
89
+ writeLog(`${msg} | failed - ${e.message ?? e.name ?? 'Unknown error'} | ${elapsed}ms`, source, level);
90
+ };
91
+
92
+ start = Date.now();
93
+ try {
94
+ ret = fn();
95
+ } catch (e) {
96
+ logException(e);
97
+ throw e;
98
+ }
99
+
100
+ if (ret instanceof Promise) {
101
+ ret.then(logCompletion, logException);
102
+ } else {
103
+ logCompletion();
104
+ }
105
+
106
+ return ret;
107
+ }
108
+
109
+ function parseSource(source) {
110
+ if (isString(source)) return source;
111
+ if (source?.displayName) return source.displayName;
112
+ if (source?.constructor) return source.constructor.name;
113
+ return '';
114
+ }
115
+
116
+ function writeLog(msg, source, level) {
117
+ if (source) msg = `[${source}] ${msg}`;
118
+ level === 'info' ? console.log(msg) : console.debug(msg);
119
+ }
package/utils/js/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  export * from './HtmlUtils';
8
8
  export * from './LangUtils';
9
9
  export * from './Decorators';
10
- export * from './WithDebug';
10
+ export * from './LogUtils';
11
11
  export * from './DomUtils';
12
12
  export * from './BrowserUtils';
13
13
  export * from './VersionUtils';
@@ -1,104 +0,0 @@
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 © 2021 Extremely Heavy Industries Inc.
6
- */
7
- import {castArray, isString} from 'lodash';
8
- import {apiDeprecated} from './LangUtils';
9
-
10
- /**
11
- * Track a function execution, logging the provided message(s) on debug with timing information in
12
- * a single message after the tracked function returns.
13
- *
14
- * If the function passed to this util returns a Promise, it will wait until the Promise resolves
15
- * or completes to finish its logging. The actual object returned by the tracked function will
16
- * always be returned directly to the caller.
17
- *
18
- * @param {(string[]|string)} msgs
19
- * @param {function} fn
20
- * @param {(Object|string)} [source] - class, function or string to label the source of the message
21
- */
22
- export function withDebug(msgs, fn, source) {
23
- return loggedDo(msgs, fn, source);
24
- }
25
-
26
-
27
- /**
28
- * Track a function execution, logging the provided message(s) on debug with timing information in
29
- * a single message after the tracked function returns.
30
- *
31
- * @deprecated use withDebug instead.
32
- */
33
- export function withShortDebug(msgs, fn, source) {
34
- apiDeprecated('withShortDebug', {msg: 'Use withDebug() instead', v: 'v44'});
35
- return withDebug(msgs, fn, source);
36
- }
37
-
38
- /**
39
- * Log a message for debugging with standardized formatting.
40
- *
41
- * @param {(string[]|string)} msgs
42
- * @param {(Object|string)} [source] - class, function or string to label the source of the message
43
- */
44
- export function logDebug(msgs, source) {
45
- return loggedDo(msgs, null, source);
46
- }
47
-
48
- //----------------------------------
49
- // Implementation
50
- //----------------------------------
51
- function loggedDo(msgs, fn, source) {
52
-
53
- source = parseSource(source);
54
- msgs = castArray(msgs);
55
- const msg = msgs.join(' | ');
56
-
57
- // Support simple message only
58
- if (!fn) {
59
- writeLog(msg, source);
60
- return;
61
- }
62
-
63
- // ..otherwise a wrapped call..
64
- const start = Date.now();
65
- let ret;
66
- try {
67
- ret = fn();
68
- } catch (e) {
69
- logException(start, msg, source, e);
70
- throw e;
71
- }
72
-
73
- if (ret instanceof Promise) {
74
- ret.then(
75
- () => logCompletion(start, msg, source),
76
- (e) => logException(start, msg, source, e)
77
- );
78
- } else {
79
- logCompletion(start, msg, source);
80
- }
81
-
82
- return ret;
83
- }
84
-
85
- function parseSource(source) {
86
- if (isString(source)) return source;
87
- if (source?.displayName) return source.displayName;
88
- if (source?.constructor) return source.constructor.name;
89
- return '';
90
- }
91
-
92
- function writeLog(msg, source) {
93
- console.debug(source ? `[${source}] ${msg}` : msg);
94
- }
95
-
96
- function logCompletion(start, msg, source) {
97
- const elapsed = Date.now() - start;
98
- writeLog(`${msg} | ${elapsed}ms`, source);
99
- }
100
-
101
- function logException(start, msg, source, e) {
102
- const elapsed = Date.now() - start;
103
- writeLog(`${msg} | failed - ${e.message || e.name || 'Unknown error'} | ${elapsed}ms`, source);
104
- }