From 215869e1ead006c5b298279c24a0b8bd016dff89 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Mon, 30 Nov 2015 11:37:41 -0500 Subject: [PATCH 01/11] Liberate StepUi This has a lot of rough code in it, much of which I do not approve of. But it'll never be liberated if I make everything pristine beforehand! --- src/ui/step/StepUi.js | 1178 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1178 insertions(+) create mode 100644 src/ui/step/StepUi.js diff --git a/src/ui/step/StepUi.js b/src/ui/step/StepUi.js new file mode 100644 index 0000000..13e6ad9 --- /dev/null +++ b/src/ui/step/StepUi.js @@ -0,0 +1,1178 @@ +/** + * General UI logic for steps + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - References to "quote" should be replaced with generic terminology + * representing a document. + * - Dependencies need to be liberated: + * - ElementStyler; + * - BucketDataValidator. + * - Global references (e.g. jQuery) must be removed. + * - Checkbox-specific logic must be extracted. + * - This class is doing too much. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + EventEmitter = require( 'events' ).EventEmitter; + + +/** + * Handles display of a step + * + * @return {StepUi} + */ +module.exports = Class( 'StepUi' ) + .extend( EventEmitter, +{ + /** + * Called after step data is processed + * @type {string} + */ + 'const EVENT_POST_PROCESS': 'postProcess', + + /** + * Called after step is appended to the DOM + * @type {string} + */ + 'const EVENT_POST_APPEND': 'postAppend', + + /** + * Called when data is changed (question value changed) + * @type {string} + */ + 'const EVENT_DATA_CHANGE': 'dataChange', + + /** + * Raised when an index is added to a group (e.g. row addition) + * @type {string} + */ + 'const EVENT_INDEX_ADD': 'indexAdd', + + /** + * Raised when an index is reset in a group (rather than removed) + * @type {string} + */ + 'const EVENT_INDEX_RESET': 'indexReset', + + /** + * Raised when an index is removed from a group (e.g. row deletion) + * @type {string} + */ + 'const EVENT_INDEX_REMOVE': 'indexRemove', + + /** + * Represents an action trigger + * @type {string} + */ + 'const EVENT_ACTION': 'action', + + /** + * Triggered when the step is active + * @type {boolean} + */ + 'const EVENT_ACTIVE': 'active', + + + /** + * Instance of step to style + * @type {Step} + */ + step: null, + + /** + * Step data (DOM representation) + * @type {jQuery} + */ + $content: null, + + /** + * Element styler + * @type {ElementStyler} + */ + styler: null, + + /** + * Whether the step should be repopulated with bucket data upon display + * @type {boolean} + */ + invalid: false, + + /** + * Stores group objects representing each group + * @type {Object.} + */ + groups: {}, + + /** + * Flag to let system know its currently saving the step + * @type {boolean} + */ + saving: false, + + /** + * Format bucket data for display + * @type {BucketDataValidator} + */ + 'private _formatter': null, + + /** + * Stores references to which group fields belong to + * @type {Object} + */ + 'private _fieldGroup': {}, + + /** + * Hash of answer contexts (jQuery) for quick lookup + * @type {Object} + */ + 'private _answerContext': {}, + + /** + * Hash of static answer indexes, if applicable + * @type {Object} + */ + 'private _answerStaticIndex': {}, + + /** + * Whether the step is the currently active (visible) step + * @type {boolean} + */ + 'private _active': false, + + /** + * Whether the step is locked (all elements disabled) + * @type {boolean} + */ + 'private _locked': false, + + 'private _forceAnswerUpdate': null, + + + /** + * Initializes StepUi object + * + * The data_get function is used to retrieve the step data, allowing the + * logic to be abstracted from the Step implementation. It must accept two + * arguments: the id of the step to load, and a callback function, as the + * operation is likely to be asynchronous. + * + * A callback function is used for when the step is ready to be used. This + * is done because the loading of the data is (ideally_ an asynchronous + * operation. This operation is performed in the constructor, to ensure + * that each instance of a Step class has data associated with it. + * Therefore, the object will be instantiated, but the data_get function + * will still be running in the background. The step should not be used + * until the data loading is complete. That is when the callback will be + * triggered. + * + * @return {undefined} + */ + 'public __construct': function( + step, + styler, + formatter + ) + { + this.step = step; + this.styler = styler; + this._formatter = formatter; + }, + + + /** + * Initializes step + * + * @return {undefined} + */ + 'public init': function() + { + var _self = this; + + this.step.on( 'updateQuote', function() + { + _self._hookBucket(); + _self._processAnswerFields(); + _self.invalidate(); + }); + + return this; + }, + + + 'public initGroupFieldData': function() + { + for ( var group in this.groups ) + { + var groupui = this.groups[ group ], + fields = groupui.group.getExclusiveFieldNames(); + + for ( var i in fields ) + { + this._fieldGroup[ fields[ i ] ] = groupui; + } + } + }, + + + /** + * Sets content to be displayed + * + * @param {jQuery} $content content to display + * + * @return {StepUi} self + */ + 'public setContent': function( $content ) + { + this.$content = $content; + + this._processAnswerFields(); + + return this; + }, + + + /** + * Returns the step that this object is styling + * + * @return lovullo.program.Step + */ + getStep: function() + { + return this.step; + }, + + + /** + * Returns the generated step content as a jQuery object + * + * @return jQuery generated step content + */ + getContent: function() + { + return this.$content; + }, + + + /** + * Detaches the step from the DOM + * + * @return StepUi self to allow for method chaining + */ + detach: function() + { + if ( this.$content instanceof jQuery ) + { + this.$content.detach(); + } + + return this; + }, + + + /** + * Will mark the step as dirty when the content is changed and update the + * staging bucket + * + * @return undefined + */ + setDirtyTrigger: function() + { + var step = this; + this.$content.bind( 'change.program', function( event ) + { + // do nothing if the step is locked + if ( step._locked ) + { + return; + } + + // get the name of the altered element + var $element = step.styler.getNameElement( $( event.target ) ), + name = $element.attr( 'name' ), + val = $element.val(); + + if ( !( name ) ) + { + // rogue field not handled by the framework! + return; + } + + // remove the trailing square brackets from the name + name = name.substring( 0, ( name.length - 2 ) ); + + // get its index + var $elements = step.$content.find( "[name='" + name + "[]']" ), + index = $elements.index( $element ); + + + // todo: this is temporary to allow noyes and legacy radios to work. + if ( $element.hasClass( 'legacyradio' ) ) + { + index = 0; + } + else if ( $element.attr( 'type' ) === 'radio' || $element.attr( 'type' ) === 'checkbox' ) + { + // if it's not checked, then this isn't the radio we're + // interested in. Sorry! + if ( !( $element.attr( 'checked' ) ) ) + { + $element.attr( 'checked', true ) + + return; + } + + // 2 in this instance is the yes/no group length. + var group_length = $element.attr( 'data-question-length' ) ? $element.attr( 'data-question-length' ) : 2; + + index = Math.floor( index / group_length ); + } + + var values = {}; + values[name] = []; + values[name][index] = val; + + + // update our bucket with this new data + step.emit( step.__self.$('EVENT_DATA_CHANGE'), values ); + }); + + // @note This is a hack. In IE8, checkbox change events don't properly fire. + this.$content.delegate('input[type="checkbox"]', 'click', function () { jQuery(this).change(); } ); + }, + + + /** + * Prepares answer fields + * + * This method will populate the answer fields with values already in the + * bucket and hook the bucket so that future updates will also be reflected. + * + * @return {undefined} + */ + _processAnswerFields: function() + { + var _self = this, + bucket = this.step.getBucket(); + + this._prepareAnswerContexts(); + + // perform initial update for the step when we are first created, then + // hook everything else (we do not need the hooks before then, as we + // will be forcefully updating the step with values) + this.__inst.once( 'postAppend', function() + { + var forceupdate = false; + + // when the value we're watching is updated in the bucket, update + // the displayed value + var doUpdate; + bucket.on( 'stagingUpdate', doUpdate = function( data ) + { + // defer updates unless we're active + if ( !( _self._active ) ) + { + if ( forceupdate === false ) + { + forceupdate = true; + + // use __inst until we get the ease.js issue sorted out + // with extending non-class protoypes + _self.__inst.once( _self.__self.$('EVENT_ACTIVE'), function() + { + doUpdate( bucket.getData() ); + forceupdate = false; + } ); + } + + return; + } + + // give the UI a chance to update the DOM; otherwise, the + // answer elements we update may no longer be used (this also + // has performance benefits since it allows repainting before + // potentially heavy processing) + setTimeout( function() + { + _self._updateAnswerFieldData( data ); + }, 25 ); + } ); + + doUpdate( bucket.getData() ); + + // set the values when a row is added + _self.__inst.on( 'postAddRow', function( index ) + { + var data = bucket.getData(); + + for ( var name in _self._answerContext ) + { + var value = ( data[ name ] || {} )[ index ]; + + if ( value === undefined ) + { + continue; + } + + _self._updateAnswer( name, index, value ); + } + } ); + + this._forceAnswerUpdate = doUpdate; + } ); + }, + + + /** + * Update DOM answer fields with respective datum in diff DATA + * + * Only watched answer fields are updated. The update is performed on + * the discovered context during step initialization. + * + * @param {Object} data bucket diff + * + * @return {undefined} + */ + 'private _updateAnswerFieldData': function( data ) + { + // we only care if the data we're watching has been + // changed + for ( var name in data ) + { + if ( !( this._answerContext[ name ] ) ) + { + continue; + } + + var curdata = data[ name ], + si = this._answerStaticIndex[ name ], + i = curdata.length; + + // static index override + if ( !( isNaN( si ) ) ) + { + // update every index on the DOM + i = this.styler.getAnswerElementByName( + name, undefined, undefined, + this._answerContext[ name ] + ).length; + } + + while ( i-- ) + { + var index = ( isNaN( si ) ) ? i : si, + value = curdata[ index ]; + + // take into account diff; note that if one of + // them is null, that means it has been removed + // (and will therefore not be displayed), so we + // don't have to worry about clearing out a value + if ( ( value === undefined ) || ( value === null ) ) + { + continue; + } + + this._updateAnswer( name, i, curdata[ index ] ); + } + } + }, + + + 'private _prepareAnswerContexts': function() + { + var _self = this; + + // get a list of all the answer elements + this.$content.find( 'span.answer' ).each( function() + { + var $this = $( this ), + ref_id = $this.attr( 'data-answer-ref' ), + index = $this.attr( 'data-answer-static-index' ); + + // clear the value (which by default contains the name of the answer + // field) + $this.text( '' ); + + // if we've already found an element for this ref, then it is + // referenced in multiple places; simply store the context as the + // entire step + if ( _self._answerContext[ ref_id ] ) + { + _self._answerContext[ ref_id ] = _self.$content; + return; + } + + // store the parent fieldset as our context to make DOM lookups a + // bit more performant + _self._answerContext[ ref_id ] = $( this ).parents( 'fieldset' ); + _self._answerStaticIndex[ ref_id ] = ( index ) + ? +index + : NaN; + } ); + }, + + + /** + * Update the display of an answer field + * + * The value will be styled before display. + * + * @param {string} name field name + * @param {number} index index to update + * @param {string} value answer value (unstyled) + * + * @return {undefined} + */ + 'private _updateAnswer': function( name, index, value ) + { + var $element = this.styler.getAnswerElementByName( + name, index, null, ( this._answerContext[ name ] || this.$content ) + ); + + var i = $element.length; + if ( i > 0 ) + { + while( i-- ) + { + var styled = this.styler.styleAnswer( name, value ), + allow_html = $element[ i ] + .attributes[ 'data-field-allow-html' ] || {}; + + if ( allow_html.value === 'true' ) + { + $element.html( styled ); + } + else + { + $element.text( styled ); + } + + var id = $element[ i ].attributes['data-field-name']; + if ( !id ) + { + continue; + } + + this.emit( 'displayChanged', id.value, index, value ); + } + } + }, + + + /** + * Monitors the bucket for data changes and updates the elements accordingly + * + * @return undefined + */ + _hookBucket: function() + { + var _self = this; + + // when the bucket data is updated, update the element to reflect the + // value + this.step.getBucket().on( 'stagingUpdate', function( data ) + { + // if we're saving (filling the bucket), this is pointless + if ( _self.saving ) + { + return; + } + + var data_fmt = _self._formatter.format( data ); + + for ( var name in _self.step.getExclusiveFieldNames() ) + { + // if this data hasn't changed, then ignore the element + if ( data_fmt[ name ] === undefined ) + { + continue; + } + + // update each of the elements (it is important to update the + // number of elements on the screen, not the number of elements + // in the data array, since the array is a diff and will contain + // information regarding removed elements) + var data_len = data_fmt[ name ].length; + + for ( var index = 0; index < data_len; index++ ) + { + var val = data_fmt[ name ][ index ]; + + // if the value is not set or has been removed (remember, + // we're dealing with a diff), then ignore it + if ( ( val === undefined ) || ( val === null ) ) + { + continue; + } + + // set the value of the element using the appropriate group + // (for performance reasons, so we don't scan the whole DOM + // for the element) + _self.getElementGroup( name ).setValueByName( + name, index, val, false + ); + } + } + }); + }, + + + /** + * Called after the step is appended to the DOM + * + * This method will simply loop through all the groups that are a part of + * this step and call their postAppend() methods. If the group does not have + * an element id, it will not function properly. + * + * @return StepUi self to allow for method chaining + */ + postAppend: function() + { + // let the styler do any final styling + this.styler.postAppend( this.$content.parent() ); + + // If we have data in the bucket (probably loaded from the server), show + // it. We use a delay to ensure that the UI is ready for the update. In + // certain cases (such as with tabs), the UI may not have rendered all + // the elements. + this.emptyBucket( null, true ); + + // monitor bucket changes and update the elements accordingly + this._hookBucket(); + + this.emit( this.__self.$('EVENT_POST_APPEND') ); + + return this; + }, + + + /** + * Empties the bucket into the step (filling the fields with its values) + * + * @param Function callback function to call when bucket has been emptied + * + * @return StepUi self to allow for method chaining + */ + emptyBucket: function( callback, delay ) + { + delay = ( delay === undefined ) ? false : true; + + var _self = this, + bucket = this.getStep().getBucket(), + fields = {}; + + // first, clear all the elements + for ( var group in this.groups ) + { + this.groups[group].preEmptyBucket( bucket ); + } + + // then update all the elements with the form values in the bucket + // (using setTimeout allows the browser UI thread to process repaints, + // added elements, etc, which will ensure that the elements will be + // available to empty into) + var empty = function() + { + var data = {}; + + for ( var name in _self.step.getExclusiveFieldNames() ) + { + data[ name ] = bucket.getDataByName( name ); + } + + // format the data (in-place, since we're the only ones using this + // object) + _self._formatter.format( data, true ); + + for ( var name in data ) + { + var values = data[ name ], + i = values.length; + + while ( i-- ) + { + // set the data and do /not/ trigger the change event + var group = _self.getElementGroup( name ); + if ( !group ) + { + // This should not happen (see FS#13653); emit an error + // and continue processing in the hopes that we can + // display most of the data + this.emit( 'error', Error( + "Unable to locate group for field `" + name + "'" + ) ); + + continue; + } + + var id = _self.getElementGroup( name ).setValueByName( + name, i, values[ i ], false + ); + } + } + + // answers are normally only updated on bucket change + _self._forceAnswerUpdate( bucket.getData() ); + + if ( callback instanceof Function ) + { + callback.call( _self ); + } + }; + + // either execute immediately or set a timer (allowing the UI to update) + // if a delay was requested + if ( delay ) + { + setTimeout( empty, 25 ); + } + else + { + empty(); + } + + return this; + }, + + + /** + * Resets a step to its previous state or hooks the event + * + * @param Function callback function to call when reset is complete + * + * @return StepUi self to allow for method chaining + */ + reset: function( callback ) + { + var step = this; + + this.getStep().getBucket().revert(); + + if ( typeof callback === 'function' ) + { + callback.call( this ); + } + + // clear invalidation flag + this.invalid = false; + + return this; + }, + + + /** + * Returns whether all the elements in the step contain valid data + * + * @return Boolean true if all elements are valid, otherwise false + */ + isValid: function( cmatch ) + { + return this.step.isValid( cmatch ); + }, + + + /** + * Returns the id of the first failed field if isValid() failed + * + * Note that the returned element may not be visible. Visible elements will + * take precidence --- that is, invisible elements will be returned only if + * there are no more invalid visible elements, except in the case of + * required fields. + * + * @param {Object} cmatch cmatch data + * + * @return String id of element, or empty string + */ + 'public getFirstInvalidField': function( cmatch ) + { + var $element = this.$content.find( '.invalid_field[data-field-name]:visible:first' ); + + if ( $element.length === 0 ) + { + $element = this.$content.find( '.invalid_field[data-field-name]:first' ); + } + + var name = $element.attr( 'data-field-name' ); + + // no invalid fields, so what about missing required fields? + if ( !name ) + { + // append 'true' indiciating that this is a required field check + var result = this.step.getNextRequired( cmatch ); + if ( result !== null ) + { + result.push( true ); + } + + return result; + } + + // return the element name and index + return [ + name, + + // calculate index of this element + this.$content.find( '[data-field-name="' + name + '"]' ) + .index( $element ), + + // not a required field failure + false + ]; + }, + + + /** + * Scrolls to the element identified by the given id + * + * @param {string} field name of field to scroll to + * @param {number} i index of field to scroll to + * @param {boolean} show_message whether to show the tooltip + * @param {string} message tooltip message to display + * + * @return {StepUi} self to allow for method chaining + */ + 'public scrollTo': function( field, i, show_message, message ) + { + show_message = ( show_message === undefined ) ? true : !!show_message; + + if ( !( field ) || ( i < 0 ) || i === undefined ) + { + // cause may be empty + var cause = this.step.getValidCause(); + + this.emit( 'error', + Error( + 'Could not scroll: no field/index provided' + + ( ( cause ) + ? ' (cause: ' + cause + ')' + : '' + ) + ) + ); + } + + var index = this.styler.getProperIndex( field, i ), + $element = this.styler.getWidgetByName( field, index ); + + // if the element couldn't be found, then this is useless + if ( $element.length == 0 ) + { + this.emit( 'error', + Error( + 'Could not scroll: could not locate ' + field + '['+i+']' + ) + ); + } + + // allow the groups to preprocess the scrolling + for ( var group in this.groups ) + { + this.groups[ group ].preScrollTo( field, index ); + } + + // is the element visible now that we've given the groups a chance to + // display it? + if ( $element.is( ':visible' ) !== true ) + { + // fail; don't bother scrolling + this.emit( 'error', Error( + 'Could not scroll: element ' + field + ' is not visible' + ) ); + } + + // scroll to just above the first invalid question so that it + // may be fixed + var stepui = this; + this.$content.parent().scrollTo( $element, 100, { + offset: { top: -150 }, + onAfter: function() + { + // focus on the element and display the tooltip + stepui.styler.focus( $element, show_message, message ); + } + } ); + + return this; + }, + + + /** + * Invalidates the step, stating that it should be reset next time it is + * displayed + * + * Resetting the step will clear the invalidation flag. + * + * @return StepUi self to allow for method chaining + */ + invalidate: function() + { + this.invalid = true; + }, + + + /** + * Returns whether the step has been invalidated + * + * @return Boolean true if step has been invalidated, otherwise false + */ + isInvalid: function() + { + return this.invalid; + }, + + + /** + * Returns the GroupUi object associated with the given element name, if + * known + * + * @param {string} name element name + * + * @return {GroupUi} group if known, otherwise null + */ + getElementGroup: function( name ) + { + return this._fieldGroup[ name ] || null; + }, + + + /** + * Forwards add/remove hiding requests to groups + * + * @param {boolean} value whether to hide (default: true) + * + * @return {StepUi} self + */ + hideAddRemove: function( value ) + { + value = ( value !== undefined ) ? !!value : true; + + for ( var group in this.groups ) + { + var groupui = this.groups[ group ]; + if ( groupui.hideAddRemove instanceof Function ) + { + groupui.hideAddRemove( value ); + } + } + + return this; + }, + + + 'public preRender': function() + { + for ( var group in this.groups ) + { + this.groups[ group ].preRender(); + } + + return this; + }, + + + 'public visit': function( callback ) + { + // "invalid" means that the displayed data is not up-to-date + if ( this.invalid ) + { + this.emptyBucket(); + this.invalid = false; + } + + for ( var group in this.groups ) + { + this.groups[group].visit(); + } + + var _self = this, + cn = 0; + + // we perform async. processing, so ideally the caller should know + // when we're actually complete + var c = function() + { + if ( --cn === 0 ) + { + callback(); + } + }; + + this.step.eachSortedGroupSet( function( ids ) + { + cn++; + _self._sortGroups( ids, c ); + } ); + + if ( cn === 0 ) + { + callback && callback(); + } + + return this; + }, + + + 'private _sortGroups': function( ids, callback ) + { + // detach them all (TODO: a more efficient method could be to detach + // only the ones that aren not already in order, or ignore ones that + // would be hidden..etc) + var len = ids.length, + groups = []; + + if ( len === 0 ) + { + return; + } + + function getGroup( name ) + { + return document.getElementById( 'group_' + name ); + } + + var nodes = []; + for ( var i in ids ) + { + nodes[ i ] = getGroup( ids[ i ] ); + } + + var prev = nodes[ 0 ]; + if ( !( prev && prev.parentNode ) ) + { + return; + } + + var parent = prev.parentNode, + container = parent.parentNode, + i = len - 1; + + if ( !container ) + { + return; + } + + // to prevent DOM updates for each and every group move, detach the node + // that contains all the groups from the DOM; we'll re-add it after + // we're done + container.removeChild( parent ); + + // we can sort the groups in place without screwing up the DOM by simply + // starting with the last node and progressively inserting nodes + // before that element; we start at the end simply because there is + // Node#insertBefore, but no Node#insertAfter + setTimeout( function() + { + try + { + do + { + var group = nodes[ i ]; + + if ( !group ) + { + continue; + } + + // remove from DOM and reposition, unless we are already in + // position + if ( prev.previousSibling !== group ) + { + parent.removeChild( group ); + parent.insertBefore( group, prev ); + } + + prev = group; + } + while ( i-- ); + } + catch ( e ) + { + // we need to make sure we re-attach the container, so don't blow up + // if sorting fails + console.error && console.error( e, group, prev ); + } + + // now that sorting is complete, re-add the groups in one large DOM + // update + container.appendChild( parent ); + + callback(); + }, 25 ); + }, + + + /** + * Marks a step as active (or inactive) + * + * A step should be marked as active when it is the step that is currently + * accessible to the user. + * + * @param {boolean} active whether step is active + * + * @return {StepUi} self + */ + 'public setActive': function( active ) + { + active = ( active === undefined ) ? true : !!active; + + this._active = active; + + // notify each individual group of whether or not they are now active + for ( var id in this.groups ) + { + this.groups[ id ].setActive( active ); + } + + if ( active ) + { + this.emit( this.__self.$('EVENT_ACTIVE') ); + } + + return this; + }, + + + /** + * Lock/unlock a step (preventing modifications) + * + * If the lock status has changed, the elements on the step will be + * disabled/enabled respectively. + * + * @param {boolean} lock whether step should be locked + * + * @return {StepUi} self + */ + 'public lock': function( lock ) + { + lock = ( lock === undefined ) ? true : !!lock; + + // if the lock has changed, then alter the elements + if ( lock !== this._locked ) + { + for ( var name in this.step.getExclusiveFieldNames() ) + { + this.styler.disableField( name, undefined, lock ); + } + } + + this._locked = lock; + return this; + } +} ); From 1c4554dd0dabfa55b887f4f0a653ca05f02f5086 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Mon, 30 Nov 2015 11:43:28 -0500 Subject: [PATCH 02/11] Minor StepUi cleanup --- src/ui/step/StepUi.js | 45 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/ui/step/StepUi.js b/src/ui/step/StepUi.js index 13e6ad9..2f75ada 100644 --- a/src/ui/step/StepUi.js +++ b/src/ui/step/StepUi.js @@ -211,7 +211,7 @@ module.exports = Class( 'StepUi' ) _self._hookBucket(); _self._processAnswerFields(); _self.invalidate(); - }); + } ); return this; }, @@ -288,14 +288,15 @@ module.exports = Class( 'StepUi' ) /** - * Will mark the step as dirty when the content is changed and update the - * staging bucket + * Will mark the step as dirty when the content is changed and update + * the staging bucket * * @return undefined */ setDirtyTrigger: function() { var step = this; + this.$content.bind( 'change.program', function( event ) { // do nothing if the step is locked @@ -328,34 +329,46 @@ module.exports = Class( 'StepUi' ) { index = 0; } - else if ( $element.attr( 'type' ) === 'radio' || $element.attr( 'type' ) === 'checkbox' ) + else if ( $element.attr( 'type' ) === 'radio' + || $element.attr( 'type' ) === 'checkbox' + ) { // if it's not checked, then this isn't the radio we're // interested in. Sorry! if ( !( $element.attr( 'checked' ) ) ) { - $element.attr( 'checked', true ) + $element.attr( 'checked', true ); return; } // 2 in this instance is the yes/no group length. - var group_length = $element.attr( 'data-question-length' ) ? $element.attr( 'data-question-length' ) : 2; + var group_length = $element.attr( 'data-question-length' ) + ? $element.attr( 'data-question-length' ) + : 2; index = Math.floor( index / group_length ); } - var values = {}; - values[name] = []; - values[name][index] = val; + var values = {}; + values[ name ] = []; + values[ name ][ index ] = val; // update our bucket with this new data step.emit( step.__self.$('EVENT_DATA_CHANGE'), values ); - }); + } ); // @note This is a hack. In IE8, checkbox change events don't properly fire. - this.$content.delegate('input[type="checkbox"]', 'click', function () { jQuery(this).change(); } ); + this.$content.delegate( + 'input[type="checkbox"]', + 'click', + function () + { + // XXX: remove global + jQuery( this ).change(); + } + ); }, @@ -630,7 +643,7 @@ module.exports = Class( 'StepUi' ) ); } } - }); + } ); }, @@ -802,11 +815,15 @@ module.exports = Class( 'StepUi' ) */ 'public getFirstInvalidField': function( cmatch ) { - var $element = this.$content.find( '.invalid_field[data-field-name]:visible:first' ); + var $element = this.$content.find( + '.invalid_field[data-field-name]:visible:first' + ); if ( $element.length === 0 ) { - $element = this.$content.find( '.invalid_field[data-field-name]:first' ); + $element = this.$content.find( + '.invalid_field[data-field-name]:first' + ); } var name = $element.attr( 'data-field-name' ); From f9b3fa0622bff5a1ebfa97e7547d2da0b504fe76 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Mon, 30 Nov 2015 13:40:11 -0500 Subject: [PATCH 03/11] Liberate MultiSort Step dependency. --- src/sort/MultiSort.js | 136 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/sort/MultiSort.js diff --git a/src/sort/MultiSort.js b/src/sort/MultiSort.js new file mode 100644 index 0000000..37c7d35 --- /dev/null +++ b/src/sort/MultiSort.js @@ -0,0 +1,136 @@ +/** + * Sorting with multiple criteria + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - References to "quote" should be replaced with generic terminology + * representing a document. + * - Dependencies need to be liberated: + * - ElementStyler; + * - BucketDataValidator. + * - Global references (e.g. jQuery) must be removed. + * - Checkbox-specific logic must be extracted. + * - This class is doing too much. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class; + + +/** + * A simple recursive sorter with support for multiple criteria + * + * For simplicity's sake, this simply uses JavaScript's built-in sort() + * method using the supplied predicates. It then iterates through the sorted + * result and, using the supplied predicates, determines how the results + * should be grouped for sub-sorting. Because of this extra iteration, this + * isn't a very efficient algorithm, but it doesn't need to be for our + * purposes. + * + * Sorting is then performed recursively using the determined groups and the + * next provided predicate. + */ +module.exports = Class( 'MultiSort', +{ + /** + * Recursively sorts the given data using the provided predicates + * + * The predicate used depends on the depth of the sort. Results will be + * grouped according to similarity and recursively sorted until either no + * predicates remain or until the results are so dissimilar that they cannot + * be further sorted. + * + * @param {Array} data data to be sorted + * @param {Array.} preds predicates for arbitrary depth + * + * @return {Array} sorted data + */ + 'public sort': function( data, preds ) + { + // nothing can be done if we (a) don't have a length (non-array?), (b) + // the array is empty or (c) if we have no more preds + if ( ( preds.length === 0 ) || ( data.length < 2 ) ) + { + return data; + } + + var sorted = Array.prototype.slice.call( data ), + pred = preds[ 0 ], + next_preds = Array.prototype.slice.call( preds, 1 ); + + // sort according to the current predicate + sorted.sort( pred ); + + // if we cannot do any more sub-sorting, then simply return this sorted + // result + if ( preds.length === 1 ) + { + return sorted; + } + + return this._subsort( sorted, pred, next_preds ); + }, + + + /** + * Recursively sorts sorted results by grouping similar elements + */ + 'private _subsort': function( sorted, pred, next_preds ) + { + var i = 0, + len = sorted.length; + + var result = [], + cur = [ sorted[ 0 ] ]; + + + // note that this increment is intentional---at the bottom of this loop, + // we push the current element into the current group. Therefore, this + // extra step (past the end of the sorted array) ensures that the last + // element will be properly processed as part of the last group. The + // fact that we push undefined onto cur before returning is of no + // consequence. + while ( i++ < len ) + { + // if we are at the last element in the array OR if the current + // element is to be sorted differently than the previous, process + // the current group of elements before continuing + if ( ( i === len ) + || ( pred( sorted[ i - 1 ], sorted[ i ] ) !== 0 ) + ) + { + // the element is different; sub-sort + var sub = ( cur.length > 1 ) + ? this.sort( cur, next_preds ) + : cur; + + for ( var j in sub ) + { + result.push( sub[ j ] ); + } + + cur = []; + } + + cur.push( sorted[ i ] ); + } + + return result; + } +} ); From 4cc240e9773ce5a3d4e31a07b0f6001a25e02fb1 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Mon, 30 Nov 2015 12:02:46 -0500 Subject: [PATCH 04/11] Liberate Step --- src/step/Step.js | 378 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 src/step/Step.js diff --git a/src/step/Step.js b/src/step/Step.js new file mode 100644 index 0000000..d7b8e21 --- /dev/null +++ b/src/step/Step.js @@ -0,0 +1,378 @@ +/** + * Step abstraction + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - References to "quote" should be replaced with generic terminology + * representing a document. + * - Sorting logic must be extracted, and MultiSort decoupled. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + EventEmitter = require( 'events' ).EventEmitter, + + // XXX: tightly coupled + MultiSort = require( '../sort/MultiSort' ); + + +/** + * Represents a single step to be displayed in the UI + */ +module.exports = Class( 'Step' ) + .extend( EventEmitter, +{ + /** + * Called when quote is changed + * @type {string} + */ + 'const EVENT_QUOTE_UPDATE': 'updateQuote', + + + /** + * Step identifier + * @type {number} + */ + 'private _id': 0, + + /** + * Data bucket to store the raw data for submission + * @type {StepDataBucket} + */ + 'private _bucket': null, + + /** + * Fields contained exclusively on the step (no linked) + * @type {Object} + */ + 'private _exclusiveFields': {}, + + /** + * Fields that must contain a value + * @type {Object} + */ + 'private _requiredFields': {}, + + /** + * Whether all fields on the step contain valid data + * @type {boolean} + */ + 'private _valid': true, + + /** + * Explanation of what made the step valid/invalid, if applicable + * + * This is useful for error messages + * + * @type {string} + */ + 'private _validCause': '', + + /** + * Sorted group sets + * @type {Object} + */ + 'private _sortedGroups': {}, + + + /** + * Initializes step + * + * @param {number} id step identifier + * @param {QuoteClient} quote quote to contain step data + * + * @return {undefined} + */ + 'public __construct': function( id, quote ) + { + var _self = this; + + this._id = +id; + + // TODO: this is temporary; do not pass bucket, pass quote + quote.visitData( function( bucket ) + { + _self._bucket = bucket; + } ); + }, + + + /** + * Returns the numeric step identifier + * + * @return Integer step identifier + */ + 'public getId': function() + { + return this._id; + }, + + + /** + * Return the bucket associated with this step + * + * XXX: Remove me; breaks encapsulation. + * + * @return {Bucket} bucket associated with step + */ + 'public getBucket': function() + { + return this._bucket; + }, + + + /** + * Set whether or not the data on the step is valid + * + * @param {boolean} valid whether the step contains only valid data + * + * @return {Step} self + */ + 'public setValid': function( valid, cause ) + { + this._valid = !!valid; + this._validCause = cause; + + return this; + }, + + + /** + * Returns whether all the elements in the step contain valid data + * + * @return Boolean true if all elements are valid, otherwise false + */ + 'public isValid': function( cmatch ) + { + if ( !cmatch ) + { + throw Error( 'Missing cmatch data' ); + } + + return this._valid && ( this.getNextRequired( cmatch ) === null ); + }, + + + 'public getValidCause': function() + { + return this._validCause; + }, + + + /** + * Retrieve the next required value that is empty + * + * Aborts on first missing required field with its name and index. + * + * @param {Object} cmatch cmatch data + * + * @return {!Array.} first missing required field + */ + 'public getNextRequired': function( cmatch ) + { + cmatch = cmatch || {}; + + // check to ensure that each required field has a value in the bucket + for ( var name in this._requiredFields ) + { + var data = this._bucket.getDataByName( name ), + cdata = cmatch[ name ]; + + // a non-empty string indicates that the data is missing (absense of + // an index has no significance) + for ( var i in data ) + { + // any falsy value will be considered empty (note that !"0" === + // false, so this will work) + if ( !data[ i ] && ( data[ i ] !== 0 ) ) + { + if ( !cdata || ( cdata && cdata.indexes[ i ] ) ) + { + return [ name, i ]; + } + } + } + } + + // all required fields have values + return null; + }, + + + /** + * Sets a new bucket to be used for data storage and retrieval + * + * @param {QuoteDataBucket} bucket new bucket + * + * @return {Step} self + */ + 'public updateQuote': function( quote ) + { + // todo: Temporary + var _self = this, + bucket = null; + quote.visitData( function( quote_bucket ) + { + bucket = quote_bucket; + } ); + + _self._bucket = bucket; + _self.emit( this.__self.$('EVENT_QUOTE_UPDATE') ); + return this; + }, + + + /** + * Adds field names exclusively contained on this step (no linked) + * + * @param {Array.} fields field names + * + * @return {StepUi} self + */ + 'public addExclusiveFieldNames': function( fields ) + { + var i = fields.length; + while ( i-- ) + { + this._exclusiveFields[ fields[ i ] ] = true; + } + + return this; + }, + + + /** + * Retrieve list of field names (no linked) + * + * @return {Object.} field names + */ + 'public getExclusiveFieldNames': function() + { + return this._exclusiveFields; + }, + + + /** + * Set names of fields that must contain a value + * + * @param {Object} required required field names + * + * @return {StepUi} self + */ + 'public setRequiredFieldNames': function( required ) + { + this._requiredFields = required; + return this; + }, + + + 'public setSortedGroupSets': function( sets ) + { + this._sortedGroups = sets; + return this; + }, + + + 'public eachSortedGroupSet': function( c ) + { + var sets = {}; + var data = []; + + for ( var id in this._sortedGroups ) + { + // call continuation with each sorted set containing the group ids + c( this._processSortedGroup( this._sortedGroups[ id ] ) ); + } + }, + + + 'private _processSortedGroup': function( group_data ) + { + var data = []; + + for ( var i in group_data ) + { + var cur = group_data[ i ], + name = cur[ 0 ], + fields = cur[ 1 ]; + + // get data for each of the fields + var fdata = []; + for ( var i in fields ) + { + fdata.push( this._bucket.getDataByName( fields[ i ] ) ); + } + + data.push( [ name, fdata ] ); + } + + var toint = [ 0, 0, 1 ]; + function pred( i, a, b ) + { + var vala = a[ 1 ][ i ][ 0 ], + valb = b[ 1 ][ i ][ 0 ]; + + // convert to numeric if it makes sense to do so (otherwise, we may + // be comparing them as strings, which does not quite give us the + // ordering we desire) + if ( toint[ i ] ) + { + vala = +vala; + valb = +valb; + } + + if ( vala > valb ) + { + return 1; + } + else if ( vala < valb ) + { + return -1; + } + + return 0; + } + + // generate predicates + var preds = []; + for ( var i in group_data[ 0 ][ 1 ] ) + { + ( function( i ) + { + preds.push( function( a, b ) + { + return pred( i, a, b ); + } ); + } )( i ); + } + + // sort the data + var sorted = MultiSort().sort( data, preds ); + + // return the group names + var ret = []; + for ( var i in sorted ) + { + // add name + ret.push( sorted[ i ][ 0 ] ); + } + + return ret; + } +} ); From 9a1dd337eb0a2f64a2674b119cc7efd1cddd0b80 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Mon, 30 Nov 2015 14:22:34 -0500 Subject: [PATCH 05/11] Liberate {,ui/}field/ --- src/field/BucketField.js | 60 +++++++ src/field/Field.js | 31 ++++ src/ui/field/DomField.js | 300 ++++++++++++++++++++++++++++++++ src/ui/field/DomFieldFactory.js | 95 ++++++++++ 4 files changed, 486 insertions(+) create mode 100644 src/field/BucketField.js create mode 100644 src/field/Field.js create mode 100644 src/ui/field/DomField.js create mode 100644 src/ui/field/DomFieldFactory.js diff --git a/src/field/BucketField.js b/src/field/BucketField.js new file mode 100644 index 0000000..4a6e8bf --- /dev/null +++ b/src/field/BucketField.js @@ -0,0 +1,60 @@ +/** + * Field representing bucket value + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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, + Field = require( './Field' ); + + +module.exports = Class( 'BucketField' ) + .implement( Field ) + .extend( +{ + /** + * Field name + * @type {string} + */ + 'private _name': '', + + /** + * Field index + * @type {string}' + */ + 'private _index': 0, + + + __construct: function( name, index ) + { + this._name = ''+name; + this._index = +index; + }, + + + 'public getName': function() + { + return this._name; + }, + + + 'public getIndex': function() + { + return this._index; + } +} ); diff --git a/src/field/Field.js b/src/field/Field.js new file mode 100644 index 0000000..83128ec --- /dev/null +++ b/src/field/Field.js @@ -0,0 +1,31 @@ +/** + * Field representation + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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; + + +module.exports = Interface( 'Field', +{ + 'public getName': [], + + 'public getIndex': [] +} ); diff --git a/src/ui/field/DomField.js b/src/ui/field/DomField.js new file mode 100644 index 0000000..d252785 --- /dev/null +++ b/src/ui/field/DomField.js @@ -0,0 +1,300 @@ +/** + * Field represented by DOM element + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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, + Field = require( '../../field/Field' ), + + EventEmitter = require( 'events' ).EventEmitter; + + +module.exports = Class( 'DomField' ) + .implement( Field ) + .extend( EventEmitter, +{ + /** + * Wrapped field + * @type {Field} + */ + 'private _field': null, + + 'private _element': null, + + 'private _idPrefix': 'q_', + + /** + * Currently active styles + * @type {Object} + */ + 'private _styles': {}, + + + __construct: function( field, element ) + { + if ( !( Class.isA( Field, field ) ) ) + { + throw TypeError( "Invalid field provided" ); + } + + this._field = field; + this._element = element; + }, + + + 'public proxy getName': '_field', + 'public proxy getIndex': '_field', + + + 'private _getElement': function( callback ) + { + // if the provided root is a function, then it should be lazily laoded + if ( this._element === null ) + { + // if the element is null, then we have some serious problems; do + // not even invoke the callback + return; + } + else if ( typeof this._element === 'function' ) + { + var _self = this, + f = this._element; + + // any further requests for this element should be queued rather + // than resulting in a thundering herd toward the DOM (imporant: do + // this *before* invoking the function, since it may be synchronous) + var queue = []; + this._element = function( c ) + { + queue.push( c ); + }; + + // attempt to retrieve our element from the DOM + f( function( element ) + { + if ( !element ) + { + _self._element = null; + _self.emit( 'error', Error( + "Cannot locate DOM element for field " + + _self.getName() + "[" + _self.getIndex() + "]" + ) ); + + // do not even finish; this shit is for real. + return; + } + + _self._element = element; + callback( element ); + + // if we have any queued requests, process them when we're not + // busy + var c; + while ( c = queue.shift() ) + { + setTimeout( function() + { + // return the element to the queued callback + c( element ); + }, 25 ); + } + } ); + + return; + } + + // we already have the element; immediately return it + callback( this._element ); + }, + + + 'private _hasStyle': function( style ) + { + return !!this._styles[ style.getId() ]; + }, + + + 'private _flagStyle': function( style, flag ) + { + this._styles[ style.getId() ] = !!flag; + }, + + + 'public applyStyle': function( style ) + { + var _self = this; + + // if we already have this style applied, then ignore this request + if ( this._hasStyle( style ) ) + { + return this; + } + + // all remaining arguments should be passed to the style + var sargs = Array.prototype.slice.call( arguments, 1 ); + + // flag style immediately to ensure we do not queue multiple application + // requests + this._flagStyle( style, true ); + + // wait for our element to become available on the DOM and perform the + // styling + this._getElement( function( root ) + { + style.applyStyle.apply( + style, + [ _self.__inst, root, _self.getContainingRow() ].concat( sargs ) + ); + } ); + + return this; + }, + + + 'public revokeStyle': function( style ) + { + var _self = this; + + // if this style is not applied, then do nothing + if ( !( this._hasStyle( style ) ) ) + { + return this; + } + + // immediately flag style to ensure that we do not queue multiple + // revocation requests + this._flagStyle( style, false ); + + this._getElement( function( root ) + { + style.revokeStyle( _self.__inst, root, _self.getContainingRow() ); + } ); + + return this; + }, + + + /** + * Resolves a field into an id that may be used to query the DOM + * + * @return {string} expected id of element on the DOM + */ + 'protected resolveId': function() + { + return this.doResolveId( + this._field.getName(), + this._field.getIndex() + ); + }, + + + /** + * Resolves a field into an id that may be used to query the DOM + * + * This may be overridden by a subtype to alter the resolution logic. The + * name and index are passed to the method to ensure that the field itself + * remains encapsulated. + * + * @param {string} name field name + * @param {number} index field index + * + * @return {string} expected id of element on the DOM + */ + 'virtual protected doResolveId': function( name, index ) + { + return ( this._idPrefix + name + '_' + index ); + }, + + + // TODO: move me + 'protected getContainingRow': function() + { + var dd = this.getParent( this._element, 'dd' ), + dt = ( dd ) ? this.getPrecedingSibling( dd, 'dt' ) : null; + + return ( dt ) + ? [ dd, dt ] + : [ this.getParent( this._element ) ]; + }, + + + 'protected getParent': function( element, type ) + { + var parent = element.parentElement; + + if ( parent === null ) + { + return null; + } + else if ( !type ) + { + return parent; + } + + // nodeName is in caps + if ( type.toUpperCase() === parent.nodeName ) + { + return parent; + } + + // otherwise, keep looking + return this.getParent( parent, type ); + }, + + + 'protected getPrecedingSibling': function( element, type ) + { + return this.getSibling( element, type, -1 ); + }, + + + 'protected getFollowingSibling': function( element, type ) + { + return this.getSibling( element, type, 1 ); + }, + + + 'protected getSibling': function( element, type, direction ) + { + // if no direction was provided, then search in both + if ( !direction ) + { + return ( this.getSibling( element, type, -1 ) + || this.getSibling( element, type, 1 ) + ); + } + + // get the next node relative to the direction + var next = element[ + ( direction === -1 ) ? 'previousSibling' : 'nextSibling' + ]; + if ( next === null ) + { + return null; + } + + // if we found our sibling, return it + if ( type.toUpperCase() === next.nodeName ) + { + return next; + } + + return this.getSibling( next, type, direction ); + } +} ); diff --git a/src/ui/field/DomFieldFactory.js b/src/ui/field/DomFieldFactory.js new file mode 100644 index 0000000..14718d5 --- /dev/null +++ b/src/ui/field/DomFieldFactory.js @@ -0,0 +1,95 @@ +/** + * Creates DomField + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Dependencies need to be liberated: + * - ElementStyler. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + + BucketField = require( '../../field/BucketField' ), + DomField = require( './DomField' ); + + +module.exports = Class( 'DomFieldFactory', +{ + 'private _elementStyler': null, + + + __construct: function( element_styler ) + { + this._elementStyler = element_styler; + }, + + + /** + * Create a DomField from the given field description + * + * The provided DomField will wait to access the DOM until an operation + * requires it. + * + * @param {string} name field name + * @param {number} index field index + * + * @param {function(HtmlElement)|HtmlElement} root root element containing + * the field (optionally + * lazy) + * + * @return {DomField} generated field + */ + 'public create': function( name, index, root ) + { + var _self = this; + + return DomField( + BucketField( name, index ), + + // lazy load on first access + function( callback ) + { + // are we lazy? + if ( typeof root === 'function' ) + { + // wait to fulfill this request until after the element + // becomes available + root( function( result ) + { + root = result; + c(); + } ); + + return; + } + + // not lazy; continue immediately + c(); + + function c() + { + callback( _self._elementStyler.getElementByName( + name, index, null, root + )[0] ); + } + } + ); + } +} ); From 44323a0b5995ba10c604ab0bc3f50dd22f249e87 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 1 Dec 2015 10:28:20 -0500 Subject: [PATCH 06/11] Liberated StepUiBuilder --- src/ui/step/StepUiBuilder.js | 269 +++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 src/ui/step/StepUiBuilder.js diff --git a/src/ui/step/StepUiBuilder.js b/src/ui/step/StepUiBuilder.js new file mode 100644 index 0000000..22c0333 --- /dev/null +++ b/src/ui/step/StepUiBuilder.js @@ -0,0 +1,269 @@ +/** + * Builds UI from template + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Global references to jQuery must be removed. + * - Dependencies need to be liberated: + * - ElementStyler; + * - UI. + * - This may not be needed, may be able to be handled differently, and + * really should load from data rather than a pre-generated template (?) + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + EventEmitter = require( 'events' ).EventEmitter; + + +module.exports = Class( 'StepUiBuilder' ) + .extend( EventEmitter, +{ + /** + * Used to style elements + * @type {ElementStyler} + */ + 'private _elementStyler': null, + + /** + * Used for building groups + * @type {function()} + */ + 'private _groupBuilder': null, + + /** + * Retrieves step data + * @type {function( step_id: number )} + */ + 'private _dataGet': null, + + /** + * Step that the StepUi is being modeled after + * @type {Step} + */ + 'private _step': null, + + /** + * Format bucket data for display + * @type {BucketDataValidator} + */ + 'private _formatter': null, + + + 'public __construct': function( + element_styler, + formatter, + groupBuilder, + dataGet + ) + { + this._elementStyler = element_styler; + this._formatter = formatter; + this._groupBuilder = groupBuilder; + this._dataGet = dataGet; + }, + + + /** + * Sets the underlying step + * + * @param {Step} step + * + * @return {StepUiBuilder} self + */ + 'public setStep': function( step ) + { + this._step = step; + return this; + }, + + + 'public build': function( StepUi, callback ) + { + var _self = this; + + if ( !( this._step ) ) + { + throw Error( 'No step provided' ); + } + + // create a new StepUi + var ui = StepUi( + this._step, + this._elementStyler, + this._formatter + ); + + // retrieve and process the step data (this kick-starts the entire + // process) + this._getData( function( data ) + { + _self._processData( data, ui ); + + // build is complete + callback.call( null, ui ); + }); + + return this; + }, + + + /** + * Retrieves step data using the previously provided function + * + * This process may be asynchronous. + * + * @param {function( data: Object )} callback function to call with data + * + * @return {undefined} + */ + 'private _getData': function( callback ) + { + this._dataGet.call( this, this._step.getId(), function( data ) + { + callback( data ); + }); + }, + + + /** + * Processes the step data after it has been retrieved + * + * @param Object data step data (source should return as JSON) + * + * @return void + */ + 'private _processData': function( data, ui ) + { + // sanity check + if ( !( data.content.html ) ) + { + // todo: show more information and give user option to retry + data.content.html = '

Error

A problem was encountered ' + + 'while attempting to view this step.

'; + } + + // enclose it in a div so that we have a single element we can query, + // making our lives much easier + ui.setContent( + $( '
' ) + .attr( 'id', '__step' + ui.getStep().getId() ) + .html( data.content.html ) + ); + + // free the content from memory, as it's no longer needed (we don't need + // both the DOM representation and the string representation in memory + // for the life of the script - it's a waste) + delete data.content; + + // create the group objects + this._createGroups( ui ); + + // track changes so we know when to validate and post + ui.setDirtyTrigger(); + + // let others do any final processing before we consider ourselves + // ready + ui.emit( ui.__self.$( 'EVENT_POST_PROCESS' ) ); + }, + + + /** + * Instantiates Group objects for each group in the step content, then + * styles them + * + * TODO: refactor into own builder + * + * @param {StepUi} ui new ui instance + * + * @return {undefined} + */ + 'private _createGroups': function( ui ) + { + // reference to self for use in closure + var _self = this, + groups = {}, + group = null, + group_id = 0, + + step = ui.getStep(); + + // instantiate a group object for each of the groups within this step + var $groups = ( ui.getContent().find( '.stepGroup' ) ).each( function() + { + group = _self._groupBuilder( $( this ), _self._elementStyler ); + group_id = group.getGroupId(); + + groups[ group_id ] = group; + + // let the step know what fields it contains + step.addExclusiveFieldNames( + group.getGroup().getExclusiveFieldNames() + ); + + _self._hookGroup( group, ui ); + } ); + + // XXX: remove public property assignment + ui.groups = groups; + ui.initGroupFieldData(); + + // we can style all the groups, since the elements that cannot be styled + // (e.g. table groups) have been removed already + _self._elementStyler.apply( $groups, false ); + }, + + + /** + * Hook various group events for processing + * + * @param {GroupUi} group group to hook + * @param {StepUi} ui new ui instance + * + * @return {undefined} + */ + 'private _hookGroup': function( group, ui ) + { + group + .invalidate( function() + { + ui.invalidate(); + } ) + .on( 'indexAdd', function( index ) + { + ui.emit( ui.__self.$( 'EVENT_INDEX_ADD' ), index, this ); + } ) + .on( 'indexRemove', function( index ) + { + ui.emit( ui.__self.$( 'EVENT_INDEX_REMOVE' ), index, this ); + } ).on( 'indexReset', function( index ) + { + ui.emit( ui.__self.$( 'EVENT_INDEX_RESET' ), index, this ); + } ) + .on( 'action', function( type, ref, index ) + { + // simply forward + ui.emit( ui.__self.$( 'EVENT_ACTION' ), type, ref, index ); + } ) + .on( 'postAddRow', function( index ) + { + ui.emit( 'postAddRow', index ); + } ); + } +} ); From 4ce78ebd9eca890d1675bc592c7622a27f20f2a2 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 1 Dec 2015 11:23:25 -0500 Subject: [PATCH 07/11] StepUiBuilder do not enclose groups in parent element This allows the caller to handle how to group and render steps. But we do add another container to hold each of the groups. --- src/ui/step/StepUi.js | 2 +- src/ui/step/StepUiBuilder.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ui/step/StepUi.js b/src/ui/step/StepUi.js index 2f75ada..df54c17 100644 --- a/src/ui/step/StepUi.js +++ b/src/ui/step/StepUi.js @@ -263,7 +263,7 @@ module.exports = Class( 'StepUi' ) /** * Returns the generated step content as a jQuery object * - * @return jQuery generated step content + * @return {jQuery} generated step content */ getContent: function() { diff --git a/src/ui/step/StepUiBuilder.js b/src/ui/step/StepUiBuilder.js index 22c0333..240c921 100644 --- a/src/ui/step/StepUiBuilder.js +++ b/src/ui/step/StepUiBuilder.js @@ -162,9 +162,8 @@ module.exports = Class( 'StepUiBuilder' ) // enclose it in a div so that we have a single element we can query, // making our lives much easier ui.setContent( - $( '
' ) - .attr( 'id', '__step' + ui.getStep().getId() ) - .html( data.content.html ) + $( '
') + .append( $( data.content.html ) ) ); // free the content from memory, as it's no longer needed (we don't need From 972856225b670eaea2da756b44138b6081227bfe Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 1 Dec 2015 11:46:53 -0500 Subject: [PATCH 08/11] Extract StepUi#detach You can't see where this is going, because there is other code that is not yet part of Liza. ...sorry. --- src/ui/step/StepUi.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/ui/step/StepUi.js b/src/ui/step/StepUi.js index df54c17..e166d0b 100644 --- a/src/ui/step/StepUi.js +++ b/src/ui/step/StepUi.js @@ -271,22 +271,6 @@ module.exports = Class( 'StepUi' ) }, - /** - * Detaches the step from the DOM - * - * @return StepUi self to allow for method chaining - */ - detach: function() - { - if ( this.$content instanceof jQuery ) - { - this.$content.detach(); - } - - return this; - }, - - /** * Will mark the step as dirty when the content is changed and update * the staging bucket From cc2129829760b110e481a7de606ddd8948a88315 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 1 Dec 2015 23:06:32 -0500 Subject: [PATCH 09/11] StepUi: Accept and return vanilla DOM content This encapsulates the use of jQuery which will eventually be entirely eliminated. --- src/ui/step/StepUi.js | 16 ++++++++++------ src/ui/step/StepUiBuilder.js | 9 ++++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/ui/step/StepUi.js b/src/ui/step/StepUi.js index e166d0b..fb4ea44 100644 --- a/src/ui/step/StepUi.js +++ b/src/ui/step/StepUi.js @@ -25,6 +25,9 @@ * - ElementStyler; * - BucketDataValidator. * - Global references (e.g. jQuery) must be removed. + * - jQuery must be eliminated. + * - The public API now accepts and returns vanilla DOM content, so at + * least it's encapsulated now. * - Checkbox-specific logic must be extracted. * - This class is doing too much. * @end needsLove @@ -235,13 +238,14 @@ module.exports = Class( 'StepUi' ) /** * Sets content to be displayed * - * @param {jQuery} $content content to display + * @param {HTMLElement} content content to display * * @return {StepUi} self */ - 'public setContent': function( $content ) + 'public setContent': function( content ) { - this.$content = $content; + // TODO: transition away from jQuery + this.$content = $( content ); this._processAnswerFields(); @@ -263,11 +267,11 @@ module.exports = Class( 'StepUi' ) /** * Returns the generated step content as a jQuery object * - * @return {jQuery} generated step content + * @return {HTMLElement} generated step content */ - getContent: function() + 'virtual getContent': function() { - return this.$content; + return this.$content[ 0 ]; }, diff --git a/src/ui/step/StepUiBuilder.js b/src/ui/step/StepUiBuilder.js index 240c921..4d7770f 100644 --- a/src/ui/step/StepUiBuilder.js +++ b/src/ui/step/StepUiBuilder.js @@ -160,10 +160,11 @@ module.exports = Class( 'StepUiBuilder' ) } // enclose it in a div so that we have a single element we can query, - // making our lives much easier + // making our lives much easier (TODO: this is transitional code + // moving from jQuery to vanilla DOM) ui.setContent( $( '
') - .append( $( data.content.html ) ) + .append( $( data.content.html ) )[ 0 ] ); // free the content from memory, as it's no longer needed (we don't need @@ -203,8 +204,10 @@ module.exports = Class( 'StepUiBuilder' ) step = ui.getStep(); + var $content = $( ui.getContent() ); + // instantiate a group object for each of the groups within this step - var $groups = ( ui.getContent().find( '.stepGroup' ) ).each( function() + var $groups = $content.find( '.stepGroup' ).each( function() { group = _self._groupBuilder( $( this ), _self._elementStyler ); group_id = group.getGroupId(); From 3d9780c39e05f4d2b76d19611809a58d1500e29b Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Wed, 2 Dec 2015 11:10:47 -0500 Subject: [PATCH 10/11] {=>General}StepUi; StepUi interface This allows us to begin development (and testing) of StepUi subtypes without having to worry about the convoluted crap that GeneralStepUi is doing. Specifically, all the jQuery stuff needs to go. --- src/ui/step/GeneralStepUi.js | 1185 ++++++++++++++++++++++++++++++++++ src/ui/step/StepUi.js | 1027 +---------------------------- 2 files changed, 1216 insertions(+), 996 deletions(-) create mode 100644 src/ui/step/GeneralStepUi.js diff --git a/src/ui/step/GeneralStepUi.js b/src/ui/step/GeneralStepUi.js new file mode 100644 index 0000000..099f64d --- /dev/null +++ b/src/ui/step/GeneralStepUi.js @@ -0,0 +1,1185 @@ +/** + * General UI logic for steps + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - References to "quote" should be replaced with generic terminology + * representing a document. + * - Dependencies need to be liberated: + * - ElementStyler; + * - BucketDataValidator. + * - Global references (e.g. jQuery) must be removed. + * - jQuery must be eliminated. + * - The public API now accepts and returns vanilla DOM content, so at + * least it's encapsulated now. + * - Checkbox-specific logic must be extracted. + * - This class is doing too much. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + EventEmitter = require( 'events' ).EventEmitter, + StepUi = require( './StepUi' ); + + +/** + * Handles display of a step + * + * @return {StepUi} + */ +module.exports = Class( 'StepUi' ) + .implement( StepUi ) + .extend( EventEmitter, +{ + /** + * Called after step data is processed + * @type {string} + */ + 'const EVENT_POST_PROCESS': 'postProcess', + + /** + * Called after step is appended to the DOM + * @type {string} + */ + 'const EVENT_POST_APPEND': 'postAppend', + + /** + * Called when data is changed (question value changed) + * @type {string} + */ + 'const EVENT_DATA_CHANGE': 'dataChange', + + /** + * Raised when an index is added to a group (e.g. row addition) + * @type {string} + */ + 'const EVENT_INDEX_ADD': 'indexAdd', + + /** + * Raised when an index is reset in a group (rather than removed) + * @type {string} + */ + 'const EVENT_INDEX_RESET': 'indexReset', + + /** + * Raised when an index is removed from a group (e.g. row deletion) + * @type {string} + */ + 'const EVENT_INDEX_REMOVE': 'indexRemove', + + /** + * Represents an action trigger + * @type {string} + */ + 'const EVENT_ACTION': 'action', + + /** + * Triggered when the step is active + * @type {boolean} + */ + 'const EVENT_ACTIVE': 'active', + + + /** + * Instance of step to style + * @type {Step} + */ + step: null, + + /** + * Step data (DOM representation) + * @type {jQuery} + */ + $content: null, + + /** + * Element styler + * @type {ElementStyler} + */ + styler: null, + + /** + * Whether the step should be repopulated with bucket data upon display + * @type {boolean} + */ + invalid: false, + + /** + * Stores group objects representing each group + * @type {Object.} + */ + groups: {}, + + /** + * Flag to let system know its currently saving the step + * @type {boolean} + */ + saving: false, + + /** + * Format bucket data for display + * @type {BucketDataValidator} + */ + 'private _formatter': null, + + /** + * Stores references to which group fields belong to + * @type {Object} + */ + 'private _fieldGroup': {}, + + /** + * Hash of answer contexts (jQuery) for quick lookup + * @type {Object} + */ + 'private _answerContext': {}, + + /** + * Hash of static answer indexes, if applicable + * @type {Object} + */ + 'private _answerStaticIndex': {}, + + /** + * Whether the step is the currently active (visible) step + * @type {boolean} + */ + 'private _active': false, + + /** + * Whether the step is locked (all elements disabled) + * @type {boolean} + */ + 'private _locked': false, + + 'private _forceAnswerUpdate': null, + + + /** + * Initializes StepUi object + * + * The data_get function is used to retrieve the step data, allowing the + * logic to be abstracted from the Step implementation. It must accept two + * arguments: the id of the step to load, and a callback function, as the + * operation is likely to be asynchronous. + * + * A callback function is used for when the step is ready to be used. This + * is done because the loading of the data is (ideally_ an asynchronous + * operation. This operation is performed in the constructor, to ensure + * that each instance of a Step class has data associated with it. + * Therefore, the object will be instantiated, but the data_get function + * will still be running in the background. The step should not be used + * until the data loading is complete. That is when the callback will be + * triggered. + * + * @return {undefined} + */ + 'public __construct': function( + step, + styler, + formatter + ) + { + this.step = step; + this.styler = styler; + this._formatter = formatter; + }, + + + /** + * Initializes step + * + * @return {undefined} + */ + 'public init': function() + { + var _self = this; + + this.step.on( 'updateQuote', function() + { + _self._hookBucket(); + _self._processAnswerFields(); + _self.invalidate(); + } ); + + return this; + }, + + + 'public initGroupFieldData': function() + { + for ( var group in this.groups ) + { + var groupui = this.groups[ group ], + fields = groupui.group.getExclusiveFieldNames(); + + for ( var i in fields ) + { + this._fieldGroup[ fields[ i ] ] = groupui; + } + } + }, + + + /** + * Sets content to be displayed + * + * @param {HTMLElement} content content to display + * + * @return {StepUi} self + */ + 'public setContent': function( content ) + { + // TODO: transition away from jQuery + this.$content = $( content ); + + this._processAnswerFields(); + + return this; + }, + + + /** + * Returns the step that this object is styling + * + * @return lovullo.program.Step + */ + getStep: function() + { + return this.step; + }, + + + /** + * Returns the generated step content as a jQuery object + * + * @return {HTMLElement} generated step content + */ + 'virtual getContent': function() + { + return this.$content[ 0 ]; + }, + + + /** + * Will mark the step as dirty when the content is changed and update + * the staging bucket + * + * @return undefined + */ + setDirtyTrigger: function() + { + var step = this; + + this.$content.bind( 'change.program', function( event ) + { + // do nothing if the step is locked + if ( step._locked ) + { + return; + } + + // get the name of the altered element + var $element = step.styler.getNameElement( $( event.target ) ), + name = $element.attr( 'name' ), + val = $element.val(); + + if ( !( name ) ) + { + // rogue field not handled by the framework! + return; + } + + // remove the trailing square brackets from the name + name = name.substring( 0, ( name.length - 2 ) ); + + // get its index + var $elements = step.$content.find( "[name='" + name + "[]']" ), + index = $elements.index( $element ); + + + // todo: this is temporary to allow noyes and legacy radios to work. + if ( $element.hasClass( 'legacyradio' ) ) + { + index = 0; + } + else if ( $element.attr( 'type' ) === 'radio' + || $element.attr( 'type' ) === 'checkbox' + ) + { + // if it's not checked, then this isn't the radio we're + // interested in. Sorry! + if ( !( $element.attr( 'checked' ) ) ) + { + $element.attr( 'checked', true ); + + return; + } + + // 2 in this instance is the yes/no group length. + var group_length = $element.attr( 'data-question-length' ) + ? $element.attr( 'data-question-length' ) + : 2; + + index = Math.floor( index / group_length ); + } + + var values = {}; + values[ name ] = []; + values[ name ][ index ] = val; + + + // update our bucket with this new data + step.emit( step.__self.$('EVENT_DATA_CHANGE'), values ); + } ); + + // @note This is a hack. In IE8, checkbox change events don't properly fire. + this.$content.delegate( + 'input[type="checkbox"]', + 'click', + function () + { + // XXX: remove global + jQuery( this ).change(); + } + ); + }, + + + /** + * Prepares answer fields + * + * This method will populate the answer fields with values already in the + * bucket and hook the bucket so that future updates will also be reflected. + * + * @return {undefined} + */ + _processAnswerFields: function() + { + var _self = this, + bucket = this.step.getBucket(); + + this._prepareAnswerContexts(); + + // perform initial update for the step when we are first created, then + // hook everything else (we do not need the hooks before then, as we + // will be forcefully updating the step with values) + this.__inst.once( 'postAppend', function() + { + var forceupdate = false; + + // when the value we're watching is updated in the bucket, update + // the displayed value + var doUpdate; + bucket.on( 'stagingUpdate', doUpdate = function( data ) + { + // defer updates unless we're active + if ( !( _self._active ) ) + { + if ( forceupdate === false ) + { + forceupdate = true; + + // use __inst until we get the ease.js issue sorted out + // with extending non-class protoypes + _self.__inst.once( _self.__self.$('EVENT_ACTIVE'), function() + { + doUpdate( bucket.getData() ); + forceupdate = false; + } ); + } + + return; + } + + // give the UI a chance to update the DOM; otherwise, the + // answer elements we update may no longer be used (this also + // has performance benefits since it allows repainting before + // potentially heavy processing) + setTimeout( function() + { + _self._updateAnswerFieldData( data ); + }, 25 ); + } ); + + doUpdate( bucket.getData() ); + + // set the values when a row is added + _self.__inst.on( 'postAddRow', function( index ) + { + var data = bucket.getData(); + + for ( var name in _self._answerContext ) + { + var value = ( data[ name ] || {} )[ index ]; + + if ( value === undefined ) + { + continue; + } + + _self._updateAnswer( name, index, value ); + } + } ); + + this._forceAnswerUpdate = doUpdate; + } ); + }, + + + /** + * Update DOM answer fields with respective datum in diff DATA + * + * Only watched answer fields are updated. The update is performed on + * the discovered context during step initialization. + * + * @param {Object} data bucket diff + * + * @return {undefined} + */ + 'private _updateAnswerFieldData': function( data ) + { + // we only care if the data we're watching has been + // changed + for ( var name in data ) + { + if ( !( this._answerContext[ name ] ) ) + { + continue; + } + + var curdata = data[ name ], + si = this._answerStaticIndex[ name ], + i = curdata.length; + + // static index override + if ( !( isNaN( si ) ) ) + { + // update every index on the DOM + i = this.styler.getAnswerElementByName( + name, undefined, undefined, + this._answerContext[ name ] + ).length; + } + + while ( i-- ) + { + var index = ( isNaN( si ) ) ? i : si, + value = curdata[ index ]; + + // take into account diff; note that if one of + // them is null, that means it has been removed + // (and will therefore not be displayed), so we + // don't have to worry about clearing out a value + if ( ( value === undefined ) || ( value === null ) ) + { + continue; + } + + this._updateAnswer( name, i, curdata[ index ] ); + } + } + }, + + + 'private _prepareAnswerContexts': function() + { + var _self = this; + + // get a list of all the answer elements + this.$content.find( 'span.answer' ).each( function() + { + var $this = $( this ), + ref_id = $this.attr( 'data-answer-ref' ), + index = $this.attr( 'data-answer-static-index' ); + + // clear the value (which by default contains the name of the answer + // field) + $this.text( '' ); + + // if we've already found an element for this ref, then it is + // referenced in multiple places; simply store the context as the + // entire step + if ( _self._answerContext[ ref_id ] ) + { + _self._answerContext[ ref_id ] = _self.$content; + return; + } + + // store the parent fieldset as our context to make DOM lookups a + // bit more performant + _self._answerContext[ ref_id ] = $( this ).parents( 'fieldset' ); + _self._answerStaticIndex[ ref_id ] = ( index ) + ? +index + : NaN; + } ); + }, + + + /** + * Update the display of an answer field + * + * The value will be styled before display. + * + * @param {string} name field name + * @param {number} index index to update + * @param {string} value answer value (unstyled) + * + * @return {undefined} + */ + 'private _updateAnswer': function( name, index, value ) + { + var $element = this.styler.getAnswerElementByName( + name, index, null, ( this._answerContext[ name ] || this.$content ) + ); + + var i = $element.length; + if ( i > 0 ) + { + while( i-- ) + { + var styled = this.styler.styleAnswer( name, value ), + allow_html = $element[ i ] + .attributes[ 'data-field-allow-html' ] || {}; + + if ( allow_html.value === 'true' ) + { + $element.html( styled ); + } + else + { + $element.text( styled ); + } + + var id = $element[ i ].attributes['data-field-name']; + if ( !id ) + { + continue; + } + + this.emit( 'displayChanged', id.value, index, value ); + } + } + }, + + + /** + * Monitors the bucket for data changes and updates the elements accordingly + * + * @return undefined + */ + _hookBucket: function() + { + var _self = this; + + // when the bucket data is updated, update the element to reflect the + // value + this.step.getBucket().on( 'stagingUpdate', function( data ) + { + // if we're saving (filling the bucket), this is pointless + if ( _self.saving ) + { + return; + } + + var data_fmt = _self._formatter.format( data ); + + for ( var name in _self.step.getExclusiveFieldNames() ) + { + // if this data hasn't changed, then ignore the element + if ( data_fmt[ name ] === undefined ) + { + continue; + } + + // update each of the elements (it is important to update the + // number of elements on the screen, not the number of elements + // in the data array, since the array is a diff and will contain + // information regarding removed elements) + var data_len = data_fmt[ name ].length; + + for ( var index = 0; index < data_len; index++ ) + { + var val = data_fmt[ name ][ index ]; + + // if the value is not set or has been removed (remember, + // we're dealing with a diff), then ignore it + if ( ( val === undefined ) || ( val === null ) ) + { + continue; + } + + // set the value of the element using the appropriate group + // (for performance reasons, so we don't scan the whole DOM + // for the element) + _self.getElementGroup( name ).setValueByName( + name, index, val, false + ); + } + } + } ); + }, + + + /** + * Called after the step is appended to the DOM + * + * This method will simply loop through all the groups that are a part of + * this step and call their postAppend() methods. If the group does not have + * an element id, it will not function properly. + * + * @return StepUi self to allow for method chaining + */ + postAppend: function() + { + // let the styler do any final styling + this.styler.postAppend( this.$content.parent() ); + + // If we have data in the bucket (probably loaded from the server), show + // it. We use a delay to ensure that the UI is ready for the update. In + // certain cases (such as with tabs), the UI may not have rendered all + // the elements. + this.emptyBucket( null, true ); + + // monitor bucket changes and update the elements accordingly + this._hookBucket(); + + this.emit( this.__self.$('EVENT_POST_APPEND') ); + + return this; + }, + + + /** + * Empties the bucket into the step (filling the fields with its values) + * + * @param Function callback function to call when bucket has been emptied + * + * @return StepUi self to allow for method chaining + */ + emptyBucket: function( callback, delay ) + { + delay = ( delay === undefined ) ? false : true; + + var _self = this, + bucket = this.getStep().getBucket(), + fields = {}; + + // first, clear all the elements + for ( var group in this.groups ) + { + this.groups[group].preEmptyBucket( bucket ); + } + + // then update all the elements with the form values in the bucket + // (using setTimeout allows the browser UI thread to process repaints, + // added elements, etc, which will ensure that the elements will be + // available to empty into) + var empty = function() + { + var data = {}; + + for ( var name in _self.step.getExclusiveFieldNames() ) + { + data[ name ] = bucket.getDataByName( name ); + } + + // format the data (in-place, since we're the only ones using this + // object) + _self._formatter.format( data, true ); + + for ( var name in data ) + { + var values = data[ name ], + i = values.length; + + while ( i-- ) + { + // set the data and do /not/ trigger the change event + var group = _self.getElementGroup( name ); + if ( !group ) + { + // This should not happen (see FS#13653); emit an error + // and continue processing in the hopes that we can + // display most of the data + this.emit( 'error', Error( + "Unable to locate group for field `" + name + "'" + ) ); + + continue; + } + + var id = _self.getElementGroup( name ).setValueByName( + name, i, values[ i ], false + ); + } + } + + // answers are normally only updated on bucket change + _self._forceAnswerUpdate( bucket.getData() ); + + if ( callback instanceof Function ) + { + callback.call( _self ); + } + }; + + // either execute immediately or set a timer (allowing the UI to update) + // if a delay was requested + if ( delay ) + { + setTimeout( empty, 25 ); + } + else + { + empty(); + } + + return this; + }, + + + /** + * Resets a step to its previous state or hooks the event + * + * @param Function callback function to call when reset is complete + * + * @return StepUi self to allow for method chaining + */ + reset: function( callback ) + { + var step = this; + + this.getStep().getBucket().revert(); + + if ( typeof callback === 'function' ) + { + callback.call( this ); + } + + // clear invalidation flag + this.invalid = false; + + return this; + }, + + + /** + * Returns whether all the elements in the step contain valid data + * + * @return Boolean true if all elements are valid, otherwise false + */ + isValid: function( cmatch ) + { + return this.step.isValid( cmatch ); + }, + + + /** + * Returns the id of the first failed field if isValid() failed + * + * Note that the returned element may not be visible. Visible elements will + * take precidence --- that is, invisible elements will be returned only if + * there are no more invalid visible elements, except in the case of + * required fields. + * + * @param {Object} cmatch cmatch data + * + * @return String id of element, or empty string + */ + 'public getFirstInvalidField': function( cmatch ) + { + var $element = this.$content.find( + '.invalid_field[data-field-name]:visible:first' + ); + + if ( $element.length === 0 ) + { + $element = this.$content.find( + '.invalid_field[data-field-name]:first' + ); + } + + var name = $element.attr( 'data-field-name' ); + + // no invalid fields, so what about missing required fields? + if ( !name ) + { + // append 'true' indiciating that this is a required field check + var result = this.step.getNextRequired( cmatch ); + if ( result !== null ) + { + result.push( true ); + } + + return result; + } + + // return the element name and index + return [ + name, + + // calculate index of this element + this.$content.find( '[data-field-name="' + name + '"]' ) + .index( $element ), + + // not a required field failure + false + ]; + }, + + + /** + * Scrolls to the element identified by the given id + * + * @param {string} field name of field to scroll to + * @param {number} i index of field to scroll to + * @param {boolean} show_message whether to show the tooltip + * @param {string} message tooltip message to display + * + * @return {StepUi} self to allow for method chaining + */ + 'public scrollTo': function( field, i, show_message, message ) + { + show_message = ( show_message === undefined ) ? true : !!show_message; + + if ( !( field ) || ( i < 0 ) || i === undefined ) + { + // cause may be empty + var cause = this.step.getValidCause(); + + this.emit( 'error', + Error( + 'Could not scroll: no field/index provided' + + ( ( cause ) + ? ' (cause: ' + cause + ')' + : '' + ) + ) + ); + } + + var index = this.styler.getProperIndex( field, i ), + $element = this.styler.getWidgetByName( field, index ); + + // if the element couldn't be found, then this is useless + if ( $element.length == 0 ) + { + this.emit( 'error', + Error( + 'Could not scroll: could not locate ' + field + '['+i+']' + ) + ); + } + + // allow the groups to preprocess the scrolling + for ( var group in this.groups ) + { + this.groups[ group ].preScrollTo( field, index ); + } + + // is the element visible now that we've given the groups a chance to + // display it? + if ( $element.is( ':visible' ) !== true ) + { + // fail; don't bother scrolling + this.emit( 'error', Error( + 'Could not scroll: element ' + field + ' is not visible' + ) ); + } + + // scroll to just above the first invalid question so that it + // may be fixed + var stepui = this; + this.$content.parent().scrollTo( $element, 100, { + offset: { top: -150 }, + onAfter: function() + { + // focus on the element and display the tooltip + stepui.styler.focus( $element, show_message, message ); + } + } ); + + return this; + }, + + + /** + * Invalidates the step, stating that it should be reset next time it is + * displayed + * + * Resetting the step will clear the invalidation flag. + * + * @return StepUi self to allow for method chaining + */ + invalidate: function() + { + this.invalid = true; + }, + + + /** + * Returns whether the step has been invalidated + * + * @return Boolean true if step has been invalidated, otherwise false + */ + isInvalid: function() + { + return this.invalid; + }, + + + /** + * Returns the GroupUi object associated with the given element name, if + * known + * + * @param {string} name element name + * + * @return {GroupUi} group if known, otherwise null + */ + getElementGroup: function( name ) + { + return this._fieldGroup[ name ] || null; + }, + + + /** + * Forwards add/remove hiding requests to groups + * + * @param {boolean} value whether to hide (default: true) + * + * @return {StepUi} self + */ + hideAddRemove: function( value ) + { + value = ( value !== undefined ) ? !!value : true; + + for ( var group in this.groups ) + { + var groupui = this.groups[ group ]; + if ( groupui.hideAddRemove instanceof Function ) + { + groupui.hideAddRemove( value ); + } + } + + return this; + }, + + + 'public preRender': function() + { + for ( var group in this.groups ) + { + this.groups[ group ].preRender(); + } + + return this; + }, + + + 'public visit': function( callback ) + { + // "invalid" means that the displayed data is not up-to-date + if ( this.invalid ) + { + this.emptyBucket(); + this.invalid = false; + } + + for ( var group in this.groups ) + { + this.groups[group].visit(); + } + + var _self = this, + cn = 0; + + // we perform async. processing, so ideally the caller should know + // when we're actually complete + var c = function() + { + if ( --cn === 0 ) + { + callback(); + } + }; + + this.step.eachSortedGroupSet( function( ids ) + { + cn++; + _self._sortGroups( ids, c ); + } ); + + if ( cn === 0 ) + { + callback && callback(); + } + + return this; + }, + + + 'private _sortGroups': function( ids, callback ) + { + // detach them all (TODO: a more efficient method could be to detach + // only the ones that aren not already in order, or ignore ones that + // would be hidden..etc) + var len = ids.length, + groups = []; + + if ( len === 0 ) + { + return; + } + + function getGroup( name ) + { + return document.getElementById( 'group_' + name ); + } + + var nodes = []; + for ( var i in ids ) + { + nodes[ i ] = getGroup( ids[ i ] ); + } + + var prev = nodes[ 0 ]; + if ( !( prev && prev.parentNode ) ) + { + return; + } + + var parent = prev.parentNode, + container = parent.parentNode, + i = len - 1; + + if ( !container ) + { + return; + } + + // to prevent DOM updates for each and every group move, detach the node + // that contains all the groups from the DOM; we'll re-add it after + // we're done + container.removeChild( parent ); + + // we can sort the groups in place without screwing up the DOM by simply + // starting with the last node and progressively inserting nodes + // before that element; we start at the end simply because there is + // Node#insertBefore, but no Node#insertAfter + setTimeout( function() + { + try + { + do + { + var group = nodes[ i ]; + + if ( !group ) + { + continue; + } + + // remove from DOM and reposition, unless we are already in + // position + if ( prev.previousSibling !== group ) + { + parent.removeChild( group ); + parent.insertBefore( group, prev ); + } + + prev = group; + } + while ( i-- ); + } + catch ( e ) + { + // we need to make sure we re-attach the container, so don't blow up + // if sorting fails + console.error && console.error( e, group, prev ); + } + + // now that sorting is complete, re-add the groups in one large DOM + // update + container.appendChild( parent ); + + callback(); + }, 25 ); + }, + + + /** + * Marks a step as active (or inactive) + * + * A step should be marked as active when it is the step that is currently + * accessible to the user. + * + * @param {boolean} active whether step is active + * + * @return {StepUi} self + */ + 'public setActive': function( active ) + { + active = ( active === undefined ) ? true : !!active; + + this._active = active; + + // notify each individual group of whether or not they are now active + for ( var id in this.groups ) + { + this.groups[ id ].setActive( active ); + } + + if ( active ) + { + this.emit( this.__self.$('EVENT_ACTIVE') ); + } + + return this; + }, + + + /** + * Lock/unlock a step (preventing modifications) + * + * If the lock status has changed, the elements on the step will be + * disabled/enabled respectively. + * + * @param {boolean} lock whether step should be locked + * + * @return {StepUi} self + */ + 'public lock': function( lock ) + { + lock = ( lock === undefined ) ? true : !!lock; + + // if the lock has changed, then alter the elements + if ( lock !== this._locked ) + { + for ( var name in this.step.getExclusiveFieldNames() ) + { + this.styler.disableField( name, undefined, lock ); + } + } + + this._locked = lock; + return this; + } +} ); diff --git a/src/ui/step/StepUi.js b/src/ui/step/StepUi.js index fb4ea44..ff42a7d 100644 --- a/src/ui/step/StepUi.js +++ b/src/ui/step/StepUi.js @@ -1,5 +1,5 @@ /** - * General UI logic for steps + * Step user interface * * Copyright (C) 2015 LoVullo Associates, Inc. * @@ -19,220 +19,27 @@ * along with this program. If not, see . * * @needsLove - * - References to "quote" should be replaced with generic terminology - * representing a document. - * - Dependencies need to be liberated: - * - ElementStyler; - * - BucketDataValidator. - * - Global references (e.g. jQuery) must be removed. - * - jQuery must be eliminated. - * - The public API now accepts and returns vanilla DOM content, so at - * least it's encapsulated now. - * - Checkbox-specific logic must be extracted. - * - This class is doing too much. + * - API is doing too much; see GeneralStepUi. * @end needsLove */ -var Class = require( 'easejs' ).Class, - EventEmitter = require( 'events' ).EventEmitter; +var Interface = require( 'easejs' ).Interface; /** - * Handles display of a step - * - * @return {StepUi} + * Interactive interface for steps */ -module.exports = Class( 'StepUi' ) - .extend( EventEmitter, +module.exports = Interface( 'StepUi', { - /** - * Called after step data is processed - * @type {string} - */ - 'const EVENT_POST_PROCESS': 'postProcess', - - /** - * Called after step is appended to the DOM - * @type {string} - */ - 'const EVENT_POST_APPEND': 'postAppend', - - /** - * Called when data is changed (question value changed) - * @type {string} - */ - 'const EVENT_DATA_CHANGE': 'dataChange', - - /** - * Raised when an index is added to a group (e.g. row addition) - * @type {string} - */ - 'const EVENT_INDEX_ADD': 'indexAdd', - - /** - * Raised when an index is reset in a group (rather than removed) - * @type {string} - */ - 'const EVENT_INDEX_RESET': 'indexReset', - - /** - * Raised when an index is removed from a group (e.g. row deletion) - * @type {string} - */ - 'const EVENT_INDEX_REMOVE': 'indexRemove', - - /** - * Represents an action trigger - * @type {string} - */ - 'const EVENT_ACTION': 'action', - - /** - * Triggered when the step is active - * @type {boolean} - */ - 'const EVENT_ACTIVE': 'active', - - - /** - * Instance of step to style - * @type {Step} - */ - step: null, - - /** - * Step data (DOM representation) - * @type {jQuery} - */ - $content: null, - - /** - * Element styler - * @type {ElementStyler} - */ - styler: null, - - /** - * Whether the step should be repopulated with bucket data upon display - * @type {boolean} - */ - invalid: false, - - /** - * Stores group objects representing each group - * @type {Object.} - */ - groups: {}, - - /** - * Flag to let system know its currently saving the step - * @type {boolean} - */ - saving: false, - - /** - * Format bucket data for display - * @type {BucketDataValidator} - */ - 'private _formatter': null, - - /** - * Stores references to which group fields belong to - * @type {Object} - */ - 'private _fieldGroup': {}, - - /** - * Hash of answer contexts (jQuery) for quick lookup - * @type {Object} - */ - 'private _answerContext': {}, - - /** - * Hash of static answer indexes, if applicable - * @type {Object} - */ - 'private _answerStaticIndex': {}, - - /** - * Whether the step is the currently active (visible) step - * @type {boolean} - */ - 'private _active': false, - - /** - * Whether the step is locked (all elements disabled) - * @type {boolean} - */ - 'private _locked': false, - - 'private _forceAnswerUpdate': null, - - - /** - * Initializes StepUi object - * - * The data_get function is used to retrieve the step data, allowing the - * logic to be abstracted from the Step implementation. It must accept two - * arguments: the id of the step to load, and a callback function, as the - * operation is likely to be asynchronous. - * - * A callback function is used for when the step is ready to be used. This - * is done because the loading of the data is (ideally_ an asynchronous - * operation. This operation is performed in the constructor, to ensure - * that each instance of a Step class has data associated with it. - * Therefore, the object will be instantiated, but the data_get function - * will still be running in the background. The step should not be used - * until the data loading is complete. That is when the callback will be - * triggered. - * - * @return {undefined} - */ - 'public __construct': function( - step, - styler, - formatter - ) - { - this.step = step; - this.styler = styler; - this._formatter = formatter; - }, - - /** * Initializes step * * @return {undefined} */ - 'public init': function() - { - var _self = this; - - this.step.on( 'updateQuote', function() - { - _self._hookBucket(); - _self._processAnswerFields(); - _self.invalidate(); - } ); - - return this; - }, + 'public init': [], - 'public initGroupFieldData': function() - { - for ( var group in this.groups ) - { - var groupui = this.groups[ group ], - fields = groupui.group.getExclusiveFieldNames(); - - for ( var i in fields ) - { - this._fieldGroup[ fields[ i ] ] = groupui; - } - } - }, + 'public initGroupFieldData': [], /** @@ -242,26 +49,15 @@ module.exports = Class( 'StepUi' ) * * @return {StepUi} self */ - 'public setContent': function( content ) - { - // TODO: transition away from jQuery - this.$content = $( content ); - - this._processAnswerFields(); - - return this; - }, + 'public setContent': [ 'content' ], /** * Returns the step that this object is styling * - * @return lovullo.program.Step + * @return {Step} */ - getStep: function() - { - return this.step; - }, + 'public getStep': [], /** @@ -269,10 +65,7 @@ module.exports = Class( 'StepUi' ) * * @return {HTMLElement} generated step content */ - 'virtual getContent': function() - { - return this.$content[ 0 ]; - }, + 'public getContent': [], /** @@ -281,358 +74,7 @@ module.exports = Class( 'StepUi' ) * * @return undefined */ - setDirtyTrigger: function() - { - var step = this; - - this.$content.bind( 'change.program', function( event ) - { - // do nothing if the step is locked - if ( step._locked ) - { - return; - } - - // get the name of the altered element - var $element = step.styler.getNameElement( $( event.target ) ), - name = $element.attr( 'name' ), - val = $element.val(); - - if ( !( name ) ) - { - // rogue field not handled by the framework! - return; - } - - // remove the trailing square brackets from the name - name = name.substring( 0, ( name.length - 2 ) ); - - // get its index - var $elements = step.$content.find( "[name='" + name + "[]']" ), - index = $elements.index( $element ); - - - // todo: this is temporary to allow noyes and legacy radios to work. - if ( $element.hasClass( 'legacyradio' ) ) - { - index = 0; - } - else if ( $element.attr( 'type' ) === 'radio' - || $element.attr( 'type' ) === 'checkbox' - ) - { - // if it's not checked, then this isn't the radio we're - // interested in. Sorry! - if ( !( $element.attr( 'checked' ) ) ) - { - $element.attr( 'checked', true ); - - return; - } - - // 2 in this instance is the yes/no group length. - var group_length = $element.attr( 'data-question-length' ) - ? $element.attr( 'data-question-length' ) - : 2; - - index = Math.floor( index / group_length ); - } - - var values = {}; - values[ name ] = []; - values[ name ][ index ] = val; - - - // update our bucket with this new data - step.emit( step.__self.$('EVENT_DATA_CHANGE'), values ); - } ); - - // @note This is a hack. In IE8, checkbox change events don't properly fire. - this.$content.delegate( - 'input[type="checkbox"]', - 'click', - function () - { - // XXX: remove global - jQuery( this ).change(); - } - ); - }, - - - /** - * Prepares answer fields - * - * This method will populate the answer fields with values already in the - * bucket and hook the bucket so that future updates will also be reflected. - * - * @return {undefined} - */ - _processAnswerFields: function() - { - var _self = this, - bucket = this.step.getBucket(); - - this._prepareAnswerContexts(); - - // perform initial update for the step when we are first created, then - // hook everything else (we do not need the hooks before then, as we - // will be forcefully updating the step with values) - this.__inst.once( 'postAppend', function() - { - var forceupdate = false; - - // when the value we're watching is updated in the bucket, update - // the displayed value - var doUpdate; - bucket.on( 'stagingUpdate', doUpdate = function( data ) - { - // defer updates unless we're active - if ( !( _self._active ) ) - { - if ( forceupdate === false ) - { - forceupdate = true; - - // use __inst until we get the ease.js issue sorted out - // with extending non-class protoypes - _self.__inst.once( _self.__self.$('EVENT_ACTIVE'), function() - { - doUpdate( bucket.getData() ); - forceupdate = false; - } ); - } - - return; - } - - // give the UI a chance to update the DOM; otherwise, the - // answer elements we update may no longer be used (this also - // has performance benefits since it allows repainting before - // potentially heavy processing) - setTimeout( function() - { - _self._updateAnswerFieldData( data ); - }, 25 ); - } ); - - doUpdate( bucket.getData() ); - - // set the values when a row is added - _self.__inst.on( 'postAddRow', function( index ) - { - var data = bucket.getData(); - - for ( var name in _self._answerContext ) - { - var value = ( data[ name ] || {} )[ index ]; - - if ( value === undefined ) - { - continue; - } - - _self._updateAnswer( name, index, value ); - } - } ); - - this._forceAnswerUpdate = doUpdate; - } ); - }, - - - /** - * Update DOM answer fields with respective datum in diff DATA - * - * Only watched answer fields are updated. The update is performed on - * the discovered context during step initialization. - * - * @param {Object} data bucket diff - * - * @return {undefined} - */ - 'private _updateAnswerFieldData': function( data ) - { - // we only care if the data we're watching has been - // changed - for ( var name in data ) - { - if ( !( this._answerContext[ name ] ) ) - { - continue; - } - - var curdata = data[ name ], - si = this._answerStaticIndex[ name ], - i = curdata.length; - - // static index override - if ( !( isNaN( si ) ) ) - { - // update every index on the DOM - i = this.styler.getAnswerElementByName( - name, undefined, undefined, - this._answerContext[ name ] - ).length; - } - - while ( i-- ) - { - var index = ( isNaN( si ) ) ? i : si, - value = curdata[ index ]; - - // take into account diff; note that if one of - // them is null, that means it has been removed - // (and will therefore not be displayed), so we - // don't have to worry about clearing out a value - if ( ( value === undefined ) || ( value === null ) ) - { - continue; - } - - this._updateAnswer( name, i, curdata[ index ] ); - } - } - }, - - - 'private _prepareAnswerContexts': function() - { - var _self = this; - - // get a list of all the answer elements - this.$content.find( 'span.answer' ).each( function() - { - var $this = $( this ), - ref_id = $this.attr( 'data-answer-ref' ), - index = $this.attr( 'data-answer-static-index' ); - - // clear the value (which by default contains the name of the answer - // field) - $this.text( '' ); - - // if we've already found an element for this ref, then it is - // referenced in multiple places; simply store the context as the - // entire step - if ( _self._answerContext[ ref_id ] ) - { - _self._answerContext[ ref_id ] = _self.$content; - return; - } - - // store the parent fieldset as our context to make DOM lookups a - // bit more performant - _self._answerContext[ ref_id ] = $( this ).parents( 'fieldset' ); - _self._answerStaticIndex[ ref_id ] = ( index ) - ? +index - : NaN; - } ); - }, - - - /** - * Update the display of an answer field - * - * The value will be styled before display. - * - * @param {string} name field name - * @param {number} index index to update - * @param {string} value answer value (unstyled) - * - * @return {undefined} - */ - 'private _updateAnswer': function( name, index, value ) - { - var $element = this.styler.getAnswerElementByName( - name, index, null, ( this._answerContext[ name ] || this.$content ) - ); - - var i = $element.length; - if ( i > 0 ) - { - while( i-- ) - { - var styled = this.styler.styleAnswer( name, value ), - allow_html = $element[ i ] - .attributes[ 'data-field-allow-html' ] || {}; - - if ( allow_html.value === 'true' ) - { - $element.html( styled ); - } - else - { - $element.text( styled ); - } - - var id = $element[ i ].attributes['data-field-name']; - if ( !id ) - { - continue; - } - - this.emit( 'displayChanged', id.value, index, value ); - } - } - }, - - - /** - * Monitors the bucket for data changes and updates the elements accordingly - * - * @return undefined - */ - _hookBucket: function() - { - var _self = this; - - // when the bucket data is updated, update the element to reflect the - // value - this.step.getBucket().on( 'stagingUpdate', function( data ) - { - // if we're saving (filling the bucket), this is pointless - if ( _self.saving ) - { - return; - } - - var data_fmt = _self._formatter.format( data ); - - for ( var name in _self.step.getExclusiveFieldNames() ) - { - // if this data hasn't changed, then ignore the element - if ( data_fmt[ name ] === undefined ) - { - continue; - } - - // update each of the elements (it is important to update the - // number of elements on the screen, not the number of elements - // in the data array, since the array is a diff and will contain - // information regarding removed elements) - var data_len = data_fmt[ name ].length; - - for ( var index = 0; index < data_len; index++ ) - { - var val = data_fmt[ name ][ index ]; - - // if the value is not set or has been removed (remember, - // we're dealing with a diff), then ignore it - if ( ( val === undefined ) || ( val === null ) ) - { - continue; - } - - // set the value of the element using the appropriate group - // (for performance reasons, so we don't scan the whole DOM - // for the element) - _self.getElementGroup( name ).setValueByName( - name, index, val, false - ); - } - } - } ); - }, + 'public setDirtyTrigger': [], /** @@ -642,140 +84,29 @@ module.exports = Class( 'StepUi' ) * this step and call their postAppend() methods. If the group does not have * an element id, it will not function properly. * - * @return StepUi self to allow for method chaining + * @return {StepUi} self to allow for method chaining */ - postAppend: function() - { - // let the styler do any final styling - this.styler.postAppend( this.$content.parent() ); - - // If we have data in the bucket (probably loaded from the server), show - // it. We use a delay to ensure that the UI is ready for the update. In - // certain cases (such as with tabs), the UI may not have rendered all - // the elements. - this.emptyBucket( null, true ); - - // monitor bucket changes and update the elements accordingly - this._hookBucket(); - - this.emit( this.__self.$('EVENT_POST_APPEND') ); - - return this; - }, + 'public postAppend': [], /** * Empties the bucket into the step (filling the fields with its values) * - * @param Function callback function to call when bucket has been emptied + * @param {Function} callback function to call when bucket has been emptied * - * @return StepUi self to allow for method chaining + * @return {StepUi} self to allow for method chaining */ - emptyBucket: function( callback, delay ) - { - delay = ( delay === undefined ) ? false : true; - - var _self = this, - bucket = this.getStep().getBucket(), - fields = {}; - - // first, clear all the elements - for ( var group in this.groups ) - { - this.groups[group].preEmptyBucket( bucket ); - } - - // then update all the elements with the form values in the bucket - // (using setTimeout allows the browser UI thread to process repaints, - // added elements, etc, which will ensure that the elements will be - // available to empty into) - var empty = function() - { - var data = {}; - - for ( var name in _self.step.getExclusiveFieldNames() ) - { - data[ name ] = bucket.getDataByName( name ); - } - - // format the data (in-place, since we're the only ones using this - // object) - _self._formatter.format( data, true ); - - for ( var name in data ) - { - var values = data[ name ], - i = values.length; - - while ( i-- ) - { - // set the data and do /not/ trigger the change event - var group = _self.getElementGroup( name ); - if ( !group ) - { - // This should not happen (see FS#13653); emit an error - // and continue processing in the hopes that we can - // display most of the data - this.emit( 'error', Error( - "Unable to locate group for field `" + name + "'" - ) ); - - continue; - } - - var id = _self.getElementGroup( name ).setValueByName( - name, i, values[ i ], false - ); - } - } - - // answers are normally only updated on bucket change - _self._forceAnswerUpdate( bucket.getData() ); - - if ( callback instanceof Function ) - { - callback.call( _self ); - } - }; - - // either execute immediately or set a timer (allowing the UI to update) - // if a delay was requested - if ( delay ) - { - setTimeout( empty, 25 ); - } - else - { - empty(); - } - - return this; - }, + 'public emptyBucket': [ 'callback', 'delay' ], /** * Resets a step to its previous state or hooks the event * - * @param Function callback function to call when reset is complete + * @param {Function} callback function to call when reset is complete * - * @return StepUi self to allow for method chaining + * @return {StepUi} self to allow for method chaining */ - reset: function( callback ) - { - var step = this; - - this.getStep().getBucket().revert(); - - if ( typeof callback === 'function' ) - { - callback.call( this ); - } - - // clear invalidation flag - this.invalid = false; - - return this; - }, + 'public reset': [ 'callback' ], /** @@ -783,10 +114,7 @@ module.exports = Class( 'StepUi' ) * * @return Boolean true if all elements are valid, otherwise false */ - isValid: function( cmatch ) - { - return this.step.isValid( cmatch ); - }, + 'public isValid': [ 'cmatch' ], /** @@ -801,46 +129,7 @@ module.exports = Class( 'StepUi' ) * * @return String id of element, or empty string */ - 'public getFirstInvalidField': function( cmatch ) - { - var $element = this.$content.find( - '.invalid_field[data-field-name]:visible:first' - ); - - if ( $element.length === 0 ) - { - $element = this.$content.find( - '.invalid_field[data-field-name]:first' - ); - } - - var name = $element.attr( 'data-field-name' ); - - // no invalid fields, so what about missing required fields? - if ( !name ) - { - // append 'true' indiciating that this is a required field check - var result = this.step.getNextRequired( cmatch ); - if ( result !== null ) - { - result.push( true ); - } - - return result; - } - - // return the element name and index - return [ - name, - - // calculate index of this element - this.$content.find( '[data-field-name="' + name + '"]' ) - .index( $element ), - - // not a required field failure - false - ]; - }, + 'public getFirstInvalidField': [ 'cmatch' ], /** @@ -853,69 +142,7 @@ module.exports = Class( 'StepUi' ) * * @return {StepUi} self to allow for method chaining */ - 'public scrollTo': function( field, i, show_message, message ) - { - show_message = ( show_message === undefined ) ? true : !!show_message; - - if ( !( field ) || ( i < 0 ) || i === undefined ) - { - // cause may be empty - var cause = this.step.getValidCause(); - - this.emit( 'error', - Error( - 'Could not scroll: no field/index provided' + - ( ( cause ) - ? ' (cause: ' + cause + ')' - : '' - ) - ) - ); - } - - var index = this.styler.getProperIndex( field, i ), - $element = this.styler.getWidgetByName( field, index ); - - // if the element couldn't be found, then this is useless - if ( $element.length == 0 ) - { - this.emit( 'error', - Error( - 'Could not scroll: could not locate ' + field + '['+i+']' - ) - ); - } - - // allow the groups to preprocess the scrolling - for ( var group in this.groups ) - { - this.groups[ group ].preScrollTo( field, index ); - } - - // is the element visible now that we've given the groups a chance to - // display it? - if ( $element.is( ':visible' ) !== true ) - { - // fail; don't bother scrolling - this.emit( 'error', Error( - 'Could not scroll: element ' + field + ' is not visible' - ) ); - } - - // scroll to just above the first invalid question so that it - // may be fixed - var stepui = this; - this.$content.parent().scrollTo( $element, 100, { - offset: { top: -150 }, - onAfter: function() - { - // focus on the element and display the tooltip - stepui.styler.focus( $element, show_message, message ); - } - } ); - - return this; - }, + 'public scrollTo': [ 'field', 'i', 'show_message', 'message' ], /** @@ -926,10 +153,7 @@ module.exports = Class( 'StepUi' ) * * @return StepUi self to allow for method chaining */ - invalidate: function() - { - this.invalid = true; - }, + 'public invalidate': [], /** @@ -937,10 +161,7 @@ module.exports = Class( 'StepUi' ) * * @return Boolean true if step has been invalidated, otherwise false */ - isInvalid: function() - { - return this.invalid; - }, + 'public isInvalid': [], /** @@ -951,10 +172,7 @@ module.exports = Class( 'StepUi' ) * * @return {GroupUi} group if known, otherwise null */ - getElementGroup: function( name ) - { - return this._fieldGroup[ name ] || null; - }, + getElementGroup: [ 'name' ], /** @@ -964,163 +182,13 @@ module.exports = Class( 'StepUi' ) * * @return {StepUi} self */ - hideAddRemove: function( value ) - { - value = ( value !== undefined ) ? !!value : true; - - for ( var group in this.groups ) - { - var groupui = this.groups[ group ]; - if ( groupui.hideAddRemove instanceof Function ) - { - groupui.hideAddRemove( value ); - } - } - - return this; - }, + 'public hideAddRemove': [ 'value' ], - 'public preRender': function() - { - for ( var group in this.groups ) - { - this.groups[ group ].preRender(); - } - - return this; - }, + 'public preRender': [], - 'public visit': function( callback ) - { - // "invalid" means that the displayed data is not up-to-date - if ( this.invalid ) - { - this.emptyBucket(); - this.invalid = false; - } - - for ( var group in this.groups ) - { - this.groups[group].visit(); - } - - var _self = this, - cn = 0; - - // we perform async. processing, so ideally the caller should know - // when we're actually complete - var c = function() - { - if ( --cn === 0 ) - { - callback(); - } - }; - - this.step.eachSortedGroupSet( function( ids ) - { - cn++; - _self._sortGroups( ids, c ); - } ); - - if ( cn === 0 ) - { - callback && callback(); - } - - return this; - }, - - - 'private _sortGroups': function( ids, callback ) - { - // detach them all (TODO: a more efficient method could be to detach - // only the ones that aren not already in order, or ignore ones that - // would be hidden..etc) - var len = ids.length, - groups = []; - - if ( len === 0 ) - { - return; - } - - function getGroup( name ) - { - return document.getElementById( 'group_' + name ); - } - - var nodes = []; - for ( var i in ids ) - { - nodes[ i ] = getGroup( ids[ i ] ); - } - - var prev = nodes[ 0 ]; - if ( !( prev && prev.parentNode ) ) - { - return; - } - - var parent = prev.parentNode, - container = parent.parentNode, - i = len - 1; - - if ( !container ) - { - return; - } - - // to prevent DOM updates for each and every group move, detach the node - // that contains all the groups from the DOM; we'll re-add it after - // we're done - container.removeChild( parent ); - - // we can sort the groups in place without screwing up the DOM by simply - // starting with the last node and progressively inserting nodes - // before that element; we start at the end simply because there is - // Node#insertBefore, but no Node#insertAfter - setTimeout( function() - { - try - { - do - { - var group = nodes[ i ]; - - if ( !group ) - { - continue; - } - - // remove from DOM and reposition, unless we are already in - // position - if ( prev.previousSibling !== group ) - { - parent.removeChild( group ); - parent.insertBefore( group, prev ); - } - - prev = group; - } - while ( i-- ); - } - catch ( e ) - { - // we need to make sure we re-attach the container, so don't blow up - // if sorting fails - console.error && console.error( e, group, prev ); - } - - // now that sorting is complete, re-add the groups in one large DOM - // update - container.appendChild( parent ); - - callback(); - }, 25 ); - }, + 'public visit': [ 'callback' ], /** @@ -1133,25 +201,7 @@ module.exports = Class( 'StepUi' ) * * @return {StepUi} self */ - 'public setActive': function( active ) - { - active = ( active === undefined ) ? true : !!active; - - this._active = active; - - // notify each individual group of whether or not they are now active - for ( var id in this.groups ) - { - this.groups[ id ].setActive( active ); - } - - if ( active ) - { - this.emit( this.__self.$('EVENT_ACTIVE') ); - } - - return this; - }, + 'public setActive': [ 'active' ], /** @@ -1164,20 +214,5 @@ module.exports = Class( 'StepUi' ) * * @return {StepUi} self */ - 'public lock': function( lock ) - { - lock = ( lock === undefined ) ? true : !!lock; - - // if the lock has changed, then alter the elements - if ( lock !== this._locked ) - { - for ( var name in this.step.getExclusiveFieldNames() ) - { - this.styler.disableField( name, undefined, lock ); - } - } - - this._locked = lock; - return this; - } + 'public lock': [ 'lock' ] } ); From a60bf6b52e732b7a02c8edb586408845e6774853 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Thu, 3 Dec 2015 00:34:07 -0500 Subject: [PATCH 11/11] Liberated Group and its various UIs The `AccordionGroupUi` was not liberated, because it is my intent to eliminate it---I did not agree with its needless addition to begin with. If we do end up keeping it, then it will be liberated as well. --- src/group/Group.js | 247 +++++++++++++ src/ui/group/CollapseTableGroupUi.js | 433 +++++++++++++++++++++++ src/ui/group/SideTableGroupUi.js | 281 +++++++++++++++ src/ui/group/TabbedBlockGroupUi.js | 495 +++++++++++++++++++++++++++ src/ui/group/TabbedGroupUi.js | 448 ++++++++++++++++++++++++ src/ui/group/TableGroupUi.js | 428 +++++++++++++++++++++++ 6 files changed, 2332 insertions(+) create mode 100644 src/group/Group.js create mode 100644 src/ui/group/CollapseTableGroupUi.js create mode 100644 src/ui/group/SideTableGroupUi.js create mode 100644 src/ui/group/TabbedBlockGroupUi.js create mode 100644 src/ui/group/TabbedGroupUi.js create mode 100644 src/ui/group/TableGroupUi.js diff --git a/src/group/Group.js b/src/group/Group.js new file mode 100644 index 0000000..29bc101 --- /dev/null +++ b/src/group/Group.js @@ -0,0 +1,247 @@ +/** + * Group of fields + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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; + + +/** + * Group of fields + */ +module.exports = Class( 'Group', +{ + /** + * Maximum number of rows permitted + * + * Must be 0 by default (not 1) to ensure we are unbounded by default. + * + * @type {number} + */ + 'private _maxRows': 0, + + /** + * Minimum number of rows permitted + * @type {number} + */ + 'private _minRows': 1, + + /** + * Whether the group is preventing from adding/removing rows + * @type {boolean} + */ + 'private _locked': false, + + /** + * Stores names of fields available in the group (includes linked) + * @type {Array.} + */ + 'private _fields': [], + + /** + * Stores names of fields available exclusively in the group (no linked) + * @type {Array.} + */ + 'private _exclusiveFields': [], + + /** + * Hashed exclusive fields for quick lookup + * @type {Object} + */ + 'private _exclusiveHash': {}, + + /** + * Names of fields that are visible to the user + * + * For example: excludes external fields, but includes display. + * + * @type {Array.} + */ + 'private _userFields': [], + + + /** + * The id of the field that will determine the number of indexes in the + * group + * + * @type {string} + */ + 'private _indexFieldName': '', + + + /** + * Gets or sets the maximum numbers of rows that may appear in the group + * + * @param Integer max maximum number of rows + * + * @return Group|Boolean self if settings, otherwise min rows value + */ + maxRows: function( max ) + { + if ( max !== undefined ) + { + this._maxRows = +max; + return this; + } + + return this._maxRows; + }, + + + /** + * Gets or sets the minimum numbers of rows that may appear in the group + * + * @param Integer min minimum number of rows + * + * @return Group|Boolean self if setting, otherwise the min row value + */ + minRows: function( min ) + { + if ( min !== undefined ) + { + this._minRows = +min; + return this; + } + + return this._minRows; + }, + + + /** + * Gets or sets the locked status of a group + * + * When a group is locked, rows/groups cannot be removed + * + * @param Boolean locked whether the group should be locked + * + * @return Group|Boolean self if setting, otherwise locked status + */ + locked: function( locked ) + { + if ( locked !== undefined ) + { + this._locked = !!locked; + return this; + } + + return this._locked; + }, + + + /** + * Set names of fields available in the group + * + * @param {Array.} fields field names + * + * @return {undefined} + */ + 'public setFieldNames': function( fields ) + { + // store copy of the fields to ensure that modifying the array that was + // passed in does not modify our values + this._fields = fields.slice( 0 ); + + return this; + }, + + + /** + * Returns the group field names + * + * @return {Array.} + */ + 'public getFieldNames': function() + { + return this._fields; + }, + + + /** + * Set names of fields available in the group (no linked) + * + * @param {Array.} fields field names + * + * @return {undefined} + */ + 'public setExclusiveFieldNames': function( fields ) + { + // store copy of the fields to ensure that modifying the array that was + // passed in does not modify our values + this._exclusiveFields = fields.slice( 0 ); + + // hash 'em for quick lookup + var i = fields.length; + while ( i-- ) + { + this._exclusiveHash[ fields[ i ] ] = true; + } + + return this; + }, + + + /** + * Returns the group field names (no linked) + * + * @return {Array.} + */ + 'public getExclusiveFieldNames': function() + { + return this._exclusiveFields; + }, + + + 'public setUserFieldNames': function( fields ) + { + this._userFields = fields.slice( 0 ); + return this; + }, + + + 'public getUserFieldNames': function() + { + return this._userFields; + }, + + + /** + * Returns whether the group contains the given field + * + * @param {string} field name of field + * + * @return {boolean} true if exclusively contains field, otherwise false + */ + 'public hasExclusiveField': function( field ) + { + return !!this._exclusiveHash[ field ]; + }, + + + 'public setIndexFieldName': function( name ) + { + this._indexFieldName = ''+name; + return this; + }, + + + 'public getIndexFieldName': function() + { + return this._indexFieldName; + } +} ); diff --git a/src/ui/group/CollapseTableGroupUi.js b/src/ui/group/CollapseTableGroupUi.js new file mode 100644 index 0000000..092d988 --- /dev/null +++ b/src/ui/group/CollapseTableGroupUi.js @@ -0,0 +1,433 @@ +/** + * Group collapsable table UI + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Remove reliance on jQuery. + * - Dependencies need to be liberated: Styler; Group. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + GroupUi = require( './GroupUi' ); + + +module.exports = Class( 'CollapseTableGroupUi' ) + .extend( GroupUi, +{ + /** + * Percentage width of the left column + * @type {number} + */ + 'private const WIDTH_COL_LEFT_PERCENT': 30, + + /** + * Base rows for each unit + * @type {jQuery} + */ + 'private _$baseRows': null, + + /** + * Number of rows in the unit + * @type {number} + */ + 'private _rowCount': 0, + + /** + * Indexes to use for styled elements + * @type {number} + */ + 'private _elementIndex': 1, + + /** + * Flags that, when true in the bucket, will replace each individual row + * with a single cell (used for ineligibility, for example + * + * @type {Array.} + */ + 'private _blockFlags': [], + + /** + * Contains true/false values of each of the block flags + * @var {Object} + */ + 'private _blockFlagValues': {}, + + /** + * Summary to display on unit row if block flag is set + * @var {string} + */ + 'private _blockFlagSummary': '', + + 'private _blockDisplays': null, + + + + 'override protected processContent': function() + { + this._processTableRows(); + + // determine if we should lock this group down + if ( this.$content.find( '.groupTable' ).hasClass( 'locked' ) ) + { + this.group.locked( true ); + } + + // if the group is locked, there will be no adding of rows + if ( this.group.locked() ) + { + this.$content.find( '.addrow' ).remove(); + } + + var $tbody = this.$content.find( 'tbody' ); + + // block flags are comma-separated (derived from the XML, which has + // comma-separated values for consistency with the other properties) + this._blockFlags = $tbody.attr( 'blockFlags' ).split( ',' ); + this._blockFlagSummary = $tbody.attr( 'blockFlagSummary' ) || ''; + + this._blockDisplays = this._getBlockDisplays(); + }, + + + 'private _processTableRows': function() + { + this._$baseRows = this.$content + .find( 'tbody > tr:not(.footer)' ) + .detach(); + + this._calcColumnWidths(); + + this._rowCount = this._$baseRows.length; + }, + + + /** + * Retrieve and detach block-mode displays for each column + * + * @return {jQuery} block-mode display elements + */ + 'private _getBlockDisplays': function() + { + return this.$content + .find( 'div.block-display' ) + .detach(); + }, + + + /** + * Calculates column widths + * + * Ensures that the left column takes up a consistent amount of space and + * that each of the remaining columns are distributed evenly across the + * remaining width of the table. + * + * As a consequence of this operation, any table with N columns will be + * perfectly aligned with any other table of N columns. + * + * See FS#7916 and FS#7917. + * + * @return {undefined} + */ + 'private _calcColumnWidths': function() + { + // the left column will take up a consistent amount of width and the + // remainder of the width (in percent) will be allocated to the + // remaining columns + var left = this.__self.$( 'WIDTH_COL_LEFT_PERCENT' ), + remain = 100 - left, + + // we will calculate and apply the width to the parent columns (this + // allows subcols to vary, which we may not want, but ensures that + // tables of N columns will always be aligned even if they have no + // subcolumns) + $cols = this.$content.find( 'tr:nth(0) > th:not(:first)' ), + count = $cols.length, + + width = Math.floor( remain / count ); + + // set the widths of the left column and each of the remaining columns + this.$content.find( 'tr:first > th:first' ) + .attr( 'width', ( left + '%' ) ); + + $cols.attr( 'width', ( width + '%' ) ); + }, + + + /** + * Collapses all units + * + * @param {jQuery} $unit unit to collapse + * + * @return {undefined} + */ + 'private _collapse': function( $unit ) + { + $unit.filter( ':not(.unit)' ).hide(); + + $unit.filter( '.unit' ) + .addClass( 'collapsed' ) + .find( 'td:first' ) + .addClass( 'first' ) + .addClass( 'collapsed' ); + }, + + + /** + * Initializes unit toggle on click + * + * @param {jQuery} $unit unit to initialize toggle on + * + * @return {undefined} + */ + 'private _initToggle': function( $unit ) + { + $unit.filter( 'tr.unit' ) + // we set the CSS here because IE doesn't like :first-child + .css( 'cursor', 'pointer' ) + .click( function() + { + var $node = $( this ); + + $node.filter( '.unit' ) + .toggleClass( 'collapsed' ) + .find( 'td:first' ) + .toggleClass( 'collapsed' ); + + $node.nextUntil( '.unit, .footer' ).toggle(); + } ) + .find( 'td:first' ) + .addClass( 'first' ); + }, + + + 'private _getTableBody': function() + { + return this.$content.find( 'tbody' ); + }, + + + /** + * Determines if the block flag is set for any column and converts it to a + * block as necessary + * + * This looks more complicated than it really is. Here's what we're doing: + * - For the unit row: + * - Remove all cells except the first and span first across area + * - For all other rows: + * - Remove all but the first cell + * - Expand first cell to fit the area occupied by all of the removed + * cells + * - Replace content with text content of the flag + * - Adjust width slightly so it doesn't take up too much room + * + * @param {jQuery} $unit generated unit nodes + * + * @return {undefined} + */ + 'private _initBlocks': function( $unit ) + { + for ( var i = 0, len = this._blockFlags.length; i < len; i++ ) + { + var flag = this._blockFlags[ i ]; + + // ignore if the flag is not set + if ( this._blockFlagValues[ flag ] === false ) + { + continue; + } + + var $rows = $unit.filter( 'tr:not(.unit)' ), + $cols = $rows.find( 'td[columnIndex="' + i + '"]' ), + + col_len = $rows + .first() + .find( 'td[columnIndex="' + i + '"]' ) + .length + ; + + // combine cells in unit row and remove content + $unit.filter( '.unit' ) + .find( 'td[columnIndex="' + i + '"]' ) + .filter( ':not(:first)' ) + .remove() + .end() + .attr( 'colspan', col_len ) + .addClass( 'blockSummary' ) + + // TODO: this doesn't really belong here; dynamic block flag + // summaries would be better + .text( ( /Please wait.../.test( '' ) ) + ? 'Please wait...' + : this._blockFlagSummary + ); + + + // remove all cells associated with this unit except for the first, + // which we will expand to fill the area previously occupied by all + // the cells and replace with the content of the block flag (so it's + // not really a flag; I know) + $cols + .filter( ':not(:first)' ) + .remove() + .end() + .addClass( 'block' ) + .attr( { + colspan: col_len, + rowspan: $rows.length + } ) + .html( '' ) + .append( this._blockDisplays[ i ] ); + } + }, + + + /** + * Returns all rows associated with a unit index + * + * The provided index is expected to be 1-based. + * + * @param {number} index 1-based index of unit + * + * @return {jQuery} unit rows + */ + 'private _getUnitByIndex': function( index ) + { + return this._getTableBody() + .find( 'tr.unit:not(.footer):nth(' + index + ')' ) + .nextUntil( '.unit, .footer' ) + .andSelf(); + }, + + + 'public addRow': function() + { + var $unit = this._$baseRows.clone( true ), + index = this.getCurrentIndex(); + + // properly name the elements to prevent id conflicts + this.setElementIdIndexes( $unit.find( '*' ), index ); + + // add the index to the row title + $unit.find( 'span.rowindex' ).text( ' ' + ( index + 1 ) ); + + // add to the table (before the footer, if one has been provided) + var footer = this._getTableBody().find( 'tr.footer' ); + if ( footer.length > 0 ) + { + footer.before( $unit ); + } + else + { + this._getTableBody().append( $unit ); + } + + // finally, style our new elements + this.styler.apply( $unit ); + + // the unit should be hidden by default and must be toggle-able (fun + // word) + this._initBlocks( $unit ); + this._initToggle( $unit ); + + // only collapse if we have multiple units + if ( index > 0 ) + { + this._collapse( $unit ); + + // if this is the 2nd unit, we need to collapse the first + if ( index === 1 ) + { + this._collapse( this._getUnitByIndex( index - 1 ) ); + } + } + + // this will handle post-add processing, such as answer hooking + this.postAddRow( $unit, index ); + }, + + + 'public removeRow': function() + { + var $rows = this._getUnit( this.getCurrentIndex() ); + + // remove rows + this.styler.remove( $rows ); + $rows.remove(); + + return this; + + }, + + + 'private _getUnit': function( index ) + { + var start = this._rowCount * index; + + return this._getTableBody() + .find( 'tr:nth(' + start + '):not( .footer )' ) + .nextUntil( '.unit, .footer' ) + .andSelf(); + }, + + + 'override public preEmptyBucket': function( bucket, updated ) + { + // retrieve values for each of the block flags + for ( var i = 0, len = this._blockFlags.length; i < len; i++ ) + { + var flag = this._blockFlags[ i ]; + + this._blockFlagValues[ flag ] = + bucket.getDataByName( flag )[ 0 ] || false; + } + + var _super = this.__super; + + // remove and then re-add each index (the super method will re-add) + // TODO: this is until we can properly get ourselves out of block mode + while ( this.getCurrentIndexCount() ) + { + this.removeIndex(); + } + + _super.call( this, bucket ); + return this; + }, + + + 'override protected addIndex': function( index ) + { + // increment id before doing our own stuff + this.__super( index ); + this.addRow(); + + return this; + }, + + + 'override public removeIndex': function( index ) + { + // remove our stuff before decrementing our id + this.removeRow(); + this.__super( index ); + + return this; + } +} ); diff --git a/src/ui/group/SideTableGroupUi.js b/src/ui/group/SideTableGroupUi.js new file mode 100644 index 0000000..6bbffdf --- /dev/null +++ b/src/ui/group/SideTableGroupUi.js @@ -0,0 +1,281 @@ +/** + * Group side-formatted table UI + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Remove reliance on jQuery. + * - Dependencies need to be liberated: Styler; Group. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + GroupUi = require( './GroupUi' ); + + +/** + * Represents a side-formatted table group + * + * This class extends from the generic Group class. It contains logic to + * support tabbed groups, allowing for the adding and removal of tabs. + */ +module.exports = Class( 'SideTableGroupUi' ) + .extend( GroupUi, +{ + /** + * Percentage width of the left column + * @type {number} + */ + 'private const WIDTH_COL_LEFT_PERCENT': 30, + + /** + * Stores the base title for each new tab + * @type {string} + */ + $baseHeadColumn: null, + + /** + * Stores the base tab content to be duplicated for tabbed groups + * @type {jQuery} + */ + $baseBodyColumn: null, + + /** + * Table element + * @type {jQuery} + */ + $table: null, + + /** + * Number of subcolumns within each column + * @type {number} + */ + subcolCount: 1, + + + /** + * Template method used to process the group content to prepare it for + * display + * + * @return void + */ + 'override protected processContent': function() + { + this.__super(); + + // determine if we should lock this group down + if ( this.$content.find( '.groupTable' ).hasClass( 'locked' ) ) + { + this.group.locked( true ); + } + + this._processTable(); + }, + + + _processTable: function() + { + this.$table = this._getTable(); + + // important: do this before we begin detaching things + this._calcColumnWidths(); + + // Any content that is not the side column is to be considered the first + // data column. detach() is used to ensure events and data remain. + this.$baseHeadColumn = this.$table.find( 'thead' ) + .find( 'th:not( .groupTableSide )' ).detach(); + this.$baseBodyColumn = this.$table.find( 'tbody' ) + .find( 'td:not( .groupTableSide )' ).detach(); + + this.subcolCount = +( $( this.$baseHeadColumn[0] ).attr( 'colspan' ) ); + + // if the group is locked, there will be no adding of rows + if ( this.group.locked() ) + { + this.$content.find( '.addrow' ).remove(); + } + }, + + + /** + * Calculates column widths + * + * Ensures that the left column takes up a consistent amount of space and + * that each of the remaining columns are distributed evenly across the + * remaining width of the table. + * + * As a consequence of this operation, any table with N columns will be + * perfectly aligned with any other table of N columns. + * + * See FS#7916 and FS#7917. + * + * @return {undefined} + */ + 'private _calcColumnWidths': function() + { + // the left column will take up a consistent amount of width and the + // remainder of the width (in percent) will be allocated to the + // remaining columns + var left = this.__self.$( 'WIDTH_COL_LEFT_PERCENT' ), + remain = 100 - left, + + $cols = this.$content.find( 'tr:nth(1) > th' ), + count = $cols.length, + + width = Math.floor( remain / count ); + + // set the widths of the left column and each of the remaining columns + this.$content.find( 'tr:first > th:first' ) + .attr( 'width', ( left + '%' ) ); + + $cols.attr( 'width', ( width + '%' ) ); + }, + + + _getTable: function() + { + return this.$content.find( 'table.groupTable' ); + }, + + + addColumn: function() + { + var $col_head = this.$baseHeadColumn.clone( true ), + $col_body = this.$baseBodyColumn.clone( true ), + $thead = this.$table.find( 'thead' ), + $tbody = this.$table.find( 'tbody' ), + index = this.getCurrentIndex(); + + // properly name the elements to prevent id conflicts + this.setElementIdIndexes( $col_head.find( '*' ), index ); + this.setElementIdIndexes( $col_body.find( '*' ), index ); + + // add the column headings + $col_head.each( function( i, th ) + { + var $th = $( th ); + + // the first cell goes in the first header row and all others go in + // the following row + var $parent = null; + if ( i === 0 ) + { + $parent = $thead.find( 'tr:nth(0)' ); + + // add the index to the column title + $th.find( 'span.colindex' ).text( ' ' + ( index + 1 ) ); + } + else + { + $parent = $thead.find( 'tr:nth(1)' ); + } + + $parent.append( $th ); + }); + + // add the column body cells + var subcol_count = this.subcolCount; + $col_body.each( function( i, $td ) + { + $tbody.find( 'tr:nth(' + ( Math.floor( i / subcol_count ) ) + ')' ) + .append( $td ); + }); + + // finally, style our new elements + this.styler + .apply( $col_head ) + .apply( $col_body ); + + // raise event + this.postAddRow( $col_head, index ) + .postAddRow( $col_body, index ); + + return this; + }, + + + /** + * Removes a column from the table + * + * @return {SideTableGroupUi} self + */ + removeColumn: function() + { + // remove the last column + var index = this.getCurrentIndex(); + + // the column index needs to take into account that the first column is + // actually the side column (which shouldn't be considered by the user) + var col_index = ( index + 1 ), + $subcols = this._getSubColumns( index ); + + // remove the top header for this column + this.$table.find( 'thead > tr:first > th:nth(' + col_index + ')' ) + .remove(); + + // remove sub-columns + this.styler.remove( $subcols ); + $subcols.remove(); + + return this; + }, + + + _getSubColumns: function( index ) + { + // calculate the positions of the sub-columns + var start = ( ( index * this.subcolCount ) + 1 ), + end = start + this.subcolCount; + + var selector = ''; + + for ( var i = start; i < end; i++ ) + { + if ( selector ) + { + selector += ','; + } + + // add this sub-column to the selector + selector += 'thead > tr:nth(1) > th:nth(' + ( i - 1 ) + '), ' + + 'tbody > tr > td:nth-child(' + ( i + 1 ) + ')'; + } + + return this.$table.find( selector ); + }, + + + 'override protected addIndex': function( index ) + { + // increment id before doing our own stuff + this.__super( index ); + this.addColumn(); + + return this; + }, + + + 'override public removeIndex': function( index ) + { + // remove our stuff before decrementing our id + this.removeColumn(); + this.__super( index ); + + return this; + } +} ); diff --git a/src/ui/group/TabbedBlockGroupUi.js b/src/ui/group/TabbedBlockGroupUi.js new file mode 100644 index 0000000..d43fd2b --- /dev/null +++ b/src/ui/group/TabbedBlockGroupUi.js @@ -0,0 +1,495 @@ +/** + * Group tabbed block UI + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Remove reliance on jQuery. + * - Dependencies need to be liberated: Styler; Group. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + GroupUi = require( './GroupUi' ); + +/** + * Represents a tabbed block group + * + * Does not currently support removing individual tabs (it will only clear + * out all tabs) + */ +module.exports = Class( 'TabbedGroupUi' ).extend( GroupUi, +{ + /** + * The parent element boxy thingy that contains all other elements + * @type {jQuery} + */ + 'private _$box': null, + + /** + * The list containing all clickable tabs + * @type {jQuery} + */ + 'private _$tabList': null, + + /** + * Element representing a tab itself + * @type {jQuery} + */ + 'private _$tabItem': null, + + /** + * Base tab content element + * @type {jQuery} + */ + 'private _$contentItem': null, + + /** + * Index of the currently selected tab + * @type {number} + */ + 'private _curIndex': 0, + + /** + * Disable flags + * @type {string} + */ + 'private _disableFlags': [], + + /** + * Bucket prefix for "tab extraction" source data + * @type {string} + */ + 'private _tabExtractSrc': '', + + /** + * Bucket prefix for "tab extraction" result data + * @type {string} + */ + 'private _tabExtractDest': '', + + 'private _bucket': null, + + /** + * Field to check for length (number of tabs); will default to first field + * @type {string} + */ + 'private _lengthField': '', + + + 'override protected processContent': function( quote ) + { + this.__super(); + + // determine if we should lock this group down + if ( this.$content.find( '.groupTabbedBlock' ).hasClass( 'locked' ) ) + { + this.group.locked( true ); + } + + this._processNonInternalHides( quote ); + this._processTabExtract(); + this._processElements(); + this._processAddButton(); + this._processLengthField(); + }, + + + 'private _processNonInternalHides': function( quote ) + { + var _self = this; + + // hide flags + this._disableFlags = this._getBox() + .attr( 'data-disable-flags' ) + .split( ';' ); + + quote.visitData( function( bucket ) + { + _self._bucket = bucket; + } ); + }, + + + 'private _processTabExtract': function() + { + var $box = this._getBox(); + + this._tabExtractSrc = $box.attr( 'data-tabextract-src' ); + this._tabExtractDest = $box.attr( 'data-tabextract-dest' ); + }, + + + 'private _processElements': function() + { + this._$box = this.$content.find( '.groupTabbedBlock' ); + this._$tabList = this._$box.find( 'ul.tabs' ); + + this._$tabItem = this._$box.find( 'li' ).detach(); + this._$contentItem = this._$box.find( '.tab-content' ).detach(); + }, + + + 'private _processAddButton': function() + { + var _self = this, + $btn = this._getAddButton(); + + if ( this.group.locked() ) + { + $btn.hide(); + return; + } + + $btn.click( function() + { + _self.initTab(); + } ); + }, + + + 'private _processLengthField': function() + { + this._lengthField = this._getBox().attr( 'data-length-field' ) || ''; + }, + + + 'private _processHideFlags': function( data ) + { + var n = 0; + + var disables = []; + for ( var i in this._disableFlags ) + { + var flag = this._disableFlags[ i ]; + + for ( var tabi in ( data[ flag ] || {} ) ) + { + var val = data[ flag ][ tabi ], + hide = !( ( val === '' ) || ( +val === 0 ) ); + + // hides should be preserved for multiple criteria + disables[ tabi ] = ( disables[ tabi ] || false ) || hide; + } + } + + // perform the show/hide + for ( var tabi in disables ) + { + var hide = disables[ tabi ]; + this._disableTab( tabi, hide ); + + // count the number of hidden + n += +hide; + } + + this._getBox().toggleClass( + 'disabled', + ( n >= this._getTabCount() ) + ); + }, + + + 'private _disableTab': function( i, disable ) + { + this._getTab( i ).toggleClass( 'disabled', disable ); + //this._getTabContent( i ).addClass( 'hidden', disable ); + }, + + + 'private _removeTab': function( index ) + { + this._getTab( index ).remove(); + this._getTabContent( index ).remove(); + }, + + + 'override public getFirstElementName': function( _ ) + { + return this._lengthField || this.__super(); + }, + + + 'override protected postPreEmptyBucketFirst': function() + { + // select the first tab + this._selectTab( 0 ); + return this; + }, + + + 'override protected addIndex': function( index ) + { + this.addTab(); + this.__super( index ); + return this; + }, + + + 'override public removeIndex': function( index ) + { + this._removeTab( this.getCurrentIndexCount() - 1 ); + this.__super( index ); + return this; + }, + + + 'private _getAddButton': function() + { + return this.$content.find( '.addTab:first' ); + }, + + + 'private _showAddButton': function() + { + // only show if we're not locked + if ( this.group.locked() ) + { + return; + } + + this._getAddButton().show(); + }, + + + 'private _hideAddButton': function() + { + this._getAddButton().hide(); + }, + + + 'private _checkAddButton': function() + { + // max rows reached + if ( this.group.maxRows() + && ( this.getCurrentIndexCount() === this.group.maxRows() ) + ) + { + this._hideAddButton(); + } + else + { + this._showAddButton(); + } + }, + + + 'private _getNextIndex': function() + { + var index = this.getCurrentIndexCount(); + + if ( this.group.maxRows() + && ( index === this.group.maxRows() ) + ) + { + throw Error( 'Max rows reached' ); + } + + return index; + }, + + + 'private _getTabCount': function() + { + return this.getCurrentIndexCount(); + }, + + + 'public addTab': function() + { + try + { + var index = this._getNextIndex(); + } + catch ( e ) + { + this._checkAddButton(); + return false; + } + + // append the tab itself + this._$tabList.append( this._createTab( index ) ); + + // append the tab content + this._$box + .find( '.tabClear:last' ) + .before( this._createTabContent( index ) ); + + // hide the add button if needed + this._checkAddButton(); + + this._hideTab( index ); + + return true; + }, + + + 'private _createTab': function( index ) + { + var _self = this; + + return this._finalizeContent( index, + this._$tabItem.clone( true ) + .click( function() + { + _self._selectTab( index ); + } ) + .find( 'a' ) + // prevent anchor clicks from updating the URL + .click( function( event ) + { + event.preventDefault(); + } ) + .end() + ); + }, + + + 'private _createTabContent': function( index ) + { + return this._finalizeContent( index, + this._$contentItem.clone( true ) + ); + }, + + + 'private _finalizeContent': function( index, $content ) + { + // apply styling and id safeguards + this.setElementIdIndexes( $content.find( '*' ), index ); + this.styler.apply( $content ); + + // allow hooks to perform their magic on our content + this.postAddRow( $content, index ); + + return $content; + }, + + + 'private _selectTab': function( index ) + { + this._hideTab( this._curIndex ); + this._showTab( this._curIndex = +index ); + + this._tabExtract( index ); + }, + + + 'private _tabExtract': function( index ) + { + var _self = this; + + function pred( name ) + { + // determine if the name matches the expected prefix (previously, + // this was a regex, but profiling showed that performance was very + // negatively impacted, so this is the faster solution) + return ( name.substr( 0, _self._tabExtractSrc.length ) === + _self._tabExtractSrc + ); + } + + // wait for a repaint so that we don't slow down the tab selection + setTimeout( function() + { + var cur = {}; + _self._bucket.filter( pred, function( data, name ) + { + var curdata = data[ index ]; + + // ignore bogus data + if ( ( curdata === undefined ) || ( curdata === null ) ) + { + return; + } + + // guess if this is an array (if not, then it needs to be, since + // we'll be storing it in the bucket) + if ( ( typeof curdata === 'string' ) + || ( curdata.length === undefined ) + ) + { + curdata = [ curdata ]; + } + + cur[ _self._tabExtractDest + name ] = curdata; + } ); + + _self._bucket.setValues( cur ); + }, 25 ); + }, + + + 'private _getBox': function() + { + // avoiding use of jQuery selector because it caches DOM elements, which + // causes problems in other parts of the framework + return $( this.$content[ 0 ].getElementsByTagName( 'div' )[ 0 ] ); + }, + + + 'private _getTabContent': function( index ) + { + return this._$box.find( '.tab-content:nth(' + index + ')' ); + }, + + + 'private _getTab': function( index ) + { + return this._$tabList.find( 'li:nth(' + index + ')' ); + }, + + + 'private _showTab': function( index ) + { + this._getTab( index ).removeClass( 'inactive' ); + this._getTabContent( index ).removeClass( 'inactive' ); + }, + + + 'private _hideTab': function( index ) + { + this._getTab( index ).addClass( 'inactive' ); + this._getTabContent( index ).addClass( 'inactive' ); + }, + + + 'private _getLastEligibleTab': function() + { + var tab_index = this._$tabList.find( 'li' ).not( '.disabled' ).last().index(); + return ( tab_index === -1 ) + ? 0 + : tab_index; + }, + + + 'override public visit': function() + { + // let supertype do its own thing + this.__super(); + + // we will have already rated once by the time this is called + this._processHideFlags( this._bucket.getData() ); + + // select first tab that is eligible and + // perform tab extraction (to reflect first eligible tab) + this._selectTab( this._getLastEligibleTab() ); + + return this; + } +} ); diff --git a/src/ui/group/TabbedGroupUi.js b/src/ui/group/TabbedGroupUi.js new file mode 100644 index 0000000..45bc61f --- /dev/null +++ b/src/ui/group/TabbedGroupUi.js @@ -0,0 +1,448 @@ +/** + * Group tabbed UI + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Remove reliance on jQuery. + * - Dependencies need to be liberated: Styler; Group. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + GroupUi = require( './GroupUi' ); + + +/** + * Represents a tabbed group + * + * This class extends from the generic Group class. It contains logic to + * support tabbed groups, allowing for the adding and removal of tabs. + */ +module.exports = Class( 'TabbedGroupUi' ) + .extend( GroupUi, +{ + /** + * Stores the base title for each new tab + * @type {string} + */ + $baseTabTitle: '', + + /** + * Stores the base tab content to be duplicated for tabbed groups + * @type {jQuery} + */ + $baseTabContent: null, + + /** + * Index of the currently selected tab + * @type {number} + */ + 'private _selectedIndex': 0, + + + /** + * Template method used to process the group content to prepare it for + * display + * + * @return void + */ + 'override protected processContent': function( quote ) + { + this.__super(); + + // determine if we should lock this group down + if ( this.$content.find( 'div.groupTabs' ).hasClass( 'locked' ) ) + { + this.group.locked( true ); + } + + this._processTabs(); + this._attachAddTabHandlers(); + this.watchFirstElement( this.$baseTabContent, quote ); + }, + + + /** + * Initializes the tabs + * + * This method will locate the area of HTML that should be tabbed and + * initialize it. The content of the first tab will be removed and stored in + * memory for duplication. + * + * @return void + */ + _processTabs: function() + { + var group = this; + var $container = this._getTabContainer(); + + if ( $container.length == 0 ) + { + return; + } + + // grab the title to be used for all the tabs + this.$baseTabTitle = $container.find( 'li:first' ).remove() + .find( 'a' ).text(); + + // the base content to be used for each of the tabs (detach() not + // remove() to ensure the data remains) + this.$baseTabContent = $container.find( 'div:first' ).detach(); + + // transform into tabbed div + $container.tabs( { + tabTemplate: + '
  • #{label}' + + ( ( this.group.locked() === false ) + ? 'Remove Tab' + : '' + ) + '
  • ', + + select: function( _, event ) + { + group._selectedIndex = event.index; + }, + + add: function() + { + var $this = $( this ); + + // if this is our max, hide the button + if ( $this.tabs( 'length' ) == group.group.maxRows() ) + { + group._getAddButton().hide(); + } + + // select the new tab + $this.tabs( 'select', $this.tabs( 'length' ) - 1 ); + + // remove tabs when the remove button is clicked (for whatever + // reason, live() stopped working, so here we are...) + $container.find( 'span.ui-icon-close:last' ).click( function() + { + var index = $container.find( 'li' ) + .index( $( this ).parent() ); + + group.destroyIndex( index ); + }); + }, + + remove: function() + { + // should we re-show the add button? + if ( $( this ).tabs( 'length' ) == + ( group.group.maxRows() - 1 ) + ) + { + group._getAddButton().show(); + } + } + } ); + }, + + + /** + * Attaches click event handlers to add tab elements + * + * @return void + */ + _attachAddTabHandlers: function() + { + // reference to ourself for use in the closure + var group = this; + + // if we're locked, we won't allow additions + if ( this.group.locked() ) + { + this._getAddButton().remove(); + return; + } + + // any time an .addrow element is clicked, we want to add a row to the + // group + this._getAddButton().click( function() + { + group.initIndex(); + }); + }, + + + /** + * Returns the element containing the tabs + * + * @return jQuery element containing the tabs + */ + _getTabContainer: function() + { + return this.$content.find( '.groupTabs' ); + }, + + + _getAddButton: function() + { + return this.$content.find( '.addTab:first' ); + }, + + + 'private _getTabTitleIndex': function() + { + return this.getCurrentIndexCount(); + }, + + + /** + * Adds a tab + * + * @return TabbedGroup self to allow for method chaining + */ + addTab: function() + { + var $container = this._getTabContainer(); + + var $content = this.$baseTabContent.clone( true ); + var id = $content.attr( 'id' ); + var index = this.getCurrentIndex(); + + // generate a new id + id = ( id + '_' + index ); + $content.attr( 'id', id ); + + // properly name the elements to prevent id conflicts + this.setElementIdIndexes( $content.find( '*' ), index ); + + // append the content + $container.append( $content ); + + // create the new tab + var title = ( this.$baseTabTitle + ' ' + this._getTabTitleIndex() ); + $container.tabs( 'add', ( '#' + id ), title ); + + // finally, style our new elements + this.styler.apply( $content ); + + // raise event + this.postAddRow( $content, index ); + + return this; + }, + + + /** + * Removes a tab + * + * @return TabbedGroup self to allow for method chaining + */ + removeTab: function() + { + // we can simply remove the last tab since the bucket will re-order + // itself and update each of the previous tabs + var index = this.getCurrentIndex(); + + var $container = this._getTabContainer(), + $panel = this._getTabContent( index ); + + // remove the tab + this.styler.remove( $panel ); + $container.tabs( 'remove', index ); + + return this; + }, + + + 'private _getTabContent': function( index ) + { + return this._getTabContainer().find( + 'div.ui-tabs-panel:nth(' + index + ')' + ); + }, + + + 'override protected postPreEmptyBucketFirst': function() + { + // select the first tab + this._getTabContainer().tabs( 'select', 0 ); + return this; + }, + + + 'override protected addIndex': function( index ) + { + // increment id before doing our own stuff + this.__super( index ); + this.addTab(); + + return this; + }, + + + 'override public removeIndex': function( index ) + { + // decrement after we do our own stuff + this.removeTab(); + this.__super( index ); + + return this; + }, + + + /** + * Display the requested field + * + * The field is not given focus; it is simply brought to the foreground. + * + * @param {string} field_name name of field to display + * @param {number} i index of field + * + * @return {TabbedGroupUi} self + */ + 'override public displayField': function( field, i ) + { + var $element = this.styler.getWidgetByName( field, i ); + + // if we were unable to locate it, then don't worry about it + if ( $element.length == 0 ) + { + return; + } + + // get the index of the tab that this element is on + var id = $element.parents( 'div.ui-tabs-panel' ).attr( 'id' ); + var index = id.substring( id.lastIndexOf( '_' ) ); + + // select that tab + this._getTabContainer().tabs( 'select', index ); + + return this; + }, + + + /** + * Shows/hides add/remove row buttons + * + * @param {boolean} value whether to hide (default: true) + * + * @return {TabbedGroupUi} self + */ + hideAddRemove: function( value ) + { + if ( value === true ) + { + this._getTabContainer().find( '.ui-icon-close' ).hide(); + this._getAddButton().hide(); + } + else + { + this._getTabContainer().find( '.ui-icon-close' ).show(); + this._getAddButton().show(); + } + }, + + + isOnVisibleTab: function( field, index ) + { + // fast check + return ( +index === this._selectedIndex ); + }, + + + 'override protected doHideField': function( field, index, force ) + { + var _self = this; + + // if we're not on the active tab, then we can defer this request until + // we're not busy + if ( !force && !this.isOnVisibleTab( field, index ) ) + { + setTimeout( function() + { + _self.doHideField( field, index, true ); + }, 25 ); + } + + var $element = this.getElementByName( field, index ); + + var $elements = ( $element.parents( 'dd' ).length ) + ? $element.parents( 'dd' ).prev( 'dt' ).andSelf() + : $element; + + $elements.stop( true, true ); + if ( this.isOnVisibleTab( field, index ) ) + { + $elements.slideUp( 500, function() + { + $( this ).addClass( 'hidden' ); + } ); + } + else + { + $elements.hide().addClass( 'hidden' ); + } + }, + + + 'override protected doShowField': function( field, index, force ) + { + var _self = this; + + // if we're not on the active tab, then we can defer this request until + // we're not busy + if ( !force && !this.isOnVisibleTab( field, index ) ) + { + setTimeout( function() + { + _self.doShowField( field, index, true ); + }, 25 ); + } + + var $element = this.getElementByName( field, index ); + + var $elements = ( $element.parents( 'dd' ).length ) + ? $element.parents( 'dd' ).prev( 'dt' ).andSelf() + : $element; + + // it's important to stop animations *before* removing the hidden class, + // since forcing its completion may add it + $elements + .stop( true, true ) + .find( '.hidden' ) + .andSelf() + .removeClass( 'hidden' ); + + if ( this.isOnVisibleTab( field, index ) ) + { + $elements.slideDown( 500 ); + } + else + { + $elements.show(); + } + }, + + + 'override public getContentByIndex': function( name, index ) + { + // get the tab that this index should be on and set a property to notify + // the caller that no index check should be performed (since there is + // only one) + var $content = this._getTabContent( index ); + $content.singleIndex = true; + + return $content; + } +} ); diff --git a/src/ui/group/TableGroupUi.js b/src/ui/group/TableGroupUi.js new file mode 100644 index 0000000..db3b9b8 --- /dev/null +++ b/src/ui/group/TableGroupUi.js @@ -0,0 +1,428 @@ +/** + * Group table UI + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * This file is part of liza. + * + * 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 . + * + * @needsLove + * - Remove reliance on jQuery. + * - Dependencies need to be liberated: Styler; Group. + * @end needsLove + */ + +var Class = require( 'easejs' ).Class, + GroupUi = require( './GroupUi' ); + + +/** + * Represents a table group + * + * This class extends from the generic Group class. It contains logic to + * support table groups, allowing for the adding and removal of rows. + */ +module.exports = Class( 'TableGroupUi' ) + .extend( GroupUi, +{ + /** + * Stores the base row to be duplicated for table groups + * @type {jQuery} + */ + $baseRow: null, + + + /** + * Template method used to process the group content to prepare it for + * display and retrieve common data + * + * @return void + */ + 'override protected processContent': function( quote ) + { + this.__super(); + + // determine if we should lock this group down + if ( this.$content.find( 'table' ).hasClass( 'locked' ) ) + { + this.group.locked( true ); + } + + this._processTables(); + this._attachAddRowHandlers(); + this.watchFirstElement( this.$baseRow, quote ); + }, + + + /** + * Attaches the add row event handlers so new rows are added on click + * + * @return void + */ + _attachAddRowHandlers: function() + { + // reference to ourself for use in the closure + var _self = this; + + // if we're locked, then there'll be no row adding + if ( this.group.locked() ) + { + this._getAddRowButton().remove(); + return; + } + + // any time an .addrow element is clicked, we want to add a row to the + // group + this._getAddRowButton().click( function() + { + // initialize a new index + _self.initIndex(); + }); + }, + + + /** + * Processes tables, preparing them for row duplication + * + * The first row is used as the model for duplication. It is removed from + * the DOM and stored in memory, which will be later cloned. It is stored + * unstyled to make manipulation easier and limit problems with restyling. + * + * This was chosen over simply duplicating and clearing out the first row + * because we (a) have a clean slate and (b) Dojo does not work well if you + * duplicate dijit HTML. + * + * @return void + */ + _processTables: function() + { + // reference to ourself for use in the closure + var groupui = this; + + // if we're locked down, we won't be removing any rows + if ( this.group.locked() ) + { + this.$content.find( '.delrow' ).remove(); + } + + // remove the first row of the group tables + this.$content.find( '.groupTable > tbody > tr:first' ) + .each( function( i ) + { + // remove the row and store it in memory as the base row, which + // will be used for duplication (adding new rows) + // + // NOTE: detach() must be used rather than remove(), because + // remove() also removes any data attached to the element + groupui.$baseRow = $( this ).detach(); + } + ); + }, + + + /** + * Returns the table associated with the given group id + * + * @return jQuery group table + */ + _getTable: function() + { + return this.$content.find( 'table.groupTable' ); + }, + + + /** + * Returns the row of the group table for the specified group and row id + * + * @param Integer row_id id of the row to retrieve + * + * @return jQuery group table row + */ + _getTableRow: function( row_id ) + { + row_id = +row_id; + + return this._getTable().find( + 'tbody > tr[id=' + this._genTableRowId( row_id ) + ']' + ); + }, + + + 'private _getLastTableRow': function() + { + return this._getTableRow( this.getCurrentIndex() ); + }, + + + /** + * Generates the id to be used for the group table row + * + * This id lets us find the row for styling and removal. + * + * @param Integer row_id id of the row + * + * @return String row id for the table row + */ + _genTableRowId: function( row_id ) + { + row_id = +row_id; + return ( this.getGroupId() + '_row_' + row_id ); + }, + + + /** + * Returns the element used to add rows to the table + * + * @return jQuery add row element + */ + _getAddRowButton: function() + { + return this.$content.find( '.addrow:first' ); + }, + + + /** + * Adds a row to a group that supports rows + * + * @return Step self to allow for method chaining + */ + addRow: function() + { + var $group_table = this._getTable(); + var row_count = $group_table.find( 'tbody > tr' ).length; + var max = this.group.maxRows(); + var _self = this; + + // hide the add row button if we've reached the max + if ( max && ( row_count == ( max - 1 ) ) ) + { + this._getAddRowButton().hide(); + } + + // html of first group_row + var $row_base = this.$baseRow; + if ( $row_base === undefined ) + { + throw "NoBaseRow " + this.getGroupId(); + } + + // duplicate the base row + $row_new = $row_base.clone( true ); + + // increment row ids + var new_index = this._incRow( $row_new ); + + // attach remove event + var $del = $row_new.find( 'td.delrow' ); + $del.click( function() + { + _self.destroyIndex( new_index ); + } ); + + // append it to the group + $group_table.find( 'tbody' ).append( $row_new ); + + // aplying styling + this._applyStyle( new_index ); + + // raise event + this.postAddRow( $row_new, $row_new.index() ); + + return this; + }, + + + /** + * Increments the index of the elements in the row + * + * This generates both a new name and a new id. The formats expected are: + * - name: foo[i] + * - id: foo_i + * + * @param jQuery $row row to increment + * + * @return Integer the new index + */ + _incRow: function( $row ) + { + var new_index = this.getCurrentIndex(); + + // update the row id + $row.attr( 'id', this._genTableRowId( new_index ) ); + + // properly name the elements to prevent id conflicts + this.setElementIdIndexes( $row.find( '*' ), new_index ); + + return new_index; + }, + + + /** + * Applies UI transformations to a row + * + * @param Integer row_id id of the row to be styled + * + * @return Step self to allow for method chaining + */ + 'private _applyStyle': function( row_id ) + { + // style only the specified row + this.styler.apply( this._getTableRow( row_id ), true ); + + return this; + }, + + + /** + * Removes the specified row from a group + * + * @return Step self to allow for method chaining + */ + removeRow: function() + { + // get parent table and row count + var $group_table = this._getTable(), + $row = this._getLastTableRow(), + row_index = $row.index(), + row_count = $group_table.find( 'tbody > tr' ).length, + group = this; + + // cleared so they can be restyled later) + this.styler.remove( $row ); + $row.remove(); + + // re-add the add row button + this._getAddRowButton().show(); + + return this; + }, + + + 'override protected addIndex': function( index ) + { + // increment id before doing our own stuff + this.__super( index ); + this.addRow(); + + return this; + }, + + + 'override public removeIndex': function( index ) + { + // remove our stuff before decrementing our id + this.removeRow(); + this.__super( index ); + + return this; + }, + + + /** + * Returns all elements that are a part of the column at the given index + * + * @param Integer index column position (0-based) + * + * @return jQuery collection of matched elements + */ + _getColumnElements: function( index ) + { + index = +index; + + return this._getTable().find( + 'thead th:nth(' + index + '), ' + + 'tr > td:nth-child(' + ( index + 1 ) + ')' + ); + }, + + + 'override protected doHideField': function( field, index ) + { + var $element = this.getElementByName( field, index ), + $parent = $element.parents( 'td' ), + cindex = $parent.index(); + + $parent.append( $( '
    ' ) + .addClass( 'na' ) + .text( 'N/A' ) + ); + $element.hide(); + + this._checkColumnVis( field, cindex ); + }, + + + 'override protected doShowField': function( field, index ) + { + var $element = this.getElementByName( field, index ), + $parent = $element.parents( 'td' ), + cindex = $parent.index(); + + $parent.find( '.na' ).remove(); + $element.show(); + + this._checkColumnVis( field, cindex ); + }, + + + 'private _checkColumnVis': function( field, cindex ) + { + var $e = this._getColumnElements( cindex ); + + if ( this.isFieldVisible( field ) ) + { + $e.stop( true, true ).slideDown( 500 ); + } + else + { + $e.stop( true, true ).slideUp( 500 ); + } + }, + + + /** + * Shows/hides add/remove row buttons + * + * @param {boolean} value whether to hide (default: true) + * + * @return {TableGroupUi} self + */ + hideAddRemove: function( value ) + { + if ( value === true ) + { + this._getAddRowButton().hide(); + this.$content.find( '.delrow' ).hide(); + } + else + { + this._getAddRowButton().show(); + this.$content.find( '.delrow' ).show(); + } + + return this; + }, + + + /** + * Returns the number of rows currently in the table + * + * @return {number} + */ + 'public getRowCount': function() + { + return this.getCurrentIndexCount(); + } +} );