@xh/hoist 59.3.1 → 59.4.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 (54) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/admin/differ/DifferModel.ts +5 -7
  3. package/appcontainer/AppContainerModel.ts +8 -10
  4. package/appcontainer/AppStateModel.ts +3 -2
  5. package/appcontainer/PageStateModel.ts +1 -2
  6. package/appcontainer/SizingModeModel.ts +2 -2
  7. package/cmp/ag-grid/AgGrid.ts +4 -3
  8. package/cmp/ag-grid/AgGridModel.ts +8 -9
  9. package/cmp/chart/Chart.ts +4 -3
  10. package/cmp/dataview/DataViewModel.ts +2 -2
  11. package/cmp/filter/FilterChooserModel.ts +5 -5
  12. package/cmp/grid/Grid.ts +2 -2
  13. package/cmp/grid/GridContextMenu.ts +2 -2
  14. package/cmp/grid/GridModel.ts +13 -21
  15. package/cmp/grid/Types.ts +6 -5
  16. package/cmp/grid/columns/Column.ts +12 -12
  17. package/cmp/grid/columns/ColumnGroup.ts +17 -6
  18. package/cmp/grid/helpers/GridCountLabel.ts +5 -4
  19. package/cmp/grid/impl/ColumnWidthCalculator.ts +3 -3
  20. package/cmp/grid/impl/GridPersistenceModel.ts +11 -4
  21. package/cmp/grid/renderers/MultiFieldRenderer.ts +28 -22
  22. package/cmp/grouping/GroupingChooserModel.ts +2 -2
  23. package/cmp/tab/TabContainerModel.ts +2 -2
  24. package/cmp/zoneGrid/ZoneGridModel.ts +52 -36
  25. package/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts +10 -4
  26. package/core/HoistBase.ts +44 -5
  27. package/core/XH.ts +3 -3
  28. package/core/elem.ts +2 -2
  29. package/core/impl/InstallServices.ts +2 -8
  30. package/core/load/LoadSupport.ts +3 -3
  31. package/core/model/HoistModel.ts +1 -1
  32. package/data/Store.ts +1 -1
  33. package/data/UrlStore.ts +3 -3
  34. package/data/filter/CompoundFilter.ts +5 -3
  35. package/data/filter/FieldFilter.ts +4 -3
  36. package/data/filter/Filter.ts +2 -3
  37. package/data/filter/FunctionFilter.ts +2 -1
  38. package/data/impl/RecordSet.ts +5 -5
  39. package/desktop/appcontainer/ToastSource.ts +1 -1
  40. package/desktop/cmp/rest/Actions.ts +15 -9
  41. package/desktop/cmp/treemap/TreeMap.ts +4 -8
  42. package/package.json +1 -1
  43. package/svc/AutoRefreshService.ts +3 -3
  44. package/svc/EnvironmentService.ts +1 -1
  45. package/svc/FetchService.ts +5 -5
  46. package/svc/GridAutosizeService.ts +4 -7
  47. package/svc/TrackService.ts +6 -6
  48. package/svc/WebSocketService.ts +14 -15
  49. package/utils/async/AsyncUtils.ts +3 -2
  50. package/utils/async/Timer.ts +32 -19
  51. package/utils/js/BrowserUtils.ts +8 -8
  52. package/utils/js/LangUtils.ts +10 -9
  53. package/utils/js/LogUtils.ts +66 -26
  54. package/utils/react/LayoutPropUtils.ts +3 -3
@@ -26,7 +26,8 @@ export class FunctionFilter extends Filter {
26
26
  readonly testFn: FilterTestFn;
27
27
 
28
28
  /**
29
- * Constructor - not typically called by apps - create from config via `parseFilter()` instead.
29
+ * Constructor - not typically called by apps - create via {@link parseFilter} instead.
30
+ * @internal
30
31
  */
31
32
  constructor({key, testFn}: FunctionFilterSpec) {
32
33
  super();
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import equal from 'fast-deep-equal';
9
- import {throwIf, logDebug} from '@xh/hoist/utils/js';
9
+ import {logWarn, throwIf} from '@xh/hoist/utils/js';
10
10
  import {maxBy, isNil} from 'lodash';
11
11
  import {StoreRecord, StoreRecordId} from '../StoreRecord';
12
12
  import {Store} from '../Store';
@@ -196,7 +196,7 @@ export class RecordSet {
196
196
  remove.forEach(id => {
197
197
  if (!newRecords.has(id)) {
198
198
  missingRemoves++;
199
- logDebug(`Attempted to remove non-existent record: ${id}`, this.store);
199
+ this.store.logDebug(`Attempted to remove non-existent record: ${id}`);
200
200
  return;
201
201
  }
202
202
  this.gatherDescendantIds(id, allRemoves);
@@ -211,7 +211,7 @@ export class RecordSet {
211
211
  existing = newRecords.get(id);
212
212
  if (!existing) {
213
213
  missingUpdates++;
214
- logDebug(`Attempted to update non-existent record: ${id}`, this.store);
214
+ this.store.logDebug(`Attempted to update non-existent record: ${id}`);
215
215
  return;
216
216
  }
217
217
  newRecords.set(id, rec);
@@ -230,9 +230,9 @@ export class RecordSet {
230
230
  }
231
231
 
232
232
  if (missingRemoves > 0)
233
- console.warn(`Failed to remove ${missingRemoves} records not found by id`);
233
+ logWarn(`Failed to remove ${missingRemoves} records not found by id`, this);
234
234
  if (missingUpdates > 0)
235
- console.warn(`Failed to update ${missingUpdates} records not found by id`);
235
+ logWarn(`Failed to update ${missingUpdates} records not found by id`, this);
236
236
 
237
237
  return new RecordSet(this.store, newRecords);
238
238
  }
@@ -95,7 +95,7 @@ class ToastSourceLocalModel extends HoistModel {
95
95
  */
96
96
  async getToasterAsync(position: ToasterPosition, container: HTMLElement) {
97
97
  if (container && !isElement(container)) {
98
- console.warn('container for Toast must be a DOM element. Argument will be ignored.');
98
+ this.logWarn('Ignoring invalid containerRef for Toast - must be a DOM element');
99
99
  container = null;
100
100
  }
101
101
  const className = `xh-toast-container ${container ? 'xh-toast-container--anchored' : ''}`;
@@ -6,15 +6,17 @@
6
6
  */
7
7
 
8
8
  import '@xh/hoist/desktop/register';
9
+ import {GridModel} from '@xh/hoist/cmp/grid';
9
10
  import {RecordActionSpec} from '@xh/hoist/data';
11
+ import {RestGridModel} from '@xh/hoist/desktop/cmp/rest/RestGridModel';
10
12
  import {Icon} from '@xh/hoist/icon/Icon';
11
13
 
12
14
  export const addAction: RecordActionSpec = {
13
15
  text: 'Add',
14
16
  icon: Icon.add(),
15
17
  intent: 'success',
16
- actionFn: ({gridModel}) => gridModel.appData.restGridModel.addRecord(),
17
- displayFn: ({gridModel}) => ({hidden: gridModel.appData.restGridModel.readonly}),
18
+ actionFn: ({gridModel}) => getRGM(gridModel).addRecord(),
19
+ displayFn: ({gridModel}) => ({hidden: getRGM(gridModel).readonly}),
18
20
  testId: 'add-action-button'
19
21
  };
20
22
 
@@ -23,8 +25,8 @@ export const editAction: RecordActionSpec = {
23
25
  icon: Icon.edit(),
24
26
  intent: 'primary',
25
27
  recordsRequired: 1,
26
- actionFn: ({record, gridModel}) => gridModel.appData.restGridModel.editRecord(record),
27
- displayFn: ({gridModel}) => ({hidden: gridModel.appData.restGridModel.readonly}),
28
+ actionFn: ({record, gridModel}) => getRGM(gridModel).editRecord(record),
29
+ displayFn: ({gridModel}) => ({hidden: getRGM(gridModel).readonly}),
28
30
  testId: 'edit-action-button'
29
31
  };
30
32
 
@@ -32,7 +34,7 @@ export const viewAction: RecordActionSpec = {
32
34
  text: 'View',
33
35
  icon: Icon.search(),
34
36
  recordsRequired: 1,
35
- actionFn: ({record, gridModel}) => gridModel.appData.restGridModel.viewRecord(record),
37
+ actionFn: ({record, gridModel}) => getRGM(gridModel).viewRecord(record),
36
38
  testId: 'view-action-button'
37
39
  };
38
40
 
@@ -40,8 +42,8 @@ export const cloneAction: RecordActionSpec = {
40
42
  text: 'Clone',
41
43
  icon: Icon.copy(),
42
44
  recordsRequired: 1,
43
- actionFn: ({record, gridModel}) => gridModel.appData.restGridModel.cloneRecord(record),
44
- displayFn: ({gridModel}) => ({hidden: gridModel.appData.restGridModel.readonly}),
45
+ actionFn: ({record, gridModel}) => getRGM(gridModel).cloneRecord(record),
46
+ displayFn: ({gridModel}) => ({hidden: getRGM(gridModel).readonly}),
45
47
  testId: 'clone-action-button'
46
48
  };
47
49
 
@@ -51,8 +53,12 @@ export const deleteAction: RecordActionSpec = {
51
53
  intent: 'danger',
52
54
  recordsRequired: true,
53
55
  displayFn: ({gridModel, record}) => ({
54
- hidden: (record && record.id === null) || gridModel.appData.restGridModel.readonly // Hide this action if we are acting on a "new" record
56
+ hidden: (record && record.id === null) || getRGM(gridModel).readonly // Hide this action if we are acting on a "new" record
55
57
  }),
56
- actionFn: ({gridModel}) => gridModel.appData.restGridModel.confirmDeleteRecords(),
58
+ actionFn: ({gridModel}) => getRGM(gridModel).confirmDeleteRecords(),
57
59
  testId: 'delete-action-button'
58
60
  };
61
+
62
+ function getRGM(gridModel: GridModel): RestGridModel {
63
+ return gridModel.appData.restGridModel as RestGridModel;
64
+ }
@@ -22,7 +22,7 @@ import {mask} from '@xh/hoist/desktop/cmp/mask';
22
22
  import '@xh/hoist/desktop/register';
23
23
  import {Highcharts} from '@xh/hoist/kit/highcharts';
24
24
  import {wait} from '@xh/hoist/promise';
25
- import {logWithDebug, withDebug} from '@xh/hoist/utils/js';
25
+ import {logWithDebug} from '@xh/hoist/utils/js';
26
26
  import {
27
27
  createObservableRef,
28
28
  getLayoutProps,
@@ -193,13 +193,9 @@ class TreeMapLocalModel extends HoistModel {
193
193
  if (parentDims.width === 0 || parentDims.height === 0) return;
194
194
 
195
195
  assign(config.chart, parentDims, {renderTo: chartElem});
196
- withDebug(
197
- `Creating new TreeMap | ${newData.length} records`,
198
- () => {
199
- this.chart = Highcharts.chart(config);
200
- },
201
- this
202
- );
196
+ this.withDebug(['Creating new TreeMap', `${newData.length} records`], () => {
197
+ this.chart = Highcharts.chart(config);
198
+ });
203
199
  }
204
200
 
205
201
  @logWithDebug
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "59.3.1",
3
+ "version": "59.4.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",
@@ -7,7 +7,7 @@
7
7
  import {HoistService, managed, XH} from '@xh/hoist/core';
8
8
  import {Timer} from '@xh/hoist/utils/async';
9
9
  import {olderThan, ONE_SECOND, SECONDS} from '@xh/hoist/utils/datetime';
10
- import {logDebug, withDefault} from '@xh/hoist/utils/js';
10
+ import {withDefault} from '@xh/hoist/utils/js';
11
11
 
12
12
  /**
13
13
  * Service to triggers an app-wide auto-refresh (if enabled, on a configurable interval) via the
@@ -58,7 +58,7 @@ export class AutoRefreshService extends HoistService {
58
58
  // Implementation
59
59
  //------------------------
60
60
  private async onTimerAsync() {
61
- if (!this.enabled || document.hidden) return;
61
+ if (!this.enabled || !XH.pageIsVisible) return;
62
62
 
63
63
  // Wait interval after lastCompleted -- this prevents extra refreshes if user refreshes
64
64
  // manually, or loading slow. Note auto-loads skipped if any load in progress.
@@ -69,7 +69,7 @@ export class AutoRefreshService extends HoistService {
69
69
  pendingLoad = lastRequested && lastRequested > lastCompleted;
70
70
 
71
71
  if (!pendingLoad && olderThan(last, this.interval * SECONDS)) {
72
- logDebug('Triggering application auto-refresh.', this);
72
+ this.logDebug('Triggering application auto-refresh.');
73
73
  await ctx.autoRefreshAsync();
74
74
  }
75
75
  }
@@ -125,7 +125,7 @@ export class EnvironmentService extends HoistService {
125
125
  } else if (mode === 'forceReload') {
126
126
  XH.suspendApp({
127
127
  reason: 'APP_UPDATE',
128
- message: `A new version of ${XH.clientAppName} is available!`
128
+ message: `A new version of ${XH.clientAppName} is now available (${appVersion}) and requires an immediate update.`
129
129
  });
130
130
  }
131
131
  }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistService, XH, Exception, PlainObject, Thunkable, FetchResponse} from '@xh/hoist/core';
7
+ import {HoistService, XH, Exception, PlainObject, FetchResponse, LoadSpec} from '@xh/hoist/core';
8
8
  import {isLocalDate, SECONDS, ONE_MINUTE, olderThan} from '@xh/hoist/utils/datetime';
9
9
  import {throwIf} from '@xh/hoist/utils/js';
10
10
  import {StatusCodes} from 'http-status-codes';
@@ -66,7 +66,7 @@ export class FetchService extends HoistService {
66
66
  * Set default headers to be sent with all subsequent requests.
67
67
  * @param headers - to be sent with all fetch requests, or a function to generate.
68
68
  */
69
- setDefaultHeaders(headers: Thunkable<PlainObject>) {
69
+ setDefaultHeaders(headers: PlainObject | ((arg: FetchOptions) => PlainObject)) {
70
70
  this.defaultHeaders = headers;
71
71
  }
72
72
 
@@ -328,9 +328,9 @@ export interface FetchOptions {
328
328
 
329
329
  /**
330
330
  * Data to send in the request body (for POSTs/PUTs of JSON).
331
- * When using `fetch`, provide a string. Otherwise, provide a PlainObject.
331
+ * When using `fetch`, provide a string. Otherwise, provide a JSON Serializable object
332
332
  */
333
- body?: PlainObject | string;
333
+ body?: any;
334
334
 
335
335
  /**
336
336
  * Parameters to encode and append as a query string, or send with the request body
@@ -360,7 +360,7 @@ export interface FetchOptions {
360
360
  * Optional metadata about the underlying request. Passed through for downstream processing by
361
361
  * utils such as {@link ExceptionHandler}.
362
362
  */
363
- loadSpec?: PlainObject;
363
+ loadSpec?: LoadSpec;
364
364
 
365
365
  /**
366
366
  * Options to pass to the underlying fetch request.
@@ -60,24 +60,21 @@ export class GridAutosizeService extends HoistService {
60
60
  );
61
61
 
62
62
  if (!requiredWidths) {
63
- console.debug('Autosize aborted, grid data is obsolete.');
63
+ this.logDebug('Autosize aborted, grid data is obsolete.');
64
64
  return;
65
65
  }
66
66
 
67
67
  runInAction(() => {
68
68
  // 4) Set columns to their required widths.
69
69
  gridModel.applyColumnStateChanges(requiredWidths);
70
- console.debug(
71
- `Column widths autosized via GridAutosizeService (${records.length} records)`,
72
- requiredWidths
73
- );
70
+ this.logDebug(`Auto-sized columns`, `${records.length} records`, requiredWidths);
74
71
 
75
72
  // 5) Grow columns to fill any remaining space, if enabled.
76
73
  const {fillMode} = options;
77
74
  if (fillMode && fillMode !== 'none') {
78
75
  const fillWidths = this.calcFillWidths(gridModel, colIds, fillMode);
79
76
  gridModel.applyColumnStateChanges(fillWidths);
80
- console.debug('Column widths filled via GridAutosizeService', fillWidths);
77
+ this.logDebug('Auto-sized columns using fillMode', fillWidths);
81
78
  }
82
79
  });
83
80
  }
@@ -170,7 +167,7 @@ export class GridAutosizeService extends HoistService {
170
167
  available = agApi?.gridPanel?.eBodyViewport?.clientWidth;
171
168
 
172
169
  if (!agApi || !isFinite(available)) {
173
- console.warn('Grid not rendered - unable to fill columns.');
170
+ this.logWarn('Grid not rendered - unable to fill columns.');
174
171
  return [];
175
172
  }
176
173
 
@@ -97,19 +97,19 @@ export class TrackService extends HoistService {
97
97
 
98
98
  const {maxDataLength} = this.conf;
99
99
  if (params.data?.length > maxDataLength) {
100
- console.warn(
101
- `[TrackService] | Track log includes ${params.data.length} chars of JSON data | exceeds limit of ${maxDataLength} | data will not be persisted`,
100
+ this.logWarn(
101
+ `Track log includes ${params.data.length} chars of JSON data`,
102
+ `exceeds limit of ${maxDataLength}`,
103
+ 'data will not be persisted',
102
104
  options.data
103
105
  );
104
106
  params.data = null;
105
107
  }
106
108
 
107
109
  const elapsedStr = params.elapsed != null ? `${params.elapsed}ms` : null,
108
- consoleMsg = ['[Track]', params.category, params.msg, elapsedStr]
109
- .filter(it => it != null)
110
- .join(' | ');
110
+ consoleMsgs = [params.category, params.msg, elapsedStr].filter(it => it != null);
111
111
 
112
- console.log(consoleMsg);
112
+ this.logInfo(...consoleMsgs);
113
113
 
114
114
  await XH.fetchJson({url: 'xh/track', params});
115
115
  } catch (e) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import {HoistService, XH} from '@xh/hoist/core';
8
8
  import {Icon} from '@xh/hoist/icon';
9
- import {action, observable, makeObservable} from '@xh/hoist/mobx';
9
+ import {action, makeObservable, observable} from '@xh/hoist/mobx';
10
10
  import {Timer} from '@xh/hoist/utils/async';
11
11
  import {SECONDS} from '@xh/hoist/utils/datetime';
12
12
  import {throwIf} from '@xh/hoist/utils/js';
@@ -55,11 +55,11 @@ export class WebSocketService extends HoistService {
55
55
  return !!this.channelKey;
56
56
  }
57
57
 
58
- /** set to true to log all sent/received messages - very chatty. */
58
+ /** Set to true to log all sent/received messages - very chatty. */
59
59
  logMessages: boolean = false;
60
60
 
61
- private _timer;
62
- private _socket;
61
+ private _timer: Timer;
62
+ private _socket: WebSocket;
63
63
  private _subsByTopic = {};
64
64
 
65
65
  enabled: boolean = XH.appSpec.webSocketsEnabled;
@@ -72,9 +72,8 @@ export class WebSocketService extends HoistService {
72
72
  override async initAsync() {
73
73
  if (!this.enabled) return;
74
74
  if (XH.environmentService.get('webSocketsEnabled') === false) {
75
- console.error(
76
- 'WebSockets have been enabled on this client app, but are disabled on the server. ' +
77
- 'Please adjust your server-side configuration to use WebSockets.'
75
+ this.logError(
76
+ `WebSockets enabled on this client app but disabled on server. Adjust your server-side config.`
78
77
  );
79
78
  this.enabled = false;
80
79
  return;
@@ -151,7 +150,7 @@ export class WebSocketService extends HoistService {
151
150
  };
152
151
  this._socket = s;
153
152
  } catch (e) {
154
- console.error('Failure creating WebSocket in WebSocketService', e);
153
+ this.logError('Failure creating WebSocket', e);
155
154
  }
156
155
 
157
156
  this.updateConnectedStatus();
@@ -170,7 +169,7 @@ export class WebSocketService extends HoistService {
170
169
  if (this.connected) {
171
170
  this.sendMessage({topic: this.HEARTBEAT_TOPIC, data: 'ping'});
172
171
  } else {
173
- console.warn('Heartbeat found websocket not connected - attempting to reconnect.');
172
+ this.logWarn('Heartbeat found websocket not connected - attempting to reconnect...');
174
173
  this.disconnect();
175
174
  this.connect();
176
175
  }
@@ -185,17 +184,17 @@ export class WebSocketService extends HoistService {
185
184
  // Socket events impl
186
185
  //------------------------
187
186
  onOpen(ev) {
188
- console.debug('WebSocket connection opened', ev);
187
+ this.logDebug('WebSocket connection opened', ev);
189
188
  this.updateConnectedStatus();
190
189
  }
191
190
 
192
191
  onClose(ev) {
193
- console.debug('WebSocket connection closed', ev);
192
+ this.logDebug('WebSocket connection closed', ev);
194
193
  this.updateConnectedStatus();
195
194
  }
196
195
 
197
196
  onError(ev) {
198
- console.error('WebSocket connection error', ev);
197
+ this.logError('WebSocket connection error', ev);
199
198
  this.updateConnectedStatus();
200
199
  }
201
200
 
@@ -221,7 +220,7 @@ export class WebSocketService extends HoistService {
221
220
 
222
221
  this.notifySubscribers(msg);
223
222
  } catch (e) {
224
- console.error('Error decoding websocket message', rawMsg, e);
223
+ this.logError('Error decoding websocket message', rawMsg, e);
225
224
  }
226
225
  this.updateConnectedStatus();
227
226
  }
@@ -236,7 +235,7 @@ export class WebSocketService extends HoistService {
236
235
  try {
237
236
  sub.fn(message);
238
237
  } catch (e) {
239
- console.error(`Failure in subscription handler for topic ${message.topic}`, e);
238
+ this.logError(`Handler for topic ${message.topic} threw`, e);
240
239
  }
241
240
  });
242
241
  }
@@ -287,7 +286,7 @@ export class WebSocketService extends HoistService {
287
286
  }
288
287
 
289
288
  maybeLogMessage(...args) {
290
- if (this.logMessages) console.log(...args);
289
+ if (this.logMessages) this.logDebug(args);
291
290
  }
292
291
  }
293
292
 
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Copyright © 2023 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {XH} from '@xh/hoist/core';
7
8
  import {wait} from '@xh/hoist/promise';
8
9
 
9
10
  /**
@@ -15,7 +16,7 @@ import {wait} from '@xh/hoist/promise';
15
16
  * allowing ongoing rendering of UI updates (e.g. load masks) and generally keeping the browser
16
17
  * event loop running.
17
18
  *
18
- * Note that if the browser tab is hidden (i.e. document.hidden is true) this loop will be executed
19
+ * Note that if the content tab is hidden (i.e. `!XH.pageIsVisible`) this loop will be executed
19
20
  * without pauses. In this case the pauses would be unduly large due to throttling of the event
20
21
  * loop by the browser, and there is no user benefit to avoiding blocking the main thread.
21
22
  *
@@ -55,7 +56,7 @@ export async function whileAsync(
55
56
  const {waitAfter = 50, waitFor = 0} = opts ?? {};
56
57
 
57
58
  // Fallback to basic loop when doc hidden: no user benefit, and throttling causes outsize waits
58
- if (document.hidden) {
59
+ if (!XH.pageIsVisible) {
59
60
  while (conditionFn()) fn();
60
61
  return;
61
62
  }
@@ -7,7 +7,7 @@
7
7
  import {XH} from '@xh/hoist/core';
8
8
  import {wait} from '@xh/hoist/promise';
9
9
  import {MILLISECONDS, MINUTES, olderThan} from '@xh/hoist/utils/datetime';
10
- import {throwIf} from '@xh/hoist/utils/js';
10
+ import {logWarn, throwIf} from '@xh/hoist/utils/js';
11
11
  import {isBoolean, isFinite, isFunction, isNil, isString, pull} from 'lodash';
12
12
 
13
13
  /**
@@ -15,19 +15,20 @@ import {isBoolean, isFinite, isFunction, isNil, isString, pull} from 'lodash';
15
15
  * Promise-aware recurring task timer for use by framework and applications.
16
16
  *
17
17
  * This object is designed to be robust across failing tasks, and never to re-run the task
18
- * simultaneously, unless in the case of a timeout. Callers can optionally specify
19
- * the duration of asynchronous tasks by returning a Promise from runFn.
18
+ * simultaneously, unless in the case of a timeout. Callers can optionally specify the duration
19
+ * of asynchronous tasks by returning a Promise from runFn.
20
20
  *
21
- * This object seeks to mirror the API and semantics of the server-side equivalent 'Timer'
22
- * as closely as possible. However, there are important differences due to the synchronous
23
- * nature of javascript. In particular, there is no support for 'runImmediatelyAndBlock', and the
24
- * 'timeout' argument will not be able to interrupt synchronous activity of the runFn.
21
+ * This object seeks to mirror the API and semantics of `Timer.groovy` from Hoist Core as closely
22
+ * as possible. However, there are important differences due to the synchronous nature of
23
+ * javascript. In particular, there is no support for `runImmediatelyAndBlock`, and the `timeout`
24
+ * argument will not be able to interrupt synchronous activity of the runFn.
25
25
  *
26
- * All public properties should be considered read-only. See `setInterval()` to change the interval
27
- * of this timer dynamically.
26
+ * All public properties should be considered read-only.
27
+ * See `setInterval()` to change the interval of this Timer dynamically.
28
28
  */
29
29
  export class Timer {
30
30
  static _timers: Timer[] = [];
31
+ static MIN_INTERVAL_MS = 500;
31
32
 
32
33
  runFn: () => any = null;
33
34
  interval: number | (() => number) = null;
@@ -41,6 +42,8 @@ export class Timer {
41
42
  isRunning: boolean = false;
42
43
  lastRun: Date = null;
43
44
 
45
+ private warnedIntervals = new Set();
46
+
44
47
  /** Create a new Timer. */
45
48
  static create({
46
49
  runFn,
@@ -67,9 +70,7 @@ export class Timer {
67
70
  this._timers = [];
68
71
  }
69
72
 
70
- /**
71
- * Permanently cancel this timer.
72
- */
73
+ /** Permanently cancel this timer. */
73
74
  cancel() {
74
75
  this.cancelInternal();
75
76
  pull(Timer._timers, this);
@@ -77,7 +78,6 @@ export class Timer {
77
78
 
78
79
  /**
79
80
  * Change the interval of this timer.
80
- *
81
81
  * @param interval - ms to wait between runs or any value `<=0` to pause the timer.
82
82
  */
83
83
  setInterval(interval: number) {
@@ -94,7 +94,10 @@ export class Timer {
94
94
  this.intervalUnits = args.intervalUnits;
95
95
  this.timeoutUnits = args.timeoutUnits;
96
96
  this.delay = this.parseDelay(args.delay);
97
- throwIf(this.interval == null || this.runFn == null, 'Missing req arguments for Timer');
97
+ throwIf(
98
+ this.interval == null || this.runFn == null,
99
+ 'Missing required arguments for Timer - both interval and runFn must be specified.'
100
+ );
98
101
 
99
102
  wait(this.delay).then(() => this.heartbeatAsync());
100
103
  }
@@ -133,18 +136,28 @@ export class Timer {
133
136
  return isString(val) ? () => XH.configService.get(val) : val;
134
137
  }
135
138
 
136
- private parseDelay(val): number {
139
+ private parseDelay(val: number | boolean): number {
137
140
  if (isBoolean(val)) return val ? this.intervalMs : 0;
138
141
  return isFinite(val) ? val : 0;
139
142
  }
140
143
 
141
144
  private get intervalMs() {
142
- const {interval, intervalUnits} = this;
145
+ const {interval, intervalUnits, warnedIntervals} = this,
146
+ min = Timer.MIN_INTERVAL_MS;
147
+
143
148
  if (isNil(interval)) return null;
149
+
144
150
  let ret = (isFunction(interval) ? interval() : interval) * intervalUnits;
145
- if (ret > 0 && ret < 500) {
146
- console.warn('Timer cannot be set for values less than 500ms.');
147
- ret = 500;
151
+
152
+ if (ret > 0 && ret < min) {
153
+ if (!warnedIntervals.has(ret)) {
154
+ warnedIntervals.add(ret);
155
+ logWarn(
156
+ `Interval of ${ret}ms requested - forcing to min interval of ${min}ms.`,
157
+ this
158
+ );
159
+ }
160
+ ret = min;
148
161
  }
149
162
  return ret;
150
163
  }
@@ -10,8 +10,7 @@ import {pick} from 'lodash';
10
10
  * Extract information (if available) about the client browser's window, screen, and network speed.
11
11
  */
12
12
  export function getClientDeviceInfo() {
13
- const data: any = pick(
14
- window,
13
+ const data: any = pick(window, [
15
14
  'screen',
16
15
  'devicePixelRatio',
17
16
  'screenX',
@@ -20,10 +19,10 @@ export function getClientDeviceInfo() {
20
19
  'innerHeight',
21
20
  'outerWidth',
22
21
  'outerHeight'
23
- );
22
+ ]);
23
+
24
24
  if (data.screen) {
25
- data.screen = pick(
26
- data.screen,
25
+ data.screen = pick(data.screen, [
27
26
  'availWidth',
28
27
  'availHeight',
29
28
  'width',
@@ -33,14 +32,15 @@ export function getClientDeviceInfo() {
33
32
  'availLeft',
34
33
  'availTop',
35
34
  'orientation'
36
- );
35
+ ]);
37
36
  if (data.screen.orientation) {
38
- data.screen.orientation = pick(data.screen.orientation, 'angle', 'type');
37
+ data.screen.orientation = pick(data.screen.orientation, ['angle', 'type']);
39
38
  }
40
39
  }
40
+
41
41
  const nav = window.navigator as any;
42
42
  if (nav.connection) {
43
- data.connection = pick(nav.connection, 'downlink', 'effectiveType', 'rtt');
43
+ data.connection = pick(nav.connection, ['downlink', 'effectiveType', 'rtt']);
44
44
  }
45
45
 
46
46
  return data;
@@ -12,7 +12,7 @@ import {
12
12
  isEmpty,
13
13
  isFunction,
14
14
  isObject,
15
- isObjectLike,
15
+ isPlainObject,
16
16
  isUndefined,
17
17
  mixin,
18
18
  uniq,
@@ -53,22 +53,23 @@ export function withDefault<T>(...args: T[]): T {
53
53
  }
54
54
 
55
55
  /**
56
- * Recursively freeze an object, preventing future modifications. Not all objects are supported -
57
- * FREEZABLE_TYPES limits what we will attempt to freeze to a whitelist of types known to be safely
58
- * freezable without side effects. This avoids freezing other types of objects where this routine
56
+ * Recursively freeze an object, preventing future modifications. Only the specific declared
57
+ * input types will be frozen. This avoids freezing other types of objects where this routine
59
58
  * could be problematic - e.g. application or library classes (such as `moment`!) which rely on
60
59
  * their internal state remaining mutable to function.
61
60
  */
62
- const FREEZABLE_TYPES: Set<String> = new Set(['Object', 'Array', 'Map', 'Set']);
63
- export function deepFreeze(obj: object) {
64
- if (!isObjectLike(obj) || !FREEZABLE_TYPES.has(obj.constructor.name)) return obj;
61
+ export function deepFreeze<
62
+ T extends Record<string, unknown> | Array<unknown> | Map<unknown, unknown> | Set<unknown>
63
+ >(obj: T): Readonly<T> {
64
+ if (!(isPlainObject(obj) || isArray(obj) || obj instanceof Map || obj instanceof Set)) {
65
+ return obj;
66
+ }
65
67
 
66
68
  const propNames = Object.getOwnPropertyNames(obj);
67
69
  for (const name of propNames) {
68
70
  deepFreeze(obj[name]);
69
71
  }
70
-
71
- return Object.freeze(obj);
72
+ return Object.freeze<T>(obj);
72
73
  }
73
74
 
74
75
  /**