/** * Program UI class * * Copyright (C) 2017 LoVullo Associates, Inc. * * This file is part of the Liza Data Collection Framework. * * liza is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * @todo this, along with Client, contains one of the largest and most * coupled messes of the system; refactor * * @todo The code was vandalized with internal references and URLs---remove * them (search "pollute")---and referenced a global variable! This * might not work for you! */ var Class = require( 'easejs' ).Class, EventEmitter = require( 'events' ).EventEmitter; // XXX: decouple var DynamicContext = require( './context/DynamicContext' ); /** * Creates a new Ui instance * * @param {Object} options ui options * * Supported options: * content: {jQuery} content to operate on * styler: {ElementStyler} element styler for misc. elements * nav: {Nav} navigation object * navStyler: {NavStyler} navigation styler * errorBox: {FormErrorBox} error box to use for form errors * sidebar: {Sidebar} sidebar ui * dialog: {UiDialog} * * stepContainer: {jQuery} for the step HTML * stepBuilder: {Function} function used to instantiate new steps * * @return {Ui} */ module.exports = Class( 'Ui' ).extend( EventEmitter, { /** * The Ui requested a step change * @type {string} */ 'const EVENT_STEP_CHANGE': 'stepChange', /** * Another step is about to be rendered * @type {string} */ 'const EVENT_PRE_RENDER_STEP': 'preRenderStep', /** * A different step has been rendered * @type {string} */ 'const EVENT_RENDER_STEP': 'renderStep', /** * Step has been rendered and all events are complete * * At this point, hooks may freely manipulate the step without risk of * running before the framework is done with the step * * @type {string} */ 'const EVENT_STEP_READY': 'stepReady', /** * Represents an action trigger * @type {string} */ 'const EVENT_ACTION': 'action', /** * Content to operate on * @type {jQuery} */ $content: null, /** * Element styler to use for misc. elements in the UI (e.g. dialogs) * @type {Styler} */ styler: null, /** * Object responsible for handling navigation * @type {Nav} */ nav: null, /** * Styles navigation menu * @type {NavStyler} */ navStyler: null, /** * Navigation bar * @type {jQuery} */ $navBar: null, /** * Element to contain the step HTML * @type {jQuery} */ $stepParent: null, /** * Builder used to create new step instances * @type {Function} */ buildStep: null, /** * Holds previously loaded steps in memory * @type {Object} */ stepCache: {}, /** * Object representing the current step * @type {Step} */ currentStep: null, /** * Stores the steps that have already been appended to the DOM once * @type {boolean} */ stepAppended: [], /** * Represents the current quote * @type {Quote} */ quote: null, /** * Event to resume when quote is ready (for step navigation) * @type {Object} */ quoteReadyEvent: null, /** * Functions to call when step is to be saved * @type {Array.} */ saveStepHooks: [], /** * Error box to use for form errors * @type {FormErrorBox} */ errorBox: null, /** * Sidebar * @type {Sidebar} */ sidebar: null, /** * Whether navigation is frozen (prevent navigation) * @type {boolean} */ navFrozen: false, /** * Handles dialog display * @type {UiDialog} */ _dialog: null, /** * Active program * @type {Program} */ 'private _program': null, /** * Handles general UI styling * @type {UiStyler} */ 'private _uiStyler': null, /** * Navigation bar * @type {UiNavBar} */ 'private _navBar': null, /** * Notification bar * @type {UiNotifyBar} */ 'private _notifyBar': null, 'private _cmatch': null, /** * Root context * @type {RootDomContext} */ 'private _rootContext': null, /** * Step content cache * @type {Array.} */ 'private _stepContent': [], /** * Track field failures and fixes * @type {DataValidator} */ 'private _dataValidator': null, /** * Initializes new UI instance * * @param {Object} options * * @return {undefined} */ __construct: function( options ) { this.$content = options.content; this.styler = options.styler; this.nav = options.nav; this.navStyler = options.navStyler; this.$navBar = this.$content.find( 'ul.step-nav' ); this.$stepParent = options.stepContainer; this.buildStep = options.stepBuilder; this.errorBox = options.errorBox; this.sidebar = options.sidebar; this._dialog = options.dialog; this._uiStyler = options.uiStyler; this._navBar = options.navBar; this._notifyBar = options.notifyBar; this._rootContext = options.rootContext; this._dataValidator = options.dataValidator; }, /** * Initializes the UI * * @return Ui self to allow for method chaining */ init: function() { var _self = this; this._initStyles(); this._initKeys(); this._initNavBar(); this.sidebar.init(); // set a context that will automatically adjust itself for the current // active step (that is, once we are actually on a step) _self.createDynamicContext( function( context ) { _self._uiStyler.setContext( context ); } ); return this; }, /** * Initializes styling * * This is used (a) because CSS cannot be used for certain conditions and * (b) because IE6 doesn't support :hover for anything other than links. * * @return undefined */ _initStyles: function() { var ui = this; this._uiStyler .init( this.$content ) .on( 'questionHover', function( element, hover_over ) { ui._renderHelp( element, hover_over ); }) .on( 'questionFocus', function( element, has_focus ) { ui._renderHelp( element, has_focus ); }); }, /** * Render help text for the provided element * * @return {undefined} */ _renderHelp: function( element, show ) { // dt's are only labels and have no fields, but their sibling // dd's do var $element = ( element.nodeName == 'DT' ) ? $( element ).next( 'dd' ) : $( element ); if ( show ) { // set help message this.sidebar.setHelpText( this.styler.getHelpMessage( $element.find( ':widget' ) ) ); } else { var text = '', $focus = this.$content.find( 'dd.focus:first :widget' ); // attempt to fall back on the help for the focused element, // if any if ( $focus.length > 0 ) { text = this.styler.getHelpMessage( $focus ); } this.sidebar.setHelpText( text ); } }, /** * Hooks the navigation bar to permit navigation * * @return void */ _initNavBar: function() { var _self = this; this._navBar.on( 'click', function( step_id ) { // do not permit navigation via nav bar if the user has not already // visited the step if ( _self.nav.isStepVisited( step_id ) ) { _self.emit( _self.__self.$('EVENT_STEP_CHANGE'), step_id ); } }); }, /** * Initializes keypress overrides * * This overrides the default enter key behavior to ensure that the correct * button is "pressed". * * @return undefined */ _initKeys: function() { var ui = this; this.$content.find( 'form input' ).live( 'keypress.program', function( e ) { if ( ( e.which && ( e.which == 13 ) ) || ( e.keyCode && ( e.keyCode == 13 ) ) ) { var $btn = ui.$content.find( 'button.default' ); // trigger the change event first to ensure any necessary // assertions are run $( this ).change(); // don't click it if it's disabled if ( $btn.attr( 'disabled' ) ) { // but don't run the default behavior return false; } $btn.click(); return false; } return true; } ); }, /** * Displays a step to the user * * If the step is already cached in memory, it will be immediately * displayed. If not, it will use the assigned step builder in order to * instantiate a new step and load it. This is an asynchronous operation. * * @param Integer step_id identifier representing step to navigate to * * @return Ui self to allow for method chaining */ 'public displayStep': function( step_id, callback ) { step_id = +step_id; var step = this.stepCache[ step_id ]; // first thing to do is cache the current step and detach it if ( this.currentStep !== null ) { // let the current this._detachStep( this.currentStep ) .setActive( false ); } // build the step only if it is not yet loaded if ( step === undefined ) { this._createStep( step_id, callback ); return this; } this.currentStep = step; this.currentStep.setActive(); this._renderStep( callback ); return this; }, /** * Detaches the step STEP from the DOM * * @param {jQuery} step step to detach * * @return StepUi STEP to allow for method chaining */ _detachStep: function( step ) { this._getStepContent( step ) .detach(); return step; }, /** * Builds and initializes a new step * * @param Integer step_id id of the step to load * * @return Step new step */ _createStep: function( step_id, callback ) { var ui = this, prevstep = this.currentStep; // prevent navigation while the step is downloading this.freezeNav(); this.buildStep( step_id, function( stepui ) { ui.currentStep = ui.stepCache[ step_id ] = stepui; ui.currentStep.setActive(); ui._renderStep( callback ); stepui .on( 'error', function() { var args = Array.prototype.slice.call( arguments ); args.unshift( 'error' ); // forward to UI error event ui.emit.apply( ui, args ); } ) .on( 'action', function( type, ref, index ) { // foward ui.emit( ui.__self.$( 'EVENT_ACTION' ), type, ref, index ); } ) .on( 'displayChanged', function( id, index, value ) { var data = {}; data[ id ] = []; data[ id ][ index ] = value; ui._uiStyler.register( 'fieldFixed' )( data ); } ); // we're done rendering the step; permit navigation ui.unfreezeNav( prevstep ); stepui.init(); }); }, /** * Renders the current step's HTML and styles it * * @return Ui self to allow for method chaining */ _renderStep: function( callback ) { var step = this.currentStep, step_id = step.getStep().getId(), prev_content = this._getStepContent( step ); var step_content = $( '
' ) .attr( 'id', '__step' + step.getStep().getId() ) .append( prev_content || $( this.currentStep.getContent() ) ); this._setStepContent( step, step_content ); // display the step (we have to append the container to the DOM before // we append the step HTML, or dojo will throw a fit, since it won't see // any of the elements it's trying to modify as part of the DOM // document) this.$stepParent.append( step_content ); // if this is the first time rendering the step, call the postAppend() // method on it if ( !( this.stepAppended[step_id] ) ) { // let the step process anything that should be done after the // elements have been added to the DOM this.currentStep.postAppend(); // let's not do this again this.stepAppended[step_id] = true; this._addNavButtons( this.currentStep ); } // we need to emit this before we display to the user, but *after* the // steps have had the chance to initialize their elements and add them // to the DOM (otherwise, selectors would fail if we are trying to // manipulate the DOM further before displaying it to the user) this.currentStep.preRender(); this.emit( this.__self.$('EVENT_PRE_RENDER_STEP'), this.currentStep, step_content ); var ui = this; setTimeout( function() { // raise the event ui.emit( ui.__self.$('EVENT_RENDER_STEP'), ui.currentStep ); }, 50 ); this._postRenderStep( function() { // call the callback before the timeout, allowing us to do stuff before // repaint callback && callback(); ui.unfreezeNav( step ); ui.currentStep.visit( function() { ui.emit( ui.__self.$('EVENT_STEP_READY'), ui.currentStep ); } ); } ); return this; }, /** * Retrieve cached stap content * * @param {StepUi=} step step to retrieve cached content of * * @return {jQuery} cached step content */ _getStepContent: function( step ) { var step_id = step.getStep().getId(); return this._stepContent[ step_id ]; }, /** * Set cached step content * * @param {StepUi} step step to retrieve cached content of * @param {jQuery} $content step content * * @return {Ui} self */ _setStepContent: function( step, $content ) { var step_id = step.getStep().getId(); this._stepContent[ step_id ] = $content; return this; }, _postRenderStep: function( callback ) { var self = this, content = this._getStepContent( this.currentStep ); if ( content === null ) { return; } // if the quote is locked, disable the form elements var disable = false; if ( this.quote.isLocked() ) { disable = true; } this.currentStep.lock( this.quote.isLocked() || ( this.currentStep.getStep().getId() < this.quote.getExplicitLockStep() ) ); if ( disable === false ) { // focus on the first element content.find( 'input:first' ).focus(); // show buttons this._getNavButtons( this.currentStep ).show(); this.currentStep.hideAddRemove( false ); } else { // hide buttons if ( this.nav.isLastStep( this.currentStep.getStep().getId() ) === false ) { this._getNavButtons( this.currentStep ).hide(); } else { // hide only the first (back) button on the last step $( this._getNavButtons( this.currentStep )[0] ).hide(); } // hide add/remove buttons on groups this.currentStep.hideAddRemove( true ); } callback && callback(); }, /** * Adds the navigation buttons to the step * * @param Step step the step to operate on * * @return undefined */ _addNavButtons: function( step ) { var ui = this, step_id = step.getStep().getId(); var $buttons = $( '