1
0
Fork 0
liza/src/client/Client.js

3046 lines
85 KiB
JavaScript

/**
* Liza client
*
* Copyright (C) 2017 R-T Specialty, LLC.
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 {ClientQuote}
*/
'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.<Function>}
*/
importHooks: [],
/**
* Functions to call when docs are requested
* @type {Array.<Function>}
*/
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.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._eventHandler = this._factory.createClientEventHandler(
this, this._dataValidator, this.elementStyler, this.dataProxy, jQuery
);
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 visq = [];
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 )
{
visq[ field ] = { event_id: 'show', name: field, indexes: show };
this._mergeCmatchHidden( field, show, false );
}
if ( hide.length )
{
visq[ field ] = { event_id: 'hide', name: field, indexes: hide };
this._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( () =>
{
Object.keys( visq ).forEach( field =>
{
const { event_id, name, indexes } = visq[ field ];
this.handleEvent( event_id, {
elementName: name,
indexes: indexes,
} );
this._dapiTrigger( name );
} );
}, 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( 'fieldLoaded', ( name, index ) =>
{
_self._dataValidator.clearFailures( {
[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 '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;
},
/**
* 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 {ClientQuote}
*/
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(
$( '<div>' ).append(
$( '<div class="text">' ).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(
$( '<div>' ).append(
$( '<div class="text">' ).html(
lock_str +
" If you wish to modify " +
"it please click <strong>Unlock Quote</strong> " +
"to the right."
)
).append( $( '<button>' )
.text( 'Unlock Quote' )
.click( function()
{
client._modifyLockedQuote();
} )
)
);
}, 25 );
},
'private _modifyLockedQuote': function()
{
var imported = this._quote.isImported();
// unlock the quote (not that this is client-side only; this does not
// affect the values on the server)
this._quote.clientSideUnlock();
// update UI to unlock quote for the user
this.ui.hideNotifyBar().updateLocked();
// if the quote has been imported, then display a bar explaining the
// modification workflow and providing a "Done Modifying" button
if ( imported )
{
this.ui.showNotifyBar( $( '<div>' )
.append(
$( '<div class="text">' ).html(
"When finished, please return " +
"to the final step and click the " +
"<strong>Done Modifying</strong> button."
)
)
.append( this._createDoneModifyingButton() )
);
}
},
/**
* Create "Done Modifying" button for lock bar
*
* @return {jQuery} hooked button
*/
'private _createDoneModifyingButton': function()
{
var client = this;
var $btn = $( '<button>' )
.text( 'Done Modifying' )
.click( ( function( client )
{
return function()
{
// first, initiate step save
client.ui.saveStep();
// re-lock
client.clientSideRelock();
client.ui.updateLocked();
client.emit( 'quoteRelock' );
client._showLockedNotification( true );
};
} )( this ) )
// disable by default if not on the last step
.attr( 'disabled',
( this.nav.isLastStep(
this._quote.getCurrentStepId()
) ) ? '' : 'disabled'
)
;
// TODO: remove hook when hiding button to free memory
this.ui.on( 'stepChange', function( step_id )
{
// if we've reached the last step, enable the button
if ( client.nav.isLastStep( step_id ) )
{
$btn.enable();
}
else
{
$btn.disable();
}
} );
return $btn;
},
'private _hideLockedNotification': function()
{
var client = this;
// permit redraw before we show/hide, since it's at the top of the creen
// and it'll redraw everything
setTimeout( function()
{
client.ui.hideNotifyBar();
}, 25 );
},
'private _monitorQuote': function( quote )
{
var _self = this,
ui = this.ui,
styler = this._uiStyler,
msgs = this._validationMessages,
err = styler.register( 'fieldError' ),
fixed = styler.register( 'fieldFixed' );
// TODO: breaks encapsulation and this klugery is simply to avoid
// another level of indentation; refactor
var bucket = {};
quote.visitData( function( the_bucket )
{
bucket = the_bucket;
} );
this._fieldMonitor
.on( 'failure', function( failures )
{
var cause = _self._genValidCause( failures );
ui.getCurrentStep().getStep().setValid( false, cause );
err( failures, msgs );
} )
.on( 'fix', function( fixes )
{
if ( !_self._fieldMonitor.hasFailures() )
{
ui.getCurrentStep()
.getStep()
.setValid( true, '' );
}
fixed( fixes );
for ( var name in fixes )
{
// don't matter if we delete indexes that are still
// in use; this data is no longer needed
delete msgs[ name ];
}
} );
// catch problems *before* the data is staged, altering the data
// directly if need be
quote.on( 'preDataUpdate', function( diff )
{
var failures = {};
// it is important that we pass `undefined` here for class data,
// _not_ an empty object
_self._dataValidator.validate( diff, undefined, ( vdiff, failures ) =>
{
_self._validateChange( msgs, bucket, vdiff, failures );
} )
.catch( e => _self.handleError( e ) );
} );
// proxy errors
this._fieldMonitor.on( 'error', function( e )
{
_self._handleError( e );
} );
},
'private _genValidCause': function( failures )
{
var cause = '';
for ( var name in failures )
{
var failure = failures[ name ];
for ( var i in failure )
{
cause += ( cause ) ? '; ' : '';
cause += name + '[' + i + ']';
}
}
return cause;
},
/**
* Handle error events
*
* Ideally, this should never happen. This method indicates an error that
* could not be properly handled by another part of the system. Let the user
* know that this should not be happening and trigger our own error event.
*
* @param {Error} e error
*
* @return {undefined}
*/
'private _handleError': function( e )
{
if ( !e )
{
e = Error( 'Client received an empty error!' );
}
// emit this error on our own error event
this.emit( 'error', e );
// if we're not internal, do not spam error dialogs (that looks bad ;))
if ( this._showingError++ )
{
// if we're internal, notify of the problems
if ( this._showingError === 2 && this.isInternal() )
{
this.uiDialog.showErrorDialog(
'[Internal] A number of errors have occurred; please ' +
'see the console for more information. (Avoiding ' +
'dialog spam.)',
"Okay"
);
}
return;
}
// let the user know that something is amiss.
this.uiDialog.showErrorDialog(
'An unexpected error has occurred. If you continue to receive ' +
'this message, please contact RT Specialty / LoVullo for support.' +
// if internal, show the actual error
( ( this.isInternal() )
? '<br /><br />[Internal] ' + e.message
: ''
),
'Close',
function()
{
this._showingError = 0;
}
);
},
'public isInternal': function()
{
return this._isInternal;
},
/**
* Retrieve current program id
*
* @return {string} program id
*/
'public getProgramId': function()
{
return this.program.id;
},
'public getCmatchData': function()
{
return this._cmatch;
}
} );