wikimedia/wikimedia-fundraising-dash

View on GitHub
src/app/widgetBase.js

Summary

Maintainability
D
2 days
Test Coverage
define( [
    'jquery',
    'knockout',
    'operators'
], function ( $, ko, ops ) {

    function zeroPad( number ) {
        if ( number < 10 ) {
            return '0' + number;
        }
        return number;
    }

    function WidgetBase( params ) {

        var self = this,
            MAX_RETRIES = 3;

        // Things to clean up when the widget is removed
        self.disposables = [];
        self.timers = [];

        self.retrievedResults = ko.observable();
        self.queryStringSQL = ko.observable( 'This widget hasn\'t been set up yet!' );
        self.config = params.configuration || {};
        self.instanceID = params.widgetInstance;
        self.widgetCode = params.widgetCode;
        self.dataLoading = params.dataLoading;
        self.chartSaved = ko.observable( !!params.configuration );
        self.optionStateChanged = ko.observable( false );
        self.chartLoaded = ko.observable( false );
        self.title = ko.observable( params.title );
        self.userChoices = ko.observable( self.config.userChoices || {} );
        self.filterQueryString = ko.observable( self.config.filterQueryString || '' );
        self.metadataRequest = ( function () {
            var storageKey = 'metadata-' + self.widgetCode,
                data = localStorage.getItem( storageKey );

            if ( data && data.timestamp + 600000 > new Date().getTime ) {
                return $.Deferred().resolve( JSON.parse( data ) ).promise();
            }
            return $.get( 'metadata/' + self.widgetCode, function ( fetchedData ) {
                fetchedData.timestamp = new Date().getTime();
                localStorage.setItem( storageKey, JSON.stringify( fetchedData ) );
            } );
        }() );

        self.filterText = ko.computed( function () {
            var filterName,
                text,
                parts = [],
                choices = self.userChoices(),
                filterChoices,
                operator;

            for ( filterName in choices ) {
                if ( !Object.prototype.hasOwnProperty.call( choices, filterName ) ) {
                    continue;
                }
                text = filterName + ' ';
                filterChoices = choices[ filterName ];
                // FIXME: this should be part of a filter base class so different
                // types of filter can define their own summary text generation
                if ( filterChoices.constructor === Array ) {
                    // Dropdown filter
                    if ( filterChoices.length === 0 ) {
                        continue;
                    }
                    if ( filterChoices.length === 1 ) {
                        text += '= ' + filterChoices[ 0 ];
                    } else {
                        text += 'in (' + filterChoices.join( ', ' ) + ')';
                    }
                } else {
                    // Text or numeric filter
                    if ( filterChoices.value === '' ) {
                        continue;
                    }
                    operator = ops[ filterChoices.operator.replace( 'fn|', '' ) ];
                    if ( operator.abbr ) {
                        text += operator.abbr;
                    } else {
                        text += operator.text.toLowerCase();
                    }
                    text += ' ' + filterChoices.value;
                }
                parts.push( text );
            }
            return parts.join( ', ' );
        } );

        self.getChartData = function ( qs, successCallback, retryCount ) {
            if ( !retryCount ) {
                retryCount = 0;
            }
            self.dataLoading( true );
            $.ajax( {
                url: '/data/' + self.widgetCode + '?' + ( qs ).replace( /\+/g, '%20' ),
                success: function ( dataget ) {
                    self.dataLoading( false );
                    self.retrievedResults( dataget.results );
                    self.queryStringSQL( dataget.sqlQuery );
                    if ( successCallback ) {
                        successCallback( dataget );
                    }
                },
                error: function ( req ) {
                    if ( req.status === 401 ) {
                        // User has been logged out. Reloading is an easy way to re-authenticate.
                        window.location.reload();
                    }
                    if ( req.status === 504 && retryCount < MAX_RETRIES ) {
                        // Retry on gateway timeout. If the previous query has finished
                        // in the background, we should get a result immediately. If it
                        // hasn't finished, we will be served via the same promise object
                        // rather than making another database query.
                        self.getChartData( qs, successCallback, retryCount + 1 );
                    }
                }
            } );
        };

        self.saveWidgetConfig = function () {
            self.config.userChoices = self.userChoices();
            self.config.filterQueryString = self.filterQueryString();

            var data = JSON.stringify( {
                configuration: self.config,
                isShared: false,
                displayName: self.title()
            } );

            if ( self.instanceID ) {
                $.ajax( {
                    method: 'PUT',
                    url: '/widget-instance/' + self.instanceID,
                    contentType: 'application/json; charset=UTF-8',
                    data: data,
                    success: function ( /* data */ ) {
                        self.chartSaved( true );
                        self.logStateChange( false );
                    }
                } );
            } else {
                $.ajax( {
                    method: 'POST',
                    url: '/widget-instance/',
                    contentType: 'application/json; charset=UTF-8',
                    data: data,
                    success: function ( data ) {
                        self.instanceID = data.id;
                        self.chartSaved( true );
                        self.logStateChange( false );
                    }
                } );
            }

        };

        self.processData = function ( rawdata, timescale, grouping, timestamp ) {

            var timeWord = ( timescale === 'Day' ? 'Dai' : timescale ) + 'ly',
                totals,
                counts,
                isGrouped = ( grouping && grouping !== '' ),
                groupValue,
                displayValue,
                displayValues,
                groupedTotals,
                groupedCounts,
                totalGroupNames,
                countGroupNames,
                totalName,
                countName,
                usedDates = [],
                xs = [ 'x' ],
                defaultYear = new Date().getFullYear(),
                defaultMonth = new Date().getMonth() + 1,
                tempDate, timeFormat, now = new Date( timestamp ),
                sortFunction;

            if ( isGrouped ) {
                // distinct values of the group column, mapped for display
                displayValues = [];
                // for c3 to stack totals with totals and counts with counts
                totalGroupNames = [];
                countGroupNames = [];
                // these two are populated in the first pass with e.g.
                // groupedTotals['US']['2015-12-02 15'] = 123.45
                groupedTotals = [];
                groupedCounts = [];
                // these will be populated in a second pass
                totals = [];
                counts = [];
            } else {
                totals = [ timeWord + ' Total' ];
                counts = [ timeWord + ' Count' ];
            }
            // coerce UTC into the default timezone.  Comparing offset values
            // so we only have to adjust 'now', not each data point
            now.setHours( now.getHours() + now.getTimezoneOffset() / 60 );
            $.each( rawdata, function ( index, dataPoint ) {
                var year = dataPoint.Year || defaultYear,
                    month = dataPoint.Month || defaultMonth,
                    day = dataPoint.Day || 1,
                    hour = dataPoint.Hour || 0;

                // Filter bogons
                if ( year < 2004 || new Date( year, month - 1, day, hour ) > now ) {
                    return;
                }

                tempDate = year + '-';
                tempDate += zeroPad( month ) + '-';
                tempDate += zeroPad( day );
                tempDate += ' ' + zeroPad( hour );

                if ( !usedDates[ tempDate ] ) {
                    xs.push( tempDate );
                    usedDates[ tempDate ] = true;
                }
                if ( isGrouped ) {
                    groupValue = dataPoint[ grouping ];
                    displayValue = self.getDisplayValue( grouping, groupValue );
                    if ( !totals[ displayValue ] ) {
                        displayValues.push( displayValue );
                        totalName = displayValue + ' total';
                        totals[ displayValue ] = [ totalName ];
                        groupedTotals[ displayValue ] = [];
                        totalGroupNames.push( totalName );
                        countName = displayValue + ' count';
                        counts[ displayValue ] = [ countName ];
                        groupedCounts[ displayValue ] = [];
                        countGroupNames.push( countName );
                    }
                    groupedTotals[ displayValue ][ tempDate ] = dataPoint.usd_total;
                    groupedCounts[ displayValue ][ tempDate ] = dataPoint.donations;
                } else {
                    // not grouped
                    totals.push( dataPoint.usd_total );
                    counts.push( dataPoint.donations );
                }
            } );

            if ( isGrouped ) {
                // second pass to create data arrays with an entry for each x val
                // FIXME: remove this and use the xs property
                // http://c3js.org/reference.html#data-xs
                $.each( xs, function ( index, xVal ) {
                    if ( xVal === 'x' ) {
                        return;
                    }
                    // clobber index because who cares
                    $.each( displayValues, function ( index, displayVal ) {
                        if ( groupedTotals[ displayVal ][ xVal ] !== undefined ) {
                            totals[ displayVal ].push( groupedTotals[ displayVal ][ xVal ] );
                            counts[ displayVal ].push( groupedCounts[ displayVal ][ xVal ] );
                        } else {
                            totals[ displayVal ].push( 0 );
                            counts[ displayVal ].push( 0 );
                        }
                    } );
                } );
                displayValues.sort();
                totalGroupNames.sort();
                countGroupNames.sort();
                sortFunction = function ( seriesA, seriesB ) {
                    return seriesA[ 0 ] < seriesB[ 0 ] ? -1 : 1;
                };
                totals.sort( sortFunction );
                counts.sort( sortFunction );
            }
            switch ( timescale ) {
                case 'Year':
                    timeFormat = '%Y';
                    break;
                case 'Month':
                    timeFormat = '%b \'%y';
                    break;
                case 'Day':
                    timeFormat = '%b %e';
                    break;
                case 'Hour':
                    timeFormat = '%H:00';
                    break;
            }
            return {
                timescale: timescale,
                totals: totals,
                counts: counts,
                xs: xs,
                timeFormat: timeFormat,
                totalGroups: totalGroupNames,
                countGroups: countGroupNames,
                groupValues: displayValues
            };
        };

        /**
         * Look up in the metadata which label corresponds to a given value for a specific filter
         *
         * @param filterName string Name of filter
         * @param value string|number Value as returned from database
         * @returns string Label corresponding to value, or value if no labels are configured
         */
        self.getDisplayValue = function ( filterName, value ) {
            var index;
            if ( !self.metadata.filters[ filterName ] || !self.metadata.filters[ filterName ].labels ) {
                return value;
            }
            if ( typeof value === 'number' ) {
                // Configured values are always specified as strings
                value = value.toString( 10 );
            }
            index = self.metadata.filters[ filterName ].values.indexOf( value );
            return self.metadata.filters[ filterName ].labels[ index ];
        };

        self.convertToQuery = function ( userChoices ) {

            var timeArray = [ 'Year', 'Month', 'Day', 'Hour' ],
                index = timeArray.indexOf( userChoices.timeBreakout ),
                query = 'group=' + userChoices.timeBreakout,
                filterQueryString = self.filterQueryString(),
                extraFilter,
                levelDiff;

            // If we're grouping by anything finer than year, add a filter and
            // also group by the next levels up.
            for ( levelDiff = 1; index - levelDiff >= 0; levelDiff++ ) {
                query = query + '&group=' + timeArray[ index - levelDiff ];
            }
            if ( userChoices.xSlice ) {
                query = query + '&group=' + userChoices.xSlice;
            }
            if ( index > 0 ) {
                extraFilter = timeArray[ index - 1 ] + 'sAgo lt \'1\'';
                if ( filterQueryString === '' ) {
                    filterQueryString = '$filter=' + extraFilter;
                } else {
                    filterQueryString = filterQueryString + ' and ' + extraFilter;
                }
            }
            if ( filterQueryString !== '' ) {
                query = query + '&' + filterQueryString;
            }

            return query;
        };

        self.logStateChange = function ( n ) {
            self.optionStateChanged( n );
            if ( n !== false ) {
                self.chartSaved( false );
            }
        };

        self.subscribe = function ( parent, member, callback ) {
            if ( !parent[ member ] ) {
                // FIXME: replace this retry crap with a promise
                window.setTimeout( function () {
                    self.subscribe( parent, member, callback );
                }, 50 );
                return;
            }
            self.disposables.push( parent[ member ].subscribe( callback ) );
            callback();
        };

        self.dispose = function () {
            ko.utils.arrayForEach( self.disposables, function ( disposable ) {
                if ( disposable && disposable.dispose ) {
                    disposable.dispose();
                }
            } );
            ko.utils.arrayForEach( self.timers, function ( timer ) {
                window.clearTimeout( timer );
            } );
        };

        return this;
    }

    return ( WidgetBase );

} );