wikimedia/mediawiki-extensions-Wikibase

View on GitHub
view/resources/jquery/wikibase/jquery.wikibase.entityselector.js

Summary

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

    // TODO: Get rid of MediaWiki context detection by submitting a message provider as option.

    /**
     * Whether loaded in MediaWiki context.
     *
     * @property {boolean}
     * @ignore
     */
    var IS_MW_CONTEXT = mw !== undefined && mw.msg;

    /**
     * Whether actual `entityselector` resource loader module is loaded.
     *
     * @property {boolean}
     * @ignore
     */
    var IS_MODULE_LOADED = (
        IS_MW_CONTEXT
        && mw.loader.getModuleNames().indexOf( 'jquery.wikibase.entityselector' ) !== -1
    );

    /**
     * Returns a message from the MediaWiki context if the `entityselector` module has been loaded.
     * If it has not been loaded, the corresponding string defined in the options will be returned.
     *
     * @ignore
     *
     * @param {string} msgKey
     * @param {string} string
     * @return {string}
     */
    function mwMsgOrString( msgKey, string ) {
        // eslint-disable-next-line mediawiki/msg-doc
        return IS_MODULE_LOADED ? mw.msg( msgKey ) : string;
    }

    /**
     * Enhances an input box with auto-complete and auto-suggestion functionality for Wikibase entities.
     *
     *     @example
     *     $( 'input' ).entityselector( {
     *         url: <{string} URL to the API of a MediaWiki instance running Wikibase repository>,
     *         language: <{string} language code of the language to fetch results in>
     *     } );
     *
     * @class jQuery.wikibase.entityselector
     * @extends jQuery.ui.suggester
     * @uses jQuery.event.special.eachchange
     * @uses jQuery.ui.ooMenu
     * @license GPL-2.0-or-later
     * @author H. Snater < mediawiki@snater.com >
     *
     * @constructor
     *
     * @param {Object} options
     * @param {string} options.url
     *        URL to retrieve results from.
     * @param {string} options.language
     *        (optional when in MediaWiki context)
     *        Language code of the language results shall be fetched in.
     *        Defaults to the user language (`mw.config.get( 'wgUserLanguage' )` when in MediaWiki
     *        context.
     * @param {string} [options.type='item']
     *        `Entity` type that will be queried for results.
     * @param {number|null} [options.limit=null]
     *         Number of results to query the API for. Will pick limit specified server-side if ´null´.
     * @param {boolean} [options.caseSensitive=false]
     *        Whether the widget shall consider letter case when determining if the first suggestion
     *        matches with the current input triggering the "select" mechanism.
     * @param {number} [options.timeout=8000]
     *        Default AJAX timeout in milliseconds.
     * @param {Object} [options.messages=Object]
     *        Strings used within the widget.
     *        Messages should be specified using `mwMsgOrString(<resource loader module message key>,
     *        <fallback message>)` in order to use the messages specified in the resource loader module
     *        (if loaded).
     * @param {string} [options.searchHookName='wikibase.entityselector.search']
     *        Name of the hook that fires when searching for entities.
     * @param {string} [options.messages.more='more']
     *        Label of the link to display more suggestions.
     * @param {string[]} [options.showErrorCodes=['failed-property-search']]
     *        Show errors with these error-codes in the ui.
     * @param {Function} [options.responseErrorFactory=null]
     *        Optional Callback to parse error message from response object
     *        @see wikibase.api.RepoApiError.newFromApiResponse
     */
    /**
     * @event selected
     * Triggered after having selected an entity.
     * @param {jQuery.Event} event
     * @param {string} entityId
     */
    $.widget( 'wikibase.entityselector', $.ui.suggester, {

        /**
         * @property {Object}
         */
        options: {
            url: null,
            language: ( IS_MW_CONTEXT ) ? mw.config.get( 'wgUserLanguage' ) : null,
            type: 'item',
            limit: null,
            caseSensitive: false,
            timeout: 8000,
            messages: {
                more: mwMsgOrString( 'wikibase-entityselector-more', 'more' ),
                notfound: mwMsgOrString( 'wikibase-entityselector-notfound', 'Nothing found' ),
                error: null
            },
            searchHookName: 'wikibase.entityselector.search',
            searchApiParametersHookName: 'wikibase.entityselector.search.api-parameters',
            showErrorCodes: [ 'failed-property-search' ],
            responseErrorFactory: null
        },

        /**
         * Caching the most current entity returned from the API.
         *
         * @property {Object}
         * @private
         */
        _selectedEntity: null,

        /**
         * Caches retrieved results.
         *
         * Warning, PropertySuggester's EntitySelector accesses this!
         *
         * @property {Object} [_cache={}]
         * @protected
         */
        _cache: null,

        /**
         * Error object from last search.
         *
         * @property {Object} [_error={}]
         * @protected
         */
        _error: null,

        /**
         * Warning, PropertySuggester's EntitySelector overrides this!
         *
         * @inheritdoc
         * @protected
         */
        _create: function () {
            var self = this;

            this._cache = {};

            if ( !this.options.source ) {
                if ( this.options.url === null ) {
                    throw new Error( 'When not specifying a dedicated source, URL option needs to be '
                        + 'specified' );
                } else if ( this.options.language === null ) {
                    throw new Error( 'When not specifying a dedicated source, language option needs to '
                        + 'be specified.' );
                }
                this.options.source = this._initDefaultSource();
            } else if ( typeof this.options.source !== 'function' && !Array.isArray( this.options.source ) ) {
                throw new Error( 'Source needs to be a function or an array' );
            }

            if ( !this.options.messages.error ) {
                this.options.messages.error = function () {
                    return self._error && self._error.detailedMessage ? self._error.detailedMessage : null;
                };
            }

            $.ui.suggester.prototype._create.call( this );

            this.element
                .addClass( 'ui-entityselector-input' )
                .prop( 'dir', $( document ).prop( 'dir' ) );

            this.options.menu.element.addClass( 'ui-entityselector-list' );

            this.element
            .off( 'blur' )
            .on( 'eachchange.' + this.widgetName, function ( event ) {
                self._search( event );
            } )
            .on( 'focusout', function () {
                self._indicateRecognizedInput();
            } )
            .on( 'focusin', function () {
                self._inEditMode();
                self._showDefaultSuggestions();
            } );
        },

        _indicateRecognizedInput: function () {
            this._resetInputHighlighting();

            if ( this._selectedEntity !== null ) {
                this.element.addClass( 'ui-entityselector-input-recognized' );
            } else if ( this.element.val() !== '' ) {
                this.element.addClass( 'ui-entityselector-input-unrecognized' );
            }
        },

        _inEditMode: function () {
            this._resetInputHighlighting();
        },

        _resetInputHighlighting: function () {
            this.element.removeClass(
                'ui-entityselector-input-recognized ui-entityselector-input-unrecognized'
            );
        },

        /**
         * @inheritdoc
         */
        destroy: function () {
            this.element.removeClass( 'ui-entityselector-input' );

            this._cache = {};

            $.ui.suggester.prototype.destroy.call( this );
        },

        /**
         * @protected
         *
         * @param {jQuery.Event} event
         */
        _search: function ( event ) {
            var self = this;

            this._select( null );

            clearTimeout( this._searching );
            this._searching = setTimeout( function () {
                self.search( event );
            }, this.options.delay );
        },

        /**
         * Create and return the data object for the api call.
         *
         * Warning, PropertySuggester's EntitySelector overrides this!
         *
         * @protected
         * @param {string} term
         * @return {Object}
         */
        _getSearchApiParameters: function ( term ) {
            var data = {
                action: 'wbsearchentities',
                search: term,
                format: 'json',
                errorformat: 'plaintext',
                language: this.options.language,
                uselang: this.options.language,
                type: this.options.type
            };

            if ( this._cache.term === term && this._cache.nextSuggestionOffset ) {
                data.continue = this._cache.nextSuggestionOffset;
            }

            if ( this.options.limit ) {
                data.limit = this.options.limit;
            }

            mw.hook( this.options.searchApiParametersHookName ).fire( data );

            return data;
        },

        /**
         * Initializes the default source pointing to the `wbsearchentities` API module via the URL
         * provided in the options.
         *
         * @protected
         *
         * @return {Function}
         */
        _initDefaultSource: function () {
            var self = this;

            return function ( term ) {
                var deferred = $.Deferred(),
                    hookResults = self._fireSearchHook( term );

                // clear previous error
                if ( self._error ) {
                    self._error = null;
                    self._cache.suggestions = null;
                    self._updateMenu( [] );
                }
                $.ajax( {
                    url: self.options.url,
                    timeout: self.options.timeout,
                    dataType: 'json',
                    data: self._getSearchApiParameters( term )
                } )
                .done( function ( response, statusText, jqXHR ) {
                    // T141955
                    if ( response.error ) {
                        deferred.reject( response.error.info );
                        return;
                    }

                    // The default endpoint wbsearchentities responds with an array of errors.
                    if ( response.errors && self.options.responseErrorFactory ) {
                        var error = self.options.responseErrorFactory( response, 'search' );

                        if ( error && self.options.showErrorCodes.indexOf( error.code ) !== -1 ) {
                            self._error = error;
                            self._cache = {};
                            self._updateMenu( [] );
                            deferred.reject( error.message );
                            return;
                        }
                    }
                    self._combineResults( hookResults, response.search ).then( function ( results ) {
                        deferred.resolve(
                            results,
                            term,
                            response[ 'search-continue' ],
                            jqXHR.getResponseHeader( 'X-Search-ID' )
                        );
                    } );
                } )
                .fail( function ( jqXHR, textStatus ) {
                    deferred.reject( textStatus );
                } );

                return deferred.promise();
            };
        },
        /**
         * @private
         */
        _fireSearchHook: function ( term ) {
            var hookResults = [],
                addPromise = function ( p ) {
                    hookResults.push( p );
                };

            if ( this._cache.term === term ) {
                return hookResults; // Don't fire hook when paginating
            }

            mw.hook( this.options.searchHookName ).fire( {
                element: this.element,
                term: term,
                options: this.options
            }, addPromise );

            return hookResults;
        },
        /**
         * @private
         */
        _combineResults: function ( hookResults, searchResults ) {
            var self = this,
                deferred = $.Deferred(),
                ids = {},
                result = [],
                uniqueFilter = function ( item ) {
                    if ( ids[ item.id ] ) {
                        return false;
                    }
                    ids[ item.id ] = true;
                    return true;
                },
                ratingSorter = function ( item1, item2 ) {
                    if ( !item1.rating && !item2.rating ) {
                        return 0;
                    }
                    if ( !item1.rating ) {
                        return 1;
                    }
                    if ( !item2.rating ) {
                        return -1;
                    }
                    if ( item1.rating < item2.rating ) {
                        return 1;
                    }
                    if ( item1.rating === item2.rating ) {
                        return 0;
                    }
                    return -1;
                };

            searchResults = searchResults || [];

            $.when.apply( $, hookResults ).then( function () {

                var args = Array.prototype.slice.call( arguments );
                args.forEach( function ( data ) {
                    result = data.concat( result );
                } );

                result = self._stableSort( result, ratingSorter );
                result = result.concat( searchResults );
                result = result.filter( uniqueFilter );
                deferred.resolve( result );
            } );

            return deferred.promise();
        },

        /**
         * @private
         */
        _stableSort: function stableSort( items, compareFn ) {
            var indices = Object.keys( items ).map( Number );
            indices.sort( function ( index1, index2 ) {
                var compare = compareFn( items[ index1 ], items[ index2 ] );
                if ( compare !== 0 ) {
                    return compare;
                }
                // fall back to comparing indices to ensure stability
                if ( index1 < index2 ) {
                    return -1;
                }
                if ( index1 > index2 ) {
                    return 1;
                }
                return 0;
            } );
            var sorted = indices.map( function ( index ) {
                return items[ index ];
            } );
            return sorted;
        },

        /**
         * @private
         */
        _showDefaultSuggestions: function () {
            if ( this.element.val() !== '' ) {
                return;
            }

            var self = this,
                term = this.element.val(),
                promises = this._fireSearchHook( term );

            this._combineResults( promises, [] ).then( function ( suggestions ) {
                if ( suggestions.length > 0 ) {
                    self._updateMenu( suggestions );
                }
            } );

        },

        /**
         * When the input is focused,
         * don’t open suggestions again if an entity was already selected.
         *
         * @protected
         *
         * @return {boolean}
         */
        _shouldSearch: function () {
            return this._selectedEntity === null;
        },

        /**
         * @inheritdoc
         * @protected
         */
        _updateMenu: function ( suggestions ) {
            var scrollTop = this.options.menu.element.scrollTop();

            $.ui.suggester.prototype._updateMenu.apply( this, arguments );

            this.options.menu.element.scrollTop( scrollTop );
        },

        /**
         * Generates the label for a suggester entity.
         *
         * @protected
         *
         * @param {Object} entityStub
         * @return {jQuery}
         */
        _createLabelFromSuggestion: function ( entityStub ) {
            var $suggestion = $( '<span>' ).addClass( 'ui-entityselector-itemcontent' ),
                $label = $( '<span>' ).addClass( 'ui-entityselector-label' ),
                $description = $();

            if ( entityStub.display && entityStub.display.label ) {
                $label.text( entityStub.display.label.value );
                $label.attr( 'lang', entityStub.display.label.language );
            } else {
                $label.text( entityStub.label || entityStub.id );
            }

            // TODO use match instead of aliases
            if ( entityStub.aliases ) {
                $label.append(
                    $( '<span>' ).addClass( 'ui-entityselector-aliases' ).text( ' (' + entityStub.aliases.join( ', ' ) + ')' )
                );
            }

            $suggestion.append( $label );

            if ( entityStub.display && entityStub.display.description ) {
                $description = $( '<span>' ).addClass( 'ui-entityselector-description' )
                    .text( entityStub.display.description.value )
                    .attr( 'lang', entityStub.display.description.language );
            } else if ( entityStub.description ) {
                $description = $( '<span>' ).addClass( 'ui-entityselector-description' )
                    .text( entityStub.description );
            }

            $suggestion.append( $description );

            return $suggestion;
        },

        /**
         * @see jQuery.ui.suggester._createMenuItemFromSuggestion
         * @protected
         *
         * @param {Object} entityStub
         * @return {jQuery.wikibase.entityselector.Item}
         */
        _createMenuItemFromSuggestion: function ( entityStub ) {
            var $label = this._createLabelFromSuggestion( entityStub ),
                value;

            if ( entityStub.display && entityStub.display.label ) {
                value = entityStub.display.label.value;
            } else {
                value = entityStub.label || entityStub.id;
            }

            return new $.wikibase.entityselector.Item( $label, value, entityStub );
        },

        /**
         * @inheritdoc
         * @protected
         */
        _initMenu: function ( ooMenu ) {
            var self = this;
            $.ui.suggester.prototype._initMenu.apply( this, arguments );

            $( this.options.menu )
            .off( 'selected.suggester' )
            .on( 'selected.entityselector', function ( event, item ) {
                if ( item.getEntityStub ) {
                    if ( !self.options.caseSensitive
                        && item.getValue().toLowerCase() === self._term.toLowerCase()
                    ) {
                        self._term = item.getValue();
                    } else {
                        self.element.val( item.getValue() );
                    }

                    self._close();
                    self._trigger( 'change' );

                    var entityStub = item.getEntityStub();

                    if ( !self._selectedEntity || entityStub.id !== self._selectedEntity.id ) {
                        self._select( entityStub );
                    }
                }
            } );

            var customItems = ooMenu.option( 'customItems' );

            customItems.unshift( new $.ui.ooMenu.CustomItem(
                this.options.messages.more,
                function () {
                    return self._cache.term === self._term && self._cache.nextSuggestionOffset;
                },
                function () {
                    self.search( $.Event( 'programmatic' ) );
                },
                'ui-entityselector-more'
            ) );

            customItems.unshift( new $.ui.ooMenu.CustomItem(
                this.options.messages.notfound,
                function () {
                    return !self._error && self._cache.suggestions && !self._cache.suggestions.length
                        && self.element.val().trim() !== '';
                },
                null,
                'ui-entityselector-notfound'
            ) );

            customItems.unshift( new $.ui.ooMenu.CustomItem(
                this.options.messages.error,
                function () {
                    return self._error !== null;
                },
                null,
                'ui-entityselector-error'
            ) );

            ooMenu._evaluateVisibility = function ( customItem ) {
                if ( customItem instanceof $.ui.ooMenu.CustomItem ) {
                    return customItem.getVisibility( ooMenu );
                } else {
                    return ooMenu._evaluateVisibility.apply( this, arguments );
                }
            };

            ooMenu.option( 'customItems', customItems );

            return ooMenu;
        },

        /**
         * @inheritdoc
         * @protected
         */
        _getSuggestions: function ( term ) {
            var self = this;

            return $.ui.suggester.prototype._getSuggestions.apply( this, arguments )
            .then( function ( suggestions, searchTerm, nextSuggestionOffset, searchId ) {
                var deferred = $.Deferred();

                if ( self._cache.term === searchTerm && self._cache.nextSuggestionOffset ) {
                    self._cache.suggestions = self._cache.suggestions.concat( suggestions );
                    self._cache.nextSuggestionOffset = nextSuggestionOffset;
                } else {
                    self._cache = {
                        term: searchTerm,
                        suggestions: suggestions,
                        nextSuggestionOffset: nextSuggestionOffset
                    };
                }
                if ( searchId ) {
                    self._cache.searchId = searchId;
                } else {
                    delete self._cache.searchId;
                }

                deferred.resolve( self._cache.suggestions, searchTerm );
                return deferred.promise();
            } );
        },

        /**
         * @inheritdoc
         * @protected
         */
        _getSuggestionsFromArray: function ( term, source ) {
            var deferred = $.Deferred(),
                // eslint-disable-next-line security/detect-non-literal-regexp
                matcher = new RegExp( this._escapeRegex( term ), 'i' );

            deferred.resolve( source.filter( function ( item ) {
                // TODO use match instead of aliases
                if ( item.aliases ) {
                    for ( var i = 0; i < item.aliases.length; i++ ) {
                        if ( matcher.test( item.aliases[ i ] ) ) {
                            return true;
                        }
                    }
                }

                var label;
                if ( item.display && item.display.label ) {
                    label = item.display.label.value;
                } else {
                    label = item.label || '';
                }

                return matcher.test( label ) || matcher.test( item.id );
            } ), term );

            return deferred.promise();
        },

        /**
         * Selects an entity.
         *
         * @protected
         *
         * @param {Object} entityStub
         */
        _select: function ( entityStub ) {
            var id = entityStub && entityStub.id;
            this._selectedEntity = entityStub;
            if ( id ) {
                this._trigger( 'selected', null, [ id ] );
            }
        },

        /**
         * Gets and sets the current state. The optional parameter can be used to let the initial
         * state of the selector reflect what can be seen in the input field the selector is
         * attached to.
         *
         * @param {string} [entityId]
         * @return {Object} Plain object featuring `Entity` stub data.
         */
        selectedEntity: function ( entityId ) {
            if ( typeof entityId === 'string' ) {
                this._selectedEntity = { id: entityId };
            }

            return this._selectedEntity;
        }
    } );

    /**
     * Default `entityselector` suggestion menu item.
     *
     * @class jQuery.wikibase.entityselector.Item
     * @extends jQuery.ui.ooMenu.Item
     *
     * @constructor
     *
     * @param {jQuery|string} label
     * @param {string} value
     * @param {Object} entityStub
     *
     * @throws {Error} if a required parameter is not specified properly.
     */
    var Item = function ( label, value, entityStub ) {
        if ( !label || !value || !entityStub ) {
            throw new Error( 'Required parameter(s) not specified properly' );
        }

        this._label = label;
        this._value = value;
        this._entityStub = entityStub;
        this._link = entityStub.url;
    };

    Item = util.inherit(
        $.ui.ooMenu.Item,
        Item,
        {
            /**
             * @property {Object}
             * @protected
             */
            _entityStub: null,

            /**
             * @return {Object}
             */
            getEntityStub: function () {
                return this._entityStub;
            }
        }
    );

    $.extend( $.wikibase.entityselector, {
        Item: Item
    } );

}() );