mediawiki-extensions-Visual.../modules/ve-mw/ui/widgets/ve.ui.MWTemplateTitleInputWidget.js
Timo Tijhof ad6e0e332e MWTemplateTitleInputWidget: Fix unstable sort affecting Firefox
I'm going to assume the order in production with Chrome, as asserted
by the unit test, is the preferred order whereby negative numbers are
intended to be prepended before the positive numbers.

This came up in I6ecbd5743f3e, where this was the only unit test across
MediaWiki core and WMF gated extensions, that fails in Firefox.

It is reproducible locally with plain MW core + VisualEditor as well.

Bug: T366299
Change-Id: I6f8546e6e604cbb41e11bd2b59f8b5f19350c676
2024-05-31 17:12:29 +01:00

330 lines
11 KiB
JavaScript

/*!
* VisualEditor UserInterface MWTemplateTitleInputWidget class.
*
* @copyright See AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Input field for entering a template title, for example when adding a template
* in the template dialog. Autocomplete fetches TemplateData and performs
* searching in the background, to display information about matching templates
* on the wiki.
*
* @class
* @extends mw.widgets.TitleInputWidget
*
* @constructor
* @param {Object} [config] Configuration options
* @param {number} [config.namespace] Namespace to prepend to queries. Defaults to template namespace.
* @param {boolean} [config.showDescriptions] Show template descriptions from the TemplateData API
* @param {mw.Api} [config.api]
*/
ve.ui.MWTemplateTitleInputWidget = function VeUiMWTemplateTitleInputWidget( config ) {
config = ve.extendObject( {}, {
namespace: mw.config.get( 'wgNamespaceIds' ).template,
showMissing: false,
// We don't need results to show up twice normalized and unnormalized
addQueryInput: false,
icon: 'search',
placeholder: ve.msg( 'visualeditor-dialog-transclusion-placeholder-input-placeholder' )
}, config );
// Parent constructor
ve.ui.MWTemplateTitleInputWidget.super.call( this, config );
// All code below expects this, but ContentTranslation doesn't necessarily set it to 2
this.api.defaults.parameters.formatversion = 2;
this.showTemplateDescriptions = this.showDescriptions;
// Clear the showDescriptions flag for subsequent requests as we implement
// description fetch ourselves
this.showDescriptions = false;
// Properties
this.descriptions = {};
// Initialization
this.$element.addClass( 've-ui-mwTemplateTitleInputWidget' );
};
/* Inheritance */
// FIXME: This should extend mw.widgets.TitleSearchWidget instead
OO.inheritClass( ve.ui.MWTemplateTitleInputWidget, mw.widgets.TitleInputWidget );
/* Methods */
// @inheritdoc mw.widgets.TitleWidget
ve.ui.MWTemplateTitleInputWidget.prototype.getApiParams = function ( query ) {
const params = ve.ui.MWTemplateTitleInputWidget.super.prototype.getApiParams.call( this, query );
// TODO: This should stay as a feature flag for 3rd-parties to fallback to prefixsearch
if ( mw.config.get( 'wgVisualEditorConfig' ).cirrusSearchLookup ) {
ve.extendObject( params, {
generator: 'search',
gsrsearch: params.gpssearch,
gsrnamespace: params.gpsnamespace,
gsrlimit: params.gpslimit
} );
// Adding the asterisk to emulate a prefix search behavior. It does not make sense in all
// cases though. We're limiting it to be add only of the term ends with a letter or numeric
// character.
// eslint-disable-next-line es-x/no-regexp-unicode-property-escapes, prefer-regex-literals
const endsWithAlpha = new RegExp( '[\\p{L}\\p{N}]$', 'u' );
if ( endsWithAlpha.test( params.gsrsearch ) ) {
params.gsrsearch += '*';
}
if ( this.showRedirectTargets ) {
params.gsrprop = 'redirecttitle';
}
delete params.gpssearch;
delete params.gpsnamespace;
delete params.gpslimit;
}
return params;
};
// @inheritdoc mw.widgets.TitleInputWidget
ve.ui.MWTemplateTitleInputWidget.prototype.getLookupRequest = function () {
let promise = ve.ui.MWTemplateTitleInputWidget.super.prototype.getLookupRequest.call( this );
if ( mw.config.get( 'wgVisualEditorConfig' ).cirrusSearchLookup ) {
promise = promise
.then( this.addExactMatch.bind( this ) )
.promise( { abort: () => {} } );
}
if ( !this.showTemplateDescriptions ) {
return promise;
}
const templateDataMessage = mw.message( 'templatedata-doc-subpage' ),
templateDataInstalled = templateDataMessage.exists(),
templateDocPageFragment = '/' + templateDataMessage.text();
let originalResponse;
return promise
.then( ( response ) => {
const redirects = ( response.query && response.query.redirects ) || [];
let newPages = ( response.query && response.query.pages ) || [];
newPages.forEach( ( page ) => {
if ( !( 'index' in page ) ) {
// Watch out for cases where the index is specified on the redirect object
// rather than the page object.
for ( const j in redirects ) {
if ( redirects[ j ].to === page.title ) {
page.index = redirects[ j ].index;
break;
}
}
}
} );
// T54448: Filter out matches which end in /doc or as configured on-wiki
if ( templateDataInstalled ) {
newPages = newPages.filter(
// Can't use String.endsWith() as that's ES6.
// page.title.endsWith( templateDocPageFragment )
( page ) => page.title.slice( -templateDocPageFragment.length ) !== templateDocPageFragment
);
}
// Ensure everything goes into the order defined by the page's index key
newPages.sort( ( a, b ) => {
// T366299: Avoid unstable sort.
if ( a.index === undefined || b.index === undefined ) {
return 0;
}
return a.index - b.index;
} );
const titles = newPages.map( ( page ) => page.title );
ve.setProp( response, 'query', 'pages', newPages );
originalResponse = response; // lie!
// Also get descriptions
// FIXME: This should go through MWTransclusionModel rather than duplicate.
if ( titles.length > 0 ) {
const xhr = this.getApi().get( {
action: 'templatedata',
titles: titles,
redirects: !!this.showRedirects,
includeMissingTitles: '1',
lang: mw.config.get( 'wgUserLanguage' )
} );
return xhr.promise( { abort: xhr.abort } );
}
} )
.then( ( templateDataResponse ) => {
const pages = ( templateDataResponse && templateDataResponse.pages ) || {};
// Look for descriptions and cache them
for ( const i in pages ) {
const page = pages[ i ];
if ( page.missing ) {
// Remember templates that don't exist in the link cache
// { title: { missing: true|false }
const missingTitle = {};
missingTitle[ page.title ] = { missing: true };
ve.init.platform.linkCache.setMissing( missingTitle );
} else if ( !page.notemplatedata ) {
// Cache descriptions
this.descriptions[ page.title ] = page.description;
}
}
// Return the original response
return originalResponse;
// API request failed; most likely, we're on a wiki which doesn't have TemplateData.
}, () => originalResponse || ve.createDeferred().reject() )
.promise( { abort: () => {} } );
};
/**
* @private
* @method
* @param {Object} response Action API response from server
* @return {Object} Modified response
*/
ve.ui.MWTemplateTitleInputWidget.prototype.addExactMatch = function ( response ) {
const query = this.getQueryValue(),
title = mw.Title.newFromText( query, this.namespace );
// No point in trying anything when the title is invalid
if ( !title ) {
return response;
}
if ( !response.query ) {
response.query = { pages: [] };
}
const lowerTitle = title.getPrefixedText().toLowerCase(),
metadata = response.query.redirects || [],
foundMatchingMetadata = metadata.some( ( redirect ) => redirect.from.toLowerCase() === lowerTitle );
if ( foundMatchingMetadata ) {
// Redirects will be carefully positioned later in TitleWidget.getOptionsFromData()
return response;
}
/**
* @typedef {Object} PageResponse
* @memberof ve.ui.MWTemplateTitleInputWidget
* @param {number} pageId Page ID
* @param {number} index Page ID
*/
/**
* @param {ve.ui.MWTemplateTitleInputWidget.PageResponse[]} pages
* @param {number} pageId
* @return {boolean}
*/
const containsPageId = ( pages, pageId ) => pageId && pages.some( ( page ) => page.pageid === pageId );
/**
* @param {ve.ui.MWTemplateTitleInputWidget.PageResponse[]} pages
* @param {Object} [newPage]
*/
const unshiftPages = ( pages, newPage ) => {
pages.forEach( ( page ) => {
page.index++;
} );
if ( newPage && newPage.title ) {
newPage.index = 1;
pages.unshift( newPage );
if ( pages.length > this.limit ) {
pages.sort( ( a, b ) => a.index - b.index ).splice( this.limit );
}
}
};
const matchingRedirects = response.query.pages.filter( ( page ) => page.redirecttitle && page.redirecttitle.toLowerCase() === lowerTitle );
if ( matchingRedirects.length ) {
for ( let i = matchingRedirects.length; i--; ) {
const matchingRedirect = matchingRedirects[ i ];
// Offer redirects as separate options when the user's input is an exact match
unshiftPages( response.query.pages, {
pageid: matchingRedirect.pageid,
ns: matchingRedirect.ns,
title: matchingRedirect.redirecttitle
} );
}
return response;
}
const matchingTitles = response.query.pages.filter( ( page ) => page.title.toLowerCase() === lowerTitle );
if ( matchingTitles.length ) {
for ( let i = matchingTitles.length; i--; ) {
// Make sure exact matches are at the very top
unshiftPages( response.query.pages );
matchingTitles[ i ].index = 1;
}
return response;
}
return this.getApi().get( {
action: 'query',
// Can't use a direct lookup by title because we need this to be case-insensitive
generator: 'prefixsearch',
gpssearch: query,
gpsnamespace: this.namespace,
// Try to fill with prefix matches, otherwise just the top-1 prefix match
gpslimit: this.limit
} ).then( ( prefixMatches ) => {
// action=query returns page objects in `{ query: { pages: [] } }`, not keyed by page id
const pages = prefixMatches.query && prefixMatches.query.pages || [];
pages.sort( ( a, b ) => a.index - b.index );
for ( const i in pages ) {
const prefixMatch = pages[ i ];
if ( !containsPageId( response.query.pages, prefixMatch.pageid ) ) {
// Move prefix matches to the top, indexed from -9 to 0, relevant for e.g. {{!!}}
// Note: Sorting happens later in mw.widgets.TitleWidget.getOptionsFromData()
prefixMatch.index -= this.limit;
response.query.pages.push( prefixMatch );
}
// Check only after the top-1 prefix match is guaranteed to be present
if ( response.query.pages.length >= this.limit ) {
break;
}
}
return response;
// Proceed with the unmodified response in case the additional API request failed
}, () => response )
.promise( { abort: () => {} } );
};
// @inheritdoc mw.widgets.TitleWidget
ve.ui.MWTemplateTitleInputWidget.prototype.getOptionWidgetData = function ( title, data ) {
return ve.extendObject(
ve.ui.MWTemplateTitleInputWidget.super.prototype.getOptionWidgetData.apply( this, arguments ),
{
description: this.descriptions[ title ],
redirecttitle: data.originalData.redirecttitle
}
);
};
// @inheritdoc mw.widgets.TitleWidget
ve.ui.MWTemplateTitleInputWidget.prototype.createOptionWidget = function ( data ) {
const widget = ve.ui.MWTemplateTitleInputWidget.super.prototype.createOptionWidget.call( this, data );
if ( data.redirecttitle ) {
// Same conditions as in mw.widgets.TitleWidget.getOptionWidgetData()
const title = new mw.Title( data.redirecttitle ),
text = this.namespace !== null && this.relative ?
title.getRelativeText( this.namespace ) :
data.redirecttitle;
let $desc = widget.$element.find( '.mw-widget-titleOptionWidget-description' );
if ( !$desc.length ) {
$desc = $( '<span>' )
.addClass( 'mw-widget-titleOptionWidget-description' )
.appendTo( widget.$element );
}
$desc.prepend( $( '<div>' )
.addClass( 've-ui-mwTemplateTitleInputWidget-redirectedfrom' )
.text( mw.msg( 'redirectedfrom', text ) ) );
}
return widget;
};