User:JDrewniak (WMF)/exploreSimilarSearchResults.js

Source: Wikipedia, the free encyclopedia.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <syntaxhighlight lang=javascript>
( function ( $, mw ) {
	'use strict';

	var $searchResultEls = $( '.mw-search-results > li' );

	// Only run on specialSearch page with default profile
	if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'Search' &&
		mw.util.getParamValue( 'profile' ) !== 'default'
	) {
		return;
	}

	$.when( mw.loader.using(
		[
			'mediawiki.api.messages',
			'mediawiki.template.mustache',
			'ext.uls.common'
		] ), $.ready )
	.then( function () {
		return new mw.Api().loadMessagesIfMissing( [
			'cirrussearch-explore-similar-related',
			'cirrussearch-explore-similar-categories',
			'cirrussearch-explore-similar-languages',
			'otherlanguages',
			'cirrussearch-explore-similar-related-none',
			'cirrussearch-explore-similar-categories-none',
			'cirrussearch-explore-similar-languages-none' ] );
	} )
	.then( function () {

		/**
		 * CSS classes used in templates
		 */
		var cssClassPrefix = 'mw-cirrus__xplr',
			cssClasses = {
				contentWrapper: cssClassPrefix + '__content-wrapper',
				content: cssClassPrefix + '__content',
				contentTitle: cssClassPrefix + '__content__title',
				contentColumns: cssClassPrefix + '__content__columns',
				buttons: cssClassPrefix + '__buttons',
				button: cssClassPrefix + '__button',
				buttonIcon: cssClassPrefix + '__button__icon',
				relatedContent: cssClassPrefix + '__content--related-pages',
				relatedPage: cssClassPrefix + '__related-page',
				relatedPageTitle: cssClassPrefix + '__related-page__title',
				relatedPageContent: cssClassPrefix + '__related-page__content',
				relatedPageThumb: cssClassPrefix + '__related-page__thumb',
				langContent: cssClassPrefix + '__content--languages',
				langLink: cssClassPrefix + '__content--languages__link',
				catContent: cssClassPrefix + '__categories',
				category: cssClassPrefix + '__category',
				activeButton: cssClassPrefix + '__button--active',
				activeSlowButton: cssClassPrefix + '__button--active-slow',
				active: cssClassPrefix + '--active'
			},
			/**
			 * l10n strings
			 */
			l10n = {
				relatedLink: mw.message( 'cirrussearch-explore-similar-related' ).text(),
				categoriesLink: mw.message( 'cirrussearch-explore-similar-categories' ).text(),
				languagesLink: mw.message( 'cirrussearch-explore-similar-languages' ).text(),
				relatedSectionTitle: mw.message( 'cirrussearch-explore-similar-related' ).text(),
				categoriesSectionTitle: mw.message( 'cirrussearch-explore-similar-categories' ).text(),
				languagesSectionTitle: mw.message( 'otherlanguages' ).text(),
				relatedSectionTitleNone: mw.message( 'cirrussearch-explore-similar-related-none' ).text(),
				categoriesSectionTitleNone: mw.message( 'cirrussearch-explore-similar-categories-none' ).text(),
				languagesSectionTitleNone: mw.message( 'cirrussearch-explore-similar-languages-none' ).text()
			};

		/**
		 * DeferredContentWidget
		 * =====================
		 * This is a factory function that abstracts the process of fetching AJAX content,
		 * processing the return data and populating a mustache template.
		 *
		 * The ajax call is wrapped in a jQuery Deferred object for convenient API usage.
		 * ex:
		 * ```
		 * deferredWidget.getData().done( function ( templateEl ) {
		 *     $('...').append( templateEl );
		 * })
		 * ```
		 * @param {Object} userConf
		 * @param {Object} userConf.apiConfig - An object containing a url and params property to fetch.
		 * @param {function} userConf.template - A mustache template string.
		 * @param {function} userConf.filterApiResponse - function that manipulates AJAX return data and returns data suitable
		 * 						for usage in template.
		 * @returns {Object} - Returns an object with a single method: getData(). This function returns a promise object
		 * 						suitable for chaining. ex: getData().then()...
		 */

		function DeferredContentWidget( userConf ) {
			var apiEndpoint = mw.util.wikiScript( 'api' ),
				conf = $.extend( true, {
					apiConfig: { url: apiEndpoint, params: {} },
					template: function ( templateData ) { return templateData; },
					filterApiResponse: function ( response ) {
						return response;
					}
				}, userConf ),
				ajaxCallRequired = true,
				deferred = $.Deferred();

			/**
			 * @param {Object} templateData - filtered API response data to populat template.
			 * @return {Element} - A compiled mustache template ready for DOM insertion.
			 */
			function compileTemplate( templateData ) {

				var compiledTemplate = mw.template.compile(
					conf.template( templateData ),
					'mustache' );

				if ( $.isEmptyObject( templateData ) ) { return ''; }

				return compiledTemplate.render( templateData );
			}

			/**
			 * Creates and executes AJAX request based on user config.
			 * Opting for $.get instead of mw.Api().get() for possibility of using RESTbase API.
			 */
			function getData() {

				if ( ajaxCallRequired ) {

					ajaxCallRequired = false; // makes sure ajax is only called once
					conf.apiConfig.params.origin = '*'; // enables cross-origin requests

					$.get( conf.apiConfig.url, conf.apiConfig.params )
						.then( conf.filterApiResponse )
						.then( compileTemplate )
						.then( function ( compiledTemplate ) {
							deferred.resolve( compiledTemplate );
						} )
						.fail( function () {
							deferred.fail();
						} );
				}
				return deferred.promise();
			}

			/* Public methods */
			return {
				getData: getData
			};
		}

		/**
		 * Extends the DeferredContentWidget function with params
		 * for getting page categories.
		 * @param {String} articleTitle
		 *
		 * @return {Object} - extended DeferredContentWidget object.
		 */
		function RelatedCategoriesWidget( articleTitle ) {
			var config = {
				apiConfig: {
					params: {
						action: 'query',
						format: 'json',
						prop: 'info',
						titles: articleTitle,
						generator: 'categories',
						inprop: 'url',
						gclshow: '!hidden',
						gcllimit: 10
					}
				},
				filterApiResponse: function ( reqResponse ) {
					var templateData,
					queryPages = ( reqResponse.query && reqResponse.query.pages ) ?
						reqResponse.query.pages : [];
					templateData = {
						sectionTitle: l10n.categoriesSectionTitle,
						cssClasses: cssClasses,
						pageCategories: $.map( queryPages, function ( page ) {
										var humanTitle = page.title.replace( /.*:/, '' ),
											url = page.fullurl;
										return {
											humanTitle: humanTitle,
											url: url
										};
							} )
					};

					if ( !templateData.pageCategories.length ) {
						templateData.sectionTitle = l10n.categoriesSectionTitleNone;
						templateData.noContent = 'no-content';
					}
					return templateData;
				},
				template: function () {
					return '<aside class="{{cssClasses.catContent}} {{noContent}}">' +
								'<strong class="{{cssClasses.contentTitle}}">' +
									'{{sectionTitle}}' +
								'</strong>' +
								'<div class="{{cssClasses.contentColumns}}">' +
									'{{#pageCategories}}' +
										'<a href="{{url}}" class="{{cssClasses.category}}" style="display:block;">' +
											'{{humanTitle}}' +
										'</a>' +
									'{{/pageCategories}}' +
								'</div>' +
							'</aside>';
				}
			};
			return DeferredContentWidget.call( this, config );
		}

		/**
		 * Extends the DeferredContentWidget function with params
		 * for getting page language links.
		 * @param {String} articleTitle
		 *
		 * @return {Object} - extended DeferredContentWidget object.
		 */
		function LangLinksWidget( articleTitle ) {
			var config = {
				apiConfig: {
					params: {
						format: 'json',
						action: 'query',
						titles: articleTitle,
						prop: 'langlinks',
						llprop: 'url|autonym',
						lllimit: '500'
					}
				},
				filterApiResponse: function ( reqResponse ) {

					var prefLangs = mw.uls.getFrequentLanguageList(),
						templateData = {
							langLinks: $.map( reqResponse.query.pages, function ( page ) {
								if ( page.langlinks ) {
									return $.grep( page.langlinks, function ( langlink ) {
										if ( prefLangs.indexOf( langlink.lang ) >= 0 ) {
											return langlink;
										}
									} );
								}
							} ),
							sectionTitle: l10n.languagesSectionTitle,
							cssClasses: cssClasses
						};

					if ( !templateData.langLinks.length ) {
						templateData.sectionTitle = l10n.languagesSectionTitleNone;
						templateData.cssNone = 'no-content';
					}

					return templateData;
				},
				template: function () {
					return '<aside class="{{cssClasses.langContent}} {{cssNone}}">' +
							'<strong class="{{cssClasses.contentTitle}}">' +
								'{{sectionTitle}}' +
							'</strong>' +
							'{{#langLinks}}' +
								'<div class="{{cssClasses.langLink}}" data-lang={{lang}}>' +
									'<div>{{autonym}}</div>' +
									'<a href="{{url}}">' +
										'{{*}}' +
									'</a>' +
								'</div>' +
							'{{/langLinks}}' +
						'</aside>';
				}
			};
			return DeferredContentWidget.call( this, config );
		}

		/**
		 * Extends the DeferredContentWidget function with params
		 * for getting related pages based on the 'morelike' API.
		 * @param {String} articleTitle
		 *
		 * @return {Object} - extended DeferredContentWidget object.
		 */
		function RelatedPagesWidget( articleTitle ) {
			var config = {
				apiConfig: {
					params: {
						action: 'query',
						format: 'json',
						formatversion: 2,
						prop: 'pageimages|pageterms|info',
						piprop: 'thumbnail',
						pithumbsize: 160,
						pilimit: 3,
						wbptterms: 'description',
						generator: 'search',
						gsrsearch: 'morelike:' + articleTitle,
						gsrnamespace: 0,
						gsrlimit: 3,
						gsrqiprofile: 'classic_noboostlinks',
						inprop: 'url',
						uselang: 'content',
						smaxage: 86400,
						maxage: 86400
					}

				},
				filterApiResponse: function ( reqResponse ) {
					var templateData;

					if ( typeof reqResponse.query !== 'undefined' &&
						reqResponse.query.pages.length
					) {
						templateData = {
							cssClasses: cssClasses,
							sectionTitle: l10n.relatedSectionTitle,
							relatedPages: reqResponse.query.pages
						};
					} else {
						templateData = {
							cssClasses: cssClasses,
							sectionTitle: l10n.relatedSectionTitleNone,
							noContent: 'no-content'
						};
					}

					return templateData;
				},
				template: function () {
					return '<aside class="{{cssClasses.relatedContent}} {{noContent}}">' +
							'<strong class="{{cssClasses.contentTitle}}">' +
								'{{sectionTitle}}' +
							'</strong>' +
							'{{#relatedPages}}' +
								'<a href="{{fullurl}}" title="{{title}}" class="{{cssClasses.relatedPage}}">' +
									'{{#thumbnail}}' +
										'<div class="{{cssClasses.relatedPageThumb}}" style="background-image:url({{thumbnail.source}});"></div>' +
									'{{/thumbnail}}' +
									'<strong class={{cssClasses.relatedPageTitle}}> {{title}} </strong>' +
									'{{#terms}}' +
										'<p>' +
											'{{description}}' +
										'</p>' +
									'{{/terms}}' +
								'</a>' +
							'{{/relatedPages}}' +
						'</aside>';
				}
			};
			return DeferredContentWidget.call( this, config );
		}

		/**
		 * Global array for storing & deleting explore similar buttons
		 * that have been triggered with a delay.
		 */
		window.ExploreSimilarTimeoutQueue = [];

		/**
		 * Instantiates Explore Similar buttons and adds their necessary behaviour.
		 * Returns a jQuery object containing the Explore Similar HTML to be inserted into DOM.
		 * @param {jQuery} $searchResult
		 * @param {string} resultTitle
		 */
		function ExploreSimilarButton( $searchResult, resultTitle ) {

			/**
			 * The ExploreSimilarWidget keys should corresponde to the
			 * 'data-es-content' attributes of the template buttons in order
			 * to map the correct data to the correct element.
			 * This mapping used in openExploreSimilarItem().
			 */
			var contentWidgets = {
					languages: new LangLinksWidget( resultTitle )
				},
				$template = $(
					'<div class="' + cssClasses.buttons + '">' +
						'<a class="' + cssClasses.button + '" data-es-content="languages">' +
							l10n.languagesLink +
							'<span class="' + cssClasses.buttonIcon + '"></span>' +
						'</a>' +
						'<div class="' + cssClasses.contentWrapper + '" style="display:none;"></div>' +
					'</div>'
				),
				$widgetContent = $template.find( ' .' + cssClasses.contentWrapper );

			/**
			 * Sets the template content
			 * @param {Element} content
			 */
			function replaceTemplateContent( content ) {
				$widgetContent.html( content );
			}

			/**
			 * Makes template content visible while
			 * hiding all other templates on the page.
			 */
			function showContent() {
				$( '.' + cssClasses.contentWrapper ).hide();
				$widgetContent.show();
			}

			/**
			 * adds 'active' class to search result while
			 * removing it from all other search results on the page.
			 */
			function activateSearchResult() {
				$( '.mw-search-results > li' ).removeClass( cssClasses.active );
				$searchResult.addClass( cssClasses.active );

			}

			/**
			 * Sets a CSS class to animate the Explore Similar button.
			 * Button can be animated slowely or quickly depending on wether
			 * it's the first button in the set the user hovers over.
			 *
			 * @param {jQuery} $this - button element wrapped in jQuery object
			 * @param {number} delay - delay with which content should appear.
			 */
			function animateButton( $this, delay ) {
				$( '.' + cssClasses.button ).removeClass( cssClasses.activeButton + ' , ' + cssClasses.activeSlowButton );
				if ( delay ) {
					$this.addClass( cssClasses.activeSlowButton );
				} else {
					$this.addClass( cssClasses.activeButton );
				}
			}

			/**
			 * removes all timers from the Explore Similar Queue.
			 * This prevents unwanted items from opening if a new item
			 * has been triggered.
			 */
			function clearExploreSimilarQueue() {
				window.ExploreSimilarTimeoutQueue.forEach( function ( timer ) {
					window.clearTimeout( timer );
				} );
			}

			/**
			 * Quasi UUID generator, for the purpose of matching 'open' & 'close' events
			 *
			 * @return {String} - UUID string based on timestamp and random number.
			 */
			function uniqueHoverId() {
				return Math.random().toString( 36 ).substring( 2 ) + ( new Date() ).getTime().toString( 36 );
			}

			/**
			 * broadcasts a custom jQuery event that can be subscribed
			 * to by other modules like eventlogging. Tailored for the
			 * searchSatisfaction2 schema
			 *
			 * Event data includes:
			 *  - hoverID: A unique identifier that pair hover-on and hover-off events.
			 *  - section: The name of the active section: 'related' || 'categories' || 'languages'
			 *             Defined as the 'es-content' attribute in the template string.
			 *  - results: Number of explore similar results.
			 *
			 * @param {jQuery} $button - Button element wrapped in jQuery.
			 * @param {string} state - 'open' || 'close' || 'click'.
			 * @param {jQuery} [$eventTarget] - $(event.target) passed from event callback.
			 *                                Only passed on click event since $button should
			 *                                the event that triggers the 'open' event.
			 * @param {jQuery} [$clickTarget] - $(this) passed from event callback. Should be
			 *                                one of the explore similar results. Only passed
			 *                                on click event.
			 **/
			function triggerCustomEvent( $button, state, $eventTarget, $clickTarget ) {
				var templateItems = $template.find(
						'.' + cssClasses.langLink +
						', .' + cssClasses.relatedPage +
						', .' + cssClasses.category ),
					eventParams = {
						hoverId: $button.data( 'hover-id' ),
						section: $button.data( 'es-content' ),
						results: templateItems.length,
						eventTarget: $eventTarget
					};
				if ( state === 'click' && $clickTarget.is( '.' + cssClasses.langLink ) ) {
					eventParams.result = $clickTarget.data( 'lang' );
				}

				if ( state === 'click' && !$clickTarget.is( '.' + cssClasses.langLink ) ) {
					eventParams.result = templateItems.index( $clickTarget );
				}
				mw.track( 'ext.CirrusSearch.exploreSimilar.' + state, eventParams );
			}
			/**
			 * Opens the Explore Similar widget based on which button was hovered.
			 * Sets a delay if this was the first item hovered in the set and
			 * clears the Explore Similar queue of any previous items.
			 *
			 * @param {Element} button - button that has been triggered.
			 * @param {*} relatedEl - The last item that was triggered (event.relatedTarget).
			 */
			function openExploreSimilarItem( button, relatedEl ) {
				var $button = $( button ),
					$relatedEl = $( relatedEl ),
					delay;

				clearExploreSimilarQueue();

				if ( $template.find( $relatedEl )[ 0 ] ) {
					delay = 0;
				} else {
					delay = 250;
				}

				$button.data( 'hover-id', uniqueHoverId() );

				animateButton( $button, delay );

				// item is pushed to the timeout queue even if the delay is 0.
				window.ExploreSimilarTimeoutQueue.push(
						window.setTimeout( function () {
							// The keys of the contentWidgets Object should correnspond
							// to the 'data-es-content' attribute of the button template.
							contentWidgets[
								$button.data( 'es-content' )
							]
							.getData()
							.done( replaceTemplateContent )
							.done( showContent )
							.done( activateSearchResult )
							.done( triggerCustomEvent.bind( null, $button, 'open' ) );
						}, delay )
					);
			}

			/**
			 * Closes the Explore Similar item based on the 'active' CSS class associated with the button,
			 * as well as all other Explore Similar items on the page.
			 * Also Triggers the custom Explore Similar event.
			 *
			 * @param {Object} $template - Explore Similar template wrapped in jQuery object.
			 */
			function closeExploreSimilarItem( $template ) {

				var $activeButton = $template.find( '.' + cssClasses.activeButton + ', .' + cssClasses.activeSlowButton ),
					$contentWrappers = $( '.' + cssClasses.contentWrapper );

				clearExploreSimilarQueue();

				if ( $searchResult.hasClass( cssClasses.active ) ) {
					triggerCustomEvent( $activeButton, 'close' );
				}

				$activeButton.removeClass( cssClasses.activeButton + ' ' + cssClasses.activeSlowButton );
				$contentWrappers.hide();
				$( '.mw-search-results > li' ).removeClass( cssClasses.active );
			}

			/**
			 * Event Handlers
			 */
			$template
			.find( '.' + cssClasses.button ) // Explore Similar item open is only triggered on button
			.on( 'mouseenter',
				function ( event ) {
					// check if item isn't already opened
					var $activeButtons = $template.find( '.' + cssClasses.activeButton +
									', .' + cssClasses.activeSlowButton ),
						selectedButtonIsActive = $( this ).is( $activeButtons );

					// if a different button is active, trigger the close event
					if ( $activeButtons.length && !selectedButtonIsActive ) {
						triggerCustomEvent( $activeButtons.first(), 'close' );
					}
					if ( !selectedButtonIsActive ) {
						openExploreSimilarItem( this, event.relatedTarget );
					}
				}
			);

			$template // Explore Similar item close is triggered on entire template
			.on( 'mouseout',
				function ( event ) {

					var $relatedTarget = $( event.relatedTarget );

					// don't close the 'active' state when moving across sections,
					// prevents css flickering of 'active' class
					if ( !$relatedTarget.hasClass( '.mw-search-result-data' ) &&
						!$template.find( $relatedTarget )[ 0 ] )					{
						closeExploreSimilarItem( $template );
					}
				}
			);

			$widgetContent // Explore Similar item close is triggered on entire template
			.on( 'click', '.' + cssClasses.relatedPage + ', .' + cssClasses.category + ', .' + cssClasses.langLink,
				function ( event ) {
					var $activeButton = $template.find( '.' + cssClasses.activeButton +
										', .' + cssClasses.activeSlowButton ).first();
					triggerCustomEvent( $activeButton, 'click', $( event.target ), $( this ) );
				}
			);

			// Returns Explore Similar template with all behaviours and events attached.
			return $template;

		}

		$searchResultEls.each( function ( index, el ) {
			var $searchResult = $( el ),
				$searchResultMeta = $searchResult.children( '.mw-search-result-data' ),
				resultTitle = $searchResult.find( '.mw-search-result-heading a' ).attr( 'title' ),
				exploreButton = new ExploreSimilarButton( $searchResult, resultTitle );

			$searchResultMeta.append( exploreButton );
		} );

	} );

}( jQuery, mediaWiki ) );
// </syntaxhighlight>