Merge "RCFilters: Actually read/write highlight parameter"
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / mw.rcfilters.Controller.js
1 ( function ( mw, $ ) {
2 /* eslint no-underscore-dangle: "off" */
3 /**
4 * Controller for the filters in Recent Changes
5 *
6 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
7 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
8 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
9 */
10 mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel ) {
11 this.filtersModel = filtersModel;
12 this.changesListModel = changesListModel;
13 this.savedQueriesModel = savedQueriesModel;
14 this.requestCounter = 0;
15 this.baseState = {};
16 };
17
18 /* Initialization */
19 OO.initClass( mw.rcfilters.Controller );
20
21 /**
22 * Initialize the filter and parameter states
23 *
24 * @param {Array} filterStructure Filter definition and structure for the model
25 */
26 mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) {
27 var parsedSavedQueries,
28 $changesList = $( '.mw-changeslist' ).first().contents();
29 // Initialize the model
30 this.filtersModel.initializeFilters( filterStructure );
31
32 this._buildBaseFilterState();
33
34 try {
35 parsedSavedQueries = JSON.parse( mw.user.options.get( 'rcfilters-saved-queries' ) || '{}' );
36 } catch ( err ) {
37 parsedSavedQueries = {};
38 }
39
40 // The queries are saved in a minimized state, so we need
41 // to send over the base state so the saved queries model
42 // can normalize them per each query item
43 this.savedQueriesModel.initialize(
44 parsedSavedQueries,
45 this._getBaseState()
46 );
47 this.updateStateBasedOnUrl();
48
49 // Update the changes list with the existing data
50 // so it gets processed
51 this.changesListModel.update(
52 $changesList.length ? $changesList : 'NO_RESULTS',
53 $( 'fieldset.rcoptions' ).first()
54 );
55 };
56
57 /**
58 * Reset to default filters
59 */
60 mw.rcfilters.Controller.prototype.resetToDefaults = function () {
61 this._updateModelState( this._getDefaultParams() );
62 this.updateChangesList();
63 };
64
65 /**
66 * Empty all selected filters
67 */
68 mw.rcfilters.Controller.prototype.emptyFilters = function () {
69 var highlightedFilterNames = this.filtersModel
70 .getHighlightedItems()
71 .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
72
73 this.filtersModel.emptyAllFilters();
74 this.filtersModel.clearAllHighlightColors();
75 // Check all filter interactions
76 this.filtersModel.reassessFilterInteractions();
77
78 this.updateChangesList();
79
80 if ( highlightedFilterNames ) {
81 this._trackHighlight( 'clearAll', highlightedFilterNames );
82 }
83 };
84
85 /**
86 * Update the selected state of a filter
87 *
88 * @param {string} filterName Filter name
89 * @param {boolean} [isSelected] Filter selected state
90 */
91 mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
92 var filterItem = this.filtersModel.getItemByName( filterName );
93
94 if ( !filterItem ) {
95 // If no filter was found, break
96 return;
97 }
98
99 isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
100
101 if ( filterItem.isSelected() !== isSelected ) {
102 this.filtersModel.toggleFilterSelected( filterName, isSelected );
103
104 this.updateChangesList();
105
106 // Check filter interactions
107 this.filtersModel.reassessFilterInteractions( filterItem );
108 }
109 };
110
111 /**
112 * Clear both highlight and selection of a filter
113 *
114 * @param {string} filterName Name of the filter item
115 */
116 mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
117 var filterItem = this.filtersModel.getItemByName( filterName ),
118 isHighlighted = filterItem.isHighlighted();
119
120 if ( filterItem.isSelected() || isHighlighted ) {
121 this.filtersModel.clearHighlightColor( filterName );
122 this.filtersModel.toggleFilterSelected( filterName, false );
123 this.updateChangesList();
124 this.filtersModel.reassessFilterInteractions( filterItem );
125 }
126
127 if ( isHighlighted ) {
128 this._trackHighlight( 'clear', filterName );
129 }
130 };
131
132 /**
133 * Toggle the highlight feature on and off
134 */
135 mw.rcfilters.Controller.prototype.toggleHighlight = function () {
136 this.filtersModel.toggleHighlight();
137 this._updateURL();
138
139 if ( this.filtersModel.isHighlightEnabled() ) {
140 mw.hook( 'RcFilters.highlight.enable' ).fire();
141 }
142 };
143
144 /**
145 * Set the highlight color for a filter item
146 *
147 * @param {string} filterName Name of the filter item
148 * @param {string} color Selected color
149 */
150 mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
151 this.filtersModel.setHighlightColor( filterName, color );
152 this._updateURL();
153 this._trackHighlight( 'set', { name: filterName, color: color } );
154 };
155
156 /**
157 * Clear highlight for a filter item
158 *
159 * @param {string} filterName Name of the filter item
160 */
161 mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
162 this.filtersModel.clearHighlightColor( filterName );
163 this._updateURL();
164 this._trackHighlight( 'clear', filterName );
165 };
166
167 /**
168 * Save the current model state as a saved query
169 *
170 * @param {string} [label] Label of the saved query
171 */
172 mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) {
173 var highlightedItems = {},
174 highlightEnabled = this.filtersModel.isHighlightEnabled();
175
176 // Prepare highlights
177 this.filtersModel.getHighlightedItems().forEach( function ( item ) {
178 highlightedItems[ item.getName() ] = highlightEnabled ?
179 item.getHighlightColor() : null;
180 } );
181 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
182
183 // Add item
184 this.savedQueriesModel.addNewQuery(
185 label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
186 {
187 filters: this.filtersModel.getSelectedState(),
188 highlights: highlightedItems
189 }
190 );
191
192 // Save item
193 this._saveSavedQueries();
194 };
195
196 /**
197 * Remove a saved query
198 *
199 * @param {string} queryID Query id
200 */
201 mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
202 var query = this.savedQueriesModel.getItemByID( queryID );
203
204 this.savedQueriesModel.removeItems( [ query ] );
205
206 // Check if this item was the default
207 if ( this.savedQueriesModel.getDefault() === queryID ) {
208 // Nulify the default
209 this.savedQueriesModel.setDefault( null );
210 }
211 this._saveSavedQueries();
212 };
213
214 /**
215 * Rename a saved query
216 *
217 * @param {string} queryID Query id
218 * @param {string} newLabel New label for the query
219 */
220 mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
221 var queryItem = this.savedQueriesModel.getItemByID( queryID );
222
223 if ( queryItem ) {
224 queryItem.updateLabel( newLabel );
225 }
226 this._saveSavedQueries();
227 };
228
229 /**
230 * Set a saved query as default
231 *
232 * @param {string} queryID Query Id. If null is given, default
233 * query is reset.
234 */
235 mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
236 this.savedQueriesModel.setDefault( queryID );
237 this._saveSavedQueries();
238 };
239
240 /**
241 * Load a saved query
242 *
243 * @param {string} queryID Query id
244 */
245 mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
246 var data, highlights,
247 queryItem = this.savedQueriesModel.getItemByID( queryID );
248
249 if ( queryItem ) {
250 data = queryItem.getData();
251 highlights = data.highlights;
252
253 // Backwards compatibility; initial version mispelled 'highlight' with 'highlights'
254 highlights.highlight = highlights.highlights || highlights.highlight;
255
256 // Update model state from filters
257 this.filtersModel.toggleFiltersSelected( data.filters );
258
259 // Update highlight state
260 this.filtersModel.toggleHighlight( !!highlights.highlight );
261 this.filtersModel.getItems().forEach( function ( filterItem ) {
262 var color = highlights[ filterItem.getName() ];
263 if ( color ) {
264 filterItem.setHighlightColor( color );
265 } else {
266 filterItem.clearHighlightColor();
267 }
268 } );
269
270 // Check all filter interactions
271 this.filtersModel.reassessFilterInteractions();
272
273 this.updateChangesList();
274 }
275 };
276
277 /**
278 * Check whether the current filter and highlight state exists
279 * in the saved queries model.
280 *
281 * @return {boolean} Query exists
282 */
283 mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
284 var highlightedItems = {};
285
286 // Prepare highlights of the current query
287 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
288 highlightedItems[ item.getName() ] = item.getHighlightColor();
289 } );
290 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
291
292 return this.savedQueriesModel.findMatchingQuery(
293 {
294 filters: this.filtersModel.getSelectedState(),
295 highlights: highlightedItems
296 }
297 );
298 };
299
300 /**
301 * Get an object representing the base state of parameters
302 * and highlights.
303 *
304 * This is meant to make sure that the saved queries that are
305 * in memory are always the same structure as what we would get
306 * by calling the current model's "getSelectedState" and by checking
307 * highlight items.
308 *
309 * In cases where a user saved a query when the system had a certain
310 * set of filters, and then a filter was added to the system, we want
311 * to make sure that the stored queries can still be comparable to
312 * the current state, which means that we need the base state for
313 * two operations:
314 *
315 * - Saved queries are stored in "minimal" view (only changed filters
316 * are stored); When we initialize the system, we merge each minimal
317 * query with the base state (using 'getNormalizedFilters') so all
318 * saved queries have the exact same structure as what we would get
319 * by checking the getSelectedState of the filter.
320 * - When we save the queries, we minimize the object to only represent
321 * whatever has actually changed, rather than store the entire
322 * object. To check what actually is different so we can store it,
323 * we need to obtain a base state to compare against, this is
324 * what #_getMinimalFilterList does
325 */
326 mw.rcfilters.Controller.prototype._buildBaseFilterState = function () {
327 var defaultParams = this.filtersModel.getDefaultParams(),
328 highlightedItems = {};
329
330 // Prepare highlights
331 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
332 highlightedItems[ item.getName() ] = null;
333 } );
334 highlightedItems.highlight = false;
335
336 this.baseState = {
337 filters: this.filtersModel.getFiltersFromParameters( defaultParams ),
338 highlights: highlightedItems
339 };
340 };
341
342 /**
343 * Get an object representing the base state of parameters
344 * and highlights. The structure is similar to what we use
345 * to store each query in the saved queries object:
346 * {
347 * filters: {
348 * filterName: (bool)
349 * },
350 * highlights: {
351 * filterName: (string|null)
352 * }
353 * }
354 *
355 * @return {Object} Object representing the base state of
356 * parameters and highlights
357 */
358 mw.rcfilters.Controller.prototype._getBaseState = function () {
359 return this.baseState;
360 };
361
362 /**
363 * Get an object that holds only the parameters and highlights that have
364 * values different than the base default value.
365 *
366 * This is the reverse of the normalization we do initially on loading and
367 * initializing the saved queries model.
368 *
369 * @param {Object} valuesObject Object representing the state of both
370 * filters and highlights in its normalized version, to be minimized.
371 * @return {Object} Minimal filters and highlights list
372 */
373 mw.rcfilters.Controller.prototype._getMinimalFilterList = function ( valuesObject ) {
374 var result = { filters: {}, highlights: {} },
375 baseState = this._getBaseState();
376
377 // XOR results
378 $.each( valuesObject.filters, function ( name, value ) {
379 if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) {
380 result.filters[ name ] = value;
381 }
382 } );
383
384 $.each( valuesObject.highlights, function ( name, value ) {
385 if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) {
386 result.highlights[ name ] = value;
387 }
388 } );
389
390 return result;
391 };
392
393 /**
394 * Save the current state of the saved queries model with all
395 * query item representation in the user settings.
396 */
397 mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
398 var stringified,
399 state = this.savedQueriesModel.getState(),
400 controller = this;
401
402 // Minimize before save
403 $.each( state.queries, function ( queryID, info ) {
404 state.queries[ queryID ].data = controller._getMinimalFilterList( info.data );
405 } );
406
407 // Stringify state
408 stringified = JSON.stringify( state );
409
410 if ( stringified.length > 65535 ) {
411 // Sanity check, since the preference can only hold that.
412 return;
413 }
414
415 // Save the preference
416 new mw.Api().saveOption( 'rcfilters-saved-queries', stringified );
417 // Update the preference for this session
418 mw.user.options.set( 'rcfilters-saved-queries', stringified );
419 };
420
421 /**
422 * Synchronize the URL with the current state of the filters
423 * without adding an history entry.
424 */
425 mw.rcfilters.Controller.prototype.replaceUrl = function () {
426 window.history.replaceState(
427 { tag: 'rcfilters' },
428 document.title,
429 this._getUpdatedUri().toString()
430 );
431 };
432
433 /**
434 * Update filter state (selection and highlighting) based
435 * on current URL and default values.
436 */
437 mw.rcfilters.Controller.prototype.updateStateBasedOnUrl = function () {
438 var uri = new mw.Uri(),
439 defaultParams = this._getDefaultParams();
440
441 this._updateModelState( $.extend( {}, defaultParams, uri.query ) );
442 this.updateChangesList();
443 };
444
445 /**
446 * Update the list of changes and notify the model
447 *
448 * @param {Object} [params] Extra parameters to add to the API call
449 */
450 mw.rcfilters.Controller.prototype.updateChangesList = function ( params ) {
451 this._updateURL( params );
452 this.changesListModel.invalidate();
453 this._fetchChangesList()
454 .then(
455 // Success
456 function ( pieces ) {
457 var $changesListContent = pieces.changes,
458 $fieldset = pieces.fieldset;
459 this.changesListModel.update( $changesListContent, $fieldset );
460 }.bind( this )
461 // Do nothing for failure
462 );
463 };
464
465 /**
466 * Update the model state from given the given parameters.
467 *
468 * This is an internal method, and should only be used from inside
469 * the controller.
470 *
471 * @param {Object} parameters Object representing the parameters for
472 * filters and highlights
473 */
474 mw.rcfilters.Controller.prototype._updateModelState = function ( parameters ) {
475 // Update filter states
476 this.filtersModel.toggleFiltersSelected(
477 this.filtersModel.getFiltersFromParameters(
478 parameters
479 )
480 );
481
482 // Update highlight state
483 this.filtersModel.toggleHighlight( !!parameters.highlight );
484 this.filtersModel.getItems().forEach( function ( filterItem ) {
485 var color = parameters[ filterItem.getName() + '_color' ];
486 if ( color ) {
487 filterItem.setHighlightColor( color );
488 } else {
489 filterItem.clearHighlightColor();
490 }
491 } );
492
493 // Check all filter interactions
494 this.filtersModel.reassessFilterInteractions();
495 };
496
497 /**
498 * Get an object representing the default parameter state, whether
499 * it is from the model defaults or from the saved queries.
500 *
501 * @return {Object} Default parameters
502 */
503 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
504 var data, queryHighlights,
505 savedParams = {},
506 savedHighlights = {},
507 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
508
509 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
510 defaultSavedQueryItem ) {
511
512 data = defaultSavedQueryItem.getData();
513
514 queryHighlights = data.highlights || {};
515 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
516
517 // Translate highlights to parameters
518 savedHighlights.highlight = queryHighlights.highlight;
519 $.each( queryHighlights, function ( filterName, color ) {
520 if ( filterName !== 'highlights' ) {
521 savedHighlights[ filterName + '_color' ] = color;
522 }
523 } );
524
525 return $.extend( true, {}, savedParams, savedHighlights );
526 }
527
528 return this.filtersModel.getDefaultParams();
529 };
530
531 /**
532 * Update the URL of the page to reflect current filters
533 *
534 * This should not be called directly from outside the controller.
535 * If an action requires changing the URL, it should either use the
536 * highlighting actions below, or call #updateChangesList which does
537 * the uri corrections already.
538 *
539 * @param {Object} [params] Extra parameters to add to the API call
540 */
541 mw.rcfilters.Controller.prototype._updateURL = function ( params ) {
542 var updatedUri,
543 notEquivalent = function ( obj1, obj2 ) {
544 var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
545 return keys.some( function ( key ) {
546 return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
547 } );
548 };
549
550 params = params || {};
551
552 updatedUri = this._getUpdatedUri();
553 updatedUri.extend( params );
554
555 if ( notEquivalent( updatedUri.query, new mw.Uri().query ) ) {
556 window.history.pushState( { tag: 'rcfilters' }, document.title, updatedUri.toString() );
557 }
558 };
559
560 /**
561 * Get an updated mw.Uri object based on the model state
562 *
563 * @return {mw.Uri} Updated Uri
564 */
565 mw.rcfilters.Controller.prototype._getUpdatedUri = function () {
566 var uri = new mw.Uri(),
567 highlightParams = this.filtersModel.getHighlightParameters();
568
569 // Add to existing queries in URL
570 // TODO: Clean up the list of filters; perhaps 'falsy' filters
571 // shouldn't appear at all? Or compare to existing query string
572 // and see if current state of a specific filter is needed?
573 uri.extend( this.filtersModel.getParametersFromFilters() );
574
575 // highlight params
576 uri.query.highlight = Number( this.filtersModel.isHighlightEnabled() );
577 Object.keys( highlightParams ).forEach( function ( paramName ) {
578 if ( highlightParams[ paramName ] ) {
579 uri.query[ paramName ] = highlightParams[ paramName ];
580 } else {
581 delete uri.query[ paramName ];
582 }
583 } );
584
585 return uri;
586 };
587
588 /**
589 * Fetch the list of changes from the server for the current filters
590 *
591 * @return {jQuery.Promise} Promise object that will resolve with the changes list
592 * or with a string denoting no results.
593 */
594 mw.rcfilters.Controller.prototype._fetchChangesList = function () {
595 var uri = this._getUpdatedUri(),
596 requestId = ++this.requestCounter,
597 latestRequest = function () {
598 return requestId === this.requestCounter;
599 }.bind( this );
600
601 return $.ajax( uri.toString(), { contentType: 'html' } )
602 .then(
603 // Success
604 function ( html ) {
605 var $parsed;
606 if ( !latestRequest() ) {
607 return $.Deferred().reject();
608 }
609
610 $parsed = $( $.parseHTML( html ) );
611
612 return {
613 // Changes list
614 changes: $parsed.find( '.mw-changeslist' ).first().contents(),
615 // Fieldset
616 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
617 };
618 },
619 // Failure
620 function ( responseObj ) {
621 var $parsed;
622
623 if ( !latestRequest() ) {
624 return $.Deferred().reject();
625 }
626
627 $parsed = $( $.parseHTML( responseObj.responseText ) );
628
629 // Force a resolve state to this promise
630 return $.Deferred().resolve( {
631 changes: 'NO_RESULTS',
632 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
633 } ).promise();
634 }
635 );
636 };
637
638 /**
639 * Track usage of highlight feature
640 *
641 * @param {string} action
642 * @param {array|object|string} filters
643 */
644 mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
645 filters = typeof filters === 'string' ? { name: filters } : filters;
646 filters = !Array.isArray( filters ) ? [ filters ] : filters;
647 mw.track(
648 'event.ChangesListHighlights',
649 {
650 action: action,
651 filters: filters,
652 userId: mw.user.getId()
653 }
654 );
655 };
656
657 }( mediaWiki, jQuery ) );