@xh/hoist 47.0.1 → 47.1.2

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,13 +1,46 @@
1
1
  # Changelog
2
2
 
3
- ## v47.0.1 - 2022-03-06
3
+ ## v47.1.2 - 2022-04-01
4
4
 
5
- [Commit Log](https://github.com/xh/hoist-react/compare/v47.0.0...v47.0.1)
5
+ ### 🐞 Bug Fixes
6
+
7
+ * `FieldFilter`'s check of `committedData` is now null safe. A record with no `committedData` will not be filtered out.
8
+
9
+
10
+ ## v47.1.1 - 2022-03-26
11
+
12
+ ### 🎁 New Features
13
+
14
+ * New "sync with system" theme option - sets the Hoist theme to light/dark based on the user's OS.
15
+ * Added `cancelAlign` config to `XH.message()` and variants. Customize to "left" to render
16
+ Cancel and Confirm actions separated by a filler.
17
+ * Added `GridModel.restoreDefaultsFn`, an optional function called after `restoreDefaultsAsync`.
18
+ Allows apps to run additional, app-specific logic after a grid has been reset (e.g. resetting
19
+ other, related preferences or state not managed by `GridModel` directly).
20
+ * Added `AppSpec.lockoutPanel`, allowing apps to specify a custom component.
21
+
22
+ ### 🐞 Bug Fixes
23
+
24
+ * Fixed column auto-sizing when `headerName` is/returns an element.
25
+ * Fixed bug where subforms were not properly registering as dirty.
26
+ * Fixed an issue where `Select` inputs would commit `null` whilst clearing the text input.
27
+ * Fixed `Clock` component bug introduced in v47 (configured timezone was not respected).
28
+
29
+ ### 📚 Libraries
30
+
31
+ * @blueprintjs/core `3.53 -> 3.54`
32
+ * @blueprintjs/datetime `3.23 -> 3.24`
33
+
34
+ [Commit Log](https://github.com/xh/hoist-react/compare/v47.0.1...v47.1.1)
35
+
36
+ ## v47.0.1 - 2022-03-06
6
37
 
7
38
  ### 🐞 Bug Fixes
8
39
 
9
40
  * Fix to mobile `ColChooser` error re. internal model handling.
10
41
 
42
+ [Commit Log](https://github.com/xh/hoist-react/compare/v47.0.0...v47.0.1)
43
+
11
44
  ## v47.0.0 - 2022-03-04
12
45
 
13
46
  ### 🎁 New Features
@@ -20,27 +20,29 @@
20
20
  }
21
21
 
22
22
  table {
23
- margin: var(--xh-pad-px);
24
- border-spacing: 0;
25
- border-collapse: collapse;
26
- min-width: 500px;
27
23
  background-color: var(--xh-bg);
28
- }
24
+ border-collapse: collapse;
25
+ border-spacing: 0;
26
+ margin: var(--xh-pad-px);
29
27
 
30
- tr {
31
- border: var(--xh-border-solid);
32
- }
28
+ tr {
29
+ border: var(--xh-border-solid);
30
+ }
33
31
 
34
- th {
35
- padding: var(--xh-pad-half-px);
36
- background-color: var(--xh-bg-alt);
37
- width: 180px;
38
- text-align: right;
39
- vertical-align: top;
40
- }
32
+ th {
33
+ background-color: var(--xh-bg-alt);
34
+ padding: var(--xh-pad-half-px);
35
+ text-align: right;
36
+ vertical-align: top;
37
+ width: 180px;
38
+ }
41
39
 
42
- td {
43
- padding: var(--xh-pad-half-px);
40
+ td {
41
+ max-width: 600px;
42
+ min-width: 600px;
43
+ padding: var(--xh-pad-half-px);
44
+ word-wrap: break-word;
45
+ }
44
46
  }
45
47
 
46
48
  &__blurb {
@@ -23,6 +23,7 @@ export class MessageModel extends HoistModel {
23
23
  input;
24
24
  confirmProps;
25
25
  cancelProps;
26
+ cancelAlign;
26
27
  onConfirm;
27
28
  onCancel;
28
29
  messageKey;
@@ -41,6 +42,7 @@ export class MessageModel extends HoistModel {
41
42
  input,
42
43
  confirmProps = {},
43
44
  cancelProps = {},
45
+ cancelAlign = 'right',
44
46
  onConfirm,
45
47
  onCancel,
46
48
 
@@ -73,6 +75,7 @@ export class MessageModel extends HoistModel {
73
75
 
74
76
  this.confirmProps = this.parseButtonProps(confirmProps, () => this.doConfirmAsync(), confirmText, confirmIntent);
75
77
  this.cancelProps = this.parseButtonProps(cancelProps, () => this.doCancel(), cancelText, cancelIntent);
78
+ this.cancelAlign = cancelAlign;
76
79
 
77
80
  this.onConfirm = onConfirm;
78
81
  this.onCancel = onCancel;
@@ -135,7 +138,7 @@ export class MessageModel extends HoistModel {
135
138
 
136
139
  /**
137
140
  * @typedef {Object} MessageInput
138
- * @property {Element} [item] - the react element to render; should be a HoistInput, defaults to a
141
+ * @property {Element} [item] - the React element to render; should be a HoistInput, defaults to a
139
142
  * platform appropriate TextInput.
140
143
  * @property {Rule[]} [rules] - validation constraints to apply.
141
144
  * @property {*} [initialValue] - initial value for the input.
@@ -13,8 +13,8 @@ import {action, observable, makeObservable} from '@xh/hoist/mobx';
13
13
  * @private
14
14
  */
15
15
  export class ThemeModel extends HoistModel {
16
-
17
- @observable darkTheme = false;
16
+ /** @member {boolean} */
17
+ @observable darkTheme;
18
18
 
19
19
  constructor() {
20
20
  super();
@@ -23,7 +23,7 @@ export class ThemeModel extends HoistModel {
23
23
 
24
24
  @action
25
25
  toggleTheme() {
26
- this.setDarkTheme(!this.darkTheme);
26
+ this.setTheme(this.darkTheme ? 'light' : 'dark');
27
27
  }
28
28
 
29
29
  @action
@@ -32,10 +32,33 @@ export class ThemeModel extends HoistModel {
32
32
  classList.toggle('xh-dark', value);
33
33
  classList.toggle('bp3-dark', value);
34
34
  this.darkTheme = value;
35
- XH.setPref('xhTheme', value ? 'dark' : 'light');
35
+
36
+ }
37
+
38
+ @action
39
+ setTheme(value) {
40
+ switch (value) {
41
+ case 'system':
42
+ this.setDarkTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
43
+ break;
44
+ case 'dark':
45
+ this.setDarkTheme(true);
46
+ break;
47
+ case 'light':
48
+ this.setDarkTheme(false);
49
+ break;
50
+ default:
51
+ throw XH.exception("Unrecognized value for theme pref. Must be either 'system', 'dark', or 'light'.");
52
+ }
53
+ XH.setPref('xhTheme', value);
36
54
  }
37
55
 
38
56
  init() {
39
- this.setDarkTheme(XH.getPref('xhTheme') === 'dark');
57
+ this.setTheme(XH.getPref('xhTheme'));
58
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
59
+ if (XH.getPref('xhTheme') === 'system') {
60
+ this.setDarkTheme(event.matches);
61
+ }
62
+ });
40
63
  }
41
64
  }
@@ -86,7 +86,7 @@ class LocalModel extends HoistModel {
86
86
  }
87
87
 
88
88
  async loadTimezoneOffsetAsync() {
89
- const {timezone} = this;
89
+ const {timezone} = this.componentProps;
90
90
 
91
91
  try {
92
92
  if (!timezone) {
@@ -95,10 +95,11 @@ class LocalModel extends HoistModel {
95
95
  return;
96
96
  }
97
97
 
98
- this.offset = await XH.fetchJson({
98
+ const offsetResp = await XH.fetchJson({
99
99
  url: 'xh/getTimeZoneOffset',
100
100
  params: {timeZoneId: timezone}
101
101
  });
102
+ this.offset = offsetResp.offset;
102
103
  this.offsetException = null;
103
104
  } catch (e) {
104
105
  XH.handleException(e, {showAlert: false, logOnServer: false});
@@ -8,7 +8,7 @@ import {managed, XH} from '@xh/hoist/core';
8
8
  import {ValidationState} from '@xh/hoist/data';
9
9
  import {action, computed, makeObservable, override} from '@xh/hoist/mobx';
10
10
  import {throwIf} from '@xh/hoist/utils/js';
11
- import {clone, defaults, flatMap, isArray, partition, without} from 'lodash';
11
+ import {clone, defaults, isEqual, flatMap, isArray, partition, without} from 'lodash';
12
12
  import {executeIfFunction, withDefault} from '../../../utils/js';
13
13
  import {FormModel} from '../FormModel';
14
14
  import {BaseFieldModel} from './BaseFieldModel';
@@ -121,7 +121,12 @@ export class SubformsFieldModel extends BaseFieldModel {
121
121
 
122
122
  @computed
123
123
  get isDirty() {
124
- return this.value.some(s => s.isDirty) || super.isDirty;
124
+ // Catch changed values within subforms, as well as adds/deletes/sorts
125
+ const {value, initialValue} = this;
126
+ return (
127
+ value.some(s => s.isDirty) ||
128
+ !isEqual(initialValue.map(s => s.getData()), value.map(s => s.getData()))
129
+ );
125
130
  }
126
131
 
127
132
  @override
@@ -111,6 +111,8 @@ export class GridModel extends HoistModel {
111
111
  useVirtualColumns;
112
112
  /** @member {GridAutosizeOptions} */
113
113
  autosizeOptions;
114
+ /** @member {function} */
115
+ restoreDefaultsFn;
114
116
  /** @member {ReactNode} */
115
117
  restoreDefaultsWarning;
116
118
  /** @member {boolean} */
@@ -218,6 +220,9 @@ export class GridModel extends HoistModel {
218
220
  * GridFilterModel, or boolean `true` to enable default. Desktop only.
219
221
  * @param {(ColChooserModelConfig|boolean)} [c.colChooserModel] - config with which to create a
220
222
  * ColChooserModel, or boolean `true` to enable default.
223
+ * @param {function} [c.restoreDefaultsFn] - Async function to be called when the user triggers
224
+ * GridModel.restoreDefaultsAsync(). This function will be called after the built-in
225
+ * defaults have been restored, and can be used to restore application specific defaults.
221
226
  * @param {?ReactNode} [c.restoreDefaultsWarning] - Confirmation warning to be presented to
222
227
  * user before restoring default grid state. Set to null to skip user confirmation.
223
228
  * @param {GridModelPersistOptions} [c.persistWith] - options governing persistence.
@@ -359,6 +364,7 @@ export class GridModel extends HoistModel {
359
364
  contextMenu,
360
365
  useVirtualColumns = false,
361
366
  autosizeOptions = {},
367
+ restoreDefaultsFn,
362
368
  restoreDefaultsWarning = GridModel.DEFAULT_RESTORE_DEFAULTS_WARNING,
363
369
  fullRowEditing = false,
364
370
  clicksToEdit = 2,
@@ -398,6 +404,7 @@ export class GridModel extends HoistModel {
398
404
  fillMode: 'none'
399
405
  }
400
406
  );
407
+ this.restoreDefaultsFn = restoreDefaultsFn;
401
408
  this.restoreDefaultsWarning = restoreDefaultsWarning;
402
409
  this.fullRowEditing = fullRowEditing;
403
410
  this.clicksToExpand = clicksToExpand;
@@ -486,6 +493,10 @@ export class GridModel extends HoistModel {
486
493
  await this.autosizeAsync();
487
494
  }
488
495
 
496
+ if (this.restoreDefaultsFn) {
497
+ await this.restoreDefaultsFn();
498
+ }
499
+
489
500
  return true;
490
501
  }
491
502
 
@@ -5,8 +5,11 @@
5
5
  * Copyright © 2021 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
+ import {XH} from '@xh/hoist/core';
8
9
  import {stripTags} from '@xh/hoist/utils/js';
9
- import {forOwn, groupBy, isEmpty, isFunction, isNil, map, max, min, sortBy} from 'lodash';
10
+ import {forOwn, groupBy, isEmpty, isArray, isFunction, isNil, isString, map, max, min, sortBy} from 'lodash';
11
+ import {isValidElement} from 'react';
12
+ import {renderToStaticMarkup} from 'react-dom/server';
10
13
 
11
14
  /**
12
15
  * Calculates the column width required to display column. Used by GridAutoSizeService.
@@ -151,33 +154,48 @@ export class ColumnWidthCalculator {
151
154
  // Autosize header cell
152
155
  //------------------
153
156
  getHeaderWidth(gridModel, column, includeHeaderIcons, bufferPx) {
154
- const {colId, headerName, agOptions, sortable, filterable} = column,
157
+ const {colId, agOptions, sortable, filterable} = column,
155
158
  {sizingMode} = gridModel,
156
- headerHtml = isFunction(headerName) ? headerName({column, gridModel}) : headerName,
159
+ headerHtml = this.getHeaderHtml(gridModel, column),
160
+ headerClass = this.getHeaderClass(gridModel, column),
157
161
  showSort = sortable && (includeHeaderIcons || gridModel.sortBy.find(sorter => sorter.colId === colId)),
158
162
  showMenu = (agOptions?.suppressMenu === false || filterable) && includeHeaderIcons;
159
163
 
160
164
  // Render to a hidden header cell to calculate the max displayed width
161
165
  const headerEl = this.getHeaderEl();
162
- this.setHeaderClassNames(sizingMode, showSort, showMenu);
166
+ this.setHeaderClassNames(sizingMode, showSort, showMenu, headerClass);
163
167
  headerEl.firstChild.innerHTML = headerHtml;
164
168
  return Math.ceil(headerEl.clientWidth) + bufferPx;
165
169
  }
166
170
 
171
+ getHeaderHtml(gridModel, column) {
172
+ const {headerName} = column,
173
+ headerValue = isFunction(headerName) ? headerName({column, gridModel}) : headerName;
174
+
175
+ if (isString(headerValue)) {
176
+ return headerValue;
177
+ } else if (isValidElement(headerValue)) {
178
+ return renderToStaticMarkup(headerValue);
179
+ }
180
+
181
+ throw XH.exception('Unable to get column header html because value is not a string or valid react element');
182
+ }
183
+
167
184
  resetHeaderClassNames() {
168
185
  const headerEl = this.getHeaderEl();
169
186
  headerEl.classList.remove(...headerEl.classList);
170
187
  headerEl.classList.add('xh-grid-autosize-header');
171
188
  }
172
189
 
173
- setHeaderClassNames(sizingMode, showSort, showMenu) {
190
+ setHeaderClassNames(sizingMode, showSort, showMenu, headerClass) {
174
191
  this.resetHeaderClassNames();
175
192
  this.getHeaderEl().classList.add(
176
193
  'xh-grid-autosize-header--active',
177
- `xh-grid-autosize-header--${sizingMode}`,
178
- showSort ? 'xh-grid-autosize-header--sort' : null,
179
- showMenu ? 'xh-grid-autosize-header--menu' : null
194
+ `xh-grid-autosize-header--${sizingMode}`
180
195
  );
196
+ if (showSort) this.getHeaderEl().classList.add('xh-grid-autosize-header--sort');
197
+ if (showMenu) this.getHeaderEl().classList.add('xh-grid-autosize-header--menu');
198
+ if (!isEmpty(headerClass)) this.getHeaderEl().classList.add(...headerClass.split(' '));
181
199
  }
182
200
 
183
201
  getHeaderEl() {
@@ -200,6 +218,24 @@ export class ColumnWidthCalculator {
200
218
  return this._headerEl;
201
219
  }
202
220
 
221
+ getHeaderClass(gridModel, column) {
222
+ let {headerClass} = column;
223
+ if (isNil(headerClass)) return '';
224
+
225
+ if (isFunction(headerClass)) {
226
+ headerClass = headerClass({column, gridModel});
227
+ }
228
+
229
+ const ret = [];
230
+ if (isString(headerClass)) {
231
+ ret.push(headerClass);
232
+ } else if (isArray(headerClass)) {
233
+ ret.push(...headerClass);
234
+ }
235
+
236
+ return ret.join(' ');
237
+ }
238
+
203
239
  //------------------
204
240
  // Autosize cell
205
241
  //------------------
package/core/AppSpec.js CHANGED
@@ -40,11 +40,14 @@ export class AppSpec {
40
40
  * @param {boolean} [c.trackAppLoad] - true (default) to write a track log statement after the
41
41
  * app has loaded and fully initialized, including elapsed time of asset loading and init.
42
42
  * @param {boolean} [c.webSocketsEnabled] - true to enable Hoist websocket connectivity,
43
- * establish a connection and initiate a heartbeat..
43
+ * establish a connection and initiate a heartbeat.
44
44
  * @param {(Class|function)} [c.idlePanel] - optional custom Component to display when App has
45
- * been suspended. The component will receive a single prop -- onReactivate -- a callback
45
+ * been suspended. The component will receive a single prop -- onReactivate -- a callback
46
46
  * called when the user has acknowledged the suspension and wishes to reload the app and
47
- * continue working. Specify as a React Component or an element factory.
47
+ * continue working. Specify as a React Component or an element factory.
48
+ * @param {(Class|function)} [c.lockoutPanel] - optional custom Component to display when the
49
+ * user is denied access to app. Intended for apps that implement custom auth flows.
50
+ * See also `lockoutMessage` for a more lightweight customization option.
48
51
  * @param {?string} [c.loginMessage] - optional message to show on login form (for non-SSO apps).
49
52
  * @param {?string} [c.lockoutMessage] - optional message to show users when denied access to app.
50
53
  * @param {boolean} [c.showBrowserContextMenu] - true to show the built-in browser context menu
@@ -67,6 +70,7 @@ export class AppSpec {
67
70
  trackAppLoad = true,
68
71
  webSocketsEnabled = false,
69
72
  idlePanel = null,
73
+ lockoutPanel = null,
70
74
  loginMessage = null,
71
75
  lockoutMessage = null,
72
76
  showBrowserContextMenu = false,
@@ -96,10 +100,11 @@ export class AppSpec {
96
100
  this.isMobileApp = isMobileApp;
97
101
  this.isSSO = isSSO;
98
102
  this.checkAccess = checkAccess;
99
- this.trackAppLoad = trackAppLoad;
100
103
 
104
+ this.trackAppLoad = trackAppLoad;
101
105
  this.webSocketsEnabled = webSocketsEnabled;
102
106
  this.idlePanel = idlePanel;
107
+ this.lockoutPanel = lockoutPanel;
103
108
  this.loginMessage = loginMessage;
104
109
  this.lockoutMessage = lockoutMessage;
105
110
  this.showBrowserContextMenu = showBrowserContextMenu;
package/core/XH.js CHANGED
@@ -29,7 +29,13 @@ import {
29
29
  } from '@xh/hoist/svc';
30
30
  import {Timer} from '@xh/hoist/utils/async';
31
31
  import {MINUTES} from '@xh/hoist/utils/datetime';
32
- import {checkMinVersion, getClientDeviceInfo, throwIf, withDebug} from '@xh/hoist/utils/js';
32
+ import {
33
+ apiDeprecated,
34
+ checkMinVersion,
35
+ getClientDeviceInfo,
36
+ throwIf,
37
+ withDebug
38
+ } from '@xh/hoist/utils/js';
33
39
  import {camelCase, compact, flatten, isBoolean, isString, uniqueId} from 'lodash';
34
40
  import ReactDOM from 'react-dom';
35
41
  import parser from 'ua-parser-js';
@@ -324,9 +330,22 @@ class XHClass extends HoistBase {
324
330
  return this.acm.themeModel.toggleTheme();
325
331
  }
326
332
 
327
- /** Enable/disable the dark theme directly (useful for custom app option controls). */
333
+ /**
334
+ * Enable/disable the dark theme directly (useful for custom app option controls).
335
+ * @param {boolean} value
336
+ * @deprecated
337
+ */
328
338
  setDarkTheme(value) {
329
- return this.acm.themeModel.setDarkTheme(value);
339
+ apiDeprecated('setDarkTheme', {v: '50', msg: 'Use setTheme instead.'});
340
+ this.setTheme(value ? 'dark' : 'light');
341
+ }
342
+
343
+ /**
344
+ * Sets the theme directly (useful for custom app option controls).
345
+ * @param {string} value - 'light', 'dark', or 'system'
346
+ */
347
+ setTheme(value) {
348
+ return this.acm.themeModel.setTheme(value);
330
349
  }
331
350
 
332
351
  /** Is the app currently rendering in dark theme? */
@@ -974,6 +993,8 @@ window['XH'] = XH;
974
993
  * @property {Object} [cancelProps] - props for secondary cancel button.
975
994
  * Must provide either text or icon for button to be displayed, or use a preconfigured
976
995
  * helper such as `XH.alert()` or `XH.confirm()` for default buttons.
996
+ * @property {string} [cancelAlign] - specify 'left' to place the Cancel button (if shown) on the
997
+ * left edge of the dialog toolbar, with a filler between it and Confirm.
977
998
  * @property {function} [onConfirm] - Callback to execute when confirm is clicked.
978
999
  * @property {function} [onCancel] - Callback to execute when cancel is clicked.
979
1000
  */
@@ -80,14 +80,15 @@ export class FieldFilter extends Filter {
80
80
 
81
81
  if (store) {
82
82
  const storeField = store.getField(field);
83
- if (!storeField) return () => true; // Ignore if field not in store
83
+ if (!storeField) return () => true; // Ignore (do not filter out) if field not in store
84
84
 
85
85
  const fieldType = storeField.type;
86
86
  value = isArray(value) ?
87
87
  value.map(v => parseFieldValue(v, fieldType)) :
88
88
  parseFieldValue(value, fieldType);
89
89
  }
90
- const getVal = store ? r => r.committedData[field] : r => r[field];
90
+ const getVal = store ? r => r.committedData[field] : r => r[field],
91
+ doNotFilter = r => store && isNil(r.committedData); // Ignore (do not filter out) record if part of a store and it has no committed data
91
92
 
92
93
  if (FieldFilter.ARRAY_OPERATORS.includes(op)) {
93
94
  value = castArray(value);
@@ -96,48 +97,66 @@ export class FieldFilter extends Filter {
96
97
  switch (op) {
97
98
  case '=':
98
99
  return r => {
100
+ if (doNotFilter(r)) return true;
99
101
  let v = getVal(r);
100
102
  if (isNil(v) || v === '') v = null;
101
103
  return value.includes(v);
102
104
  };
103
105
  case '!=':
104
106
  return r => {
107
+ if (doNotFilter(r)) return true;
105
108
  let v = getVal(r);
106
109
  if (isNil(v) || v === '') v = null;
107
110
  return !value.includes(v);
108
111
  };
109
112
  case '>':
110
113
  return r => {
114
+ if (doNotFilter(r)) return true;
111
115
  const v = getVal(r);
112
116
  return !isNil(v) && v > value;
113
117
  };
114
118
  case '>=':
115
119
  return r => {
120
+ if (doNotFilter(r)) return true;
116
121
  const v = getVal(r);
117
122
  return !isNil(v) && v >= value;
118
123
  };
119
124
  case '<':
120
125
  return r => {
126
+ if (doNotFilter(r)) return true;
121
127
  const v = getVal(r);
122
128
  return !isNil(v) && v < value;
123
129
  };
124
130
  case '<=':
125
131
  return r => {
132
+ if (doNotFilter(r)) return true;
126
133
  const v = getVal(r);
127
134
  return !isNil(v) && v <= value;
128
135
  };
129
136
  case 'like':
130
137
  regExps = value.map(v => new RegExp(escapeRegExp(v), 'i'));
131
- return r => regExps.some(re => re.test(getVal(r)));
138
+ return r => {
139
+ if (doNotFilter(r)) return true;
140
+ return regExps.some(re => re.test(getVal(r)));
141
+ };
132
142
  case 'not like':
133
143
  regExps = value.map(v => new RegExp(escapeRegExp(v), 'i'));
134
- return r => regExps.every(re => !re.test(getVal(r)));
144
+ return r => {
145
+ if (doNotFilter(r)) return true;
146
+ regExps.every(re => !re.test(getVal(r)));
147
+ };
135
148
  case 'begins':
136
149
  regExps = value.map(v => new RegExp('^' + escapeRegExp(v), 'i'));
137
- return r => regExps.some(re => re.test(getVal(r)));
150
+ return r => {
151
+ if (doNotFilter(r)) return true;
152
+ regExps.some(re => re.test(getVal(r)));
153
+ };
138
154
  case 'ends':
139
155
  regExps = value.map(v => new RegExp(escapeRegExp(v) + '$', 'i'));
140
- return r => regExps.some(re => re.test(getVal(r)));
156
+ return r => {
157
+ if (doNotFilter(r)) return true;
158
+ regExps.some(re => re.test(getVal(r)));
159
+ };
141
160
  default:
142
161
  throw XH.exception(`Unknown operator: ${op}`);
143
162
  }
@@ -96,7 +96,7 @@ function viewForState() {
96
96
  case S.LOGIN_REQUIRED:
97
97
  return loginPanel();
98
98
  case S.ACCESS_DENIED:
99
- return lockoutPanel();
99
+ return lockoutView();
100
100
  case S.RUNNING:
101
101
  return appContainerView();
102
102
  case S.SUSPENDED:
@@ -107,6 +107,14 @@ function viewForState() {
107
107
  }
108
108
  }
109
109
 
110
+ const lockoutView = hoistCmp.factory({
111
+ displayName: 'LockoutView',
112
+ render() {
113
+ const content = XH.appSpec.lockoutPanel ?? lockoutPanel;
114
+ return elementFromContent(content);
115
+ }
116
+ });
117
+
110
118
  const appContainerView = hoistCmp.factory({
111
119
  displayName: 'AppContainerView',
112
120
 
@@ -54,6 +54,7 @@ export const impersonationBar = hoistCmp.factory({
54
54
  enableCreate: true,
55
55
  placeholder: 'Select User...',
56
56
  width: 250,
57
+ menuWidth: 300,
57
58
  onCommit: model.onCommit
58
59
  }),
59
60
  button({
@@ -59,7 +59,7 @@ const inputCmp = hoistCmp.factory(
59
59
  item: withDefault(input.item, textInput({
60
60
  autoFocus: true,
61
61
  selectOnFocus: true,
62
- onKeyDown: evt => {if (evt.key == 'Enter') model.doConfirmAsync();}
62
+ onKeyDown: evt => {if (evt.key === 'Enter') model.doConfirmAsync();}
63
63
  }))
64
64
  })
65
65
  });
@@ -68,13 +68,19 @@ const inputCmp = hoistCmp.factory(
68
68
 
69
69
  const bbar = hoistCmp.factory(
70
70
  ({model}) => {
71
- const {confirmProps, cancelProps, formModel} = model,
72
- ret = [filler()];
71
+ const {confirmProps, cancelProps, cancelAlign, formModel} = model,
72
+ ret = [];
73
73
 
74
74
  if (cancelProps) {
75
75
  ret.push(button(cancelProps));
76
76
  }
77
77
 
78
+ if (cancelAlign === 'left') {
79
+ ret.push(filler());
80
+ } else {
81
+ ret.unshift(filler());
82
+ }
83
+
78
84
  if (confirmProps) {
79
85
  // Merge in formModel.isValid here in render stage to get reactivity.
80
86
  ret.push(formModel ?
@@ -22,14 +22,15 @@ export const themeAppOption = ({formFieldProps, inputProps} = {}) => {
22
22
  label: 'Theme',
23
23
  item: buttonGroupInput({
24
24
  items: [
25
- button({value: false, text: 'Light', icon: Icon.sun(), width: '50%'}),
26
- button({value: true, text: 'Dark', icon: Icon.moon(), width: '50%'})
25
+ button({value: 'light', text: 'Light', icon: Icon.sun(), width: '33.33%'}),
26
+ button({value: 'dark', text: 'Dark', icon: Icon.moon(), width: '33.33%'}),
27
+ button({value: 'system', text: 'System', icon: Icon.sync(), width: '33.33%'})
27
28
  ],
28
29
  ...inputProps
29
30
  }),
30
31
  ...formFieldProps
31
32
  },
32
- valueGetter: () => XH.darkTheme,
33
- valueSetter: (v) => XH.setDarkTheme(v)
33
+ prefName: 'xhTheme',
34
+ valueSetter: (v) => XH.setTheme(v)
34
35
  };
35
36
  };
@@ -303,7 +303,6 @@ class Model extends HoistInputModel {
303
303
  if (action === 'input-change') {
304
304
  this.inputValue = value;
305
305
  this.inputValueChangedSinceSelect = true;
306
- if (!value) this.noteValueChange(null);
307
306
  } else if (action === 'input-blur') {
308
307
  this.inputValue = null;
309
308
  this.inputValueChangedSinceSelect = false;
@@ -652,6 +651,7 @@ const cmp = hoistCmp.factory(
652
651
  if (model.manageInputValue) {
653
652
  rsProps.inputValue = model.inputValue || '';
654
653
  rsProps.onInputChange = model.onInputChange;
654
+ rsProps.controlShouldRenderValue = !model.hasFocus;
655
655
  rsProps.onMenuOpen = () => {
656
656
  wait().then(()=> {
657
657
  const selectedEl = document.getElementsByClassName('xh-select__option--is-selected')[0];
@@ -84,7 +84,7 @@ function viewForState() {
84
84
  case S.LOGIN_REQUIRED:
85
85
  return loginPanel();
86
86
  case S.ACCESS_DENIED:
87
- return lockoutPanel();
87
+ return lockoutView();
88
88
  case S.RUNNING:
89
89
  return appContainerView();
90
90
  case S.SUSPENDED:
@@ -95,6 +95,14 @@ function viewForState() {
95
95
  }
96
96
  }
97
97
 
98
+ const lockoutView = hoistCmp.factory({
99
+ displayName: 'LockoutView',
100
+ render() {
101
+ const content = XH.appSpec.lockoutPanel ?? lockoutPanel;
102
+ return elementFromContent(content);
103
+ }
104
+ });
105
+
98
106
  const appContainerView = hoistCmp.factory({
99
107
  displayName: 'AppContainerView',
100
108
 
@@ -25,8 +25,8 @@ export const message = hoistCmp.factory({
25
25
  model: uses(MessageModel),
26
26
 
27
27
  render({model}) {
28
- const isOpen = model && model.isOpen,
29
- {icon, title, message, formModel, cancelProps, confirmProps} = model,
28
+ const isOpen = model?.isOpen,
29
+ {icon, title, message, formModel, confirmProps, cancelProps, cancelAlign} = model,
30
30
  buttons = [];
31
31
 
32
32
  if (!isOpen) return null;
@@ -35,6 +35,14 @@ export const message = hoistCmp.factory({
35
35
  buttons.push(button({minimal: true, ...cancelProps}));
36
36
  }
37
37
 
38
+ if (cancelProps || confirmProps) {
39
+ if (cancelAlign === 'left') {
40
+ buttons.push(filler());
41
+ } else {
42
+ buttons.unshift(filler());
43
+ }
44
+ }
45
+
38
46
  if (confirmProps) {
39
47
  // Merge in formModel.isValid here in render stage to get reactivity.
40
48
  buttons.push(formModel ?
@@ -43,10 +51,6 @@ export const message = hoistCmp.factory({
43
51
  );
44
52
  }
45
53
 
46
- if (buttons.length) {
47
- buttons.unshift(filler());
48
- }
49
-
50
54
  return dialog({
51
55
  isOpen,
52
56
  icon,
@@ -22,16 +22,22 @@ export const themeAppOption = ({formFieldProps, inputProps} = {}) => {
22
22
  item: buttonGroupInput({
23
23
  items: [
24
24
  button({
25
- value: false,
25
+ value: 'light',
26
26
  text: 'Light',
27
27
  icon: Icon.sun(),
28
- width: '50%'
28
+ width: '33.33%'
29
29
  }),
30
30
  button({
31
- value: true,
31
+ value: 'dark',
32
32
  text: 'Dark',
33
33
  icon: Icon.moon(),
34
- width: '50%'
34
+ width: '33.33%'
35
+ }),
36
+ button({
37
+ value: 'system',
38
+ text: 'System',
39
+ icon: Icon.sync(),
40
+ width: '33.33%'
35
41
  })
36
42
  ],
37
43
  width: '100%',
@@ -39,7 +45,7 @@ export const themeAppOption = ({formFieldProps, inputProps} = {}) => {
39
45
  }),
40
46
  ...formFieldProps
41
47
  },
42
- valueGetter: () => XH.darkTheme,
43
- valueSetter: (v) => XH.setDarkTheme(v)
48
+ prefName: 'xhTheme',
49
+ valueSetter: (v) => XH.setTheme(v)
44
50
  };
45
51
  };
@@ -209,7 +209,7 @@ const editableChild = hoistCmp.factory({
209
209
  model,
210
210
  bind: 'value',
211
211
  disabled: props.disabled || disabled,
212
- ref: composeRefs(model._boundInputRef, child.ref)
212
+ ref: composeRefs(model?._boundInputRef, child.ref)
213
213
  };
214
214
 
215
215
  // If FormField is sized and item doesn't specify its own dimensions,
@@ -288,7 +288,6 @@ class Model extends HoistInputModel {
288
288
  if (action === 'input-change') {
289
289
  this.inputValue = value;
290
290
  this.inputValueChangedSinceSelect = true;
291
- if (!value) this.noteValueChange(null);
292
291
  } else if (action === 'input-blur') {
293
292
  this.inputValue = null;
294
293
  this.inputValueChangedSinceSelect = false;
@@ -615,6 +614,7 @@ const cmp = hoistCmp.factory(
615
614
  if (model.manageInputValue) {
616
615
  rsProps.inputValue = model.inputValue || '';
617
616
  rsProps.onInputChange = model.onInputChange;
617
+ rsProps.controlShouldRenderValue = !model.hasFocus;
618
618
  }
619
619
 
620
620
  if (model.asyncMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "47.0.1",
3
+ "version": "47.1.2",
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",
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@blueprintjs/core": "~3.53.0",
30
- "@blueprintjs/datetime": "~3.23.20",
29
+ "@blueprintjs/core": "~3.54.0",
30
+ "@blueprintjs/datetime": "~3.24.0",
31
31
  "@fortawesome/fontawesome-pro": "~5.15.4",
32
32
  "@fortawesome/fontawesome-svg-core": "~1.3.0",
33
33
  "@fortawesome/pro-light-svg-icons": "~5.15.4",