From b516b80750f09b6110e75fa3d279aa6eec226d3d Mon Sep 17 00:00:00 2001 From: Stephane Bisson Date: Thu, 22 Dec 2016 15:51:10 +0100 Subject: [PATCH] RC filters: AJAX and pushState/popState Selecting/unselecting a filter now refreshes the results list using AJAX. Also added pushState to update the URL, and popstate handling to make the back button work. Bug: T153949 Change-Id: I8c1ec557ccfe4b1d20aaaab3ef0d3182a1993f24 --- resources/Resources.php | 5 ++ .../mw.rcfilters.dm.ChangesListViewModel.js | 59 ++++++++++++++ .../mw.rcfilters.Controller.js | 77 +++++++++++++++---- .../mediawiki.rcfilters/mw.rcfilters.init.js | 50 +++++------- .../ui/mw.rcfilters.ui.CapsuleItemWidget.js | 13 +++- ...w.rcfilters.ui.ChangesListWrapperWidget.js | 60 +++++++++++++++ .../ui/mw.rcfilters.ui.CheckboxInputWidget.js | 41 ++++++++++ ...lters.ui.FilterCapsuleMultiselectWidget.js | 23 ++---- .../ui/mw.rcfilters.ui.FilterItemWidget.js | 4 +- .../ui/mw.rcfilters.ui.FilterWrapperWidget.js | 13 +--- .../ui/mw.rcfilters.ui.FormWrapperWidget.js | 47 +++++++++++ 11 files changed, 313 insertions(+), 79 deletions(-) create mode 100644 resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js diff --git a/resources/Resources.php b/resources/Resources.php index 7961139101..02487eac55 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1757,12 +1757,16 @@ return [ 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js', 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js', 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js', + 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js', 'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js', 'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js', ], @@ -1820,6 +1824,7 @@ return [ 'rcfilters-filter-categorization-description', 'rcfilters-filter-logactions-label', 'rcfilters-filter-logactions-description', + 'recentchanges-noresult', ], 'dependencies' => [ 'oojs-ui', diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js new file mode 100644 index 0000000000..edb6744bac --- /dev/null +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js @@ -0,0 +1,59 @@ +( function ( mw ) { + /** + * View model for the changes list + * + * @mixins OO.EventEmitter + * + * @constructor + */ + mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel() { + // Mixin constructor + OO.EventEmitter.call( this ); + + this.valid = true; + }; + + /* Initialization */ + OO.initClass( mw.rcfilters.dm.ChangesListViewModel ); + OO.mixinClass( mw.rcfilters.dm.ChangesListViewModel, OO.EventEmitter ); + + /* Events */ + + /** + * @event invalidate + * + * The list of changes is now invalid (out of date) + */ + + /** + * @event update + * @param {jQuery|string} changesListContent + * + * The list of change is now up to date + */ + + /* Methods */ + + /** + * Invalidate the list of changes + * + * @fires invalidate + */ + mw.rcfilters.dm.ChangesListViewModel.prototype.invalidate = function () { + if ( this.valid ) { + this.valid = false; + this.emit( 'invalidate' ); + } + }; + + /** + * Update the model with an updated list of changes + * + * @param {jQuery|string} changesListContent + */ + mw.rcfilters.dm.ChangesListViewModel.prototype.update = function ( changesListContent ) { + this.valid = true; + this.emit( 'update', changesListContent ); + }; + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 28d9f28738..88f32b4d9d 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -1,13 +1,14 @@ -( function ( mw ) { +( function ( mw, $ ) { /** * Controller for the filters in Recent Changes * - * @param {mw.rcfilters.dm.FiltersViewModel} model View model + * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model + * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model */ - mw.rcfilters.Controller = function MwRcfiltersController( model ) { - this.model = model; - // TODO: When we are ready, update the URL when a filter is updated - // this.model.connect( this, { itemUpdate: 'updateURL' } ); + mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel ) { + this.filtersModel = filtersModel; + this.changesListModel = changesListModel; + this.requestCounter = 0; }; /* Initialization */ @@ -17,13 +18,18 @@ * Initialize the filter and parameter states */ mw.rcfilters.Controller.prototype.initialize = function () { + this.updateFromURL(); + }; + + /** + * Update the model state based on the URL parameters. + */ + mw.rcfilters.Controller.prototype.updateFromURL = function () { var uri = new mw.Uri(); - // Give the model a full parameter state from which to - // update the filters - this.model.updateFilters( + this.filtersModel.updateFilters( // Translate the url params to filter select states - this.model.getFiltersFromParameters( uri.query ) + this.filtersModel.getFiltersFromParameters( uri.query ) ); }; @@ -31,14 +37,18 @@ * Reset to default filters */ mw.rcfilters.Controller.prototype.resetToDefaults = function () { - this.model.setFiltersToDefaults(); + this.filtersModel.setFiltersToDefaults(); + this.updateURL(); + this.updateChangesList(); }; /** * Empty all selected filters */ mw.rcfilters.Controller.prototype.emptyFilters = function () { - this.model.emptyAllFilters(); + this.filtersModel.emptyAllFilters(); + this.updateURL(); + this.updateChangesList(); }; /** @@ -51,7 +61,9 @@ var obj = {}; obj[ filterName ] = isSelected; - this.model.updateFilters( obj ); + this.filtersModel.updateFilters( obj ); + this.updateURL(); + this.updateChangesList(); }; /** @@ -64,9 +76,44 @@ // TODO: Clean up the list of filters; perhaps 'falsy' filters // shouldn't appear at all? Or compare to existing query string // and see if current state of a specific filter is needed? - uri.extend( this.model.getParametersFromFilters() ); + uri.extend( this.filtersModel.getParametersFromFilters() ); // Update the URL itself window.history.pushState( { tag: 'rcfilters' }, document.title, uri.toString() ); }; -}( mediaWiki ) ); + + /** + * Fetch the list of changes from the server for the current filters + * + * @returns {jQuery.Promise} Promise object that will resolve with the changes list + */ + mw.rcfilters.Controller.prototype.fetchChangesList = function () { + var uri = new mw.Uri(), + requestId = ++this.requestCounter, + latestRequest = function () { + return requestId === this.requestCounter; + }.bind( this ); + uri.extend( this.filtersModel.getParametersFromFilters() ); + return $.ajax( uri.toString(), { contentType: 'html' } ) + .then( function ( html ) { + return latestRequest() ? + $( $.parseHTML( html ) ).find( '.mw-changeslist' ).first().contents() : + null; + } ).then( null, function () { + return latestRequest() ? 'NO_RESULTS' : null; + } ); + }; + + /** + * Update the list of changes and notify the model + */ + mw.rcfilters.Controller.prototype.updateChangesList = function () { + this.changesListModel.invalidate(); + this.fetchChangesList() + .always( function ( changesListContent ) { + if ( changesListContent ) { + this.changesListModel.update( changesListContent ); + } + }.bind( this ) ); + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 94fc959338..ef0489c382 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -9,13 +9,23 @@ var rcfilters = { /** */ init: function () { - var model = new mw.rcfilters.dm.FiltersViewModel(), - controller = new mw.rcfilters.Controller( model ), + var filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + changesListModel = new mw.rcfilters.dm.ChangesListViewModel(), + controller = new mw.rcfilters.Controller( filtersModel, changesListModel ), $overlay = $( '
' ) .addClass( 'mw-rcfilters-ui-overlay' ), - widget = new mw.rcfilters.ui.FilterWrapperWidget( controller, model, { $overlay: $overlay } ); + filtersWidget = new mw.rcfilters.ui.FilterWrapperWidget( + controller, filtersModel, { $overlay: $overlay } ); - model.initializeFilters( { + // eslint-disable-next-line no-new + new mw.rcfilters.ui.ChangesListWrapperWidget( + changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) ); + + // eslint-disable-next-line no-new + new mw.rcfilters.ui.FormWrapperWidget( + changesListModel, $( '.rcoptions form' ) ); + + filtersModel.initializeFilters( { registration: { title: mw.msg( 'rcfilters-filtergroup-registration' ), type: 'send_unselected_if_any', @@ -149,7 +159,7 @@ } } ); - $( '.rcoptions' ).before( widget.$element ); + $( '.rcoptions' ).before( filtersWidget.$element ); $( 'body' ).append( $overlay ); // Initialize values @@ -179,7 +189,7 @@ name = 'hidemyself'; } // This span corresponds to a filter that's in our model, so remove it - if ( model.getItemByName( name ) ) { + if ( filtersModel.getItemByName( name ) ) { // HACK: Remove the text node after the span. // If there isn't one, we're at the end, so remove the text node before the span. // This would be unnecessary if we added separators with CSS. @@ -193,31 +203,9 @@ } } ); - $( '.rcoptions form' ).submit( function () { - var $form = $( this ); - - // Get current filter values - $.each( model.getParametersFromFilters(), function ( paramName, paramValue ) { - var $existingInput = $form.find( 'input[name=' + paramName + ']' ); - // Check if the hidden input already exists - // This happens if the parameter was already given - // on load - if ( $existingInput.length ) { - // Update the value - $existingInput.val( paramValue ); - } else { - // Append hidden fields with filter values - $form.append( - $( '' ) - .attr( 'type', 'hidden' ) - .attr( 'name', paramName ) - .val( paramValue ) - ); - } - } ); - - // Continue the submission process - return true; + window.addEventListener( 'popstate', function () { + controller.updateFromURL(); + controller.updateChangesList(); } ); } }; diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js index 547db1bfa0..ca47f16b5d 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js @@ -7,11 +7,12 @@ * @mixins OO.ui.mixin.PopupElement * * @constructor + * @param {mw.rcfilters.Controller} controller * @param {mw.rcfilters.dm.FilterItem} model Item model * @param {Object} config Configuration object * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups */ - mw.rcfilters.ui.CapsuleItemWidget = function MwRcfiltersUiCapsuleItemWidget( model, config ) { + mw.rcfilters.ui.CapsuleItemWidget = function MwRcfiltersUiCapsuleItemWidget( controller, model, config ) { var $popupContent = $( '
' ) .addClass( 'mw-rcfilters-ui-capsuleItemWidget-popup' ), descLabelWidget = new OO.ui.LabelWidget(); @@ -19,6 +20,7 @@ // Configuration initialization config = config || {}; + this.controller = controller; this.model = model; this.$overlay = config.$overlay || this.$element; this.positioned = false; @@ -46,6 +48,8 @@ // Events this.model.connect( this, { update: 'onModelUpdate' } ); + this.closeButton.connect( this, { click: 'onCapsuleRemovedByUser' } ); + // Initialization this.$overlay.append( this.popup.$element ); this.$element @@ -86,4 +90,11 @@ } } }; + + /** + * Respond to the user removing the capsule with the close button + */ + mw.rcfilters.ui.CapsuleItemWidget.prototype.onCapsuleRemovedByUser = function () { + this.controller.updateFilter( this.model.getName(), false ); + }; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js new file mode 100644 index 0000000000..f929eb2653 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js @@ -0,0 +1,60 @@ +( function ( mw ) { + /** + * List of changes + * + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.PendingElement + * + * @constructor + * @param {mw.rcfilters.dm.ChangesListViewModel} model View model + * @param {jQuery} $changesListRoot Root element of the changes list to attach to + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget( model, $changesListRoot, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.ChangesListWrapperWidget.parent.call( this, $.extend( {}, config, { + $element: $changesListRoot + } ) ); + // Mixin constructors + OO.ui.mixin.PendingElement.call( this, config ); + + this.model = model; + + // Events + this.model.connect( this, { + invalidate: 'onModelInvalidate', + update: 'onModelUpdate' + } ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.ChangesListWrapperWidget, OO.ui.Widget ); + OO.mixinClass( mw.rcfilters.ui.ChangesListWrapperWidget, OO.ui.mixin.PendingElement ); + + /** + * Respond to model invalidate + */ + mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelInvalidate = function () { + this.pushPending(); + }; + + /** + * Respond to model update + * + * @param {jQuery|string} changesListContent The content of the updated changes list + */ + mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelUpdate = function ( changesListContent ) { + var isEmpty = changesListContent === 'NO_RESULTS'; + this.$element.toggleClass( 'mw-changeslist', !isEmpty ); + this.$element.toggleClass( 'mw-changeslist-empty', isEmpty ); + this.$element.empty().append( + isEmpty ? + document.createTextNode( mw.message( 'recentchanges-noresult' ).text() ) : + changesListContent + ); + this.popPending(); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js new file mode 100644 index 0000000000..86b3b11f24 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js @@ -0,0 +1,41 @@ +( function ( mw ) { + /** + * A widget representing a single toggle filter + * + * @extends OO.ui.CheckboxInputWidget + * + * @constructor + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.CheckboxInputWidget.parent.call( this, config ); + + // Event + this.$input.on( 'change', this.onUserChange.bind( this ) ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.CheckboxInputWidget, OO.ui.CheckboxInputWidget ); + + /* Events */ + + /** + * @event userChange + * @param {boolean} Current state of the checkbox + * + * The user has checked or unchecked this checkbox + */ + + /* Methods */ + + /** + * Respond to checkbox change by a user and emit 'userChange'. + */ + mw.rcfilters.ui.CheckboxInputWidget.prototype.onUserChange = function () { + this.emit( 'userChange', this.$input.prop( 'checked' ) ); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js index bf80cd6439..56303d5d07 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js @@ -154,7 +154,11 @@ return; } - return new mw.rcfilters.ui.CapsuleItemWidget( item, { $overlay: this.$overlay } ); + return new mw.rcfilters.ui.CapsuleItemWidget( + this.controller, + item, + { $overlay: this.$overlay } + ); }; /** @@ -205,23 +209,6 @@ this.focus(); }; - /** - * @inheritdoc - */ - mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.removeItems = function ( items ) { - var filterData = {}; - - // Parent - mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.prototype.removeItems.call( this, items ); - - items.forEach( function ( itemWidget ) { - filterData[ itemWidget.getData() ] = false; - } ); - - // Update the model - this.model.updateFilters( filterData ); - }; - /** * @inheritdoc */ diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js index f353051209..f9829d4c2e 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js @@ -22,7 +22,7 @@ this.controller = controller; this.model = model; - this.checkboxWidget = new OO.ui.CheckboxInputWidget( { + this.checkboxWidget = new mw.rcfilters.ui.CheckboxInputWidget( { value: this.model.getName(), selected: this.model.isSelected() } ); @@ -46,7 +46,7 @@ } ); // Event - this.checkboxWidget.connect( this, { change: 'onCheckboxChange' } ); + this.checkboxWidget.connect( this, { userChange: 'onCheckboxChange' } ); this.model.connect( this, { update: 'onModelUpdate' } ); this.$element diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js index c863f2f092..315ca86fc8 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -8,7 +8,7 @@ * @constructor * @param {mw.rcfilters.Controller} controller Controller * @param {mw.rcfilters.dm.FiltersViewModel} model View model - * @param {Object} config Configuration object + * @param {Object} [config] Configuration object * @cfg {Object} [filters] A definition of the filter groups in this list * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups */ @@ -24,8 +24,6 @@ this.model = model; this.$overlay = config.$overlay || this.$element; - this.filtersInCapsule = []; - this.filterPopup = new mw.rcfilters.ui.FiltersListWidget( this.controller, this.model, @@ -93,13 +91,4 @@ } } ); }; - - /** - * Add a capsule item by its filter name - * - * @param {string} itemName Filter name - */ - mw.rcfilters.ui.FilterWrapperWidget.prototype.addCapsuleItemFromName = function ( itemName ) { - this.capsule.addItemByName( [ itemName ] ); - }; }( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js new file mode 100644 index 0000000000..2513b075ea --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js @@ -0,0 +1,47 @@ +( function ( mw ) { + /** + * Wrapper for the RC form with hide/show links + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model + * @param {jQuery} $formRoot Root element of the form to attach to + * @param {Object} config Configuration object + */ + mw.rcfilters.ui.FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( model, $formRoot, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.FormWrapperWidget.parent.call( this, $.extend( {}, config, { + $element: $formRoot + } ) ); + + this.model = model; + this.$submitButton = this.$element.find( 'input[type=submit]' ); + + // Events + this.model.connect( this, { + invalidate: 'onModelInvalidate', + update: 'onModelUpdate' + } ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.FormWrapperWidget, OO.ui.Widget ); + + /** + * Respond to model invalidate + */ + mw.rcfilters.ui.FormWrapperWidget.prototype.onModelInvalidate = function () { + this.$submitButton.prop( 'disabled', true ); + }; + + /** + * Respond to model update + */ + mw.rcfilters.ui.FormWrapperWidget.prototype.onModelUpdate = function () { + this.$submitButton.prop( 'disabled', false ); + }; +}( mediaWiki ) ); -- 2.20.1