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/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/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; + } +} ); 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; + } +} ); 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] ); + } + } + ); + } +} ); 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(); + } +} ); 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 new file mode 100644 index 0000000..ff42a7d --- /dev/null +++ b/src/ui/step/StepUi.js @@ -0,0 +1,218 @@ +/** + * Step user interface + * + * 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 + * - API is doing too much; see GeneralStepUi. + * @end needsLove + */ + +var Interface = require( 'easejs' ).Interface; + + +/** + * Interactive interface for steps + */ +module.exports = Interface( 'StepUi', +{ + /** + * Initializes step + * + * @return {undefined} + */ + 'public init': [], + + + 'public initGroupFieldData': [], + + + /** + * Sets content to be displayed + * + * @param {HTMLElement} content content to display + * + * @return {StepUi} self + */ + 'public setContent': [ 'content' ], + + + /** + * Returns the step that this object is styling + * + * @return {Step} + */ + 'public getStep': [], + + + /** + * Returns the generated step content as a jQuery object + * + * @return {HTMLElement} generated step content + */ + 'public getContent': [], + + + /** + * Will mark the step as dirty when the content is changed and update + * the staging bucket + * + * @return undefined + */ + 'public setDirtyTrigger': [], + + + /** + * 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 + */ + '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 + * + * @return {StepUi} self to allow for method chaining + */ + '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 + * + * @return {StepUi} self to allow for method chaining + */ + 'public reset': [ 'callback' ], + + + /** + * Returns whether all the elements in the step contain valid data + * + * @return Boolean true if all elements are valid, otherwise false + */ + 'public 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': [ 'cmatch' ], + + + /** + * 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': [ 'field', 'i', 'show_message', 'message' ], + + + /** + * 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 + */ + 'public invalidate': [], + + + /** + * Returns whether the step has been invalidated + * + * @return Boolean true if step has been invalidated, otherwise false + */ + 'public isInvalid': [], + + + /** + * 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: [ 'name' ], + + + /** + * Forwards add/remove hiding requests to groups + * + * @param {boolean} value whether to hide (default: true) + * + * @return {StepUi} self + */ + 'public hideAddRemove': [ 'value' ], + + + 'public preRender': [], + + + 'public visit': [ 'callback' ], + + + /** + * 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': [ 'active' ], + + + /** + * 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': [ 'lock' ] +} ); diff --git a/src/ui/step/StepUiBuilder.js b/src/ui/step/StepUiBuilder.js new file mode 100644 index 0000000..4d7770f --- /dev/null +++ b/src/ui/step/StepUiBuilder.js @@ -0,0 +1,271 @@ +/** + * 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 (TODO: this is transitional code + // moving from jQuery to vanilla DOM) + ui.setContent( + $( '
    ') + .append( $( data.content.html ) )[ 0 ] + ); + + // 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(); + + var $content = $( ui.getContent() ); + + // instantiate a group object for each of the groups within this step + var $groups = $content.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 ); + } ); + } +} );