wikimedia/mediawiki-extensions-Translate

View on GitHub
resources/src/ext.translate.groupselector/index.js

Summary

Maintainability
D
1 day
Test Coverage
( function () {
    'use strict';

    var groupsLoader, delay;

    /**
     * options
     *  - position: accepts same values as jquery.ui.position
     *  - onSelect:
     *  - language:
     *  - preventSelector: boolean to not allow selection of subgroups.
     *  - recent: list of recent group ids
     * groups: list of message group ids
     *
     * @private
     * @param {Element} element
     * @param {Object} options
     * @param {Object} [options.position] Accepts same values as jquery.ui.position.
     * @param {Function} [options.onSelect] Callback with message group id when selected.
     * @param {string} options.language Language code for statistics.
     * @param {boolean} [options.preventSelector] Do not allow selection of subgroups.
     * @param {string[]} [options.recent] List of recent message group ids.
     * @param {string[]} [options.showWatched] Whether to show watched message groups
     * @param {string} [options.menuClass] A CSS class to add to the menu element
     * @param {string[]} [groups] List of message group ids to show.
     */
    function TranslateMessageGroupSelector( element, options, groups ) {
        this.$trigger = $( element );
        this.$menu = null;
        this.$search = null;
        this.$list = null;
        this.$loader = null;

        this.parentGroupId = null;
        this.options = $.extend( true, {}, $.fn.msggroupselector.defaults, options );
        // Store the explicitly given options, which can be passed to subgroup
        // selectors.
        this.customOptions = options;
        this.flatGroupList = null;
        this.groups = groups;
        this.firstShow = true;
        this.watchedGroups = [];

        this.init();
    }

    TranslateMessageGroupSelector.prototype = {
        constructor: TranslateMessageGroupSelector,

        /**
         * Initialize the plugin
         *
         * @private
         */
        init: function () {
            this.parentGroupId = this.$trigger.data( 'msggroupid' );
            this.prepareSelectorMenu();
            this.listen();
        },

        /**
         * Prepare the selector menu rendering
         *
         * @private
         */
        prepareSelectorMenu: function () {
            this.$menu = $( '<div>' )
                .addClass( 'tux-groupselector' )
                .addClass( 'grid hide' );

            if ( this.customOptions.menuClass ) {
                // eslint-disable-next-line mediawiki/class-doc
                this.$menu.addClass( this.customOptions.menuClass );
            }

            var $searchIcon = $( '<div>' )
                .addClass( 'two columns tux-groupselector__filter__search__icon' );

            this.$search = $( '<input>' )
                .prop( 'type', 'text' )
                .addClass( 'tux-groupselector__filter__search__input' )
                .prop( 'placeholder', mw.msg( 'translate-msggroupselector-search-placeholder' ) );

            var $search = $( '<div>' )
                .addClass( 'ten columns' )
                .append( this.$search );

            var $listFilters = $( '<div>' )
                .addClass( 'tux-groupselector__filter__tabs' )
                .addClass( 'six columns' )
                .append(
                    $( '<div>' )
                        .addClass( 'tux-grouptab tux-grouptab--all tux-grouptab--selected' )
                        .text( mw.msg( 'translate-msggroupselector-search-all' ) )
                );

            if ( this.options.recent && this.options.recent.length ) {
                $listFilters.append(
                    $( '<div>' )
                        .addClass( 'tux-grouptab tux-grouptab--recent' )
                        .text( mw.msg( 'translate-msggroupselector-search-recent' ) )
                );
            }

            if ( this.options.showWatched ) {
                $listFilters.append(
                    $( '<div>' )
                        .addClass( 'tux-grouptab tux-grouptab--watched' )
                        .text( mw.msg( 'translate-msggroupselector-search-watched' ) )
                );
            }

            var $searchGroup = $( '<div>' )
                .addClass( 'tux-groupselector__filter__search' )
                .addClass( 'six columns' )
                .append( $searchIcon, $search );

            var $listFiltersGroup = $( '<div>' )
                .addClass( 'tux-groupselector__filter' )
                .addClass( 'row' )
                .append( $listFilters, $searchGroup );

            var manageSubscriptions = require( './data.json' ).pagelink;
            var $footer = $( '<div>' )
                .addClass( 'tux-groupselector__footer hide' )
                .append( $( '<a>' )
                    .prop( 'href', mw.util.getUrl( manageSubscriptions ) )
                    .text( mw.msg( 'translate-msggroupselector-special-msgsubscriptions-label' ) )
                );

            this.$list = $( '<div>' )
                .addClass( 'tux-grouplist' )
                .addClass( 'row' );

            this.$loader = $( '<div>' )
                .addClass( 'tux-loading-indicator tux-loading-indicator--centered' );

            this.$menu.append( $listFiltersGroup, this.$loader, this.$list, $footer );

            this.$menu.appendTo( document.body );
        },

        /**
         * Show the selector
         *
         * @private
         */
        show: function () {
            this.$menu.addClass( 'open' ).removeClass( 'hide' );
            this.position();
            // Place the focus in the message group search box.
            this.$search.trigger( 'focus' );
            // Start loading the groups, but assess the situation again after
            // they are loaded, in case user has made further interactions.
            if ( this.firstShow ) {
                this.loadGroups().done( this.showList.bind( this ) );
                this.firstShow = false;
            }
        },

        /**
         * Hide the selector
         *
         * @private
         * @param {jQuery.Event} e
         */
        hide: function ( e ) {
            // Do not hide if the trigger is clicked
            if ( e && ( this.$trigger.is( e.target ) || this.$trigger.has( e.target ).length ) ) {
                return;
            }

            this.$menu.addClass( 'hide' ).removeClass( 'open' );
        },

        /**
         * Toggle the menu open/close state
         *
         * @private
         */
        toggle: function () {
            if ( this.$menu.hasClass( 'open' ) ) {
                this.hide();
            } else {
                this.show();
            }
        },

        /**
         * Attach event listeners
         *
         * @private
         */
        listen: function () {
            var groupSelector = this;

            // Hide the selector panel when clicking outside of it
            $( document.documentElement ).on( 'click', this.hide.bind( this ) );

            groupSelector.$trigger.on( 'click', function () {
                groupSelector.toggle();
            } );

            groupSelector.$menu.on( 'click', function ( e ) {
                e.stopPropagation();
            } );

            // Handle click on row item. This selects the group, and in case it has
            // subgroups, also opens a new menu to show them.
            groupSelector.$menu.on( 'click', '.tux-grouplist__item', function () {
                var messageGroup = $( this ).data( 'msggroup' );

                groupSelector.hide();

                groupSelector.$trigger.nextAll().remove();

                if ( !groupSelector.options.preventSelector ) {
                    var $newLink = $( '<span>' )
                        .addClass( 'grouptitle grouplink' )
                        .text( messageGroup.label )
                        .data( 'msggroupid', messageGroup.id );

                    groupSelector.$trigger.after( $newLink );

                    if ( messageGroup.groups && messageGroup.groups.length > 0 ) {
                        // Show the new menu immediately.
                        // Pass options for callbacks, language etc. but ignore the position
                        // option unless explicitly given to allow automatic recalculation
                        // of the position compared to the new trigger.
                        $newLink
                            .addClass( 'tux-breadcrumb__item--aggregate' )
                            .msggroupselector( groupSelector.customOptions )
                            .data( 'msggroupselector' ).show();
                        $newLink.data( 'msggroup-subgroup-count', messageGroup.groups.length );
                    }
                }

                if ( groupSelector.options.onSelect ) {
                    groupSelector.options.onSelect( messageGroup );
                }
            } );

            // Handle the tabs All | Recent
            var $tabs = groupSelector.$menu.find( '.tux-grouptab' );
            $tabs.on( 'click', function () {
                var $this = $( this );

                /* Do nothing if user clicks the active tab.
                 * Fixes two things:
                 * - The blue bottom border highlight doesn't jump around
                 * - No flash when clicking recent tab again
                 */
                if ( $this.hasClass( 'tux-grouptab--selected' ) ) {
                    return;
                } else {
                    $tabs.removeClass( 'tux-grouptab--selected' );
                    $this.addClass( 'tux-grouptab--selected' );
                }

                groupSelector.$search.val( '' );
                groupSelector.showList();
                groupSelector.$menu.find( '.tux-groupselector__footer' )
                    .toggleClass( 'hide', !$this.hasClass( 'tux-grouptab--watched' ) );
            } );

            this.$search.on( 'click', this.show.bind( this ) )
                .on( 'keypress', this.keyup.bind( this ) )
                .on( 'keyup', this.keyup.bind( this ) );

            if ( this.eventSupported( 'keydown' ) ) {
                this.$search.on( 'keydown', this.keyup.bind( this ) );
            }
        },

        /**
         * Handle the keypress/keyup events in the message group search box.
         *
         * @private
         */
        keyup: function () {
            delay( this.showList.bind( this ), 300 );
        },

        /**
         * Position the menu
         *
         * @private
         */
        position: function () {
            if ( this.options.position.of === undefined ) {
                // eslint-disable-next-line no-jquery/variable-pattern
                this.options.position.of = this.$trigger;
            }

            var positionElement = require( './ui.position.js' );
            positionElement( this.$menu, this.options.position );
        },

        /**
         * Shows suitable list for current view, taking possible filter into account
         *
         * @private
         */
        showList: function () {
            var query = this.$search.val().trim().toLowerCase();

            if ( query ) {
                this.filter( query );
            } else {
                this.showUnfilteredList();
            }
        },

        /**
         * Shows an unfiltered list of groups depending on the selected tab.
         *
         * @private
         */
        showUnfilteredList: function () {
            var $selected = this.$menu.find( '.tux-grouptab--selected' );

            if ( $selected.hasClass( 'tux-grouptab--all' ) ) {
                if ( this.groups ) {
                    this.showSelectedGroups( this.groups );
                } else {
                    this.showDefaultGroups();
                }
            } else if ( $selected.hasClass( 'tux-grouptab--recent' ) ) {
                this.showRecentGroups();
            } else if ( $selected.hasClass( 'tux-grouptab--watched' ) ) {
                this.showWatchedGroups();
            }
        },

        /**
         * Shows the list of message groups excluding subgroups.
         *
         * In case a parent message group has been given, only subgroups of that
         * message group are shown, otherwise all top-level message groups are shown.
         *
         * @private
         */
        showDefaultGroups: function () {
            var groupSelector = this;

            this.$loader.removeClass( 'hide' );

            this.loadGroups().done( function ( groups ) {
                var groupsToShow = mw.translate.findGroup( groupSelector.parentGroupId, groups );

                // We do not want to display the group itself, only its subgroups
                if ( groupSelector.parentGroupId ) {
                    groupsToShow = groupsToShow.groups;
                }

                groupSelector.$loader.addClass( 'hide' );
                groupSelector.$list.empty();
                groupSelector.addGroupRows( groupsToShow );
            } );
        },

        /**
         * Show recent message groups.
         *
         * @private
         */
        showRecentGroups: function () {
            var recent = this.options.recent || [];

            this.showSelectedGroups( recent );
        },

        /**
         * Show watched message groups.
         */
        showWatchedGroups: function () {
            if ( this.options.showWatched ) {
                this.showSelectedGroups( this.watchedGroups || [] );
            }
        },

        /**
         * Load message groups.
         *
         * @private
         * @param {Array} groups List of the message group ids to show.
         */
        showSelectedGroups: function ( groups ) {
            var groupSelector = this;
            this.$loader.removeClass( 'hide' );
            this.loadGroups()
                .then( function ( allGroups ) {
                    var rows = [];
                    groups.forEach( function ( id ) {
                        var group = mw.translate.findGroup( id, allGroups );
                        if ( group ) {
                            rows.push( groupSelector.prepareMessageGroupRow( group ) );
                        }
                    } );
                    return rows;
                } )
                .always( function () {
                    groupSelector.$loader.addClass( 'hide' );
                    groupSelector.$list.empty();
                } )
                .done( function ( rows ) {
                    groupSelector.$list.append( rows );
                } );
        },

        /**
         * Flattens a message group tree.
         *
         * @private
         * @param {Array} messageGroups An array or data object.
         * @param {Object} foundIDs The array in which the keys are IDs of message groups that were found already.
         */
        flattenGroupList: function ( messageGroups, foundIDs ) {
            var messageGroupList;
            if ( messageGroups.groups ) {
                messageGroupList = messageGroups.groups;
            } else {
                messageGroupList = messageGroups;
            }

            for ( var i = 0; i < messageGroupList.length; i++ ) {
                // Avoid duplicate groups, and add the parent before subgroups
                if ( !foundIDs[ messageGroupList[ i ].id ] ) {
                    this.flatGroupList.push( messageGroupList[ i ] );
                    foundIDs[ messageGroupList[ i ].id ] = true;
                }

                // In case there are subgroups, add them recursively
                if ( messageGroupList[ i ].groups ) {
                    this.flattenGroupList( messageGroupList[ i ].groups, foundIDs );
                }
            }
        },

        /**
         * Search the message groups based on label or id.
         * Label match is prefix match, while id match is exact match.
         *
         * @private
         * @param {string} query
         */
        filter: function ( query ) {
            var self = this;

            this.loadGroups().done( function ( groups ) {
                var foundGroups = [];

                if ( !self.flatGroupList ) {
                    self.flatGroupList = [];
                    var currentGroup = mw.translate.findGroup( self.parentGroupId, groups );
                    if ( self.parentGroupId ) {
                        currentGroup = currentGroup.groups;
                    }
                    self.flattenGroupList( currentGroup, {} );
                }

                // Optimization, assuming that people search the beginning
                // of the group name.
                var matcher = new RegExp( '\\b' + escapeRegex( query ), 'i' );

                for ( var index = 0; index < self.flatGroupList.length; index++ ) {
                    if ( matcher.test( self.flatGroupList[ index ].label ) ||
                        query === self.flatGroupList[ index ].id ) {
                        foundGroups.push( self.flatGroupList[ index ] );
                    }
                }

                self.$loader.addClass( 'hide' );
                self.$list.empty();
                self.addGroupRows( foundGroups );
            } );
        },

        /**
         * Load message groups and relevant properties using the API.
         *
         * @private
         * @return {jQuery.Promise}
         */
        loadGroups: function () {
            if ( groupsLoader !== undefined ) {
                return groupsLoader;
            }

            var params = {
                action: 'query',
                meta: 'messagegroups',
                mgformat: 'tree',
                mgprop: 'id|label|icon',
                mgiconsize: '32',
                mglanguageFilter: this.options.language
            };

            groupsLoader = new mw.Api()
                .get( params )
                .then( function ( result ) {
                    return result.query.messagegroups;
                } )
                .promise();

            return groupsLoader;
        },

        /**
         * Add rows with message groups to the selector.
         *
         * @private
         * @param {Array} groups Array of message group objects to add.
         */
        addGroupRows: function ( groups ) {
            var groupSelector = this,
                $msgGroupRows = [];

            if ( !groups ) {
                return;
            }

            groups.forEach( function ( group ) {
                $msgGroupRows.push( groupSelector.prepareMessageGroupRow( group ) );
            } );

            if ( this.parentGroupId ) {
                var $parent = this.$list.find( '.tux-grouplist__item[data-msggroupid="' +
                    this.parentGroupId + '"]' );

                if ( $parent.length ) {
                    $parent.after( $msgGroupRows );
                    return;
                }
            }

            this.$list.append( $msgGroupRows );
        },

        /**
         * Prepare a message group row in the selector.
         *
         * @private
         * @param {Object} messagegroup object.
         * @return {Object} a jQuery object with the groups selector row (<div>).
         */
        prepareMessageGroupRow: function ( messagegroup ) {
            var $row = $( '<div>' )
                .addClass( 'row tux-grouplist__item' )
                .attr( 'data-msggroupid', messagegroup.id )
                .data( 'msggroup', messagegroup );

            var $icon = $( '<div>' )
                .addClass( 'tux-grouplist__item__icon' )
                .addClass( 'one column' );

            var $statsbar = $( '<div>' ).languagestatsbar( {
                language: this.options.language,
                group: messagegroup.id
            } );

            var $label = $( '<div>' )
                .addClass( 'tux-grouplist__item__label' )
                .addClass( 'seven columns' )
                .append(
                    $( '<span>' )
                        // T130390: must be attr for IE/Edge.
                        .attr( { dir: 'auto' } )
                        .text( messagegroup.label ),
                    $statsbar
                );

            var style = '';
            if ( messagegroup.icon && messagegroup.icon.raster ) {
                style += 'background-image: url(--);';
                style = style.replace( /--/g, messagegroup.icon.raster );
            }

            if ( messagegroup.icon && messagegroup.icon.vector ) {
                style += 'background-image: url(--);';
                style = style.replace( /--/g, messagegroup.icon.vector );
            }

            if ( style !== '' ) {
                $icon.attr( 'style', style );
            }

            var $subGroupsLabel = $( [] );

            if ( messagegroup.groups && messagegroup.groups.length > 0 ) {
                $subGroupsLabel = $( '<div>' )
                    .addClass( 'tux-grouplist__item__subgroups' )
                    .addClass( 'four columns' )
                    .text( mw.msg( 'translate-msggroupselector-view-subprojects',
                        messagegroup.groups.length ) );
            }

            return $row.append( $icon, $label, $subGroupsLabel );
        },

        /**
         * Check that a DOM event is supported by the $menu jQuery object.
         *
         * @private
         * @param {string} eventName
         * @return {boolean}
         */
        eventSupported: function ( eventName ) {
            var $search = this.$menu.find( '.tux-groupselector__filter__search__input' ),
                isSupported = eventName in $search;

            if ( !isSupported ) {
                this.$element.setAttribute( eventName, 'return;' );
                isSupported = typeof this.$element[ eventName ] === 'function';
            }

            return isSupported;
        },

        /**
         * Only shows message groups translatable to given target language
         *
         * @internal
         * @param {string} targetLanguage
         */
        updateTargetLanguage: function ( targetLanguage ) {
            this.options.language = targetLanguage;
            groupsLoader = undefined;
            this.firstShow = true;
        },

        /**
         * Set the list of watched message group ids
         *
         * @param {string[]} groupIds
         */
        setWatchedGroups: function ( groupIds ) {
            this.watchedGroups = groupIds;
            var $watchedTab = this.$menu.find( '.tux-grouptab--watched' );
            if ( $watchedTab.hasClass( 'tux-grouptab--selected' ) ) {
                this.showWatchedGroups();
            }
        }
    };

    /**
     * msggroupselector PLUGIN DEFINITION
     *
     * @internal
     * @param {Object} options
     * @param {string[]} groups
     * @return {jQuery}
     */
    $.fn.msggroupselector = function ( options, groups ) {
        return this.each( function () {
            var $this = $( this ),
                data = $this.data( 'msggroupselector' );

            if ( !data ) {
                $this.data( 'msggroupselector',
                    ( data = new TranslateMessageGroupSelector( this, options, groups ) )
                );
            }

            if ( typeof options === 'string' ) {
                data[ options ].call( $this );
            }
        } );
    };

    $.fn.msggroupselector.Constructor = TranslateMessageGroupSelector;

    /**
     * Default options when initializing the message group selector
     *
     * @private
     */
    $.fn.msggroupselector.defaults = {
        language: 'en',
        position: {
            my: 'left top',
            at: 'left-90 bottom+5'
        }
    };

    /*
     * Private functions
     */

    /**
     * Escape the search query for regex match
     *
     * @param {string} value A search string to be escaped.
     * @return {string} Escaped string that is safe to use for a search.
     */
    function escapeRegex( value ) {
        return value.replace( /[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&' );
    }

    delay = ( function () {
        var timer = 0;

        return function ( callback, milliseconds ) {
            clearTimeout( timer );
            timer = setTimeout( callback, milliseconds );
        };
    }() );
}() );