From dd7c2760f41cfd6480243306980ef6d3fc6ed476 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 7 Feb 2017 14:37:11 -0500 Subject: [PATCH] Client: Extract from internal repo This thing is an ugly monstrosity that has witheld some pretty rough development times. As code is touched, it is being removed. This depends on many things not yet in the liza repo; they'll be added in time. These classes were changed slightly to work within liza (e.g. paths). * src/client/Client.js: Extract from rating-fw as of 5d4019f1973f9271f4b1bd24ce1f55b56ccda09e. * src/client/ClientDependencyFactory.js: Extract from rating-fw as of 5d4019f1973f9271f4b1bd24ce1f55b56ccda09e. --- src/client/Client.js | 3106 +++++++++++++++++++++++++ src/client/ClientDependencyFactory.js | 364 +++ 2 files changed, 3470 insertions(+) create mode 100644 src/client/Client.js create mode 100644 src/client/ClientDependencyFactory.js diff --git a/src/client/Client.js b/src/client/Client.js new file mode 100644 index 0000000..b6e6cd5 --- /dev/null +++ b/src/client/Client.js @@ -0,0 +1,3106 @@ +/** + * Liza client + * + * 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 . + */ + +const Class = require( 'easejs' ).Class; +const EventEmitter = require( 'events' ).EventEmitter; +const DomFieldNotFoundError = require( '../ui/field/DomFieldNotFoundError' ); +const UnknownEventError = require( '../event/UnknownEventError' ); + + +/** + * Controller for the program client + * + * This controls and mediates pretty much everything that goes on in the + * client. It has far too many responsibilities. + * + * @todo any time this class is touched, extract code. + * @todo bring up to par with modern code standards + */ +module.exports = Class( 'Client' ) + .extend( EventEmitter, +{ + /** + * When an event is triggered (before it is handled) + * @type {string} + */ + 'const EVENT_TRIGGER': 'trigger', + + /** + * Triggered after a quote is changed, after a response is received from the + * server + * + * @type {string} + */ + 'const EVENT_QUOTE_CHANGE': 'quoteChange', + + /** + * Trigger after rates are retrieved from the server + * @type {string} + */ + 'const EVENT_POST_RATE': 'postRate', + + /** + * Triggered after save is complete and a response from the server is + * received + * + * @type {string} + */ + 'const EVENT_POST_SAVE': 'postSave', + 'const EVENT_POST_SAVE_ALL': 'postSaveAll', + + + /** + * Factory used to create all needed objects + * @type {ClientDependencyFactory} + */ + _factory: null, + + /** + * Element that the client should operate upon + * @type {jQuery} + */ + $body: null, + + /** + * Used to communicate with the server + * @type {ClientDataProxy} + */ + dataProxy: null, + + /** + * Used to style all elements for the UI + * @type {ElementStyler} + */ + elementStyler: null, + + /** + * Handles navigation + * @type {Nav} + */ + nav: null, + + /** + * Contains the object that controls the user interface + * @type {Ui} + */ + ui: null, + + /** + * Handles dialog display + * @type {UiDialog} + */ + uiDialog: null, + + /** + * Navigation bar + * @type {jQuery} + */ + $navBar: null, + + /** + * Current quote + * @type {QuoteClient} + */ + 'private _quote': null, + + /** + * Holds group metadata + * @type {Object} + */ + groupData: {}, + + /** + * Holds the program id (e.g. 'artisan') + * @type {string} + */ + programId: '', + + /** + * Holds the Program object generated from the XML + * @type {Object} + */ + program: null, + + /** + * Whether to run the submit event client-side to provide a better user + * experience + * + * Set this to FALSE to test server-side functionality. + * + * @type {boolean} + */ + clientSideSubmitEvent: true, + + /** + * Functions to call when quote is ready for importing + * @type {Array.} + */ + importHooks: [], + + /** + * Functions to call when docs are requested + * @type {Array.} + */ + viewDocHooks: [], + + /** + * The number of outstanding save requests + * @type {number} + */ + saving: 0, + + /** + * Whether we are logged in internally + * @type {boolean} + */ + 'private _isInternal': false, + + /** + * Quick reference to the current step id + * @type {number} + */ + 'private _currentStepId': 0, + + /** + * Validate bucket data types + * @type {DataValidator} + */ + 'private _dataValidator': null, + + /** + * Track field failures and fixes + * @type {ValidStateMonitor} + */ + 'private _fieldMonitor': null, + + /** + * Validate and format fields + * @type {BucketDataValidator} + */ + 'private _validatorFormatter': null, + + /** + * Contains classification match data per field + * + * TODO: Move this to somewhere more appropriate + * + * @type {Object} + */ + 'private _cmatch': {}, + + /** + * Performs classification matching on fields + * + * A field will have a positive match for a given index if all of its + * classes match + * + * @type {FieldClassMatcher} + */ + 'private _classMatcher': null, + + + /** + * Fields that were hidden (including indexes) since the last cmatch clear + * @type {Object} + */ + 'private _cmatchHidden': {}, + + /** + * Automatically discards staging bucket contents + * @type {StagingBucketAutoDiscard} + */ + 'private _stagingDiscard': null, + + /** + * Current save event + * @type {Object} + */ + 'private _saveEvent': undefined, + + /** + * UI styler controller + * @type {UiStyler} + */ + 'private _uiStyler': null, + + /** + * Handles client-side events + * @type {DelegateEventHandler} + */ + 'private _eventHandler': null, + + /** + * Greater than 0 if urrently showing an error dialog + * @type {number} + */ + 'private _showingError': 0, + + /** + * Root DOM document node + */ + 'private _rootContext': null, + + /** + * User-visible validation error messages + * @type {Object} + */ + 'private _validationMessages': {}, + + + /** + * Instantiates all the necessary objects and initializes the UI. + * + * @param {jQuery} $body element that should act as the body for the client + * + * @return undefined + */ + __construct: function( $body, factory ) + { + this._factory = factory; + this.$body = $body; + this.elementStyler = factory.createElementStyler( jQuery ); + this.$navBar = this.$body.find( 'ul.step-nav' ); + + // initialize our more complicated objects + this._init(); + }, + + + /** + * Returns the UI object + * + * @return lovullo.program.Ui + * + * XXX: Breaks encapsulation + */ + getUi: function() + { + return this.ui; + }, + + + /** + * Returns the Element Styler object + * + * @return lovullo.program.ElementStyler + */ + getElementStyler: function() + { + return this.elementStyler; + }, + + + /** + * Initializes common objects + * + * @return undefined + */ + _init: function() + { + var client = this; + + // create the widget selector for jQuery + $.extend( $.expr[':'], { + widget: this.elementStyler.getWidgetSelector(), + 'widget-id': this.elementStyler.getWidgetIdSelector() + } ); + + // used to communicate with the server + this.dataProxy = this._createDataProxy( jQuery ); + + this._eventHandler = this._factory.createClientEventHandler( + this, this.elementStyler, this.dataProxy, jQuery + ); + + this.uiDialog = this._factory.createUiDialog(); + this.programId = this._getProgramId(); + this.program = this._createProgram(); + this.nav = this._factory.createNav( this.program.steps ); + this.hashNav = this._createHashNav( this.nav ); + + this._stagingDiscard = this._factory.createStagingBucketDiscard(); + + this._fieldMonitor = this._factory.createFieldValidator(); + + this._dataValidator = this._factory.createDataValidator( + this.program.meta.qtypes, + this._fieldMonitor + ); + + this.ui = this._createUi( this.nav ); + + this._classMatcher = this._factory.createFieldClassMatcher( + this.program.whens + ); + + this._validatorFormatter = this._factory.createValidatorFormatter( + this.program.meta.qtypes + ); + + // set sidebar data + this.ui.getSidebar().setData( this.program.sidebar ); + + // no use in doing anything if our program logic is missing + if ( this.program == null ) + { + return; + } + + this.nav.setFirstStepId( this.program.getFirstStepId() ); + + var meta = this.program.meta; + this.elementStyler + .setTypeData( meta.qtypes ) + .setAnswerRefs( meta.arefs ) + .setHelpData( this.program.help ) + .setDefaults( this.program.defaults ) + .setDisplayDefaults( this.program.displayDefaults ) + .setSelectData( meta.qdata ) + ; + + this.groupData = meta.groups; + + // when the quote id changes, initialize a new quote + this.nav.on( 'quoteIdChange', function( quote_id, clear_step ) + { + var step_cur = client.ui.getCurrentStep(), + + do_change = function() + { + // perform quote change + client._changeQuote( quote_id, clear_step ); + }; + + var quote = client.getQuote(); + + // if the current step is dirty, first prompt the user + // TODO: check discardable flag + if ( step_cur && quote.isDirty() ) + { + client.uiDialog.showDirtyDialog( + // save + function() + { + // save and perform quote change + client.ui.saveStep( step_cur, function() + { + do_change(); + }); + }, + + // discard + function() + { + // get rid of the error box if it's shown + // fixme + client.ui.errorBox.hide(); + + // now perform the quote change + do_change(); + } + ); + + return; + } + + // step isn't dirty, so we're good to perform the quote change + do_change(); + + } ).on( 'stepChange', function( step_id ) + { + client.ui.displayStep( step_id, function() + { + client.forceCmatchAction(); + } ); + + client._currentStepId = step_id; + + // scroll to the top of the page + $.scrollTo( 0 ); + + // ensure the scroll event was kicked off (FS#11036) + $( document ).scroll(); + } ).on( 'unload', function( event ) + { + if ( !( client.ui.getCurrentStep() ) ) + { + return; + } + + var quote = client.getQuote(); + if ( quote && quote.isDirty() ) + { + event.returnValue = 'You have unsaved changes to the ' + + 'current step. If you leave this page, changes to ' + + 'this step will be lost.'; + } + }); + + this._initBeforeLoadHook(); + this.ui.init(); + + this.hashNav.init(); + }, + + + /** + * Performs quote change + */ + 'private _changeQuote': function( quote_id, clear_step ) + { + var client = this; + + this.ui.setQuote( client._quote = null ); + + // initialize the quote (we don't need to do anything because the + // hooks on the data proxy will allow us to get all the info we're + // looking for) + client.dataProxy.get( quote_id + "/init", function( data ) + { + // if the server responds that the quote is invalid, then don't + // bother passing it off to the UI (by now we probably already + // have another quote object instantiated for the quote that the + // server redirected us to) + if ( data.content.valid !== true ) + { + return; + } + + // stop any currently running XHRs to ensure they don't conflict + // with the new quote + client.dataProxy.abortAll(); + + // create a new quote instance + client._quote = client._factory.createQuote( + quote_id, + data.content + ); + + // TODO: this seems like it should be a ctor argument + client._quote.setProgram( client.program ); + + client.nav.setMinStepId( client._quote.getExplicitLockStep() ); + + client._monitorQuote( client._quote ); + + client._quote.setQuickSaveData( data.content.quicksave || {} ); + + client._hookClassifier(); + + // store internal status + client._isInternal = client.program.isInternal = + ( data.content.internal ) + ? true + : false; + client.ui.setInternal( client._isInternal ); + + // attach the bucket to the sidebar (note: order of these method + // calls is important) + client.ui.getSidebar() + .setInternal( client._isInternal ) + .setQuote( client._quote ); + + // initialize + client._quote.visitData( function( bucket ) + { + client.program.initQuote( bucket ); + } ); + + client.ui.setQuote( client._quote, client.program, clear_step ); + + // if logged in internally, show internal questions and do other + // internal stuff (no, not that stuff) + if ( data.content.internal === true ) + { + client.elementStyler.showInternal(); + } + + if ( client._quote.isLocked() + || ( + data.content.internal + && ( client._quote.getExplicitLockStep() > 0 ) + ) + ) + { + client._showLockedNotification( data.content.internal ); + } + else + { + client._hideLockedNotification(); + } + + client.emit( client.__self.$( 'EVENT_QUOTE_CHANGE' ) ); + + // kick off the classifier (it may not be kicked off on step change + // if there are no questions on the step that are used by it) + client._quote.forceClassify(); + } ); + }, + + + /** + * Force handling of the most recent cmatch data + * + * This can be used to refresh the UI to ensure that it is consistent with + * the cmatch data. + * + * @return {Client} self + */ + 'public forceCmatchAction': function() + { + if ( !( this._cmatch ) ) + { + return this; + } + + this._handleClassMatch( this._cmatch, true ); + + return this; + }, + + + 'private _hookClassifier': function() + { + var _self = this, + program = this.program; + + // clear/initialize cmatches + this._cmatch = {}; + + var cmatchprot = false; + + // set classifier + this._quote + .setClassifier( program.getClassifierKnownFields(), function() + { + return program.classify.apply( program, arguments ); + } ) + .on( 'classify', function( classes ) + { + if ( cmatchprot === true ) + { + _self._handleError( Error( 'cmatch recursion' ) ); + } + + cmatchprot = true; + + // handle field fixes + _self._dataValidator.validate( undefined, classes ) + .catch( e => _self.handleError( e ) ); + + _self._classMatcher.match( classes, function( cmatch ) + { + // it's important that we do this here so that everything + // that uses the cmatch data will consistently benefit + _self._postProcessCmatch( cmatch ); + + // if we're not on a current step, defer + if ( !( _self.ui.getCurrentStep() ) ) + { + _self._cmatch = cmatch; + cmatchprot = false; + return; + } + + _self._handleClassMatch( cmatch ); + cmatchprot = false; + } ); + } ); + }, + + + 'private _postProcessCmatch': function( cmatch ) + { + // for any matches that are scalars (they will have no indexes), loop + // through each field and set the index to the value of 'all' + for ( var field in cmatch ) + { + if ( field === '__classes' ) + { + continue; + } + + var cfield = cmatch[ field ]; + + if ( cfield.indexes.length === 0 ) + { + var data = this.getQuote().getDataByName( field ), + i = data.length; + + // this will do nothing if there is no data found + while ( i-- ) + { + cfield.indexes[ i ] = cfield.all; + } + } + } + + return cmatch; + }, + + + // from UI + 'private _cmatchVisFromUi': function( field, all ) + { + var step = this.getUi().getCurrentStep(); + + if ( !step ) + { + return []; + } + + var group = step.getElementGroup( field ); + if ( !group ) + { + return []; + } + + var i = group.getCurrentIndexCount(), + ret = []; + + while ( i-- ) + { + ret.push( all ); + } + + return ret; + }, + + + 'private _handleClassMatch': function( cmatch, force ) + { + force = !!force; + + this.ui.setCmatch( cmatch ); + + var _self = this, + quote = this.getQuote(), + + // oh dear god...(Demeter, specifically..) + fields = this.getUi().getCurrentStep().getStep() + .getExclusiveFieldNames(); + + + var showq = [], hideq = []; + for ( var field in cmatch ) + { + // ignore fields that are not on the current step + if ( !( fields[ field ] ) ) + { + continue; + } + + // if the match is still false, then we can rest assured + // that nothing has changed (and skip the overhead) + if ( !force + && ( cmatch[ field ] === false ) + && ( _self._cmatch[ field ] === false ) + ) + { + continue; + } + + var show = [], + hide = [], + + cfield = cmatch[ field ], + + vis = cfield.indexes, + cur = ( + ( _self._cmatch[ field ] || {} ).indexes + || [] + ); + + // the system was previously unable to determine the length, so + // let's attempt to get it from the UI + if ( vis.length === 0 ) + { + vis = this._cmatchVisFromUi( field, cfield.all ); + } + + // consider the number of indexes in the bucket first; + // otherwise, we might try to operate on fields that don't + // exist (bucket/class indexes not the same). the check for + // undefined in the first index is a workaround for the explicit + // setting of the length property of the bucket value when + // indexes are removed + var curdata = quote.getDataByName( field ), + fieldn = ( curdata.length > 0 && ( curdata[ 0 ] !== undefined ) ) + ? curdata.length + : vis.length; + + for ( var i = 0; i < fieldn; i++ ) + { + // do not record unchanged indexes as changed + // (avoiding the event overhead) + if ( !force && ( vis[ i ] === cur[ i ] ) ) + { + continue; + } + + ( ( vis[ i ] ) ? show : hide ).push( i ); + } + + if ( show.length ) + { + showq[ field ] = show; + _self._mergeCmatchHidden( field, show, false ); + } + if ( hide.length ) + { + hideq[ field ] = hide; + _self._mergeCmatchHidden( field, hide, true ); + } + } + + // it's important to do this before showing/hiding fields, since + // those might trigger events that check the current cmatches + this._cmatch = cmatch; + + + // allow DOM operations to complete before we trigger + // manipulations on it (TODO: this is a workaround for group + // show/hide issues; we need a better solution to guarantee + // order + setTimeout( function() + { + _self._hideFields( showq, 'show' ); + _self._hideFields( hideq, 'hide' ); + }, 25 ); + }, + + + 'private _mergeCmatchHidden': function( name, indexes, hidden ) + { + if ( !( this._cmatchHidden[ name ] ) ) + { + this._cmatchHidden[ name ] = {}; + } + + var cindexes = this._cmatchHidden[ name ]; + + for ( i in indexes ) + { + if ( hidden ) + { + cindexes[ indexes[ i ] ] = i; + } + else + { + delete cindexes[ indexes[ i ] ]; + } + } + + var some = false; + for ( var i in cindexes ) + { + some = true; + break; + } + + if ( !some ) + { + // v8 devs do not recomment delete as it progressively slows down + // property access on the object + this._cmatchHidden[ name ] = undefined; + } + }, + + + /** + * Hooks quote for performing validations on data change + * + * @return {undefined} + */ + 'private _validateChange': function( msgobj, bucket, diff, failures ) + { + var trigger_callback = this._getValidationTriggerHandler(); + + var diff_count = 0; + + for ( var name in diff ) + { + diff_count++; + + // if we already have a problem with the field, then save + // ourselves some effort and ignore it for now + if ( failures[ name ] ) + { + continue; + } + + var result = this.program.change( + this._currentStepId, + name, + bucket, + diff, + this._cmatch, + function() + { + var args = arguments; + + setTimeout( function() + { + // allow DOM operations to complete before we + // trigger manipulations on it (TODO: this is + // a workaround for group show/hide issues; we + // need a better solution to guarantee order + trigger_callback.apply( null, args ); + }, 25 ); + } + ); + + for ( var rname in result ) + { + failures[ rname ] = []; + + for ( var i in result[ rname ] ) + { + // the expected format is for it to contain the + // value for each index + failures[ rname ][ i ] = result[ rname ][ i ]; + } + + this._genValidationMessages( + this._validationMessages, + result + ); + } + } + + return; + }, + + + /** + * Produce validation error messages intended for user display + * + * @param {Object} msg_dest destination for messages per field and index + * @param {Object} failures failures per field name and index + * + * @return {undefined} + */ + 'private _genValidationMessages': function( msg_dest, failures ) + { + for ( var rname in failures ) + { + msg_dest[ rname ] = []; + + for ( var i in failures[ rname ] ) + { + msg_dest[ rname ][ i ] = failures[ rname ][ i ]; + } + } + }, + + + /** + * Perform a validation and invalidate the form if necessary + * + * @param {Function} validate_callback function to perform validation + * + * @return {undefined} + */ + 'private _performValidation': function( validate_callback ) + { + var _self = this; + + this.getQuote().visitData( function( bucket ) + { + // N.B.: We pass {} as the diff because nothing has actually changed + _self.ui.invalidateForm( + validate_callback( bucket, {}, _self._cmatch ) + ); + } ); + }, + + + /** + * Retrieve function to handle validation triggers + * + * @return {Function} trigger handler + */ + 'private _getValidationTriggerHandler': function() + { + var client = this; + return function( event_name, element_name, value, indexes ) + { + client.handleEvent( event_name, { + elementName: element_name, + indexes: indexes, + value: value + } ); + }; + }, + + + /** + * Merge quick save data with bucket data + * + * This has the wonderful consequence of allowing the user to refresh the + * page and retain the majority of the data. This is useful if the broker + * experiences problems that require a refresh to resolve. This also allows + * us (developers) to jump into a quote to aid the broker before the step + * is saved. + * + * @return {undefined} + */ + 'private _mergeQuickSaveData': function() + { + var merge = {}, + qs_data = this._quote.getQuickSaveData(); + + // merge quick save data with bucket + for ( var name in qs_data ) + { + var values = qs_data[ name ], + i = values.length || 0; + + merge[ name ] = []; + + // merge each of the values individually, skipping unchanged (null) + // values + while ( i-- ) + { + var val = values[ i ]; + + // ignore null values, as they are unchanged (well, this isn't + // 100% true (removing tabs/rows), but it will suffice for now) + if ( val !== null ) + { + merge[ name ][ i ] = val; + } + }; + } + + this._quote.setData( merge ); + + // empty quick save data + this._quote.setQuickSaveData( {} ); + + // force UI cmatch update, since we may have fields that have been added + // that need to be shown/hidden based on the current set of + // classifications + this.forceCmatchAction(); + }, + + + /** + * Retrieves the program ID from the URL + * + * @return String program id + */ + _getProgramId: function() + { + // grab out of the url + var data = window.location.href.match( /\/quote\/([a-z0-9-]+)\//i ); + return data[1] || ''; + }, + + + /** + * Instantiates the program object + * + * If it cannot be found, an error is displayed to the user + * + * @return Program|null the program object, or null if it could not be found + */ + _createProgram: function() + { + var _self = this; + + try + { + var dapi_manager = this._factory.createDataApiManager(); + + var program = this._factory.createProgram( + this.programId, + dapi_manager + ); + } + catch ( e ) + { + // todo: better suited for brokers + this._handleError( Error( + "Error loading program data: " + e.message + ) ); + + return null; + } + + program.on( 'error', function( e ) + { + _self._handleError( e ); + } ); + + // handle field updates + dapi_manager + .on( 'fieldLoading', function( name, index ) + { + var group = _self.getUi().getCurrentStep().getElementGroup( + name + ); + + if ( !group ) + { + return; + } + + // -1 represents "all indexes" + if ( index === -1 ) + { + index = undefined; + } + } ) + .on( 'updateFieldData', function( name, index, data, fdata ) + { + var group = _self.getUi().getCurrentStep().getElementGroup( + name + ); + + if ( !group ) + { + return; + } + + var cur_data = _self._quote.getDataByName( name ); + if ( +index === -1 ) + { + // -1 is the "combined" index, representing every field + indexes = cur_data; + } + else + { + indexes = []; + indexes[ index ] = index; + } + + + var update = []; + for ( var i in indexes ) + { + var cur = undefined; + + if ( data.length ) + { + cur = cur_data[ i ]; + + update[ i ] = ( fdata[ cur ] ) + ? cur + : data[ 0 ].value; + } + else + { + update[ i ] = ''; + } + + // populate and enable field *only if* results were returned + // and if the quote has not been locked; but first, give the + // UI a chance to finish updating + setTimeout( function() + { + group + .setOptions( name, i, data, cur ); + }, 25 ); + + } + + update.length && _self._quote.setDataByName( name, update ); + } ) + .on( 'clearFieldData', function( name, index ) + { + if ( !_self.getUi().getCurrentStep().getElementGroup( name ) ) + { + return; + } + + // clear and disable the field (if there's no value, then there + // is no point in allowing them to do something with it) + _self.getUi().getCurrentStep().getElementGroup( name ) + .clearOptions( name, index ); + } ) + .on( 'error', function( e ) + { + _self._handleError( e ); + } ); + + return program; + }, + + + _createDataProxy: function( jquery, prohibit_abort ) + { + prohibit_abort = !!prohibit_abort; + + var client = this; + var proxy = this._factory.createDataProxy( jquery ); + + // process the data before returning it to the requesters + proxy.on( 'received', function( data, event ) + { + var quote_id = data.quoteId || proxy.quoteId; + var has_error = data.hasError || false; + var actions = data.actions || []; + + // the requester shouldn't be bothered with details that we're going + // to be handling + delete data.quoteId; + delete data.hasError; + + // if there's an error, then the content should be treated as the + // error message + if ( has_error ) + { + // don't let the data get to the requester; there was a problem + if ( prohibit_abort ) + { + data.hasError = has_error; + } + else + { + event.abort(); + } + + var caption = data.btnCaption || ''; + var callback = null; + + // if an action was provided, we want it to be executed when the + // dialog is closed + if ( actions.length > 0 ) + { + callback = function() + { + client._processActions( actions ); + } + } + + // is there an error callback? + if ( data.errorCallback instanceof Function ) + { + if ( data.errorCallback() === true ) + { + // they handled displaying the dialog + return; + } + } + + // show the dialog only if there's an error message + if ( data.content ) + { + client.uiDialog.showErrorDialog( + data.content, caption, callback, + + // if we're internal, it's likely our error messages + // will be more involved, so increase the width + ( ( client._isInternal ) ? 450 : undefined ) + ); + client.ui.unfreezeNav(); + } + // otherwise, just call the callback + else if ( callback instanceof Function ) + { + callback(); + } + + return; + } + + // if the quote id changed, then change the quote id + var curid = ( client._quote ) ? client._quote.getId() : 0; + if ( quote_id !== curid ) + { + client.nav.setQuoteId( quote_id ); + } + + // was there an action? + if ( actions.length > 0 ) + { + client._processActions( actions ); + + // no longer needed + delete data.actions; + } + } ); + + return proxy; + }, + + + _processActions: function( actions ) + { + actions = actions || []; + + // don't do anything if we don't have any actions + if ( actions.length == 0 ) + { + return; + } + + // process each of the actions + var len = actions.length; + for ( var i = 0; i < len; i++ ) + { + this._processAction( actions[i] ); + } + }, + + + /** + * Processes server actions + * + * These actions are received from the server and should be carried out by + * the client obediently. + * + * @param Object action action data + * + * @return undefined + */ + _processAction: function( action ) + { + var action_type = action.action, + client = this; + + switch ( action_type ) + { + case 'gostep': + var id = action.id || this.nav.getCurrentStepId(); + this.nav.navigateToStep( id, true ); + + break; + + case 'invalidate': + var errors = action.errors || []; + this.ui.invalidateForm( errors ); + + break; + + case 'quotePrompt': + this.uiDialog.showQuoteNumberPrompt( + // ok + function( quote_id ) + { + // attempt to navigate to the quote id + client.nav.setQuoteId( quote_id ); + } + ); + break; + + case 'warning': + this.uiDialog.showErrorDialog( action.message ); + break; + + case 'setProgram': + document.location.href = '/quote/' + action.id + '/#' + + action.quoteId; + break; + + case 'lock': + // we don't need the reason client-side + this._quote.setExplicitLock( "quote server" ); + this.ui.updateLocked(); + this._showLockedNotification( this.isInternal() ); + break; + + case 'unlock': + this._quote.clearExplicitLock().setImported( false ); + this.ui.updateLocked(); + this._hideLockedNotification(); + break; + + case 'indvRate': + this._eventHandler.handle( action_type, function() {}, { + stepId: this.nav.getCurrentStepId(), + indv: action.id + } ); + break; + + default: + window.console && console.error( 'Unrecognized action: %s', action.action ); + } + }, + + + /** + * Instantiates the UI object + * + * @param lovullo.program.Nav nav navigation object to use for UI + * + * @return lovullo.program.Ui new UI object + */ + _createUi: function( nav ) + { + var client = this, + $rater_step = this.$body.find( '#rater-step' ), + $sidebar = this.$body.find( '#rater-sidebar' ), + $error_box = $sidebar.find( '#error-box' ), + $rater_content = this.$body.find( '#rater-content' ), + errbox = this._factory.createFormErrorBox( $error_box ), + root_context = null; + + var ui = this._factory.createUi( { + content: this.$body, + styler: this.elementStyler, + nav: nav, + navStyler: this._factory.createNavStyler( this.$navBar, nav ), + errorBox: errbox, + sidebar: this._createSidebar( $sidebar, this.elementStyler ), + dialog: this.uiDialog, + notifyBar: this._factory.createNotifyBar( $rater_content ), + + uiStyler: this._createUiStyler( $error_box ), + navBar: this._factory.createUiNavBar( jQuery, this.$navBar ), + + dataValidator: this._dataValidator, + + rootContext: root_context = this._factory.createRootDomContext( + // root html node + document.childNodes[ + document.childNodes.length - 1 + ], + + this._factory.createDomFieldFactory( + this.elementStyler + ) + ), + + stepContainer: $rater_step, + stepBuilder: function( id, callback ) + { + return client._buildStep( id, callback ); + } + } ); + + this._rootContext = root_context; + + // handle context errors + root_context.on( 'error', function( e ) + { + client._handleError( e ); + } ); + + // must init after the Ui obj is available + this._initUiStyler( ui, errbox ); + + ui + .on( 'stepChange', function( step_id ) + { + // don't do anything if navigation is frozen + if ( ui.navFrozen ) + { + window.console && console.log( 'Navigation is frozen. Ignoring input.' ); + return; + } + + if ( nav.isValidNextStep( step_id ) ) + { + // clear out any validation problems we may have had, since + // clearly they didn't prevent us from moving forward + // (FS#11252) + if ( ui.getCurrentStep() ) + { + ui.getCurrentStep().getStep().setValid( true ); + } + + nav.navigateToStep( step_id ); + } + } ) + .on( 'action', function( type, ref, index ) + { + // use a char that's prohibited in event names as the separator + var action_event = 'action$' + type; + + if ( client._eventHandler.hasHandler( action_event ) ) + { + client._eventHandler.handle( + action_event, function( err, data ) {}, { + ref: ref, + index: index + } + ); + } + + client._quote.visitData( function( bucket ) + { + // trigger the action (this is part of the Program code, + // is generated from the program XML) + client.program.action( + client._quote.getCurrentStepId(), + type, + ref, + index, + bucket, + client._getValidationTriggerHandler() + ); + } ); + } ) + .on( 'error', function( e ) + { + client._handleError( e ); + } ); + + return ui.saveStep( function( stepui ) + { + var event = this; + + // attempt to save the step and abort the operation if it failed + client.saveStep( stepui, event, function( result ) + { + if ( result === false ) + { + event.abort(); + } + } ); + } ).on( 'renderStep', function( step ) + { + var step_id = step.getStep().getId(), + url = client._quote.getId() + '/step/' + step_id + '/visit'; + + client._quote.setCurrentStepId( step_id ); + + // run any visit hooks + client._quote.visitData( function( bucket ) + { + client.program.visitStep( step_id, bucket ); + } ); + + // Just let the server know we're visiting this step (we don't even + // care about a response). This will allow the server to save our + // current step even if it's cached client-side. + client.dataProxy.get( url ); + + // merge any quick save data *after* the UI is rendered, or we will + // run into validation/display issues + client._mergeQuickSaveData(); + } ).on( 'preRenderStep', function( step, $content ) + { + client.elementStyler.setContext( $content ); + } ); + }, + + + 'private _createUiStyler': function( $error_box ) + { + return this._uiStyler = this._factory.createUiStyler( + this.$body, this.elementStyler + ); + }, + + + 'private _initUiStyler': function( ui, errbox ) + { + this._uiStyler + // default error messages to the help message, if any + .attach( this._factory.createStepErrorStyler( this.program.help ) ) + .attach( this._factory.createSidebarErrorStyler( + this.program.help, errbox, ui + ) ); + }, + + + _createSidebar: function( $sidebar, styler ) + { + var _self = this, + sidebar = this._factory.createSidebar( + $sidebar, styler + ); + + sidebar.on( 'uwmanage', function() + { + // TODO: will it always be one? Magic number! Use constant if need + // be. + _self.nav.navigateToStep( 1 ); + } ); + + return sidebar + .on( 'quoteIdClick', function quoteIdClick() + { + // when the quote id is clicked, display a dialog listing their + // options + _self.uiDialog.showNavErrorDialog( { + title: 'Change quote id', + text: 'Would you like to:', + width: 550, + noX: false, + + search: function() + { + _self._doQuoteSearch(); + }, + + enter: function() + { + _self._doQuoteIdPrompt( { + error: function() + { + // re-call this function + quoteIdClick(); + } + } ); + }, + + cancel: function() {} + } );; + } ) + .on( 'agentIdClick', function agentIdClick() + { + // do nothing if we're not internal + if ( _self._isInternal === false ) + { + return; + } + + // XXX: hardcoding internal links is not the best of ideas; + // ideally, send to a page that will redirect, or receive URL + // from the server as a configuration value + window.open( + "http://marketing.lovullo.local/" + + _self._quote.getAgentId() + ); + } ); + }, + + + /** + * Creates the hash navigation object + * + * This method also sets up an error dialog to be displayed when hash + * navigation fails. + * + * @param Nav nav navigation object + * + * @return undefined + */ + _createHashNav: function( nav ) + { + var client = this, + hashnav = this._factory.createHashNav( nav, this.program.steps ); + + return hashnav.hashError( function() + { + client.uiDialog.showNavErrorDialog( { + noX: true, + + search: function() + { + client._doQuoteSearch(); + }, + + enter: function() + { + client._doQuoteIdPrompt( { + error: function() + { + hashnav.hashError(); + } + } ); + } + } ); + return hashnav; + }); + }, + + + _doQuoteSearch: function() + { + // redirect to pa_rating (this will change in the future) + window.location.href = '/pa_rating.php'; + }, + + + _doQuoteIdPrompt: function( options ) + { + var client = this; + + // prompt for the quote number + this.uiDialog.showQuoteNumberPrompt( + // ok + function( quote_id ) + { + // if the quote id is the same, just restore the + // hash + if ( client._quote + && ( quote_id == client._quote.getId() ) + ) + { + client.hashNav.updateHash(); + return; + } + + // attempt to navigate to the quote id + client.nav.setQuoteId( quote_id, true ); + }, + + // cancel + function() + { + // redisplay the error + if ( options.error instanceof Function ) + { + options.error(); + } + } + ); + }, + + + /** + * Retrieves the step from the server + * + * @param Integer step_id id of the step to retrieve + * @param Function callback function to call after retrieval is successful + * + * @return undefined + */ + _getStepContent: function( step_id, callback ) + { + var _self = this, + quote_id = this._quote.getId(); + + if ( this.saving > 1 ) + { + // if we're in the process of saving more than one step, then block + // until at least one of them finishes (in an attempt to prevent + // race conditions as in FS#12085 that would prevent navigating + // ahead two steps) + setTimeout( function() + { + // re-try + _self._getStepContent.apply( _self, arguments ); + }, 100 ); + + return; + } + + // retrieve the step + this.dataProxy.get( ( quote_id + '/step/' + step_id ), callback ); + }, + + + /** + * Builds a new group object from the given content + * + * @param {jQuery} $content group content + * @param {ElementStyler} styler styler to use for elements + * @param {FieldStyler} na_styler N/A field styler + * + * @return lovullo.program.Group new group object + */ + _buildGroup: function( $content, styler, na_styler ) + { + var group = this._factory.createGroup(), + ui = this._factory.createGroupUi( + group, $content, styler, this._rootContext, na_styler + ), + id = ui.getId(), + data = this.groupData[id]; + + group + .setIndexFieldName( this.program.groupIndexField[ id ] || '' ) + .setFieldNames( this.program.groupFields[ id ] || [] ) + .setExclusiveFieldNames( + this.program.groupExclusiveFields[ id ] || [] + ) + .setUserFieldNames( + this.program.groupUserFields[ id ] || [] + ); + + // do we have any data on this group? + if ( data ) + { + // apply it + if ( data.max ) + { + group.maxRows( +data.max ); + } + if ( data.min ) + { + group.minRows( +data.min ); + } + } + + // initialize the group + ui.init( this._quote ); + + return ui; + }, + + + /** + * Builds a new step + * + * @param {number} id id of the step + * + * @return {StepUi} new instance + */ + _buildStep: function( id, callback ) + { + var client = this; + + var step = this._factory.createStep( id, this._quote ) + .setRequiredFieldNames( this.program.requiredFields[ id ] ) + .setSortedGroupSets( this.program.sortedGroups[ id ] ); + + var step_ui = this._factory.createStepUi( + step, + this.elementStyler, + this._validatorFormatter, + + // group builder + function( $content, styler ) + { + return client._buildGroup( + $content, + styler, + client._factory.createNaFieldStyler() + ); + }, + + // step builder + function( step_id, callback ) + { + return client._getStepContent( step_id, callback ); + }, + + function( ui ) + { + client._initStepUi( ui, callback ); + + ui.on( 'dataChange', function( data ) + { + client._quote.setData( data ); + }); + } + ); + }, + + + _initStepUi: function( step_ui, callback ) + { + var client = this, + id = step_ui.getStep().getId(); + + step_ui.on( 'indexAdd', function( index, groupui ) + { + var fields = groupui.getGroup().getFieldNames(), + i = fields.length, + data = {}; + + while ( i-- ) + { + var name = fields[ i ]; + + data[ name ] = []; + data[ name ][ index ] = client.elementStyler.getDefault( + name + ); + } + + // add defaults to staging bucket + client._quote.setData( data ); + } ).on( 'indexRemove', function( index, groupui ) + { + var fields = groupui.getGroup().getFieldNames(), + i = fields.length, + values = {}, + quote = client._quote; + + // loop through each of the fields associated with the group (note + // that this will include linked groups) + while ( i-- ) + { + var cur_i = index, + name = fields[ i ], + prev_data = quote.getDataByName( name ), + len = prev_data.length, + prev_val = null; + + values[ name ] = []; + + // cascade the values downward atop of the index that is being + // removed + while ( ++cur_i < len ) + { + prev_val = prev_data[ cur_i ]; + + values[ name ][ cur_i - 1 ] = prev_val; + } + + if ( cur_i == 1 ) + { + // first row reset value to default + var def_val = client.elementStyler.getDefault( name ); + values[ name ][ cur_i - 1 ] = def_val; + } + else + { + // mark as removed in dirty bucket + values[ name ][ cur_i - 1 ] = null; + } + } + + // set data all at once to avoid extraneous calls + quote.setData( values, true ); + } ).on( 'indexReset', function( index, groupui ) + { + var fields = groupui.getGroup().getFieldNames(), + i = fields.length, + values = {}, + quote = client._quote; + + // loop through each of the fields associated with the group (note + // that this will include linked groups) + while ( i-- ) + { + var name = fields[ i ], + def_val = client.elementStyler.getDefault( name ); + + // set index to original value + values[ name ] = []; + values[ name ][ index ] = def_val; + } + + // set data all at once to avoid extraneous calls + quote.setData( values, true ); + } ); + + // when the step is rendered, run the onchange events + this.ui.on( 'renderStep', function( step ) + { + if ( step.getStep().getId() !== id ) + { + return; + } + + client.program.eachChangeById( id, function( name, callback ) + { + client._performValidation( callback ); + }, client._getValidationTriggerHandler() ); + }); + + callback( step_ui ); + }, + + + /** + * Saves a step + * + * @param StepUi stepui step to save + * + * @return {boolean} true on success, false on failure + */ + 'public saveStep': function( stepui, event, callback ) + { + var client = this; + + // if the step contains invalid data, they must correct it + if ( !( stepui.isValid( this._cmatch ) ) ) + { + // well we didn't get very far + callback( false ); + } + + if ( this._quote.isLocked() === true ) + { + // we still want to call the callback + event.forceCallback = true; + callback( false ); + } + + var step_id = stepui.getStep().getId(); + var bucket = stepui.getStep().getBucket(); + + // we want to do this before save so that we don't re-mark the bucket as + // dirty by populating it with uncommitted data + client._clearCmatchFields(); + + // give devs the option to disable client-side submit events so we can + // test server-side functionality + if ( this.clientSideSubmitEvent ) + { + // let's see what our program class has to say about this so-called + // "valid data" + // XXX: Shouldn't this have a trigger_callback? If triggerse + // shouldn't occurr, we should still throw an exception if one is + // triggered + var failures = this.program.submit( + step_id, bucket, this._cmatch + ); + + if ( failures !== null ) + { + this._genValidationMessages( + this._validationMessages, + failures + ); + + // TODO: move above validation logic into here + client._dataValidator.updateFailures( {}, failures ); + + event.errors = failures; + callback( false ); + + return; + } + } + + this._quote.needsImport( true ); + + // transport used to transfer the bucket data to the server, prohibiting + // callback aborts (to ensure that we can handle failures ourselves) + var transport = this._createBucketTransport( step_id, true ); + + var finish, timeout; + function dosave() + { + // if we're already saving, then block + if ( client.isSaving() ) + { + // request a continuation that will allow us to finish the + // request when we are ready + if ( !finish ) + { + finish = event.hold(); + timeout = client._setSaveWaitTimeout( event ); + } + + // only poll if the event has not been aborted + if ( !( event.aborted ) ) + { + setTimeout( dosave, 50 ); + } + + return; + } + + // store the save event so that it can be aborted in case of an + // error that we cannot handle + client._clearSaveWaitTimeout( timeout ); + client._saveEvent = event; + client.saving++; + + // save the quote + // todo: refactor this saving crap + stepui.saving = true; + client._quote.save( transport, function( data ) + { + client.saving--; + client._saveEvent = undefined; + stepui.saving = false; + + // do not process save callback if the save failed + if ( data.hasError ) + { + return; + } + + // can be hooked to perform an actual after saving is fully + // complete (preventing, say, race conditions for future + // requests) + client.emit( client.__self.$('EVENT_POST_SAVE'), + client.saving + ); + + if ( client.saving === 0 ) + { + client.emit( client.__self.$('EVENT_POST_SAVE_ALL') ); + } + } ); + + callback( true ); + finish && finish(); + } + + // run post-submit hooks (it is important that we do this immediately, + // otherwise we may run hooks intended for the current step while we're + // on another) + client.program.postSubmit( + stepui.getStep().getId(), + bucket, + function( event, question_id, value ) + { + client.handleEvent( event, { stepId: +value } ); + } + ); + + dosave(); + }, + + + 'private _setSaveWaitTimeout': function( event ) + { + var _self = this; + + // display a timeout if the save seems to not be completing...just as a + // fallback + return setTimeout( function() + { + event.abort(); + + _self._handleError( Error( + 'Save timeout; please try again' + ) ); + }, 15000 ); + }, + + + 'private _clearSaveWaitTimeout': function( timeout ) + { + clearTimeout( timeout ); + }, + + + 'public isSaving': function() + { + return ( this._saveEvent !== undefined ); + }, + + + 'public abortSave': function() + { + if ( !( this.isSaving() ) ) + { + return this; + } + + this._saveEvent.abort(); + this._saveEvent = undefined; + + return this; + }, + + + /** + * Save the staging bucket to the server (for debug/recovery purposes) + * + * @return {Client} self + */ + 'public saveStaging': function() + { + // abort if no quote is currently loaded + if ( !this._quote ) + { + return this; + } + + var transport = this._createStagingBucketTransport(); + + // we don't care whether or not it succeeds; just give it a shot + this._quote.saveStaging( transport ); + return this; + }, + + + 'private _createBucketTransport': function( step_id, prohibit_abort ) + { + return this._factory.createDataBucketTransport( + this._quote.getId(), step_id, + this._createDataProxy( jQuery, prohibit_abort ) + ); + }, + + + 'private _createStagingBucketTransport': function() + { + return this._factory.createStagingDataBucketTransport( + this._quote.getId() + ); + }, + + + /** + * Initializes hook that will trigger the beforeLoad event + * + * @return undefined + */ + _initBeforeLoadHook: function() + { + var client = this; + + this.nav.on( 'preStepChange', function( event ) + { + var step = client.ui.getCurrentStep(), + quote = client.getQuote(); + + // if we don't even have a quote loaded, then don't allow navigation + // fixme + if ( client.ui.quote === null ) + { + event.abort = true; + + // fixme + client.ui.quoteReadyEvent = event; + return; + } + + // if this is the initial step change, we may not yet have a current + // step + if ( step ) + { + var step_id = step.getStep().getId(), + ui = client.ui; + + // forward validations should be run when advancing a step + if ( client._forwardValidate( event ) === false ) + { + event.abort = true; + return; + } + + // if the current step is not dirty or the quote has been + // locked, just let them through + if ( !( + client._quote.isLocked() + || ( step_id < quote.getExplicitLockStep() ) + ) + && quote.isDirty() + && !event.force + ) + { + // the step is dirty; abort the navigation and display the + // dirty dialog, prompting the user what to do + event.abort = true; + + var dosave = function() + { + ui.saveStep( step, function() + { + event.resume( true ); + } ); + }; + + // if discarding is not permitted, then do not even show the + // dialog; just save and continue + if ( !( client.program.discardable[ step_id ] ) ) + { + dosave(); + return; + } + + client.uiDialog.showDirtyDialog( + // save + dosave, + + // discard + function() + { + // errors for this step are no longer valid + client._dataValidator.clearFailures(); + + client._queueBucketDiscard(); + step.reset( function() + { + event.resume( true, function() + { + client._disableBucketDiscard( false ); + } ); + } ); + } + ); + + return; + } + } + + // if this is the last step and the user is trying to go further, + // we'll be doing the import + if ( ( this.isLastStep( event.currentStepId ) ) + && ( event.stepId > event.currentStepId ) + ) + { + // don't allow navigation + event.abort = true; + + // if the quote has not yet been imported, or needs to be + // updated, then import it + if ( client._quote.needsImport() ) + { + client.importQuote(); + } + // otherwise, request the documents + else + { + client.viewDocs(); + } + + return; + } + + // keep track of the events so we know whether or not we need to + // wait for the asynchronous ones to complete + var event_count = 0, + fail_count = 0, + waiting = false; + + var try_continue_nav = function() + { + event_count--; + + // if they're waiting on us and there's no more + // events, resume navigation + if ( waiting && ( event_count == 0 ) ) + { + client.ui.unfreezeNav(); + + // if there's any failures, we do not want to + // unfreeze the UI + if ( fail_count == 0 ) + { + event.resume(); + } + } + } + + // the trigger callback is not asynchronous (XXX: we shouldn't have + // to do this with the bucket; refactor) + client._quote.visitData( function( bucket ) + { + client.program.beforeLoadStep( event.stepId, bucket, + function( event_name ) + { + event_count++; + + client.handleEvent( event_name, + { stepId: event.stepId }, + function() + { + try_continue_nav(); + }, + // failure + function() + { + fail_count++; + try_continue_nav(); + } + ); + } + ); + } ); + + // if we still have events running, then abort until they're + // complete + waiting = true; + if ( ( event_count > 0 ) || ( fail_count > 0 ) ) + { + event.abort = true; + + // freeze navigation to ensure user doesn't try to navigate + // again while events are still running, thereby triggering a + // bunch of them + if ( event_count > 0 ) + { + client.ui.freezeNav(); + } + } + }); + }, + + + 'private _queueBucketDiscard': function() + { + var _self = this; + this.getQuote().visitData( function( staging ) + { + staging.once( 'revert', function() + { + _self._clearValidationErrors(); + _self._stagingDiscard.enable( staging ); + } ); + } ); + }, + + + /** + * Clear all validation errors + * + * @return {undefined} + */ + 'private _clearValidationErrors': function() + { + var _self = this; + + this.getQuote().visitData( function( bucket ) + { + _self._dataValidator.updateFailures( bucket.getData(), {} ); + } ); + }, + + + 'private _disableBucketDiscard': function() + { + var _self = this; + this.getQuote().visitData( function( staging ) + { + _self._stagingDiscard.disable( staging ); + } ); + }, + + + 'private _clearCmatchFields': function() + { + var step = this.getUi().getCurrentStep(), + program = this.program; + + // don't bother if we're not yet on a step + if ( !step ) + { + return; + } + + var reset = {}; + for ( var name in step.getStep().getExclusiveFieldNames() ) + { + var data = this._cmatchHidden[ name ]; + + // if there is no data or we have been asked to retain this field's + // value, then do not clear + if ( !data || program.cretain[ name ] ) + { + continue; + } + + // what state is the current data in? + var cur = this.getQuote().getDataByName( name ); + + // we could have done Array.join(',').split(','), but we're trying + // to keep performance sane here + var indexes = []; + for ( var i in data ) + { + // we do *not* want to reset fields that have been removed + if ( cur[ i ] === undefined ) + { + break; + } + + indexes.push( i ); + } + + reset[ name ] = indexes; + } + + // batch reset (limit the number of times events are kicked off) + this._resetFields( reset ); + + // we've done our deed; reset it for the next time around + this._cmatchHidden = {}; + }, + + + /** + * Perform `forward' validations if needed + * + * Forward validations are performed when the user advances one or more + * steps, permitting the user to save and return to previous steps without + * receiving certain errors. See FS#9014. + * + * @param {Object} event navigation event as received from preStepChange + * + * @return {undefined} + */ + 'private _forwardValidate': function( event ) + { + var step = this.ui.getCurrentStep().getStep(), + cur_step_id = step.getId(), + bucket = step.getBucket(); + + // perform the validations only if we are advancing one or more steps + if ( event.stepId <= cur_step_id ) + { + return; + } + + // same concept as "submit" event + var failures = this.program.forward( + cur_step_id, + bucket, + this._cmatch, + function( trigger_event, question_id, value ) + { + client.handleEvent( trigger_event, { stepId: +value } ); + } + ); + + // in the event of a failure, abort navigation and display the errors + // just as we would with the `submit' event. + if ( failures !== null ) + { + this.ui.invalidateForm( failures ); + return false; + } + + return true; + }, + + + importQuote: function( hook ) + { + var client = this; + + if ( hook instanceof Function ) + { + this.importHooks.push( hook ); + return this; + } + + var hook_count = this.importHooks.length, + callback_count = 0, + callback_check = function( show_locked ) + { + show_locked = ( show_locked === undefined ) + ? true + : !!show_locked; + + // did we receive responses from each of the hooks (crude + // check - a single hook could be pushy and call it multiple + // times) + if ( callback_count === hook_count ) + { + // import is complete + client.setImported( client.isInternal(), show_locked ); + } + }; + + // call the hooks + for ( var i = 0; i < hook_count; i++ ) + { + this.importHooks[i].call( this, this._quote, function( show_lock ) + { + callback_count++; + callback_check( show_lock ); + }); + } + + return this; + }, + + + 'public setImported': function( internal, show_locked ) + { + show_locked = ( show_locked === undefined ) + ? true + : !!show_locked; + + this._quote.setImported( true ); + this.ui.importComplete(); + + if ( show_locked ) + { + this._showLockedNotification( internal ); + } + }, + + + viewDocs: function( hook ) + { + if ( hook instanceof Function ) + { + this.viewDocHooks.push( hook ); + return this; + } + + // call the hooks + for ( var i = 0, len = this.viewDocHooks.length; i < len; i++ ) + { + this.viewDocHooks[i].call( this, this._quote ); + } + + return this; + }, + + + /** + * Creates a new quote + * + * @return Client self to allow for method chaining + */ + newQuote: function() + { + // temporary way to accomplish this + this.nav.setQuoteId( 0 ); + + return this; + }, + + + /** + * Handles client-side events + * + * @param String event_name name of the event + * @param Object data data to pass to event + * @param Function callback function to call when event is done (if + * not asynchronous, it'll be called immediately) + * + * @param Function error_callback function to call if event fails + * + * @return Client self to allow for method chaining + */ + handleEvent: function( event_name, data, callback, error_callback ) + { + var _self = this, + stepui = this.ui.getCurrentStep(); + + this.emit( this.__self.$('EVENT_TRIGGER'), event_name, data ); + + try + { + this._eventHandler.handle( + event_name, function( err, data ) + { + if ( err ) + { + error_callback( err ); + return; + } + + // XXX: move me + if ( event_name === 'rate' ) + { + _self.emit( _self.__self.$('EVENT_POST_RATE'), data ); + } + + callback && callback( data ); + }, data + ); + + // we had no problem handling this event; no need to continue with + // the old event handling system + return; + } + catch ( e ) + { + // segue into the old event handling system + if ( !( Class.isA( UnknownEventError, e ) ) ) + { + // ruh roh + this._handleError( e ); + return; + } + } + + // perform event (XXX: replace me; see above) + switch ( event_name ) + { + case 'enable': + case 'disable': + case 'hide': + case 'show': + var fdata = {}; + fdata[ data.elementName ] = data.indexes; + + this._hideFields( fdata, event_name ); + break; + + case 'set': + var setdata = {}; + setdata[ data.elementName ] = []; + + for ( var i in data.indexes ) + { + var index = data.indexes[ i ]; + setdata[ data.elementName ][ index ] = data.value[ index ]; + } + + this._quote.setData( setdata ); + break; + + case 'reset': + var reset = {}; + reset[ data.elementName ] = data.indexes; + + this._resetFields( reset ); + break; + + default: + this._handleError( Error( + 'Unknown client-side event: ' + event_name + ) ); + } + + // call the callback, if one was provided + if ( callback instanceof Function ) + { + callback.call( this ); + } + + return this; + }, + + + 'private _hideFields': function( fields, event_name ) + { + var stepui = this.ui.getCurrentStep(); + + if ( !stepui ) + { + return; + } + + for ( var field in fields ) + { + var indexes = fields[ field ], + indexes_len = indexes.length; + + for ( var i = 0; i < indexes_len; i++ ) + { + var index = indexes[ i ]; + + if ( index === undefined ) + { + continue; + } + + var group = stepui.getElementGroup( field ); + if ( group === null ) + { + window.console && console.warn && console.warn( + 'No group found for %s event: %s[%s]', + event_name, + field, + index + ); + + continue; + } + + this._dapiTrigger( field ); + + if ( event_name === 'show' ) + { + group.showField( field, index ); + } + else if ( event_name === 'hide' ) + { + group.hideField( field, index ); + } + else + { + // locate the element within the group + var $element = group.getElementByName( + field, index + ); + + if ( event_name === 'enable' ) + { + $element.attr( 'readonly', false ); + } + else if ( event_name === 'disable' ) + { + $element.attr( 'readonly', true ); + } + } + } + } + }, + + + /** + * Trigger DataApi event for field FIELD + * + * @param {string} field field name + * + * @return {undefined} + */ + 'private _dapiTrigger': function( field ) + { + var _self = this; + + this.getQuote().visitData( function( bucket ) + { + _self.program.dapi( + _self._currentStepId, + field, + bucket, + {}, + _self._cmatch, + null + ); + } ); + }, + + + 'private _resetFields': function( fields ) + { + var update = {}; + + for ( var field in fields ) + { + var cur = fields[ field ], + cdata = this._quote.getDataByName( field ), + val = this.elementStyler.getDefault( field ); + + var data = []; + for ( var i in cur ) + { + var index = cur[ i ]; + + if ( cdata[ index ] === val ) + { + continue; + } + + data[ index ] = val; + } + + update[ field ] = data; + } + + this._quote.setData( update ); + }, + + + /** + * Returns the current quote + * + * @return {QuoteClient} + */ + getQuote: function() + { + return this._quote; + }, + + + 'private _showLockedNotification': function( internal ) + { + var client = this, + explicit_step = this._quote.getExplicitLockStep(); + + // if the step is locked to step 1, then there is no noticable effect; + // don't bother + if ( explicit_step == 1 ) + { + return; + } + + // do not allow modification of programs that cannot be unlocked, or if + // we're not logged in as an internal user + if ( !( this.program.unlockable ) || !( internal ) ) + { + // delay to permit repaint (prevent lockup in IE6) + setTimeout( function() + { + client.ui.showNotifyBar( + $( '
' ).append( + $( '
' ).html( + "The quote is locked and cannot be modified." + ) + ) + ); + }, 25 ); + + return; + } + + // delay to permit repaint (prevent lockup in IE6) + setTimeout( function() + { + var lock_str = "This quote has been locked."; + + if ( explicit_step > 0 ) + { + lock_str = "The first " + explicit_step + " steps of this " + + "quote have been locked."; + } + + client.ui.showNotifyBar( + $( '
' ).append( + $( '
' ).html( + lock_str + + " If you wish to modify " + + "it please click Unlock Quote " + + "to the right." + ) + ).append( $( '