diff --git a/admin/App.js b/admin/App.js index 2a2e3f67a3..f8de57b2eb 100644 --- a/admin/App.js +++ b/admin/App.js @@ -32,7 +32,9 @@ export class App { } get componentClass() {return AppComponent} - + + get idleDetectionDisabled() {return true} + @action requestRefresh() { this.tabModel.requestRefresh(); diff --git a/admin/AppComponent.js b/admin/AppComponent.js index 362a24e163..53b467d20b 100644 --- a/admin/AppComponent.js +++ b/admin/AppComponent.js @@ -7,7 +7,7 @@ import {Component} from 'react'; import {HoistComponent, XH} from '@xh/hoist/core'; -import {lockoutPanel} from '@xh/hoist/app'; +import {lockoutPanel} from '@xh/hoist/impl'; import {tabContainer, tabSwitcher} from '@xh/hoist/cmp/tab'; import {panel} from '@xh/hoist/cmp/layout'; import {button} from '@xh/hoist/cmp/button'; diff --git a/app/index.js b/app/index.js deleted file mode 100644 index 373e1872e9..0000000000 --- a/app/index.js +++ /dev/null @@ -1,9 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2018 Extremely Heavy Industries Inc. - */ - -export * from './AppContainer'; -export * from './LockoutPanel'; \ No newline at end of file diff --git a/cmp/message/MessageModel.js b/cmp/message/MessageModel.js index a7e2b9b9f0..462404122a 100644 --- a/cmp/message/MessageModel.js +++ b/cmp/message/MessageModel.js @@ -58,6 +58,7 @@ export class MessageModel { */ constructor(config) { this.initialConfig = config; + if (config.isOpen) this.show(); } /** diff --git a/core/AppState.js b/core/AppState.js new file mode 100644 index 0000000000..bc9950bc47 --- /dev/null +++ b/core/AppState.js @@ -0,0 +1,22 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2018 Extremely Heavy Industries Inc. + */ + + +/** + * Enumeration of possible App States + * + * @see XH.appState. + */ +export const AppState = { + PRE_AUTH: 'PRE_AUTH', + LOGIN_REQUIRED: 'LOGIN_REQUIRED', + ACCESS_DENIED: 'ACCESS_DENIED', + INITIALIZING: 'INITIALIZING', + RUNNING: 'RUNNING', + SUSPENDED: 'SUSPENDED', + LOAD_FAILED: 'LOAD_FAILED' +}; diff --git a/core/HoistApp.js b/core/HoistApp.js index 939186c685..dc8f08a3fd 100644 --- a/core/HoistApp.js +++ b/core/HoistApp.js @@ -62,6 +62,27 @@ export function HoistApp(C) { }; }, + /** + * Is app suspension by IdleService disabled? + * + * @see IdleService. App suspension is also configurable in soft config, and via user preference. + */ + idleDetectionDisabled: { + get() {return false} + }, + + /** + * Component to indicate App has been suspended. + * + * The component will receive a single prop -- onReactivate -- a callback called when user has acknowledged + * the suspension and wishes to reload the app and continue working. + * + * @see IdleService. + */ + suspendedDialogClass: { + get() {return null} + }, + /** * Call this once when application mounted in order to trigger initial authentication and * initialization of the application and its services. diff --git a/core/XH.js b/core/XH.js index 9a3572adbd..d57882baf5 100644 --- a/core/XH.js +++ b/core/XH.js @@ -8,12 +8,12 @@ import ReactDOM from 'react-dom'; import {isPlainObject, defaults, flatten} from 'lodash'; -import {elem, HoistModel} from '@xh/hoist/core'; +import {elem, HoistModel, AppState} from '@xh/hoist/core'; import {Exception, ExceptionHandler} from '@xh/hoist/exception'; -import {observable, setter, action} from '@xh/hoist/mobx'; +import {observable, action} from '@xh/hoist/mobx'; import {MultiPromiseModel, never} from '@xh/hoist/promise'; import {RouterModel} from '@xh/hoist/router'; -import {appContainer} from '@xh/hoist/app'; +import {appContainer} from '@xh/hoist/impl'; import {MessageSourceModel} from '@xh/hoist/cmp/message'; import {throwIf} from '@xh/hoist/utils/JsUtils'; @@ -24,6 +24,7 @@ import { FeedbackService, FetchService, IdentityService, + IdleService, LocalStorageService, PrefService, TrackService @@ -84,6 +85,7 @@ class XHClass { feedbackService = new FeedbackService(); fetchService = new FetchService(); identityService = new IdentityService(); + idleService = new IdleService(); localStorageService = new LocalStorageService(); prefService = new PrefService(); trackService = new TrackService(); @@ -91,8 +93,8 @@ class XHClass { //----------------------------- // Observable State //----------------------------- - /** State of app loading -- see HoistLoadState for valid values. */ - @setter @observable loadState = LoadState.PRE_AUTH; + /** State of app -- see AppState for valid values. */ + @observable appState = AppState.PRE_AUTH; /** Currently authenticated user. */ @observable authUsername = null; @@ -152,6 +154,22 @@ class XHClass { ReactDOM.render(rootView, document.getElementById('root')); } + /** + * Transition the application state. + * + * @param {AppState} appState - state to transition to. + * + * Used by framework. Not intended for application use. + */ + @action + setAppState(appState) { + if (this.appState != appState) { + this.appState = appState; + this.fireEvent('appStateChanged', {appState}); + } + } + + /** Trigger a full reload of the app. */ @action reloadApp() { @@ -261,7 +279,7 @@ class XHClass { /** * Show a modal 'confirm' dialog with message and default OK/Cancel buttons. * - * @param {Object} config - see MessageModel.show() for available options. + * @param {Object} config - see MessageModel.show() for available options. */ confirm(config) { config = defaults({}, config, {confirmText: 'OK', cancelText: 'Cancel'}); @@ -289,25 +307,26 @@ class XHClass { * Not for application use. */ async initAsync() { + const S = AppState; // Add xh-app class to body element to power Hoist CSS selectors document.body.classList.add('xh-app'); try { - this.setLoadState(LoadState.PRE_AUTH); + this.setAppState(S.PRE_AUTH); const authUser = await this.getAuthUserFromServerAsync(); if (!authUser) { throwIf(this.app.requireSSO, 'Failed to authenticate user via SSO.'); - this.setLoadState(LoadState.LOGIN_REQUIRED); + this.setAppState(S.LOGIN_REQUIRED); return; } await this.completeInitAsync(authUser.username); } catch (e) { - this.setLoadState(LoadState.FAILED); + this.setAppState(S.LOAD_FAILED); XH.handleException(e, {requireReload: true}); } } @@ -319,7 +338,9 @@ class XHClass { */ @action async completeInitAsync() { - this.setLoadState(LoadState.INITIALIZING); + const S = AppState; + + this.setAppState(S.INITIALIZING); try { // Delay to workaround hot-reload styling issues in dev. const delay = XH.isDevelopmentMode ? 300 : 1; @@ -331,15 +352,15 @@ class XHClass { const access = this.app.checkAccess(XH.getUser()); if (!access.hasAccess) { this.accessDeniedMessage = access.message || 'Access denied.'; - this.setLoadState(LoadState.ACCESS_DENIED); + this.setAppState(S.ACCESS_DENIED); return; } await this.app.initAsync(); this.startRouter(); - this.setLoadState(LoadState.COMPLETE); + this.setAppState(S.RUNNING); } catch (e) { - this.setLoadState(LoadState.FAILED); + this.setAppState(S.LOAD_FAILED); XH.handleException(e, {requireReload: true}); } } @@ -368,6 +389,7 @@ class XHClass { this.environmentService, this.feedbackService, this.identityService, + this.idleService, this.trackService ); } @@ -442,16 +464,3 @@ class XHClass { } export const XH = window.XH = new XHClass(); -/** - * Enumeration of possible Load States for Hoist. - * - * See XH.loadState. - */ -export const LoadState = { - PRE_AUTH: 'PRE_AUTH', - LOGIN_REQUIRED: 'LOGIN_REQUIRED', - ACCESS_DENIED: 'ACCESS_DENIED', - INITIALIZING: 'INITIALIZING', - COMPLETE: 'COMPLETE', - FAILED: 'FAILED' -}; diff --git a/core/index.js b/core/index.js index c71bf28961..10710f0f9d 100644 --- a/core/index.js +++ b/core/index.js @@ -8,6 +8,7 @@ export * from './elem'; export * from './mixins/Reactive'; export * from './mixins/EventTarget'; +export * from './AppState'; export * from './HoistApp'; export * from './HoistComponent'; export * from './HoistModel'; diff --git a/app/impl/AboutDialog.js b/impl/AboutDialog.js similarity index 100% rename from app/impl/AboutDialog.js rename to impl/AboutDialog.js diff --git a/app/impl/AboutDialog.scss b/impl/AboutDialog.scss similarity index 100% rename from app/impl/AboutDialog.scss rename to impl/AboutDialog.scss diff --git a/app/AppContainer.js b/impl/AppContainer.js similarity index 71% rename from app/AppContainer.js rename to impl/AppContainer.js index 0a13ac8cf2..c4e4e8ba3c 100644 --- a/app/AppContainer.js +++ b/impl/AppContainer.js @@ -8,7 +8,7 @@ import {Children, Component} from 'react'; import {ContextMenuTarget} from '@xh/hoist/kit/blueprint'; import {observable, observer, setter} from '@xh/hoist/mobx'; -import {elemFactory, LoadState, XH} from '@xh/hoist/core'; +import {elemFactory, elem, AppState, XH} from '@xh/hoist/core'; import {contextMenu} from '@xh/hoist/cmp/contextmenu'; import {loadMask} from '@xh/hoist/cmp/mask'; import {messageSource} from '@xh/hoist/cmp/message'; @@ -23,24 +23,25 @@ import { impersonationBar, loginPanel, updateBar, - versionBar -} from './impl'; - -import {lockoutPanel} from './'; + versionBar, + lockoutPanel, + SuspendedDialog +} from './'; /** * Top-level wrapper to provide core Hoist Application layout and infrastructure to an application's - * root Component. Provides initialized Hoist services and a standard viewport that also includes - * standard UI elements such as an impersonation bar header, version bar footer, app-wide load mask, - * context menu, and popup message support. + * root Component. Provides a standard viewport that includes standard UI elements such as an + * impersonation bar header, version bar footer, an app-wide load mask, a base context menu, + * popup message support, and exception rendering. * - * Construction of this container triggers the init of the core XH singleton, which queries for an - * authorized user and then proceeds to init all core Hoist and app-level services. + * Successful construction of this container indicates that all app code has been loaded, and is + * being mounted. This triggers a call to XH.initAsync() to begin the Hoist and Application loading + * lifecycle. * - * If the user is not yet known (and the app does *not* use SSO), this container will display a - * standardized loginPanel component to prompt for a username and password. Once the user is - * confirmed, this container will again mask until Hoist has completed its initialization, at - * which point the app's UI will be rendered. + * If the application is in the 'LOGIN_REQUIRED' state, this container will display a + * standardized loginPanel component to prompt for a username and password. During loading and initialization + * this componenent will render standard masks. Once the application has reached "RUNNING" state it will be fully + * rendered. * * @private */ @@ -63,21 +64,23 @@ export class AppContainer extends Component { } renderContent() { + const S = AppState; if (this.caughtException) return null; - switch (XH.loadState) { - case LoadState.PRE_AUTH: - case LoadState.INITIALIZING: + switch (XH.appState) { + case S.PRE_AUTH: + case S.INITIALIZING: return viewport(loadMask({isDisplayed: true})); - case LoadState.LOGIN_REQUIRED: + case S.LOGIN_REQUIRED: return loginPanel(); - case LoadState.ACCESS_DENIED: + case S.ACCESS_DENIED: return lockoutPanel({ message: this.unauthorizedMessage() }); - case LoadState.FAILED: + case S.LOAD_FAILED: return null; - case LoadState.COMPLETE: + case S.RUNNING: + case S.SUSPENDED: return viewport( vframe( impersonationBar(), @@ -88,7 +91,8 @@ export class AppContainer extends Component { loadMask({model: XH.appLoadModel}), messageSource({model: XH.messageSourceModel}), feedbackDialog({model: XH.feedbackDialogModel}), - aboutDialog() + aboutDialog(), + this.renderSuspendedDialog() ); default: return null; @@ -127,6 +131,14 @@ export class AppContainer extends Component { //------------------------ // Implementation //------------------------ + renderSuspendedDialog() { + const dialogClass = XH.app.suspendedDialogClass || SuspendedDialog; + + return XH.appState == AppState.SUSPENDED && dialogClass ? + elem(dialogClass, {onReactivate: () => XH.reloadApp()}) : + null; + } + unauthorizedMessage() { const user = XH.getUser(); diff --git a/app/impl/ExceptionDialog.js b/impl/ExceptionDialog.js similarity index 100% rename from app/impl/ExceptionDialog.js rename to impl/ExceptionDialog.js diff --git a/app/impl/ExceptionDialogDetails.js b/impl/ExceptionDialogDetails.js similarity index 100% rename from app/impl/ExceptionDialogDetails.js rename to impl/ExceptionDialogDetails.js diff --git a/app/impl/ExceptionDialogModel.js b/impl/ExceptionDialogModel.js similarity index 100% rename from app/impl/ExceptionDialogModel.js rename to impl/ExceptionDialogModel.js diff --git a/app/impl/FeedbackDialog.js b/impl/FeedbackDialog.js similarity index 100% rename from app/impl/FeedbackDialog.js rename to impl/FeedbackDialog.js diff --git a/app/impl/FeedbackDialogModel.js b/impl/FeedbackDialogModel.js similarity index 100% rename from app/impl/FeedbackDialogModel.js rename to impl/FeedbackDialogModel.js diff --git a/app/impl/ImpersonationBar.js b/impl/ImpersonationBar.js similarity index 100% rename from app/impl/ImpersonationBar.js rename to impl/ImpersonationBar.js diff --git a/app/impl/ImpersonationBarModel.js b/impl/ImpersonationBarModel.js similarity index 100% rename from app/impl/ImpersonationBarModel.js rename to impl/ImpersonationBarModel.js diff --git a/app/LockoutPanel.js b/impl/LockoutPanel.js similarity index 96% rename from app/LockoutPanel.js rename to impl/LockoutPanel.js index 6bbd3afd37..f609d430ac 100644 --- a/app/LockoutPanel.js +++ b/impl/LockoutPanel.js @@ -11,7 +11,7 @@ import {box, filler, vframe, viewport} from '@xh/hoist/cmp/layout'; import {PropTypes as PT} from 'prop-types'; import './LockoutPanel.scss'; -import {impersonationBar} from './impl'; +import {impersonationBar} from './'; /** * Panel for display to prevent user access to all content. diff --git a/app/LockoutPanel.scss b/impl/LockoutPanel.scss similarity index 100% rename from app/LockoutPanel.scss rename to impl/LockoutPanel.scss diff --git a/app/impl/LoginPanel.js b/impl/LoginPanel.js similarity index 100% rename from app/impl/LoginPanel.js rename to impl/LoginPanel.js diff --git a/app/impl/LoginPanel.scss b/impl/LoginPanel.scss similarity index 100% rename from app/impl/LoginPanel.scss rename to impl/LoginPanel.scss diff --git a/impl/SuspendedDialog.js b/impl/SuspendedDialog.js new file mode 100644 index 0000000000..f989f7bc53 --- /dev/null +++ b/impl/SuspendedDialog.js @@ -0,0 +1,36 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2018 Extremely Heavy Industries Inc. + */ + +import {Component} from 'react'; +import {HoistComponent} from '@xh/hoist/core'; +import {Icon} from '@xh/hoist/icon'; +import {MessageModel, message} from '@xh/hoist/cmp/message'; + +/** + * Default display of application suspension. + * + * This display can be overridden by applications. + * @see HoistApp.suspendedDialogClass + * + * @private + */ +@HoistComponent() +export class SuspendedDialog extends Component { + + localModel = new MessageModel({ + title: 'Application Sleeping', + icon: Icon.moon(), + message: 'This application is sleeping due to inactivity. Please click below to reload it.', + confirmText: 'Reload', + onConfirm: this.props.onReactivate, + isOpen: true + }); + + render() { + return message({model: this.model}); + } +} \ No newline at end of file diff --git a/app/impl/UpdateBar.js b/impl/UpdateBar.js similarity index 100% rename from app/impl/UpdateBar.js rename to impl/UpdateBar.js diff --git a/app/impl/UpdateBar.scss b/impl/UpdateBar.scss similarity index 100% rename from app/impl/UpdateBar.scss rename to impl/UpdateBar.scss diff --git a/app/impl/VersionBar.js b/impl/VersionBar.js similarity index 100% rename from app/impl/VersionBar.js rename to impl/VersionBar.js diff --git a/app/impl/VersionBar.scss b/impl/VersionBar.scss similarity index 100% rename from app/impl/VersionBar.scss rename to impl/VersionBar.scss diff --git a/app/impl/index.js b/impl/index.js similarity index 80% rename from app/impl/index.js rename to impl/index.js index 16eed7ec39..7cf3873f45 100644 --- a/app/impl/index.js +++ b/impl/index.js @@ -4,11 +4,13 @@ * * Copyright © 2018 Extremely Heavy Industries Inc. */ - +export * from './AppContainer'; export * from './AboutDialog'; export * from './ExceptionDialog'; export * from './ImpersonationBar'; +export * from './LockoutPanel'; export * from './LoginPanel'; export * from './UpdateBar'; export * from './VersionBar'; export * from './FeedbackDialog'; +export * from './SuspendedDialog'; diff --git a/svc/ConfigService.js b/svc/ConfigService.js index 44e021f681..53aa8690d6 100644 --- a/svc/ConfigService.js +++ b/svc/ConfigService.js @@ -18,11 +18,7 @@ import {cloneDeep} from 'lodash'; * * Note that for a config to be available here on the client, it must have its `clientVisible` flag * set to true. This is to provide support for configurations that should *not* be sent down for - * possible inspection by end-users. - * - * Configs can be specified on the server with support for different values per supported Hoist - * Environment. Note however that the client only receives the values for the current environment. - * (Values for non-production environments inherit from production if left unspecified.) + * possible inspection by end users. * * Note that this service does *not* currently attempt to reload or update configs once the client * application has loaded. A refresh of the application is required to load new entries. diff --git a/svc/IdleService.js b/svc/IdleService.js new file mode 100644 index 0000000000..db2b9d86cc --- /dev/null +++ b/svc/IdleService.js @@ -0,0 +1,56 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2018 Extremely Heavy Industries Inc. + */ +import {XH, HoistService, AppState} from '@xh/hoist/core'; +import {MINUTES} from '@xh/hoist/utils/DateTimeUtils'; +import {debounce} from 'lodash'; +import {Timer} from '@xh/hoist/utils/Timer'; + +/** + * Manage the idling/suspension of this application after a certain period of user + * inactivity. + * + * This service is goverened by the property App.disableIdleDetection, the configuration + * 'xhIdleTimeoutMins', and the user-specific preference 'xh.disableIdleDetection' respectively. + * Any of these can be used to disable app suspension. + */ +@HoistService() +export class IdleService { + + ACTIVITY_EVENTS = ['keydown', 'mousemove', 'mousedown', 'scroll']; + + async initAsync() { + const timeout = XH.getConf('xhIdleTimeoutMins') * MINUTES, + appDisabled = XH.app.idleDetectionDisabled, + configDisabled = timeout <= 0, + userDisabled = XH.getPref('xhIdleDetectionDisabled'); + + if (!appDisabled && !configDisabled && !userDisabled) { + this.startCountdown = debounce(() => this.suspendApp(), timeout, {trailing: true}); + this.startCountdown(); + this.createAppListeners(); + } + } + + //------------------------------------- + // Implementation + //------------------------------------- + createAppListeners() { + this.ACTIVITY_EVENTS.forEach(e => addEventListener(e, this.startCountdown, true)); + } + + destroyAppListeners() { + this.ACTIVITY_EVENTS.forEach(e => removeEventListener(e, this.startCountdown, true)); + } + + suspendApp() { + if (XH.appState != AppState.SUSPENDED) { + XH.setAppState(AppState.SUSPENDED); + this.destroyAppListeners(); + Timer.cancelAll(); + } + } +} \ No newline at end of file diff --git a/svc/index.js b/svc/index.js index 99947e6ee2..8fae611eae 100644 --- a/svc/index.js +++ b/svc/index.js @@ -9,6 +9,7 @@ export * from './EnvironmentService'; export * from './ErrorTrackingService'; export * from './FeedbackService'; export * from './FetchService'; +export * from './IdleService'; export * from './LocalStorageService'; export * from './IdentityService'; export * from './PrefService';