' )
+ .append( $( '
' )
+ .text(
+ "Below is a list of all assertions performed (in real " +
+ "time)."
+ )
+ .append( $( '' )
+ .text( 'Clear' )
+ .click( function()
+ {
+ return _self._clearTable();
+ } )
+ )
+ )
+ .append( this._getAssertionsLegend() )
+ .append( this._getAssertionsTable() )
+ ;
+ },
+
+
+ /**
+ * Monitor assertions
+ *
+ * Each time an assertion occurs, it will be added to a stack (since the
+ * events will occur in reverse order). Once the assertion depth reaches
+ * zero, it will clear the stack and output each of the assertions/events.
+ *
+ * @return {undefined}
+ */
+ 'private _hookAssertEvent': function()
+ {
+ var _self = this;
+
+ this._client.program.on( 'assert', function()
+ {
+ // the hook needs to be refactored; too many arguments
+ var depth = arguments[ 7 ];
+
+ // add to the stack so that we can output the assertion and its
+ // subassertions once we reach the root node (this trigger is
+ // called in reverse order, since we don't know the end result
+ // of a parent assertion until we know the results of its
+ // children)
+ _self._stack.push( arguments );
+ if ( depth > 0 )
+ {
+ return;
+ }
+
+ // our depth is 0; output the log data
+ _self._processStack();
+ } );
+ },
+
+
+ /**
+ * Monitor triggers
+ *
+ * @return {undefined}
+ */
+ 'private _hookTriggerEvent': function()
+ {
+ var _self = this;
+
+ this._client.on( 'trigger', function( event_name, data )
+ {
+ _self._stack.push( [ 'trigger', event_name, data ] );
+ } );
+ },
+
+
+ /**
+ * Process stack, appending data to log table
+ *
+ * @return {undefined}
+ */
+ 'private _processStack': function()
+ {
+ var _self = this,
+ item;
+
+ while ( item = this._stack.pop() )
+ {
+ if ( item[ 0 ] === 'trigger' )
+ {
+ _self._appendTrigger.apply( _self, item );
+ }
+ else
+ {
+ _self._appendAssertion.apply( _self, item );
+ }
+ }
+ },
+
+
+ /**
+ * Clear all results from the assertions log table
+ *
+ * @return {boolean} true (to prevent navigation)
+ */
+ 'private _clearTable': function()
+ {
+ // remove all records and reset counter
+ this._getAssertionsTable().find( 'tbody tr' ).remove();
+ this._logIndex = 0;
+
+ return true;
+ },
+
+
+ /**
+ * Generate table to contain assertion log
+ *
+ * @return {jQuery} generated log table
+ */
+ 'private _getAssertionsTable': function()
+ {
+ return this._$table = ( this._$table || ( function()
+ {
+ return $( '' )
+ .attr( 'id', 'assertions-table' )
+ .append( $( '' ).append( $( '' )
+ .append( $( '' ).text( '#' ) )
+ .append( $( ' ' ).text( 'question_id' ) )
+ .append( $( ' ' ).text( 'method' ) )
+ .append( $( ' ' ).text( 'expected' ) )
+ .append( $( ' ' ).text( 'given' ) )
+ .append( $( ' ' ).text( 'thisresult' ) )
+ .append( $( ' ' ).text( 'result' ) )
+ ) )
+ .append( $( ' ' ) )
+ ;
+ } )() );
+ },
+
+
+ /**
+ * Append an assertion to the log
+ *
+ * XXX: This needs refactoring (rather, the hook does)
+ *
+ * @param {string} assertion assertion method
+ * @param {string} qid question id
+ * @param {Array} expected expected data
+ * @param {Array} given data given to the assertion
+ * @param {boolean} thisresult result before sub-assertions
+ * @param {boolean} result result after sub-assertions
+ * @param {boolean} record whether failures will be recorded as such
+ * @param {number} depth sub-assertion depth
+ *
+ * @return {undefined}
+ */
+ 'private _appendAssertion': function(
+ assertion, qid, expected, given, thisresult, result, record, depth
+ )
+ {
+ this._getAssertionsTable().find( 'tbody' ).append( $( '' )
+ .addClass( ( result ) ? 'success' : 'failure' )
+ .addClass( ( thisresult ) ? 'thissuccess' : 'thisfailure' )
+ .addClass( ( record ) ? 'recorded' : '' )
+ .addClass( 'adepth' + depth )
+ .append( $( '' )
+ .text( this._logIndex++ )
+ .addClass( 'index' )
+ )
+ .append( $( ' ' ).append( $( '' )
+ .attr( 'title', ( 'Depth: ' + depth ) )
+ .html(
+ Array( ( depth + 1 ) * 4 ).join( ' ') + qid
+ )
+ ) )
+ .append( $( ' ' ).text( assertion.getName() ) )
+ .append( $( ' ' ).text( JSON.stringify( expected ) ) )
+ .append( $( ' ' ).text( JSON.stringify( given ) ) )
+ .append( $( ' ' ).text( ''+( thisresult ) ) )
+ .append( $( ' ' ).text(
+ ''+( result ) + ( ( record ) ? '' : '*' )
+ ) )
+ );
+
+ // let the system know that the paint line should be drawn
+ this._paintLine();
+ },
+
+
+ 'private _appendTrigger': function( _, event_name, data )
+ {
+ this._getAssertionsTable().find( 'tbody' ).append( $( ' ' )
+ .addClass( 'trigger' )
+ .append( $( '' ).text( ' ' ) )
+ .append( $( ' ' ).text( event_name ) )
+ .append( $( ' ' )
+ .attr( 'colspan', 6 )
+ .text( JSON.stringify( data ) )
+ )
+ );
+
+ this._paintLine();
+ },
+
+
+ /**
+ * Generate assertions legend
+ *
+ * @return {jQuery} div containing legend
+ */
+ 'private _getAssertionsLegend': function()
+ {
+ return $( '' )
+ .attr( 'id', 'assert-legend' )
+ .append(
+ $( '
' )
+ .addClass( 'assert-legend-item' )
+ .addClass( 'root' )
+ )
+ .append( "
Root Assertion " )
+ .append(
+ $( '
' )
+ .addClass( 'assert-legend-item' )
+ .text( '*' )
+ )
+ .append( "
Unrecorded " )
+ .append(
+ $( '
' )
+ .addClass( 'assert-legend-item' )
+ .addClass( 'trigger' )
+ )
+ .append( "
Trigger " )
+ .append(
+ $( '
' )
+ .addClass( 'assert-legend-item' )
+ .addClass( 'paint' )
+ )
+ .append( "
Paint " )
+ .append( '
' )
+ .append(
+ $( '
' )
+ .addClass( 'assert-legend-item' )
+ .addClass( 'failure' )
+ )
+ .append( "
Failure " )
+ .append(
+ $( '
' )
+ .addClass( 'assert-legend-item' )
+ .addClass( 'unrecorded' )
+ )
+ .append(
+ "
Failure, but suceeded by subassertion or " +
+ "unrecorded "
+ )
+ ;
+ },
+
+
+ /**
+ * Draw paint line
+ *
+ * The paint line represents when a paint operation was able to occur. This
+ * allows us to see how many bucket values were updated between paints,
+ * which (depending on what hooks the bucket) could have negative
+ * consequences on performance.
+ *
+ * This is simple to detect - simply use a setTimeout() and it will execute
+ * after the stack has cleared and the page has been painted.
+ *
+ * @return {undefined}
+ */
+ 'private _paintLine': function()
+ {
+ var _self = this;
+
+ this._paintTimeout && clearTimeout( this._paintTimeout );
+ this._paintTimeout = setTimeout( function()
+ {
+ _self._getAssertionsTable().find( 'tr:last' )
+ .addClass( 'last-pre-paint' );
+ }, 25 );
+ }
+} );
+
diff --git a/src/client/debug/BucketClientDebugTab.js b/src/client/debug/BucketClientDebugTab.js
new file mode 100644
index 0000000..4684d06
--- /dev/null
+++ b/src/client/debug/BucketClientDebugTab.js
@@ -0,0 +1,628 @@
+/**
+ * Contains BucketClientDebugTab class
+ *
+ * Copyright (C) 2017 LoVullo Associates, Inc.
+ *
+ * This file is part of the Liza Data Collection Framework
+ *
+ * Liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
.
+ */
+
+var Class = require( 'easejs' ).Class,
+ EventEmitter = require( 'events' ).EventEmitter,
+ ClientDebugTab = require( './ClientDebugTab' );
+
+
+/**
+ * Provides additional information and manipulation options for buckets
+ */
+module.exports = Class( 'BucketClientDebugTab' )
+ .implement( ClientDebugTab )
+ .extend( EventEmitter,
+{
+ /**
+ * Current index of bucket monitor
+ * @type {number}
+ */
+ 'private _bmonIndex': 0,
+
+ /**
+ * Table representing the bucket monitor
+ * @type {jQuery}
+ */
+ 'private _$bmonTable': null,
+
+ /**
+ * Input box used to filter the bmonTable
+ * @type {jQuery}
+ */
+ 'private _$bmonTableFilter': null,
+
+ /**
+ * Reference to paint timeout timer
+ * @type {?number}
+ */
+ 'private _paintTimeout': null,
+
+
+ /**
+ * Retrieve tab title
+ *
+ * @return {string} tab title
+ */
+ 'public getTitle': function()
+ {
+ return 'Bucket';
+ },
+
+
+ /**
+ * Retrieve tab content
+ *
+ * @param {Client} client active client being debugged
+ * @param {StagingBucket} bucket bucket to reference for data
+ *
+ * @return {jQuery|string} tab content
+ */
+ 'public getContent': function( client, bucket )
+ {
+ return this._$content =
+ ( this._content || this._createBaseContent( bucket ) );
+ },
+
+
+ /**
+ * Create content
+ *
+ * @param {StagingBucket} staging bucket
+ *
+ * @return {jQuery} div containing tab content
+ */
+ 'private _createBaseContent': function( staging )
+ {
+ this._getBucketMonitorTable();
+ this._hookBucket( staging );
+
+ return $( '
' )
+ .append( this._getHeader() )
+ .append( $( '
' )
+ .append( $( '' ).text( "Staging Bucket" ) )
+ .append( $( '' ).text(
+ "The staging bucket contains modified data that has not " +
+ "yet been committed to the quote data bucket in " +
+ "addition to the actual quote data. This " +
+ "bucket will be passed to assertions."
+ ) )
+ .append( this._getStagingButtons( staging ) )
+ .append( this._getBucketMonitorLegend() )
+ .append( this._getBucketMonitorTable() )
+ )
+ ;
+ },
+
+
+ /**
+ * Generate tab header text
+ *
+ * @return {jQuery} div containing header paragraphs
+ */
+ 'private _getHeader': function()
+ {
+ var _self = this;
+
+ return $( '
' )
+ .append( $( '
' ).text(
+ "All quote data is contained within the buckets. The Client " +
+ "exists purely to populate the quote data bucket."
+ ) )
+ .append( $( '
' ).html(
+ "N.B. This tab does not currently bind " +
+ "to the new data bucket on quote change. Please refresh " +
+ "the page when changing quotes if you wish to use this tab."
+ ) )
+ .append( $( '
' )
+ .append( $( ' ' )
+ .attr( 'type', 'checkbox' )
+ .attr( 'id', 'field-overlay' )
+ .change( function()
+ {
+ // trigger toggle event
+ _self.emit( 'fieldOverlayToggle',
+ $( this ).is( ':checked' )
+ );
+ } )
+ )
+ .append( $( '' )
+ .attr( 'for', 'field-overlay' )
+ .text( 'Render field overlays (requires modern browser)' )
+ )
+ );
+ },
+
+
+ /**
+ * Generate staging bucket buttons
+ *
+ * TODO: This could use further refactoring.
+ *
+ * @param {StagingBucket} staging bucket
+ *
+ * @return {jQuery} div containing buttons
+ */
+ 'private _getStagingButtons': function( staging )
+ {
+ var _self = this;
+
+ return $( '' )
+ .append( $( '
' )
+ .text( 'Data To Console' )
+ .click( function()
+ {
+ console.log( staging.getData() );
+ } )
+ )
+ .append( $( '' )
+ .text( 'Diff To Console' )
+ .click( function()
+ {
+ console.log( staging.getDiff() );
+ } )
+ )
+ .append( $( '' )
+ .text( 'Commit' )
+ .click( function()
+ {
+ staging.commit();
+ console.info(
+ 'Commited staged changes to data bucket'
+ );
+ } )
+ )
+ .append( $( '' )
+ .text( 'Editor' )
+ .click( function()
+ {
+ _self.showBucketEditor(
+ staging,
+ function( name, value )
+ {
+ var data = {};
+ data[ name ] = value;
+
+ // set the data
+ staging.setValues( data );
+ console.log( "%s updated", name, data );
+ }
+ );
+ } )
+ )
+ .append( $( '' )
+ .text( 'Clear Monitor' )
+ .click( function()
+ {
+ return _self._clearTable();
+ } )
+ );
+ },
+
+
+ /**
+ * Clear all results from the assertions log table
+ *
+ * @return {boolean} true (to prevent navigation)
+ */
+ 'private _clearTable': function()
+ {
+ // clear monitor and reset count
+ this._$bmonTable.find( 'tbody tr' ).remove();
+ this._bmonIndex = 0;
+
+ // re-filter table
+ this._filterBucketTable();
+
+ return true;
+ },
+
+
+ /**
+ * Generate bucket monitor table or return existing table
+ *
+ * @return {jQuery} bucket monitor table
+ */
+ 'private _getBucketMonitorTable': function()
+ {
+ return this._$bmonTable = ( this._$bmonTable || ( function()
+ {
+ return $( '' )
+ .attr( 'id', 'bmon-table' )
+ .append( $( '' ).append( $( '' )
+ .append( $( '' ).text( '#' ) )
+ .append( $( ' ' ).text( 'key' ) )
+ .append( $( ' ' ).text( 'staged value' ) )
+ .append( $( ' ' ).text( 'prev. staged value' ) )
+ .append( $( ' ' ).text( 'modifier' ) )
+ .append( $( ' ' ).text( 'bucket value' ) )
+ ) )
+ .append( $( ' ' ) )
+ ;
+ } )() );
+ },
+
+
+ /**
+ * Generate legend for bucket monitor
+ *
+ * @return {jQuery} div containing legend
+ */
+ 'private _getBucketMonitorLegend': function()
+ {
+ return $( '' )
+ .attr( 'id', 'bmon-legend' )
+ .append(
+ $( '
' )
+ .addClass( 'bmon-legend-item' )
+ .addClass( 'set' )
+ )
+ .append( '
End of set ' )
+ .append(
+ $( '
' )
+ .addClass( 'bmon-legend-item' )
+ .addClass( 'paint' )
+ )
+ .append( '
Paint ' )
+ .append(
+ $( '
' )
+ .addClass( 'bmon-legend-item' )
+ .addClass( 'commit' )
+ )
+ .append( '
Commit ' )
+ .append(
+ $( '
' )
+ .addClass( 'bmon-legend-item' )
+ .addClass( 'nochange' )
+ )
+ .append( '
Unchanged ' )
+ .append( this._getBucketMonitorFilter() )
+ ;
+ },
+
+
+ /**
+ * Generate filter for bucket monitor
+ *
+ * @return {jQuery} div containing filter
+ */
+ 'private _getBucketMonitorFilter': function()
+ {
+ var _self = this;
+
+ return this._$bmonTableFilter = $( '
' )
+ .keyup(
+ function() { _self._filterBucketTable() }
+ );
+ },
+
+
+ /**
+ * Filter bucket monitor table
+ */
+ 'private _filterBucketTable': function()
+ {
+ var search_qry = this._$bmonTableFilter.val();
+ this._$bmonTable.find( 'tbody tr' ).show();
+
+ if ( search_qry != "" )
+ {
+ var reg = new RegExp( search_qry );
+ this._$bmonTable.find( 'tbody tr' )
+ .filter( function() { return !$( this ).find( 'td' ).eq( 1 ).text().match( reg ) } )
+ .hide();
+ }
+
+ },
+
+
+ /**
+ * Perform all necessary hooks for bucket monitor
+ *
+ * @param {StagingBucket} staging bucket
+ *
+ * @return {undefined}
+ */
+ 'private _hookBucket': function( staging )
+ {
+ this._hookBucketUpdate( staging );
+ this._hookBucketCommit( staging );
+ },
+
+
+ /**
+ * Hook staging bucket for updates
+ *
+ * @param {StagingBucket} staging bucket
+ *
+ * @return {undefined}
+ */
+ 'private _hookBucketUpdate': function( staging )
+ {
+ var _self = this,
+ $table = this._$bmonTable,
+ pre = {};
+
+ staging.on( 'preStagingUpdate', function( data )
+ {
+ // set previous data so we can output it after the update (when we
+ // output the actual row in the table)
+ for ( var key in data )
+ {
+ pre[ key ] = staging.getDataByName( key );
+ }
+ } );
+
+ staging.on( 'stagingUpdate', function( data )
+ {
+ for ( var key in data )
+ {
+ // get the new value
+ var value = JSON.stringify( staging.getDataByName( key ) ),
+ pre_val = JSON.stringify( pre[ key ] ),
+ pre_out = ( pre_val === value )
+ ? '(identical)'
+ : ( ( pre_val !== undefined )
+ ? pre_val
+ : '(undefined)'
+ ),
+
+ orig_value = JSON.stringify(
+ staging.getOriginalDataByName( key )
+ ),
+ orig_out = ( ( orig_value === value )
+ ? '(identical)'
+ : ( ( orig_value !== undefined )
+ ? orig_value
+ : '(undefined)'
+ )
+ ),
+
+ err = Error(),
+
+ // get stack trace
+ stack = err.stack && err.stack.replace(
+ /(.*\n){2}/,
+ ( key + ' set stack trace:\n' )
+ )
+ ;
+
+ $table.find( 'tbody' ).append( $( '
' )
+ .addClass( ( pre_val === value ) ? 'nochange' : '' )
+ .append( $( '' )
+ .text( _self._bmonIndex++ )
+ .addClass( 'index' ) )
+ .append( $( ' ' ).text( key ) )
+ .append( $( ' ' ).text( value ) )
+ .append( $( ' ' ).text( pre_out ) )
+ .append( $( ' ' ).text( JSON.stringify( data[ key ] ) ) )
+ .append( $( ' ' ).text( orig_out ) )
+ .click( ( function( stack_trace )
+ {
+ return function()
+ {
+ console.log( stack_trace );
+ };
+ } )( stack ) )
+ );
+ }
+
+ $table.find( 'tr:last' )
+ .addClass( 'last-in-set' );
+
+ _self._paintLine();
+
+ // clear out prev. data
+ pre = {};
+
+ // re-filter table
+ _self._filterBucketTable();
+ } );
+ },
+
+
+ /**
+ * Hook staging bucket for commits
+ *
+ * @param {StagingBucket} staging bucket
+ *
+ * @return {undefined}
+ */
+ 'private _hookBucketCommit': function( staging )
+ {
+ var _self = this,
+ $table = this._getBucketMonitorTable();
+
+ staging.on( 'preCommit', function()
+ {
+ var data = staging.getDiff();
+
+ for ( var key in data )
+ {
+ var value = JSON.stringify( data[ key ] ),
+ commit_value = JSON.stringify(
+ staging.getDataByName( key )
+ );
+
+ $table.find( 'tbody' ).append( $( ' ' )
+ .addClass( 'commit' )
+ .append( $( '' )
+ .text( _self._bmonIndex++ )
+ .addClass( 'index' ) )
+ .append( $( ' ' ).text( key ) )
+ .append( $( ' ' ).text( commit_value ) )
+ .append( $( ' ' )
+ .attr( 'colspan', 3 )
+ .text( value )
+ )
+ );
+
+ _self._paintLine();
+ }
+ } );
+ },
+
+
+ /**
+ * Draw paint line
+ *
+ * The paint line represents when a paint operation was able to occur. This
+ * allows us to see how many bucket values were updated between paints,
+ * which (depending on what hooks the bucket) could have negative
+ * consequences on performance.
+ *
+ * This is simple to detect - simply use a setTimeout() and it will execute
+ * after the stack has cleared and the page has been painted.
+ *
+ * @return {undefined}
+ */
+ 'private _paintLine': function()
+ {
+ var _self = this;
+
+ this._paintTimeout && clearTimeout( this._paintTimeout );
+ this._paintTimeout = setTimeout( function()
+ {
+ _self._getBucketMonitorTable().find( 'tr:last' )
+ .addClass( 'last-pre-paint' );
+ }, 25 );
+ },
+
+
+ /**
+ * Displays the bucket editor
+ *
+ * The bucket editor allows the monitoring and modification of bucket
+ * values.
+ *
+ * @param {StagingBucket} staging bucket
+ * @param {Function} change_callback callback to call on value change
+ *
+ * @todo move into another class
+ */
+ 'public showBucketEditor': function( staging, change_callback )
+ {
+ var $editor = $( '' )
+ .dialog( {
+ title: "Bucket Editor",
+ width: 500,
+ height: 600,
+
+ close: function()
+ {
+ staging.removeListener( 'stagingUpdate', listener );
+ }
+ } ),
+
+ listener = function( data )
+ {
+ for ( name in data )
+ {
+ $editor.find( 'input[name="' + name + '"]' )
+ .val( JSON.stringify(
+ // get the full data set for this key
+ staging.getDataByName( name )
+ ) );
+ }
+ }
+ ;
+
+ staging.on( 'stagingUpdate', listener );
+
+ $editor
+ .append( $( '
' )
+ .text( 'Import' )
+ .click( function()
+ {
+ var data = prompt( 'Paste bucket JSON' );
+ if ( data )
+ {
+ console.log( 'Overwriting bucket.' );
+ staging.overwriteValues( JSON.parse( data ) );
+ }
+ } )
+ )
+ .append( $( '' )
+ .text( 'Dump' )
+ .click( function()
+ {
+ console.log( staging.getDataJson() );
+ } )
+ );
+
+ this._genBucketEditorFields( staging, $editor, change_callback );
+ },
+
+
+ /**
+ * Generates a field for each value in the bucket
+ *
+ * @param {StagingBucket} staging bucket
+ * @param {jQuery} $editor editor to append fields to
+ * @param {Function} change_callback callback to call on value change
+ *
+ * @return {undefined}
+ */
+ 'private _genBucketEditorFields': function(
+ staging, $editor, change_callback
+ )
+ {
+ var data = staging.getData();
+
+ for ( name in data )
+ {
+ // The data we've been provided with does not include the staging
+ // data. If we request it by name, however, that data will then be
+ // merged in.
+ var vals = staging.getDataByName( name );
+
+ $editor.append(
+ $( '' )
+ .append( $( '
' )
+ .text( name )
+ .css( {
+ fontWeight: 'bold'
+ } )
+ .append( $( '
' )
+ .attr( {
+ name: name,
+ type: 'text'
+ } )
+ .val( JSON.stringify( vals ) )
+ .css( {
+ width: '450px',
+ marginBottom: '1em'
+ } )
+ .change( ( function( name )
+ {
+ return function()
+ {
+ var $this = $( this );
+ change_callback(
+ name, JSON.parse( $this.val() )
+ );
+ }
+ } )( name ) )
+ )
+ )
+ );
+ }
+ }
+} );
diff --git a/src/client/debug/CalcClientDebugTab.js b/src/client/debug/CalcClientDebugTab.js
new file mode 100644
index 0000000..db90aa0
--- /dev/null
+++ b/src/client/debug/CalcClientDebugTab.js
@@ -0,0 +1,193 @@
+/**
+ * Contains CalcClientDebugTab class
+ *
+ * Copyright (C) 2017 LoVullo Associates, Inc.
+ *
+ * This file is part of the Liza Data Collection Framework
+ *
+ * Liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
.
+ */
+
+var Class = require( 'easejs' ).Class,
+
+ ClientDebugTab = require( './ClientDebugTab' ),
+
+ calc = require( 'program/Calc' )
+;
+
+
+/**
+ * Monitors client-side assertions
+ */
+module.exports = Class( 'CalcClientDebugTab' )
+ .implement( ClientDebugTab )
+ .extend(
+{
+ 'private _$content': null,
+
+
+ /**
+ * Retrieve tab title
+ *
+ * @return {string} tab title
+ */
+ 'public getTitle': function()
+ {
+ return 'Calculated Values';
+ },
+
+
+ /**
+ * Retrieve tab content
+ *
+ * @param {Client} client active client being debugged
+ * @param {StagingBucket} bucket bucket to reference for data
+ *
+ * @return {jQuery|string} tab content
+ */
+ 'public getContent': function( client, bucket )
+ {
+ this._$content = $( '
' )
+ .append( $( '
' ).text(
+ "Quick-n-dirty calculated value test tool. Select the " +
+ "method to test below, followed by the data and value " +
+ "arguments. See the Calc module for more information."
+ ) );
+
+ this._addRow();
+ this._addButtons();
+
+ return this._$content;
+ },
+
+
+ /**
+ * Add calculated value row which will perform the requested calculation
+ * with the provided parameter values
+ *
+ * @return {undefined}
+ */
+ 'private _addRow': function()
+ {
+ var _self = this,
+ $sel = null,
+ $data = null,
+ $value = null,
+ $result = null,
+
+ changeCallback = function()
+ {
+ _self._doCalc( $sel.val(), $data.val(), $value.val(), $result );
+ }
+ ;
+
+ this._$content.append( $( '
' )
+ .addClass( 'row' )
+ .append( $sel = $( '
' )
+ .change( changeCallback )
+ )
+ .append( $data = $( ' ' )
+ .val( '[]' )
+ .change( changeCallback )
+ )
+ .append( $value = $( ' ' )
+ .val( '[]' )
+ .change( changeCallback )
+ )
+ .append( $result = $( '' ) )
+ );
+
+ for ( method in calc )
+ {
+ $sel.append( $( '' )
+ .val( method )
+ .text( method )
+ );
+ }
+
+ changeCallback();
+ },
+
+
+ /**
+ * Perform calculation and update given result element
+ *
+ * @param {string} sel selected method
+ * @param {string} data given data argument
+ * @param {string} value given value argument
+ * @param {jQuery} $result result element to update
+ *
+ * @return {undefined}
+ */
+ 'private _doCalc': function( sel, data, value, $result )
+ {
+ var result = null;
+
+ // don't do anything if no method was selected
+ if ( !sel)
+ {
+ return;
+ }
+
+ try
+ {
+ result = calc[ sel ](
+ JSON.parse( data || [] ),
+ JSON.parse( value || [] )
+ );
+ }
+ catch ( e )
+ {
+ result = 'ERROR (see console)';
+ console.error( e );
+ }
+
+ $result.text( JSON.stringify( result ) );
+ },
+
+
+ /**
+ * Append button that allows for the creation of additional calc rows and a
+ * clear button
+ *
+ * @return {undefined}
+ */
+ 'private _addButtons': function()
+ {
+ var _self = this;
+
+ this._$content.append( $( '' )
+ .append( $( '
' )
+ .text( '+' )
+ .click( function()
+ {
+ _self._addRow();
+
+ // move the button down to the bottom (quick and easy means
+ // of doing so)
+ $( this ).parents( 'div:first' )
+ .detach().appendTo( _self._$content );
+ } )
+ )
+ .append( $( '' )
+ .text( 'Clear' )
+ .click( function()
+ {
+ _self._$content.find( 'div.row:not(:first)' ).remove();
+ } )
+ )
+ );
+ }
+} );
+
diff --git a/src/client/debug/ClassifyClientDebugTab.js b/src/client/debug/ClassifyClientDebugTab.js
new file mode 100644
index 0000000..5ee9a52
--- /dev/null
+++ b/src/client/debug/ClassifyClientDebugTab.js
@@ -0,0 +1,239 @@
+/**
+ * Contains ClassifyClientDebugTab class
+ *
+ * Copyright (C) 2017 LoVullo Associates, Inc.
+ *
+ * This file is part of the Liza Data Collection Framework
+ *
+ * Liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ EventEmitter = require( 'events' ).EventEmitter,
+ ClientDebugTab = require( './ClientDebugTab' );
+
+
+/**
+ * Monitors client-side assertions
+ */
+module.exports = Class( 'AssertionClientDebugTab' )
+ .implement( ClientDebugTab )
+ .extend( EventEmitter,
+{
+ /**
+ * Client being monitored
+ * @type {Client}
+ */
+ 'private _client': null,
+
+ /**
+ * List storing classes
+ * @type {jQuery}
+ */
+ 'private _$list': null,
+
+ /**
+ * Class cache
+ * @type {Object}
+ */
+ 'private _cache': {},
+
+
+ /**
+ * Retrieve tab title
+ *
+ * @return {string} tab title
+ */
+ 'public getTitle': function()
+ {
+ return 'Classifications';
+ },
+
+
+ /**
+ * Retrieve tab content
+ *
+ * @param {Client} client active client being debugged
+ * @param {StagingBucket} bucket bucket to reference for data
+ *
+ * @return {jQuery} tab content
+ */
+ 'public getContent': function( client, bucket )
+ {
+ // cut down on argument list
+ this._client = client;
+
+ this._hookClient( client );
+
+ return this._createContent();
+ },
+
+
+ 'private _hookClient': function( client )
+ {
+ var _self = this,
+ cache = this._cache;
+
+ var sorted = null;
+
+ this._client.getQuote().on( 'classify', function( classes )
+ {
+ setTimeout( function()
+ {
+ // only sort the first time around (since we should always
+ // receive the same list of classifiers)
+ if ( sorted === null )
+ {
+ sorted = _self._sortClasses( classes );
+ }
+
+ for ( var i in sorted )
+ {
+ var c = sorted[ i ];
+
+ if ( cache[ c ] === undefined )
+ {
+ cache[ c ] = classes[ c ].is;
+ _self._addClass( c );
+
+ added = true;
+ }
+
+ // no need to update if the status hasn't changed
+ if ( cache[ c ] === c.is )
+ {
+ continue;
+ }
+
+ var sc = _self._sanitizeName( c );
+ _self._markClass( sc, classes[ c ].is );
+ _self._updateIndexes( sc, classes[ c ].indexes );
+ }
+ }, 25 );
+ } );
+ },
+
+
+ 'private _addClass': function( cname )
+ {
+ var sc = this._sanitizeName( cname );
+
+ this._$list.append( $( '' )
+ .attr( 'id', ( '-class-' + sc ) )
+ .text( cname )
+ .append( $ ( '
' ) )
+ );
+ },
+
+
+ 'private _updateIndexes': function( cname, indexes )
+ {
+ this._$list.find( '#' + '-class-' + cname + ' > span' )
+ .text( JSON.stringify( indexes ) );
+ },
+
+
+ 'private _markClass': function( cname, is )
+ {
+ this._$list.find( '#' + '-class-' + cname ).toggleClass( 'is', is );
+ },
+
+
+ 'private _sanitizeName': function( cname )
+ {
+ return cname.replace( /:/, '-' );
+ },
+
+
+ 'private _sortClasses': function( classes )
+ {
+ var names = [];
+ for ( var c in classes )
+ {
+ names.push( c );
+ }
+
+ // sort the classifiers by name
+ return names.sort( function( a, b )
+ {
+ if ( a < b ) return -1;
+ else if ( a > b ) return 1;
+ else return 0;
+ } );
+ },
+
+
+ /**
+ * Create tab content
+ *
+ * @return {jQuery} tab content
+ */
+ 'private _createContent': function()
+ {
+ var _self = this,
+ $div = null;
+
+ // classifier list with filter box
+ this._$list = $( '
' )
+ .addClass( 'class-listing' )
+ .append( $( '
' )
+ .keyup( function()
+ {
+ var reg = new RegExp( this.value );
+ $( 'div.class-listing div' )
+ .toggle( true )
+ .filter( function() { return !$( this ).text().match( reg ) } )
+ .toggle( false );
+ }
+ ) );
+
+ return $div = $( '
' )
+ .append( $( '
' )
+ .text( 'Toggle Dock' )
+ .toggle(
+ function()
+ {
+ $( '#content' ).append(
+ _self._$list.addClass( 'dock' ).detach()
+ );
+ },
+ function()
+ {
+ $div.append(
+ _self._$list.removeClass( 'dock' ).detach()
+ );
+ }
+ )
+ )
+ .append( $( ' ' )
+ .attr( 'type', 'checkbox' )
+ .attr( 'id', 'classify-nohide' )
+ .change( function()
+ {
+ // trigger toggle event
+ _self.emit( 'classifyNoHideToggle',
+ $( this ).is( ':checked' )
+ );
+ } )
+ )
+ .append( $( '' )
+ .attr( 'for', 'classify-nohide' )
+ .text( 'Inhibit field hiding by classifications' )
+ )
+ .append( this._$list );
+ }
+
+
+} );
+
diff --git a/src/client/debug/ClientDebug.js b/src/client/debug/ClientDebug.js
new file mode 100644
index 0000000..1694c5f
--- /dev/null
+++ b/src/client/debug/ClientDebug.js
@@ -0,0 +1,295 @@
+/**
+ * Contains ClientDebug class
+ *
+ * Copyright (C) 2017 LoVullo Associates, Inc.
+ *
+ * This file is part of the Liza Data Collection Framework
+ *
+ * Liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ Client = require( '../Client' ),
+ ClientDebugDialog = require( './ClientDebugDialog' );
+
+
+/**
+ * Facade for the debug dialog
+ *
+ * The tight coupling is intentional.
+ */
+module.exports = Class( 'ClientDebug',
+{
+ /**
+ * Name of flag that will determine whether the dialog should auto-load
+ * @type {string}
+ */
+ 'private const AUTOLOAD_FLAG': 'devdialog-autoload',
+
+ /**
+ * Name of flag that will determine whether the debugger should be invoked
+ * on client-handled errors
+ *
+ * @type {string}
+ */
+ 'private const ERRDEBUG_FLAG': 'devdialog-errdebug',
+
+ /**
+ * Program client to debug
+ * @type {Client}
+ */
+ 'private _client': null,
+
+ /**
+ * Developer dialog
+ * @type {ClientDebugDialog}
+ */
+ 'private _dialog': null,
+
+ /**
+ * Persistent session storage
+ * @type {Storage}
+ */
+ 'private _storage': null,
+
+
+ /**
+ * Initialize debugger with the program client instance to debug
+ *
+ * @param {Client} client program client to debug
+ * @param {Storage} storage [persistent] session storage
+ */
+ __construct: function( client, storage )
+ {
+
+ if ( !( Class.isA( Client, program_client ) ) )
+ {
+ throw TypeError( 'Expected Client, given ' + program_client );
+ }
+
+ var _self = this,
+ staging = this.getStagingBucketFrom( client );
+
+ this._client = client;
+ this._storage = storage;
+
+ this._dialog = ClientDebugDialog( client, staging )
+ .addTab(
+ require( './BucketClientDebugTab' )()
+ .on( 'fieldOverlayToggle', function( value )
+ {
+ client.$body.toggleClass( 'show-field-overlay', value );
+ } )
+ )
+ .addTab( require( './AssertionClientDebugTab' )() )
+ .addTab( require( './CalcClientDebugTab' )() )
+ .addTab(
+ require( './ClassifyClientDebugTab' )()
+ .on( 'classifyNoHideToggle', function( value )
+ {
+ // XXX this should be encapsulated
+ client.$body.toggleClass( 'show-hidden-fields', value );
+ } )
+ )
+ .on( 'autoloadToggle', function( value )
+ {
+ _self.setAutoloadFlag( value );
+ } )
+ .on( 'errDebugToggle', function( value )
+ {
+ _self.setErrorDebugFlag( value );
+ } )
+ ;
+
+ this._bindKeys();
+ },
+
+
+ /**
+ * Initialize dialog in background to begin gathering data
+ *
+ * This is useful to ensure data from the beginning of the page load is
+ * gathered without pestering the user.
+ *
+ * @return {ClientDebug} self
+ */
+ 'public bgInit': function()
+ {
+ // autoload only if the flag has been set
+ if ( this._hasAutoloadFlag() )
+ {
+ this._dialog
+ .show( false )
+ .setAutoloadStatus( true );
+ }
+
+ return this;
+ },
+
+
+ /**
+ * Determines if the dialog should be displayed automatically
+ *
+ * All session data is stored as as a string.
+ *
+ * @return {boolean} true if autoload should be performed, otherwise false
+ */
+ 'private _hasAutoloadFlag': function()
+ {
+ // if we do not support session storage, then the flag cannot be set
+ if ( !( this._storage ) )
+ {
+ return false;
+ }
+
+ var flag = this._storage.getItem( this.__self.$( 'AUTOLOAD_FLAG' ) );
+ return ( flag === "true" );
+ },
+
+
+ /**
+ * Sets whether the dialog should be loaded in the background on page load
+ *
+ * @param {boolean} val whether to load in background on page load
+ *
+ * @return {ClientDebug} self
+ */
+ 'public setAutoloadFlag': function( val )
+ {
+ // only set the flag if we support session storage
+ if ( this._storage )
+ {
+ this._storage.setItem( this.__self.$( 'AUTOLOAD_FLAG' ), !!val );
+ }
+
+ return this;
+ },
+
+
+ 'public setErrorDebugFlag': function( val )
+ {
+ // only set the flag if we support session storage
+ if ( this._storage )
+ {
+ this._storage.setItem( this.__self.$( 'ERRDEBUG_FLAG' ), !!val );
+ }
+
+ return this;
+ },
+
+
+ 'public hasErrorDebugFlag': function()
+ {
+ // if we do not support session storage, then the flag cannot be set
+ if ( !( this._storage ) )
+ {
+ return false;
+ }
+
+ var flag = this._storage.getItem( this.__self.$( 'ERRDEBUG_FLAG' ) );
+ return ( flag === "true" );
+ },
+
+
+ /**
+ * Toggle display of developer dialog
+ *
+ * @return {ClientDebug} self
+ */
+ 'public toggle': function()
+ {
+ this._dialog
+ .toggle()
+ .setErrorDebugStatus( this.hasErrorDebugFlag() );
+
+ return this;
+ },
+
+
+ /**
+ * CTRL+SHIFT+D will display
+ *
+ * @return {undefined}
+ */
+ 'private _bindKeys': function()
+ {
+ var _self = this,
+ ctrl = false,
+ shift = false;
+
+ $( document ).keydown( function( event )
+ {
+ switch ( event.which )
+ {
+ case 16:
+ shift = true;
+ break;
+
+ case 17:
+ ctrl = true;
+ break;
+
+ // d
+ case 68:
+ if ( shift && ctrl )
+ {
+ // show developer dialog
+ _self.toggle();
+ return false;
+ }
+ }
+ } )
+ .keyup( function( event )
+ {
+ switch ( event.which )
+ {
+ case 16:
+ shift = false;
+ break;
+
+ case 17:
+ ctrl = false;
+ break;
+ }
+ } );
+ },
+
+
+ /**
+ * Returns the staging bucket from the given client instance
+ *
+ * This breaks encapsulation! Use it for debugging only.
+ *
+ * @param {Client} program_client client from which to retrieve staging
+ * bucket
+ *
+ * @return {StagingBucket} staging bucket associated with given client
+ */
+ 'public getStagingBucketFrom': function( program_client )
+ {
+ if ( !( Class.isA( Client, program_client ) ) )
+ {
+ throw Error( 'Expected Client, given ' + program_client );
+ }
+
+ var retval;
+ program_client.getQuote().visitData( function( bucket )
+ {
+ retval = bucket;
+ } );
+
+ return retval;
+ }
+} );
+
diff --git a/src/client/debug/ClientDebugDialog.js b/src/client/debug/ClientDebugDialog.js
new file mode 100644
index 0000000..3dcf915
--- /dev/null
+++ b/src/client/debug/ClientDebugDialog.js
@@ -0,0 +1,351 @@
+/**
+ * Contains ClientDialog class
+ *
+ * Copyright (C) 2017 LoVullo Associates, Inc.
+ *
+ * This file is part of the Liza Data Collection Framework
+ *
+ * Liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ EventEmitter = require( 'events' ).EventEmitter,
+ Client = require( '../Client' ),
+ ClientDebugTab = require( './ClientDebugTab' );
+
+
+/**
+ * Provides runtime debugging options
+ *
+ * Everything in here can be done from the console. This just makes life easier.
+ */
+module.exports = Class( 'ClientDebugDialog' )
+ .extend( EventEmitter,
+{
+ /**
+ * Program client
+ * @type {ProgramClient}
+ */
+ 'private _client': null,
+
+ /**
+ * Staging bucket associated with the given client
+ * @type {StagingBucket}
+ */
+ 'private _staging': null,
+
+ /**
+ * Dialog to be displayed
+ * @type {jQuery}
+ */
+ 'private _$dialog': null,
+
+ /**
+ * Whether the dialog is visible
+ * @type {boolean}
+ */
+ 'private _dialogVisible': false,
+
+ /**
+ * List of tabs to be displayed
+ * @type {Object.}
+ */
+ 'private _tabs': {},
+
+
+ /**
+ * Initialize client debugger with a client instance
+ *
+ * @param {Client} program_client client instance to debug
+ * @param {StagingBucket} staging bucket
+ */
+ __construct: function( program_client, staging, storage )
+ {
+ if ( !( Class.isA( Client, program_client ) ) )
+ {
+ throw TypeError( 'Expected Client, given ' + program_client );
+ }
+
+ this._client = program_client;
+ this._staging = staging;
+ },
+
+
+ /**
+ * Add a tab to the dialog
+ *
+ * @param {ClientDebugTab} tab tab to add
+ *
+ * @return {ClientDebugDialog} self
+ */
+ 'public addTab': function( tab )
+ {
+ if ( !( Class.isA( ClientDebugTab, tab ) ) )
+ {
+ throw TypeError( 'Expected ClientDebugTab, given ' + tab );
+ }
+
+ this._tabs[ 'dbg-' + tab.getTitle().replace( / /, '-' ) ] = tab;
+
+ return this;
+ },
+
+
+ /**
+ * Display developer dialog
+ *
+ * If a dialog had been previously displayed (in this instance), it will be
+ * re-opened.
+ *
+ * @param {boolean=} fg if set to false, initialize the dialog in the
+ * background, but do not display
+ *
+ * @return {ClientDebugTab} self
+ */
+ 'public show': function( fg )
+ {
+ this._$dialog = ( this._$dialog || this._createDialog() );
+
+ if ( fg !== false )
+ {
+ this._$dialog.dialog( 'open' );
+ }
+
+ return this;
+ },
+
+
+ /**
+ * Hide developer dialog
+ *
+ * @return {ClientDebugTab} self
+ */
+ 'public hide': function()
+ {
+ this._$dialog && this._$dialog.dialog( 'close' );
+ return this;
+ },
+
+
+ /**
+ * Toggle developer dialog
+ *
+ * @return {ClientDebugTab} self
+ */
+ 'public toggle': function()
+ {
+ return ( ( this._dialogVisible ) ? this.hide() : this.show() );
+ },
+
+
+ /**
+ * Sets the autoload toggle value (display only)
+ *
+ * @param {boolean} val whether or not autoload is enabled
+ *
+ * @return {ClientDebugDialog} self
+ */
+ 'public setAutoloadStatus': function( val )
+ {
+ this._$dialog.find( '#devdialog-autoload' ).attr( 'checked', !!val );
+ return this;
+ },
+
+ 'public setErrorDebugStatus': function( val )
+ {
+ this._$dialog.find( '#devdialog-errdebug' ).attr( 'checked', !!val );
+ return this;
+ },
+
+
+ /**
+ * Create the dialog
+ *
+ * @return {jQuery} dialog
+ */
+ 'private _createDialog': function()
+ {
+ var _self = this,
+ $tabs;
+
+ this._showSidebarWarning();
+
+ return $( '' )
+ .append( $( '
' ).text(
+ "Everything in this dialog can be done via the console. " +
+ "This simply exists to make life easier."
+ ) )
+ .append( $( '
' ).html(
+ "To view this dialog: " +
+ "one can use the key combination " +
+ "CTRL+SHIFT+D , or getProgramDebug()" +
+ ".toggle()
from the console. The latter may " +
+ "also be used even if the user is not logged in internally."
+ ) )
+ .append( this._createAutoloadToggle() )
+ .append( $tabs = this._createTabs() )
+ .dialog( {
+ title: "Developer Dialog",
+ modal: false,
+ width: 800,
+ height: 600,
+
+ autoOpen: false,
+
+ open: function()
+ {
+ $tabs.tabs();
+ _self._dialogVisible = true;
+ },
+
+ close: function()
+ {
+ _self._dialogVisible = false;
+ }
+ } );
+ },
+
+
+ /**
+ * Create autoload toggle elements
+ *
+ * When toggled, the autoloadToggle event will be triggered with its value.
+ *
+ * @return {undefined}
+ */
+ 'private _createAutoloadToggle': function()
+ {
+ var _self = this;
+
+ return $( '
' )
+ .append( $( '
' )
+ .append( $( '
' )
+ .attr( 'type', 'checkbox' )
+ .attr( 'id', 'devdialog-autoload' )
+ .change( function()
+ {
+ // trigger toggle event
+ _self.emit( 'autoloadToggle',
+ $( this ).is( ':checked' )
+ );
+ } )
+ )
+ .append( $( '
' )
+ .attr( 'for', 'devdialog-autoload' )
+ .text( 'Load automatically in background on page load' )
+ )
+ )
+ .append( $( '' )
+ .append( $( '
' )
+ .attr( 'type', 'checkbox' )
+ .attr( 'id', 'devdialog-errdebug' )
+ .change( function()
+ {
+ // trigger toggle event
+ _self.emit( 'errDebugToggle',
+ $( this ).is( ':checked' )
+ );
+ } )
+ )
+ .append( $( '
' )
+ .attr( 'for', 'devdialog-errdebug' )
+ .text( 'Execute debugger on client-handled errors' )
+ )
+ );
+ },
+
+
+ // XXX: This doesn't belong in this class!
+ 'private _showSidebarWarning': function()
+ {
+ $( '#sidebar-help-text' ).after( $( '' )
+ .attr( 'id', 'dev-dialog-perf-warning' )
+ .text(
+ 'Developer dialog is monitoring events on this page. ' +
+ 'Performance may be negatively impacted.'
+ )
+ );
+ },
+
+
+ /**
+ * Generate tabs and their content
+ *
+ * The developer dialog contains a tab for each major section.
+ *
+ * The div that is returned can be used by jQuery UI for tab styling. It is
+ * important that you do not attempt to style the tabs until after it has
+ * been appended to the DOM, or jQuery UI will fail to properly process it.
+ *
+ * @return {jQuery} tab div
+ */
+ 'private _createTabs': function()
+ {
+ var $tabs = $( '
' )
+ .attr( 'id', 'dbg-tabs' )
+ .append( this._getTabUl() )
+ ;
+
+ this._appendTabContent( $tabs );
+
+ return $tabs;
+ },
+
+
+ /**
+ * Generate tab ul element
+ *
+ * @return {jQuery} tab ul element
+ */
+ 'private _getTabUl': function()
+ {
+ var $ul = $( '
' );
+
+ for ( var i in this._tabs )
+ {
+ $ul.append(
+ $( '' ).append(
+ $( '' )
+ .attr( 'href', ( '#' + i ) )
+ .text( this._tabs[ i ].getTitle() )
+ )
+ );
+ }
+
+ return $ul;
+ },
+
+
+ /**
+ * Appends the content of each of the tabs to the provided tab div
+ *
+ * @param {jQuery} $tabs tab div to append to
+ *
+ * @return {undefined}
+ */
+ 'private _appendTabContent': function( $tabs )
+ {
+ for ( var i in this._tabs )
+ {
+ $tabs.append(
+ $( '' )
+ .attr( 'id', i )
+ .append( this._tabs[ i ].getContent(
+ this._client, this._staging
+ ) )
+ );
+ }
+ }
+} );
+
diff --git a/src/client/debug/ClientDebugTab.js b/src/client/debug/ClientDebugTab.js
new file mode 100644
index 0000000..fd7715a
--- /dev/null
+++ b/src/client/debug/ClientDebugTab.js
@@ -0,0 +1,48 @@
+/**
+ * Contains ClientDebugTab interface
+ *
+ * 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 .
+ */
+
+var Interface = require( 'easejs' ).Interface;
+
+
+/**
+ * Represents a tab within the developer dialog
+ */
+module.exports = Interface( 'ClientDebugTab',
+{
+ /**
+ * Retrieve tab title
+ *
+ * @return {string} tab title
+ */
+ 'public getTitle': [],
+
+
+ /**
+ * Retrieve tab content
+ *
+ * @param {Client} client active client being debugged
+ * @param {StagingBucket} bucket bucket to reference for data
+ *
+ * @return {jQuery|string} tab content
+ */
+ 'public getContent': [ 'client', 'bucket' ]
+} );
+