Merge "TitleInputWidget: Allow suppressing search suggestions"
[lhc/web/wiklou.git] / resources / src / mediawiki.widgets / mw.widgets.TitleInputWidget.js
1 /*!
2 * MediaWiki Widgets - TitleInputWidget class.
3 *
4 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
6 */
7 ( function ( $, mw ) {
8
9 /**
10 * Creates an mw.widgets.TitleInputWidget object.
11 *
12 * @class
13 * @extends OO.ui.TextInputWidget
14 * @mixins OO.ui.mixin.LookupElement
15 *
16 * @constructor
17 * @param {Object} [config] Configuration options
18 * @cfg {number} [limit=10] Number of results to show
19 * @cfg {number} [namespace] Namespace to prepend to queries
20 * @cfg {boolean} [relative=true] If a namespace is set, return a title relative to it
21 * @cfg {boolean} [suggestions=true] Display search suggestions
22 * @cfg {boolean} [showRedirectTargets=true] Show the targets of redirects
23 * @cfg {boolean} [showRedlink] Show red link to exact match if it doesn't exist
24 * @cfg {boolean} [showImages] Show page images
25 * @cfg {boolean} [showDescriptions] Show page descriptions
26 * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument
27 */
28 mw.widgets.TitleInputWidget = function MwWidgetsTitleInputWidget( config ) {
29 var widget = this;
30
31 // Config initialization
32 config = config || {};
33
34 // Parent constructor
35 mw.widgets.TitleInputWidget.parent.call( this, $.extend( {}, config, { autocomplete: false } ) );
36
37 // Mixin constructors
38 OO.ui.mixin.LookupElement.call( this, config );
39
40 // Properties
41 this.limit = config.limit || 10;
42 this.namespace = config.namespace !== undefined ? config.namespace : null;
43 this.relative = config.relative !== undefined ? config.relative : true;
44 this.suggestions = config.suggestions !== undefined ? config.suggestions : true;
45 this.showRedirectTargets = config.showRedirectTargets !== false;
46 this.showRedlink = !!config.showRedlink;
47 this.showImages = !!config.showImages;
48 this.showDescriptions = !!config.showDescriptions;
49 this.cache = config.cache;
50
51 // Initialization
52 this.$element.addClass( 'mw-widget-titleInputWidget' );
53 this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu' );
54 if ( this.showImages ) {
55 this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withImages' );
56 }
57 if ( this.showDescriptions ) {
58 this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withDescriptions' );
59 }
60 this.setLookupsDisabled( !this.suggestions );
61
62 this.interwikiPrefixes = [];
63 this.interwikiPrefixesPromise = new mw.Api().get( {
64 action: 'query',
65 meta: 'siteinfo',
66 siprop: 'interwikimap'
67 } ).done( function ( data ) {
68 $.each( data.query.interwikimap, function ( index, interwiki ) {
69 widget.interwikiPrefixes.push( interwiki.prefix );
70 } );
71 } );
72 };
73
74 /* Setup */
75
76 OO.inheritClass( mw.widgets.TitleInputWidget, OO.ui.TextInputWidget );
77 OO.mixinClass( mw.widgets.TitleInputWidget, OO.ui.mixin.LookupElement );
78
79 /* Methods */
80
81 /**
82 * @inheritdoc
83 */
84 mw.widgets.TitleInputWidget.prototype.onLookupMenuItemChoose = function ( item ) {
85 this.closeLookupMenu();
86 this.setLookupsDisabled( true );
87 this.setValue( item.getData() );
88 this.setLookupsDisabled( !this.suggestions );
89 };
90
91 /**
92 * @inheritdoc
93 */
94 mw.widgets.TitleInputWidget.prototype.focus = function () {
95 var retval;
96
97 // Prevent programmatic focus from opening the menu
98 this.setLookupsDisabled( true );
99
100 // Parent method
101 retval = mw.widgets.TitleInputWidget.parent.prototype.focus.apply( this, arguments );
102
103 this.setLookupsDisabled( !this.suggestions );
104
105 return retval;
106 };
107
108 /**
109 * @inheritdoc
110 */
111 mw.widgets.TitleInputWidget.prototype.getLookupRequest = function () {
112 var req,
113 widget = this,
114 promiseAbortObject = { abort: function () {
115 // Do nothing. This is just so OOUI doesn't break due to abort being undefined.
116 } };
117
118 if ( mw.Title.newFromText( this.value ) ) {
119 return this.interwikiPrefixesPromise.then( function () {
120 var params, props,
121 interwiki = widget.value.substring( 0, widget.value.indexOf( ':' ) );
122 if (
123 interwiki && interwiki !== '' &&
124 widget.interwikiPrefixes.indexOf( interwiki ) !== -1
125 ) {
126 return $.Deferred().resolve( { query: {
127 pages: [{
128 title: widget.value
129 }]
130 } } ).promise( promiseAbortObject );
131 } else {
132 params = {
133 action: 'query',
134 generator: 'prefixsearch',
135 gpssearch: widget.value,
136 gpsnamespace: widget.namespace !== null ? widget.namespace : undefined,
137 gpslimit: widget.limit,
138 ppprop: 'disambiguation'
139 };
140 props = [ 'info', 'pageprops' ];
141 if ( widget.showRedirectTargets ) {
142 params.redirects = '1';
143 }
144 if ( widget.showImages ) {
145 props.push( 'pageimages' );
146 params.pithumbsize = 80;
147 params.pilimit = widget.limit;
148 }
149 if ( widget.showDescriptions ) {
150 props.push( 'pageterms' );
151 params.wbptterms = 'description';
152 }
153 params.prop = props.join( '|' );
154 req = new mw.Api().get( params );
155 promiseAbortObject.abort = req.abort.bind( req ); // todo: ew
156 return req;
157 }
158 } ).promise( promiseAbortObject );
159 } else {
160 // Don't send invalid titles to the API.
161 // Just pretend it returned nothing so we can show the 'invalid title' section
162 return $.Deferred().resolve( {} ).promise( promiseAbortObject );
163 }
164 };
165
166 /**
167 * Get lookup cache item from server response data.
168 *
169 * @method
170 * @param {Mixed} response Response from server
171 */
172 mw.widgets.TitleInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
173 return response.query || {};
174 };
175
176 /**
177 * Get list of menu items from a server response.
178 *
179 * @param {Object} data Query result
180 * @returns {OO.ui.MenuOptionWidget[]} Menu items
181 */
182 mw.widgets.TitleInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {
183 var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects,
184 items = [],
185 titles = [],
186 titleObj = mw.Title.newFromText( this.value ),
187 redirectsTo = {},
188 pageData = {};
189
190 if ( data.redirects ) {
191 for ( i = 0, len = data.redirects.length; i < len; i++ ) {
192 redirect = data.redirects[i];
193 redirectsTo[redirect.to] = redirectsTo[redirect.to] || [];
194 redirectsTo[redirect.to].push( redirect.from );
195 }
196 }
197
198 for ( index in data.pages ) {
199 suggestionPage = data.pages[index];
200 pageData[suggestionPage.title] = {
201 missing: suggestionPage.missing !== undefined,
202 redirect: suggestionPage.redirect !== undefined,
203 disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined,
204 imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ),
205 description: OO.getProp( suggestionPage, 'terms', 'description' )
206 };
207
208 // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true
209 // and we encounter a cross-namespace redirect.
210 if ( this.namespace === null || this.namespace === suggestionPage.ns ) {
211 titles.push( suggestionPage.title );
212 }
213
214 redirects = redirectsTo[suggestionPage.title] || [];
215 for ( i = 0, len = redirects.length; i < len; i++ ) {
216 pageData[redirects[i]] = {
217 missing: false,
218 redirect: true,
219 disambiguation: false,
220 description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title )
221 };
222 titles.push( redirects[i] );
223 }
224 }
225
226 // If not found, run value through mw.Title to avoid treating a match as a
227 // mismatch where normalisation would make them matching (bug 48476)
228
229 pageExistsExact = titles.indexOf( this.value ) !== -1;
230 pageExists = pageExistsExact || (
231 titleObj && titles.indexOf( titleObj.getPrefixedText() ) !== -1
232 );
233
234 if ( !pageExists ) {
235 pageData[this.value] = {
236 missing: true, redirect: false, disambiguation: false,
237 description: mw.msg( 'mw-widgets-titleinput-description-new-page' )
238 };
239 }
240
241 if ( this.cache ) {
242 this.cache.set( pageData );
243 }
244
245 // Offer the exact text as a suggestion if the page exists
246 if ( pageExists && !pageExistsExact ) {
247 titles.unshift( this.value );
248 }
249 // Offer the exact text as a new page if the title is valid
250 if ( this.showRedlink && !pageExists && titleObj ) {
251 titles.push( this.value );
252 }
253 for ( i = 0, len = titles.length; i < len; i++ ) {
254 page = pageData[titles[i]] || {};
255 items.push( new mw.widgets.TitleOptionWidget( this.getOptionWidgetData( titles[i], page ) ) );
256 }
257
258 return items;
259 };
260
261 /**
262 * Get menu option widget data from the title and page data
263 *
264 * @param {mw.Title} title Title object
265 * @param {Object} data Page data
266 * @return {Object} Data for option widget
267 */
268 mw.widgets.TitleInputWidget.prototype.getOptionWidgetData = function ( title, data ) {
269 var mwTitle = new mw.Title( title );
270 return {
271 data: this.namespace !== null && this.relative
272 ? mwTitle.getRelativeText( this.namespace )
273 : title,
274 title: mwTitle,
275 imageUrl: this.showImages ? data.imageUrl : null,
276 description: this.showDescriptions ? data.description : null,
277 missing: data.missing,
278 redirect: data.redirect,
279 disambiguation: data.disambiguation,
280 query: this.value
281 };
282 };
283
284 /**
285 * Get title object corresponding to #getValue
286 *
287 * @returns {mw.Title|null} Title object, or null if value is invalid
288 */
289 mw.widgets.TitleInputWidget.prototype.getTitle = function () {
290 var title = this.getValue(),
291 // mw.Title doesn't handle null well
292 titleObj = mw.Title.newFromText( title, this.namespace !== null ? this.namespace : undefined );
293
294 return titleObj;
295 };
296
297 /**
298 * @inheritdoc
299 */
300 mw.widgets.TitleInputWidget.prototype.isValid = function () {
301 return $.Deferred().resolve( !!this.getTitle() ).promise();
302 };
303
304 }( jQuery, mediaWiki ) );