wikimedia/mediawiki-extensions-Wikibase

View on GitHub
client/resources/jquery.wikibase/jquery.wikibase.linkitem.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * @license GPL-2.0-or-later
 * @author Marius Hoch < hoo@online.de >
 */
( function ( wb ) {
    'use strict';
    var PageConnector = require( '../wikibase.client.PageConnector.js' ),
        getMwApiForRepo = require( '../wikibase.client.getMwApiForRepo.js' );

    require( './jquery.wikibase.siteselector.js' );
    require( './jquery.wikibase.wbtooltip.js' );
    require( '../jquery.event.special.eachchange.js' );

    // wikibase.api module
    require( '../../../lib/resources/wikibase-api/src/namespace.js' );
    require( '../../../lib/resources/wikibase-api/src/RepoApi.js' );
    require( '../../../lib/resources/wikibase-api/src/getLocationAgnosticMwApi.js' );
    require( '../../../lib/resources/wikibase-api/src/RepoApiError.js' );

    /**
     * This widget allows linking articles with Wikibase items or creating new wikibase items directly
     * in client wikis.
     * The widget can take a couple of arguments to make it work on pages and sites other than the
     * current one. All these options default to global state / the current page's attributes.
     *
     * @option mwApiForRep {mediaWiki.Api} A mw.Api instance configured to use the repo's API.
     *
     * @option pageTitle {string} Title of the page to link.
     *
     * @option globalSiteId {string} Id of the site the given page is on.
     *
     * @option namespaceNumber {number} Number of the namespace the given title is in.
     *         This is used to determine the linkable pages on the target wiki.
     *
     * @option repoArticlePath {string} Article path (like wgArticlePath) for the repo.
     *
     * @option langLinkSiteGroup {string} Group of sites we allow the user to link the given page with.
     *
     * @event dialogclose: Triggered when the interaction dialog is closed.
     *        (1) {jQuery.Event}
     *
     * @event success: Triggered when pages have been linked successfully.
     *        (1) {jQuery.Event}
     */
    $.widget( 'wikibase.linkitem', {
        _pageConnector: null,

        /**
         * @type jQuery
         */
        $dialog: null,

        /**
         * Spinner (set if there's something ongoing)
         *
         * @type jQuery
         */
        $spinner: null,

        /**
         * Button to go on (next step)
         *
         * @type jQuery
         */
        $goButton: null,

        /**
         * Global ID of the site to link with
         *
         * @type {string}
         */
        targetSite: null,

        /**
         * Name of the page title to link with
         *
         * @type {string}
         */
        targetArticle: null,

        mwApiForRepo: getMwApiForRepo(),

        /**
         * Options
         *
         * @see jQuery.Widget.options
         */
        options: {
            pageTitle: null,
            globalSiteId: null,
            namespaceNumber: null,
            repoArticlePath: null,
            langLinkSiteGroup: null,
            tags: []
        },

        /**
         * Check whether the user is logged in on both the client and the repo
         * show the dialog if he is, error if not
         *
         * @see jQuery.Widget._create
         */
        _create: function () {
            var self = this,
                $dialogSpinner = $.createSpinner();

            this.element
            .hide()
            .after( $dialogSpinner );

            this.mwApiForRepo.get( {
                action: 'query',
                meta: 'userinfo'
            } )
            .done( function ( data ) {
                $dialogSpinner.remove();

                if ( data.query.userinfo.anon !== undefined ) {
                    // User isn't logged into the repo
                    self._notLoggedIn();
                    return;
                }

                self._createDialog();
                $( '#wbclient-linkItem-site' ).trigger( 'focus' );
            } )
            .fail( function ( errorCode, errorInfo ) {
                $dialogSpinner.remove();
                self.element.show();

                self.element.wbtooltip( {
                    content: mw.msg( 'wikibase-error-unexpected',
                        ( errorInfo.error && errorInfo.error.info ) || errorInfo.exception ),
                    gravity: 'w'
                } );

                self.element.data( 'wbtooltip' ).show();
                self.element.one( 'click.' + self.widgetName, function () {
                    // Remove the tooltip by the time the user clicks the link again.
                    self.element.data( 'wbtooltip' ).destroy();
                } );
            } );
        },

        /**
         * Show an error to the user in case he isn't logged in on both the client and the repo
         */
        _notLoggedIn: function () {
            var self = this;

            var userLogin = this._linkRepoTitle( 'Special:UserLogin' );
            $( '<div>' )
            .dialog( {
                title: mw.message( 'wikibase-linkitem-not-loggedin-title' ).escaped(),
                width: 400,
                height: 200,
                resizable: true
            } )
            .on( 'dialogclose', function () {
                self._trigger( 'dialogclose' );
            } )
            .append(
                $( '<p>' )
                .addClass( 'wbclient-linkItem-not-loggedin-message' )
                .html( mw.message( 'wikibase-linkitem-not-loggedin', userLogin ).parse() )
            );
        },

        /**
         * Create the dialog asking for a page the user wants to link with the current one
         */
        _createDialog: function () {
            this.$dialog = $( '<div>' )
                .attr( 'id', 'wbclient-linkItem-dialog' )
                .dialog( {
                    title: mw.message( 'wikibase-linkitem-title' ).escaped(),
                    width: 500,
                    resizable: false,
                    position: { my: 'top', at: 'top+50', of: window },
                    buttons: [ {
                        text: mw.msg( 'wikibase-linkitem-linkpage' ),
                        id: 'wbclient-linkItem-goButton',
                        disabled: 'disabled',
                        click: this._secondStep.bind( this )
                    } ],
                    modal: true
                } )
                // Use .on instead of passing this to dialog() as close as we want to be able to remove
                // it later:
                .on( 'dialogclose', function () {
                    this.element.show();
                    this._trigger( 'dialogclose' );
                }.bind( this ) )
                .append( $( '<p>' ).text( mw.msg( 'wikibase-linkitem-selectlink' ) ) )
                .append( this._createSiteLinkForm() );

            this.$goButton = $( '#wbclient-linkItem-goButton' );
        },

        /**
         * @see jQuery.Widget.destroy
         */
        destroy: function () {
            if ( this.$dialog && this.$dialog.length ) {
                this.$dialog.remove();
            }
            if ( this.$spinner && this.$spinner.length ) {
                this.$spinner.remove();
            }
            if ( this.$goButton && this.$goButton.length ) {
                this.$goButton.remove();
            }

            $.Widget.prototype.destroy.call( this );

            // FIXME: The destroy() method should be final. Re-showing the element should be done
            // outside the scope of destroy().
            this.element.show();
        },

        /**
         * Creates a form for selecting the site and the page to link in a user-friendly manner (with
         * auto-completion).
         *
         * @return {jQuery}
         */
        _createSiteLinkForm: function () {
            return $( '<form>' )
            .attr( 'name', 'wikibase-linkItem-form' )
            .append( this._createSiteInput() )
            .append( $( '<br>' ) )
            .append( this._createPageInput() );
        },

        /**
         * Creates a labeled input box for selecting client sites.
         *
         * @return {jQuery}
         */
        _createSiteInput: function () {
            return $( '<label>' )
            .attr( 'for', 'wbclient-linkItem-site' )
            .text( mw.msg( 'wikibase-linkitem-input-site' ) )
            .add(
                $( '<input>' )
                .attr( {
                    name: 'wbclient-linkItem-site',
                    id: 'wbclient-linkItem-site',
                    class: 'wbclient-linkItem-input'
                } )
                .siteselector( {
                    source: this._getLinkableSites()
                } )
                .on(
                    'siteselectoropen siteselectorclose siteselectorautocomplete blur',
                    this._onSiteSelectorChangeHandler.bind( this )
                )
            );
        },

        /**
         * Gets an object with all linkable sites despite the current one (as pages on the same wiki
         * cannot be linked).
         *
         * @return {Object}
         */
        _getLinkableSites: function () {
            var sites,
                linkableSites = [],
                site,
                currentSiteId,
                sitesModule = require( '../wikibase.sites.js' );

            currentSiteId = this.options.globalSiteId;
            sites = sitesModule.getSitesOfGroup( this.options.langLinkSiteGroup );

            for ( site in sites ) {
                if ( sites[ site ].getId() !== currentSiteId ) {
                    linkableSites.push( sites[ site ] );
                }
            }

            return linkableSites;
        },

        /**
         * Handles changes to the siteselector
         */
        _onSiteSelectorChangeHandler: function () {
            var apiUrl,
                $page = $( '#wbclient-linkItem-page' );

            $page.val( '' );

            try {
                apiUrl = $( '#wbclient-linkItem-site' ).siteselector( 'getSelectedSite' ).getApi();
            } catch ( e ) {
                // Invalid input (likely incomplete). Disable the page input an re-disable to button
                $page.prop( 'disabled', true );
                this.$goButton.button( 'disable' );
                return;
            }

            // If the language gets changed the yet selected page is no longer available so we clear the
            // input element. Furthermore, we remove the old suggestor (if there's one) and create a new
            // one working on the right wiki.
            $page
            .prop( 'disabled', false )
            .suggester( {
                source: function ( term ) {
                    var deferred = $.Deferred();

                    $.ajax( {
                        url: apiUrl,
                        dataType: 'jsonp',
                        data: {
                            search: term,
                            action: 'opensearch'
                        },
                        timeout: 8000
                    } )
                    .done( function ( response ) {
                        deferred.resolve( response[ 1 ], response[ 0 ] );
                    } )
                    .fail( function ( jqXHR, textStatus ) {
                        deferred.reject( textStatus );
                    } );

                    return deferred.promise();
                }
            } );
        },

        /**
         * Creates a labeled input box for selecting pages on a client site.
         *
         * @return {jQuery}
         */
        _createPageInput: function () {
            var self = this;

            return $( '<label>' )
            .attr( 'for', 'wbclient-linkItem-page' )
            .text( mw.msg( 'wikibase-linkitem-input-page' ) )
            .add(
                $( '<input>' )
                .attr( {
                    name: 'wbclient-linkItem-page',
                    id: 'wbclient-linkItem-page',
                    class: 'wbclient-linkItem-input'
                } )
                .prop( 'disabled', true )
                .on( 'eachchange', function () {
                    // Enable the button if the field has a value
                    self.$goButton.button( $( this ).val() === '' ? 'disable' : 'enable' );
                } )
                .on( 'keydown', function ( e ) {
                    if ( !self.$goButton.prop( 'disabled' ) && e.which === 13 ) {
                        // Enter should submit
                        self.$goButton.trigger( 'click' );
                    }
                } )
            );
        },

        /**
         * Called after the user specified site and a page name. Looks up any existing items or tries to
         * link the currently viewed page with an existing item.
         */
        _secondStep: function () {
            this.targetSite = $( '#wbclient-linkItem-site' ).siteselector( 'getSelectedSite' ).getId();
            this.targetArticle = $( '#wbclient-linkItem-page' ).val();

            this._pageConnector = new PageConnector(
                new wb.api.RepoApi(
                    this.mwApiForRepo,
                    mw.config.get( 'wgUserLanguage' ),
                    this.options.tags
                ),
                this.options.globalSiteId,
                this.options.pageTitle,
                this.targetSite,
                this.targetArticle
            );

            // Show a spinning animation and do an API request
            this._showSpinner();

            this._pageConnector.getNewlyLinkedPages()
            .done( this._onConfirmationDataLoad.bind( this ) )
            // This will (as a side effect) also catch errors where the target page doesn't exist:
            .fail( this._onError.bind( this ) );
        },

        /**
         * Replaces the $goButton button with a loading spinner.
         */
        _showSpinner: function () {
            this.$spinner = $.createSpinner();
            this.$goButton
                .hide()
                .after( this.$spinner );
        },

        /**
         * Removes the spinner created with _showSpinner and shows the original button again.
         */
        _removeSpinner: function () {
            if ( !this.$spinner || !this.$spinner.length ) {
                return;
            }
            this.$spinner.remove();
            this.$goButton.show();
        },

        /**
         * Handles the data from getNewlyLinkedPages and either creates a new item or shows the user a
         * confirmation form in case an item exists already.
         *
         * @param {Object} entity
         */
        _onConfirmationDataLoad: function ( entity ) {
            var i, itemLink;

            if ( entity && entity.sitelinks ) {
                var siteLinkCount = 0;

                // Show a table with links to the user and ask for confirmation
                itemLink = this._linkRepoTitle( entity.title );

                // Count site links and abort in case the entity already is linked with a page on this
                // wiki:
                for ( i in entity.sitelinks ) {
                    if ( entity.sitelinks[ i ].site ) {
                        siteLinkCount += 1;
                        if ( entity.sitelinks[ i ].site === this.options.globalSiteId ) {
                            // Abort as the entity already is linked with a page on this wiki
                            this._onError( mw.message(
                                'wikibase-linkitem-alreadylinked',
                                itemLink,
                                entity.sitelinks[ i ].title
                            ).parse() );
                            return;
                        }
                    }
                }

                if ( siteLinkCount === 1 ) {
                    // The item we want to link with only has a single sitelink so we don't have to ask
                    // for confirmation:
                    this._pageConnector.linkPages()
                    .done( this._onSuccess.bind( this ) )
                    .fail( this._onError.bind( this ) );
                } else {
                    // Let the user verify this is indeed the entity to link with and link it after.
                    this._removeSpinner();
                    this._userConfirmEntity( entity, siteLinkCount, itemLink );
                }
            } else {
                this._pageConnector.linkPages()
                .done( this._onSuccess.bind( this ) )
                .fail( this._onError.bind( this ) );
            }
        },

        /**
         * Let the user verify this is indeed the entity to link with and link it after.
         *
         * @param {Object} entity
         * @param {number} siteLinkCount Number of sitelinks attached to the entity
         * @param {string} itemLink Link to the entity on the repo
         */
        _userConfirmEntity: function ( entity, siteLinkCount, itemLink ) {
            var self = this,
                confirmationMsg = mw.message(
                    'wikibase-linkitem-confirmitem-text',
                    itemLink,
                    siteLinkCount
                ).parse();

            this.$dialog
                .empty()
                .append( $( '<div>' ).html( confirmationMsg ) )
                .append( $( '<br>' ) )
                .append( this._createSiteLinkTable( entity ) );

            this.$goButton
                .off( 'click' )
                .button( 'option', 'label', mw.msg( 'wikibase-linkitem-confirmitem-button' ) )
                .on( 'click', function () {
                    // The user confirmed that this is the right item...
                    self._showSpinner();
                    self._pageConnector.linkPages()
                    .done( self._onSuccess.bind( self ) )
                    .fail( self._onError.bind( self ) );
                } );
        },

        /**
         * Creates a table with all sitelinks linked to an entity.
         *
         * @param {Object} entity
         *
         * @return {jQuery}
         */
        _createSiteLinkTable: function ( entity ) {
            var i, $siteLinks, sites = require( '../wikibase.sites.js' );

            $siteLinks = $( '<div>' )
                .attr( 'id', 'wbclient-linkItem-siteLinks' )
                .append( $( '<table>' ) );

            // Table head
            $( '<thead>' )
            .append(
                $( '<tr>' )
                .append( $( '<th>' ).text( mw.msg( 'wikibase-sitelinks-sitename-columnheading' ) ) )
                .append( $( '<th>' ).text( mw.msg( 'wikibase-sitelinks-link-columnheading' ) ) )
            )
            .appendTo( $siteLinks.find( 'table' ) );

            // Table body
            for ( i in entity.sitelinks ) {
                if ( entity.sitelinks[ i ].site ) {
                    // Show a row for each page that is linked with the current entity
                    $siteLinks
                    .find( 'table' )
                    .append(
                        this._createSiteLinkRow(
                            sites.getSite( entity.sitelinks[ i ].site ),
                            entity.sitelinks[ i ]
                        )
                    );
                }
            }
            return $siteLinks;
        },

        /**
         * Creates a table row for a site link.
         *
         * @param {wikibase.Site} site
         * @param {Object} entitySitelinks
         *
         * @return {jQuery}
         */
        _createSiteLinkRow: function ( site, entitySitelinks ) {
            return $( '<tr>' )
                .append(
                    $( '<td>' )
                    .addClass( 'wbclient-linkItem-column-site' )
                    .text( site.getName() )
                    .css( 'direction', site.getLanguageDirection() )
                )
                .append(
                    $( '<td>' )
                    .addClass( 'wbclient-linkItem-column-page' )
                    .append( site.getLinkTo( entitySitelinks.title ) )
                    .css( 'direction', site.getLanguageDirection() )
                );
        },

        /**
         * Called after an entity has successfully been linked or created. Replaces the dialog content
         * with a useful message linking the (new) item.
         */
        _onSuccess: function () {
            var mwApi = new mw.Api(),
                itemUri = this._linkRepoTitle(
                    'Special:ItemByTitle/' + this.options.globalSiteId + '/' + this.options.pageTitle
                );

            this.$dialog
                .empty()
                .append(
                    $( '<p>' )
                    .addClass( 'wbclient-linkItem-success-message' )
                    .html( mw.message( 'wikibase-linkitem-success-link', itemUri ).parse() )
                )
                .append( $( '<p>' ).text( mw.msg( 'wikibase-replicationnote' ) ) );

            this._removeSpinner();

            // Replace the button with one asking to close the dialog and reload the current page
            this.$goButton
                .off( 'click' )
                .on( 'click', function () {
                    this._showSpinner();
                    window.location.reload( true );
                }.bind( this ) )
                .button( 'option', 'label', mw.msg( 'wikibase-linkitem-close' ) );

            // Purge this page in the background... we shouldn't confuse the user with the newly added
            // link(s) not being there:
            mwApi.post( {
                formatversion: 2,
                action: 'purge',
                titles: this.options.pageTitle
            } );

            this._trigger( 'success' );
        },

        /**
         * Called in case an error occurs and displays an error message.
         *
         * Can either show a given errorCode (as html) or use data from an
         * API failure (pass two parameters in this case).
         *
         * @param {string} errorCode
         * @param {Object} [errorInfo]
         */
        _onError: function ( errorCode, errorInfo ) {
            var error = errorInfo
                ? wb.api.RepoApiError.newFromApiResponse( errorInfo )
                : errorCode;

            var $elem = $( '#wbclient-linkItem-page' );

            if ( $elem.length === 0 ) {
                $elem = $( '#wbclient-linkItem-siteLinks' );
            }

            $elem.wbtooltip( {
                content: error,
                permanent: true
            } );

            this._removeSpinner();
            $elem.data( 'wbtooltip' ).show();

            // Remove the tooltip if the user clicks onto the dialog trying to correct the input
            // Also remove the tooltip in case the dialog is getting closed
            this.$dialog.one( 'dialogclose click', function () {
                if ( $elem.data( 'wbtooltip' ) ) {
                    $elem.data( 'wbtooltip' ).destroy();
                }
            } );
        },

        /**
         * Returns a link to the given title on the repo.
         *
         * @param {string} title
         *
         * @return {string}
         */
        _linkRepoTitle: function ( title ) {
            return this.options.repoArticlePath.replace( /\$1/g, mw.util.wikiUrlencode( title ) );
        }
    } );

}( wikibase ) );