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