jenkins-bot has submitted this change and it was merged. Change subject: Add a cross-wiki sidebar to the Special:Notifications page ......................................................................
Add a cross-wiki sidebar to the Special:Notifications page Add a sidebar with cross-wiki sources and pages of unread notifications. The filter allows the user to fetch notifications from a foreign source and specific pages if those exist. Bug: T129366 Change-Id: I57d827a47f80274d75364c2099a9624049a26834 --- M Resources.php M i18n/en.json M i18n/qqq.json M modules/api/mw.echo.api.APIHandler.js M modules/api/mw.echo.api.EchoApi.js M modules/api/mw.echo.api.ForeignAPIHandler.js M modules/api/mw.echo.api.LocalAPIHandler.js M modules/controller/mw.echo.Controller.js M modules/echo.variables.less M modules/model/mw.echo.dm.FiltersModel.js M modules/model/mw.echo.dm.ModelManager.js M modules/model/mw.echo.dm.NotificationItem.js M modules/model/mw.echo.dm.NotificationsList.js M modules/model/mw.echo.dm.PaginationModel.js A modules/model/mw.echo.dm.SourcePagesModel.js A modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less M modules/styles/mw.echo.ui.NotificationsInboxWidget.less A modules/styles/mw.echo.ui.PageFilterWidget.less A modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less A modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js M modules/ui/mw.echo.ui.DatedNotificationsWidget.js M modules/ui/mw.echo.ui.NotificationsInboxWidget.js A modules/ui/mw.echo.ui.PageFilterWidget.js A modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js M modules/ui/mw.echo.ui.SortedListWidget.js M modules/ui/mw.echo.ui.SubGroupListWidget.js 26 files changed, 1,142 insertions(+), 98 deletions(-) Approvals: Catrope: Looks good to me, approved jenkins-bot: Verified diff --git a/Resources.php b/Resources.php index e815aea..99922bf 100644 --- a/Resources.php +++ b/Resources.php @@ -170,6 +170,7 @@ 'scripts' => array( 'mw.echo.js', 'model/mw.echo.dm.js', + 'model/mw.echo.dm.SourcePagesModel.js', 'model/mw.echo.dm.PaginationModel.js', 'model/mw.echo.dm.FiltersModel.js', 'model/mw.echo.dm.ModelManager.js', @@ -286,6 +287,9 @@ 'ui/mw.echo.ui.DatedSubGroupListWidget.js', 'ui/mw.echo.ui.DatedNotificationsWidget.js', 'ui/mw.echo.ui.ReadStateButtonSelectWidget.js', + 'ui/mw.echo.ui.PageNotificationsOptionWidget.js', + 'ui/mw.echo.ui.PageFilterWidget.js', + 'ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js', 'ui/mw.echo.ui.NotificationsInboxWidget.js', 'special/ext.echo.special.js', ), @@ -294,6 +298,9 @@ 'styles/mw.echo.ui.DatedSubGroupListWidget.less', 'styles/mw.echo.ui.DatedNotificationsWidget.less', 'styles/mw.echo.ui.NotificationsInboxWidget.less', + 'styles/mw.echo.ui.PageNotificationsOptionWidget.less', + 'styles/mw.echo.ui.PageFilterWidget.less', + 'styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less', ), 'dependencies' => array( 'ext.echo.ui', @@ -307,6 +314,8 @@ 'echo-notification-placeholder-filters', 'echo-specialpage-pagination-numnotifications', 'echo-specialpage-pagination-range', + 'echo-specialpage-pagefilters-title', + 'echo-specialpage-pagefilters-subtitle', 'echo-more-info', 'echo-feedback', 'echo-specialpage-section-markread', diff --git a/i18n/en.json b/i18n/en.json index e7422bb..b175404 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -99,6 +99,8 @@ "echo-specialpage-markasread-invalid-id": "Invalid event ID", "echo-specialpage-pagination-numnotifications": "$1 {{PLURAL:$1|notification|notifications}}", "echo-specialpage-pagination-range": "$1 - $2", + "echo-specialpage-pagefilters-title": "Recent activity", + "echo-specialpage-pagefilters-subtitle": "Pages with unread notifications", "notificationsmarkread-legend": "Mark notification as read", "echo-anon": "To receive notifications, [$1 create an account] or [$2 log in].", "echo-none": "You have no notifications.", diff --git a/i18n/qqq.json b/i18n/qqq.json index 7ffb45f..f6c2648 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -90,6 +90,8 @@ "echo-specialpage-markasread-invalid-id": "Error message shown to users who try to mark a notification as read with an invalid event ID.", "echo-specialpage-pagination-numnotifications": "Label noting the number of notifications displayed in the page. This only appears if there is a single page of results.\n\nParameters:\n* $1 - Number of notifications in the page.\n{{Identical|Notification}}", "echo-specialpage-pagination-range": "Label noting the range of the notifications displayed in the page. This only appears if there are multiple pages of results available.\n\nParameters:\n* $1 - Number of the first item.\n* $2 - Number of the last item.", + "echo-specialpage-pagefilters-title": "Title of the page filter box in Special:Notifications page.", + "echo-specialpage-pagefilters-subtitle": "Subtitle of the page filter box in Special:Notifications page.", "notificationsmarkread-legend": "Title for the form that marks a notification as read in [[Special:NotificationsMarkAsRead]]", "echo-anon": "Error message shown to users who try to visit [[Special:Notifications]] as an anon.\n\nParameters:\n* $1 - URL of signup page, with returnto pointing to Special:Notifications\n* $2 - URL of login page, with returnto pointing to Special:Notifications", "echo-none": "Message shown to users who have no notifications. Also shown in the overlay.", diff --git a/modules/api/mw.echo.api.APIHandler.js b/modules/api/mw.echo.api.APIHandler.js index dce0d6d..213e87a 100644 --- a/modules/api/mw.echo.api.APIHandler.js +++ b/modules/api/mw.echo.api.APIHandler.js @@ -57,6 +57,48 @@ mw.echo.api.APIHandler.prototype.fetchNotifications = null; /** + * Fetch all pages with unread notifications in them per wiki + * + * @param {string|string[]} [sources=*] Requested sources. If not given + * or if a '*' is given, all available sources will be queried + * @return {jQuery.Promise} Promise that is resolved with an object + * of pages with the number of unread notifications per wiki + */ + mw.echo.api.APIHandler.prototype.fetchUnreadNotificationPages = function ( sources ) { + var params = { + action: 'query', + meta: 'unreadnotificationpages' + }; + + if ( !sources || sources === '*' ) { + params.unpwikis = '*'; + } else { + sources = Array.isArray( sources ) ? sources : [ sources ]; + params.unpwikis = sources.join( '|' ); + } + + return this.api.get( params ); + }; + + /** + * Check if the given source is local + * + * @param {string|string[]} sources Source names + * @return {boolean} Source is local + */ + mw.echo.api.APIHandler.prototype.isSourceLocal = function ( sources ) { + return Array.isArray( sources ) ? + ( + sources.indexOf( 'local' ) !== -1 || + sources.indexOf( mw.config.get( 'wgDBname' ) ) !== -1 + ) : + ( + sources === 'local' || + sources === mw.config.get( 'wgDBname' ) + ); + }; + + /** * Create a new fetchNotifications promise that queries the API and overrides * the cached promise. * @@ -82,7 +124,7 @@ uselang: this.userLang }, this.getTypeParams( type ) ); - if ( Array.isArray( sources ) && sources.indexOf( 'local' ) === -1 ) { + if ( !this.isSourceLocal( sources ) ) { params.notwikis = sources.join( '|' ); params.notfilter = '!read'; fetchingSource = 'foreign'; @@ -172,7 +214,7 @@ mw.echo.api.APIHandler.prototype.isFetchingErrorState = function ( type, sources ) { var fetchingSource = 'local'; - if ( Array.isArray( sources ) && sources.indexOf( 'local' ) === -1 ) { + if ( !this.isSourceLocal( sources ) ) { fetchingSource = 'foreign'; } return !!( this.apiErrorState[ type ] && this.apiErrorState[ type ][ fetchingSource ] ); @@ -191,7 +233,7 @@ mw.echo.api.APIHandler.prototype.getFetchNotificationPromise = function ( type, sources, overrideParams ) { var fetchingSource = 'local'; - if ( Array.isArray( sources ) && sources.indexOf( 'local' ) === -1 ) { + if ( !this.isSourceLocal( sources ) ) { fetchingSource = 'foreign'; } if ( overrideParams || !this.fetchNotificationsPromise[ type ] || !this.fetchNotificationsPromise[ type ][ fetchingSource ] ) { diff --git a/modules/api/mw.echo.api.EchoApi.js b/modules/api/mw.echo.api.EchoApi.js index bd5ad6d..a37a0b7 100644 --- a/modules/api/mw.echo.api.EchoApi.js +++ b/modules/api/mw.echo.api.EchoApi.js @@ -1,4 +1,4 @@ -( function ( mw ) { +( function ( mw, $ ) { /** * A class defining Echo API instructions and network operations * @@ -23,11 +23,20 @@ * Register a set of foreign sources. * * @param {Object} sources Object mapping source names to config objects + * @param {boolean} [unreadOnly=false] Fetch only unread notifications + * @param {number} [limit] Specific limit of notifications. Defaults to + * the default limit stated in the class. */ - mw.echo.api.EchoApi.prototype.registerForeignSources = function ( sources ) { + mw.echo.api.EchoApi.prototype.registerForeignSources = function ( sources, unreadOnly, limit ) { var s; + + limit = limit || this.limit; + for ( s in sources ) { - this.network.setApiHandler( s, new mw.echo.api.ForeignAPIHandler( sources[ s ].url ) ); + this.network.setApiHandler( s, new mw.echo.api.ForeignAPIHandler( sources[ s ].url, { + unreadOnly: !!unreadOnly, + limit: limit + } ) ); } }; @@ -46,36 +55,129 @@ }; /** + * Fetch all pages with unread notifications in them per wiki + * + * @param {string[]} [sources=all] Requested sources + * @return {jQuery.Promise} Promise that is resolved with an object + * of pages with the number of unread notifications per wiki + */ + mw.echo.api.EchoApi.prototype.fetchUnreadNotificationPages = function ( sources ) { + return this.network.getApiHandler( 'local' ).fetchUnreadNotificationPages( sources ) + .then( function ( data ) { + return OO.getProp( data, 'query', 'unreadnotificationpages' ); + } ); + }; + + /** + * Fetch notifications from a given source with given filters + * + * @param {string} type Notification type to fetch: 'alert', 'message', or 'all' + * @param {string} [source] The source from which to fetch the notifications. + * If not given, the local notifications will be fetched. + * @param {Object} [filters] Filter values + * @return {jQuery.Promise} Promise that is resolved with all notifications for the + * requested types. + */ + mw.echo.api.EchoApi.prototype.fetchFilteredNotifications = function ( type, source, filters ) { + source = source || 'local'; + + if ( source === 'local' || source === mw.config.get( 'wgDBname' ) ) { + return this.fetchNotifications( type, source, true, filters ); + } else { + return this.fetchNotificationsFromRemoteSource( type, source, true, filters ); + } + }; + + /** + * Convert the filter object to the relevant API parameters. + * + * @param {Object} [filterObject] The filter object + * @param {string} [filterObject.continue] A continue variable + * defining the offset to fetch notifications + * @param {string} [filterObject.readState] Notification read + * state, 'all', 'read' or 'unread' + * @param {string|string[]} [filterObject.titles] Requested titles + * @return {Object} API parameter definitions to override + */ + mw.echo.api.EchoApi.prototype.convertFiltersToAPIParams = function ( filterObject ) { + var overrideParams = {}; + + filterObject = filterObject || {}; + + if ( filterObject.continue ) { + overrideParams.notcontinue = filterObject.continue; + } + + if ( filterObject.readState && filterObject.readState !== 'all' ) { + overrideParams.notfilter = filterObject.readState === 'read' ? + 'read' : + '!read'; + } + + if ( filterObject.titles ) { + overrideParams.nottitles = Array.isArray( filterObject.titles ) ? + filterObject.titles.join( '|' ) : + filterObject.titles; + } + + return overrideParams; + }; + + /** + * Fetch remote notifications from a given source. This skips the local fetching that is + * usually done and calls the remote wiki directly. + * + * @param {string} type Notification type to fetch: 'alert', 'message', or 'all' + * @param {string|string[]} [source] The source from which to fetch the notifications. + * If not given, the local notifications will be fetched. + * @param {boolean} [isForced] Force a refresh on the fetch notifications promise + * @param {Object} [filters] Filter values + * @return {jQuery.Promise} Promise that is resolved with all notifications for the + * requested types. + */ + mw.echo.api.EchoApi.prototype.fetchNotificationsFromRemoteSource = function ( type, source, isForced, filters ) { + var handler = this.network.getApiHandler( source ); + + if ( !handler ) { + return $.Deferred().reject().promise(); + } + + return handler.fetchNotifications( + type, + // For the remote source, we are fetching 'local' notifications + 'local', + !!isForced, + this.convertFiltersToAPIParams( filters ) + ) + .then( function ( result ) { + return OO.getProp( result.query, 'notifications' ); + } ); + }; + + /** * Fetch notifications from the server based on type * * @param {string} type Notification type to fetch: 'alert', 'message', or 'all' * @param {string|string[]} [sources] The source from which to fetch the notifications. * If not given, the local notifications will be fetched. * @param {boolean} [isForced] Force a refresh on the fetch notifications promise - * @param {string} [continueValue] A value for the continue parameter, defining a page - * @param {string} [readStatus='all'] Read status of the notifications: 'read', 'unread' or 'all' + * @param {Object} [filters] Filter values * @return {jQuery.Promise} Promise that is resolved with all notifications for the * requested types. */ - mw.echo.api.EchoApi.prototype.fetchNotifications = function ( type, sources, isForced, continueValue, readStatus ) { - var overrideParams = {}; + mw.echo.api.EchoApi.prototype.fetchNotifications = function ( type, sources, isForced, filters ) { sources = Array.isArray( sources ) ? sources : sources ? [ sources ] : - null; + 'local'; - if ( continueValue ) { - overrideParams.notcontinue = continueValue; - } - - if ( readStatus && readStatus !== 'all' ) { - overrideParams.notfilter = readStatus === 'read' ? - 'read' : - '!read'; - } - - return this.network.getApiHandler( 'local' ).fetchNotifications( type, sources, isForced, overrideParams ) + return this.network.getApiHandler( 'local' ).fetchNotifications( + type, + sources, + isForced, + this.convertFiltersToAPIParams( filters ) + ) .then( function ( result ) { return OO.getProp( result.query, 'notifications' ); } ); @@ -90,7 +192,7 @@ * names to an array of their items' API data objects. */ mw.echo.api.EchoApi.prototype.fetchNotificationGroups = function ( sourceArray, type ) { - return this.network.getApiHandler( 'local' ).fetchNotifications( type, sourceArray ) + return this.network.getApiHandler( 'local' ).fetchNotifications( type, sourceArray, true ) .then( function ( result ) { var i, items = OO.getProp( result, 'query', 'notifications', 'list' ), @@ -197,4 +299,4 @@ mw.echo.api.EchoApi.prototype.getLimit = function () { return this.limit; }; -} )( mediaWiki ); +} )( mediaWiki, jQuery ); diff --git a/modules/api/mw.echo.api.ForeignAPIHandler.js b/modules/api/mw.echo.api.ForeignAPIHandler.js index 48b31c4..3f8cfe6 100644 --- a/modules/api/mw.echo.api.ForeignAPIHandler.js +++ b/modules/api/mw.echo.api.ForeignAPIHandler.js @@ -9,6 +9,8 @@ * @param {string} apiUrl A url for the access point of the * foreign API. * @param {Object} [config] Configuration object + * @cfg {boolean} [unreadOnly] Whether this handler should request unread + * notifications by default. */ mw.echo.api.ForeignAPIHandler = function MwEchoApiForeignAPIHandler( apiUrl, config ) { config = config || {}; @@ -17,6 +19,7 @@ mw.echo.api.ForeignAPIHandler.parent.call( this, config ); this.api = new mw.ForeignApi( apiUrl ); + this.unreadOnly = config.unreadOnly !== undefined ? !!config.unreadOnly : false; }; /* Setup */ @@ -27,10 +30,15 @@ * @inheritdoc */ mw.echo.api.ForeignAPIHandler.prototype.getTypeParams = function ( type ) { - return $.extend( {}, this.typeParams[ type ], { - notfilter: '!read', + var params = { // Backwards compatibility - notnoforn: 1 - } ); + notforn: 1 + }; + + if ( this.unreadOnly ) { + params = $.extend( {}, params, { notfilter: '!read' } ); + } + + return $.extend( {}, this.typeParams[ type ], params ); }; } )( mediaWiki, jQuery ); diff --git a/modules/api/mw.echo.api.LocalAPIHandler.js b/modules/api/mw.echo.api.LocalAPIHandler.js index 3c562a4..eaa4260 100644 --- a/modules/api/mw.echo.api.LocalAPIHandler.js +++ b/modules/api/mw.echo.api.LocalAPIHandler.js @@ -28,9 +28,9 @@ mw.echo.api.LocalAPIHandler.prototype.fetchNotifications = function ( type, source, isForced, overrideParams ) { if ( overrideParams ) { return this.createNewFetchNotificationPromise( type, source, overrideParams ); - } else if ( overrideParams || this.isFetchingErrorState( type, source ) ) { + } else if ( isForced || this.isFetchingErrorState( type, source ) ) { // Force new promise - this.createNewFetchNotificationPromise( type, source, overrideParams ); + return this.createNewFetchNotificationPromise( type, source, overrideParams ); } return this.getFetchNotificationPromise( type, source, overrideParams ); diff --git a/modules/controller/mw.echo.Controller.js b/modules/controller/mw.echo.Controller.js index ea08418..3db7601 100644 --- a/modules/controller/mw.echo.Controller.js +++ b/modules/controller/mw.echo.Controller.js @@ -17,15 +17,26 @@ OO.initClass( mw.echo.Controller ); /** - * Update a filter value + * Update a filter value. + * The method accepts a filter name and as many arguments + * as needed. * * @param {string} filter Filter name - * @param {string} value Filter value */ - mw.echo.Controller.prototype.setFilter = function ( filter, value ) { + mw.echo.Controller.prototype.setFilter = function ( filter ) { + var filtersModel = this.manager.getFiltersModel(), + values = Array.prototype.slice.call( arguments ); + + values.shift(); + if ( filter === 'readState' ) { - this.manager.getFiltersModel().setReadState( value ); + filtersModel.setReadState( values[ 0 ] ); + } else if ( filter === 'sourcePage' ) { + filtersModel.setCurrentSourcePage( values[ 0 ], values[ 1 ] ); } + + // Reset pagination + this.manager.getPaginationModel().reset(); }; /** @@ -62,6 +73,41 @@ }; /** + * Fetch unread pages in all wikis and create foreign API sources + * as needed. + * + * @return {jQuery.Promise} A promise that resolves when the page filter + * model is updated with the unread notification count per page per wiki + */ + mw.echo.Controller.prototype.fetchUnreadPagesByWiki = function () { + var controller = this, + filterModel = this.manager.getFiltersModel(), + sourcePageModel = filterModel.getSourcePagesModel(); + + return this.api.fetchUnreadNotificationPages() + .then( function ( data ) { + var source, + foreignSources = {}; + + for ( source in data ) { + if ( source !== mw.config.get( 'wgDBname' ) ) { + // Collect sources for API + foreignSources[ source ] = data[ source ].source; + } + } + + // Register the foreign sources in the API + controller.api.registerForeignSources( foreignSources, false ); + + // Register local source with the wiki name + controller.api.registerLocalSources( [ mw.config.get( 'wgDBname' ) ] ); + + // Register pages + sourcePageModel.setAllSources( data ); + } ); + }; + + /** * Fetch notifications from the local API and sort them by date. * This method ignores cross-wiki notifications and bundles. * @@ -74,17 +120,19 @@ var controller = this, pagination = this.manager.getPaginationModel(), filters = this.manager.getFiltersModel(), - // When we have multiple possible sources, this will change - currentSource = 'local', + currentSource = filters.getSourcePagesModel().getCurrentSource(), continueValue = pagination.getPageContinue( page || pagination.getCurrPageIndex() ); pagination.setItemsPerPage( this.api.getLimit() ); - return this.api.fetchNotifications( + + return this.api.fetchFilteredNotifications( this.manager.getTypeString(), - 'local', - true, - continueValue, - filters.getReadState() + currentSource, + { + continue: continueValue, + readState: filters.getReadState(), + titles: filters.getSourcePagesModel().getCurrentPageTitle() + } ) .then( function ( data ) { var i, notifData, newNotifData, date, itemModel, symbolicName, count, @@ -203,7 +251,7 @@ foreignListModel.setForeign( true ); // Register foreign sources - controller.api.registerForeignSources( notifData.sources ); + controller.api.registerForeignSources( notifData.sources, true ); // Add the lists according to the sources for ( source in notifData.sources ) { foreignListModel.getList().addGroup( @@ -341,7 +389,7 @@ notifData = controller.createNotificationData( groupItems[ i ] ); items.push( new mw.echo.dm.NotificationItem( groupItems[ i ].id, $.extend( notifData, { - modelName: group, + modelName: 'xwiki', source: group, bundled: true, foreign: true @@ -369,16 +417,15 @@ * * @param {number} itemId Item ID * @param {string} modelName The name of the model that these items belong to - * @param {boolean} [isForeign=false] The model is foreign, inside a cross-wiki - * bundle. + * @param {boolean} [isCrossWiki=false] The item is inside a cross-wiki bundle * @param {boolean} [isRead=true] The read state of the item; true for marking the * item as read, false for marking the item as unread * @return {jQuery.Promise} A promise that is resolved when the operation * is complete, with the number of unread notifications still remaining * for the set type of this controller, in the given source. */ - mw.echo.Controller.prototype.markSingleItemRead = function ( itemId, modelName, isForeign, isRead ) { - if ( isForeign ) { + mw.echo.Controller.prototype.markSingleItemRead = function ( itemId, modelName, isCrossWiki, isRead ) { + if ( isCrossWiki ) { return this.markCrossWikiItemsRead( [ itemId ], modelName, isRead ); } @@ -477,7 +524,7 @@ // Mark items as read in the API promises.push( - controller.markCrossWikiItemsRead( idArray, listModel.getSource() ) + controller.markCrossWikiItemsRead( idArray, listModel.getName() ) ); } diff --git a/modules/echo.variables.less b/modules/echo.variables.less index db1d2da..dcb9551 100644 --- a/modules/echo.variables.less +++ b/modules/echo.variables.less @@ -16,3 +16,11 @@ @opacity-mid: 0.8; @specialpage-separation-unit: 0.7em; +@specialpage-sidebar-width: 20em; + +@grey-light: #777; +@grey-medium: #555; +@grey-dark: #333; +@grey-darkest: #000; + +@border-color: #ccc; diff --git a/modules/model/mw.echo.dm.FiltersModel.js b/modules/model/mw.echo.dm.FiltersModel.js index 656d255..2e0cef9 100644 --- a/modules/model/mw.echo.dm.FiltersModel.js +++ b/modules/model/mw.echo.dm.FiltersModel.js @@ -9,6 +9,7 @@ * @param {Object} config Configuration object * @cfg {string} [readState='all'] Notifications read state. Allowed * values are 'all', 'read' or 'unread'. + * @cfg {string} [selectedSource] Currently selected source */ mw.echo.dm.FiltersModel = function MwEchoDmFiltersModel( config ) { config = config || {}; @@ -16,10 +17,11 @@ // Mixin constructor OO.EventEmitter.call( this ); - this.readState = 'all'; - if ( config.readState ) { - this.setReadState( config.readState ); - } + this.readState = config.readState || 'all'; + + this.sourcePagesModel = new mw.echo.dm.SourcePagesModel(); + this.selectedSource = config.selectedSource || ''; + this.selectedSourcePage = null; }; /* Initialization */ @@ -61,4 +63,37 @@ mw.echo.dm.FiltersModel.prototype.getReadState = function () { return this.readState; }; + + /** + * Set the currently selected source and page. + * If no page is given, or if page is null, the source title + * is assumed to be selected. + * + * @param {string} source Source name + * @param {string} [page] Page name + */ + mw.echo.dm.FiltersModel.prototype.setCurrentSourcePage = function ( source, page ) { + this.sourcePagesModel.setCurrentSourcePage( source, page ); + }; + + /** + * Get the total count of a source. This sums the count of all + * sub pages in that source. + * + * @param {string} source Symbolic name for source + * @return {number} Total count + */ + mw.echo.dm.FiltersModel.prototype.getSourceTotalCount = function ( source ) { + return this.sourcePagesModel.getSourceTotalCount( source ); + }; + + /** + * Get the source page model + * + * @return {mw.echo.dm.SourcePagesModel} Source pages model + */ + mw.echo.dm.FiltersModel.prototype.getSourcePagesModel = function () { + return this.sourcePagesModel; + }; + } )( mediaWiki ); diff --git a/modules/model/mw.echo.dm.ModelManager.js b/modules/model/mw.echo.dm.ModelManager.js index c538e48..15ae21a 100644 --- a/modules/model/mw.echo.dm.ModelManager.js +++ b/modules/model/mw.echo.dm.ModelManager.js @@ -40,7 +40,9 @@ this.notificationModels = {}; this.paginationModel = new mw.echo.dm.PaginationModel(); - this.filtersModel = new mw.echo.dm.FiltersModel(); + this.filtersModel = new mw.echo.dm.FiltersModel( { + selectedSource: mw.config.get( 'wgDBname' ) + } ); // Properties this.seenTime = mw.config.get( 'wgEchoSeenTime' ) || {}; diff --git a/modules/model/mw.echo.dm.NotificationItem.js b/modules/model/mw.echo.dm.NotificationItem.js index 145d61a..8dd1cd6 100644 --- a/modules/model/mw.echo.dm.NotificationItem.js +++ b/modules/model/mw.echo.dm.NotificationItem.js @@ -64,6 +64,7 @@ this.foreign = !!config.foreign; this.bundled = !!config.bundled; this.source = config.source || ''; + this.modelName = config.modelName || 'local'; this.iconType = config.iconType; this.iconURL = config.iconURL; diff --git a/modules/model/mw.echo.dm.NotificationsList.js b/modules/model/mw.echo.dm.NotificationsList.js index c0da050..f98fab3 100644 --- a/modules/model/mw.echo.dm.NotificationsList.js +++ b/modules/model/mw.echo.dm.NotificationsList.js @@ -164,6 +164,15 @@ }; /** + * Get the name associated with this list. + * + * @return {string} List name + */ + mw.echo.dm.NotificationsList.prototype.getName = function () { + return this.name; + }; + + /** * Get the source article url associated with this list. * * @return {string} List source article url diff --git a/modules/model/mw.echo.dm.PaginationModel.js b/modules/model/mw.echo.dm.PaginationModel.js index 4d516ff..28e5b9a 100644 --- a/modules/model/mw.echo.dm.PaginationModel.js +++ b/modules/model/mw.echo.dm.PaginationModel.js @@ -48,6 +48,18 @@ /* Methods */ /** + * Reset pagination data + * + * @fires update + */ + mw.echo.dm.PaginationModel.prototype.reset = function () { + this.pagesContinue = []; + this.currPageIndex = 0; + this.lastPageItemCount = 0; + + this.emit( 'update' ); + }; + /** * Set a page index with its 'continue' value, used for API fetching * * @param {number} page Page index diff --git a/modules/model/mw.echo.dm.SourcePagesModel.js b/modules/model/mw.echo.dm.SourcePagesModel.js new file mode 100644 index 0000000..c5f679f --- /dev/null +++ b/modules/model/mw.echo.dm.SourcePagesModel.js @@ -0,0 +1,218 @@ +( function ( mw ) { + /** + * Source pages model for notification filtering + * + * @class + * @mixins OO.EventEmitter + * + * @constructor + * @param {Object} config Configuration object + * @cfg {string} [currentSource] The selected source for the model. + * Defaults to the current wiki. + */ + mw.echo.dm.SourcePagesModel = function MwEchoDmSourcePagesModel( config ) { + config = config || {}; + + // Mixin constructor + OO.EventEmitter.call( this ); + + this.sources = {}; + + this.currentSource = config.currentSource || mw.config.get( 'wgDBname' ); + this.currentPage = null; + }; + + /* Initialization */ + OO.initClass( mw.echo.dm.SourcePagesModel ); + OO.mixinClass( mw.echo.dm.SourcePagesModel, OO.EventEmitter ); + + /* Events */ + + /** + * @event update + * + * The state of the source page model has changed + */ + + /* Methds */ + + /** + * Set the current source and page. + * + * @param {string} source New source + * @param {string} page New page + * @fires update + */ + mw.echo.dm.SourcePagesModel.prototype.setCurrentSourcePage = function ( source, page ) { + if ( + this.currentSource !== source || + this.currentPage !== page + ) { + this.currentSource = source; + this.currentPage = page; + this.emit( 'update' ); + } + }; + + /** + * Get the current source + * + * @return {string} Current source + */ + mw.echo.dm.SourcePagesModel.prototype.getCurrentSource = function () { + return this.currentSource; + }; + + /** + * Get the current page or pages' id. + * Returns null if no page is selected. + * + * @return {number|number[]} Current page id + */ + mw.echo.dm.SourcePagesModel.prototype.getCurrentPage = function () { + return this.currentPage; + }; + /** + * Get the current source + * + * @return {string} Current source + */ + mw.echo.dm.SourcePagesModel.prototype.getCurrentSource = function () { + return this.currentSource; + }; + + /** + * Get the title of the currently selected page + * + * @return {string} Page title + */ + mw.echo.dm.SourcePagesModel.prototype.getCurrentPageTitle = function () { + return this.getPageTitle( + this.getCurrentSource(), + this.getCurrentPage() + ); + }; + + /** + * Set all sources and pages. This will also reset and override any + * previously set information. + * + * @param {Object} sourceData A detailed object about sources and pages + */ + mw.echo.dm.SourcePagesModel.prototype.setAllSources = function ( sourceData ) { + var source; + + this.reset(); + for ( source in sourceData ) { + if ( sourceData.hasOwnProperty( source ) ) { + this.setSourcePagesDetails( source, sourceData[ source ] ); + } + } + this.emit( 'update' ); + }; + + /** + * Get an array of all source names + * + * @return {string[]} Array of source names + */ + mw.echo.dm.SourcePagesModel.prototype.getSourcesArray = function () { + return Object.keys( this.sources ); + }; + + /** + * Get the title of a source + * + * @param {string} source Symbolic name of the source + * @return {string} Source title + */ + mw.echo.dm.SourcePagesModel.prototype.getSourceTitle = function ( source ) { + return this.sources[ source ] && this.sources[ source ].title; + }; + + /** + * Get the total count of a source + * + * @param {string} source Symbolic name of the source + * @return {number} Total count + */ + mw.echo.dm.SourcePagesModel.prototype.getSourceTotalCount = function ( source ) { + return ( this.sources[ source ] && this.sources[ source ].totalCount ) || 0; + }; + + /** + * Get all pages in a source + * + * @param {string} source Symbolic name of the source + * @return {Object} Page definitions in this source + */ + mw.echo.dm.SourcePagesModel.prototype.getSourcePages = function ( source ) { + return this.sources[ source ] && this.sources[ source ].pages; + }; + + /** + * Get a specific page's title + * + * @param {string} source Symbolic name for source + * @param {number} pageId Page ID + * @return {string} Page title + */ + mw.echo.dm.SourcePagesModel.prototype.getPageTitle = function ( source, pageId ) { + return this.getPageTitleById( source, pageId ); + }; + + /** + * Get page title by the source and page ID + * + * @param {string} source Symbolic name of the source + * @param {number} pageId Page ID + * @return {string} Page title + */ + mw.echo.dm.SourcePagesModel.prototype.getPageTitleById = function ( source, pageId ) { + return this.sources[ source ] && + this.sources[ source ].pages[ pageId ] && + this.sources[ source ].pages[ pageId ].title; + }; + + /** + * Reset the data + */ + mw.echo.dm.SourcePagesModel.prototype.reset = function () { + this.sources = {}; + }; + + /** + * Set the details of a source and its page definitions + * + * @private + * @param {string} source Source symbolic name + * @param {Object} details Details object + */ + mw.echo.dm.SourcePagesModel.prototype.setSourcePagesDetails = function ( source, details ) { + var id, pageDetails, count; + + // Source information + this.sources[ source ] = { + title: details.source.title, + base: details.source.base, + totalCount: 0, + pages: {} + }; + + // Fill in pages + count = 0; + for ( id in details.pages ) { + pageDetails = details.pages[ id ]; + this.sources[ source ].pages[ id ] = { + title: pageDetails.title, + count: pageDetails.count, + id: id + }; + + count += parseInt( pageDetails.count ); + } + + // Update total count + this.sources[ source ].totalCount = count; + }; +} )( mediaWiki ); diff --git a/modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less b/modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less new file mode 100644 index 0000000..f6c6fdc --- /dev/null +++ b/modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less @@ -0,0 +1,16 @@ +@import '../echo.variables'; + +.mw-echo-ui-crossWikiUnreadFilterWidget { + border: 1px solid @border-color; + padding: @specialpage-separation-unit; + width: @specialpage-sidebar-width; + + &-title { + font-size: 1.3em; + font-weight: bold; + } + + &-subtitle { + color: @grey-light; + } +} diff --git a/modules/styles/mw.echo.ui.NotificationsInboxWidget.less b/modules/styles/mw.echo.ui.NotificationsInboxWidget.less index da0683e..1784d5c 100644 --- a/modules/styles/mw.echo.ui.NotificationsInboxWidget.less +++ b/modules/styles/mw.echo.ui.NotificationsInboxWidget.less @@ -1,31 +1,43 @@ @import '../echo.variables'; .mw-echo-ui-notificationsInboxWidget { - &-toolbar { - &-row { - display: table-row; - } - &-cell { + &-row { + display: table-row; + width: 100%; + } + &-cell { + display: table-cell; + vertical-align: middle; + &-placeholder { display: table-cell; - vertical-align: middle; - } - - &-top { - display: table; - margin-bottom: 3 * @specialpage-separation-unit; - - - &-placeholder { - display: table-cell; - width: 100%; - } - } - - &-bottom { - display: table; - width: inherit; - margin-left: auto; - margin-right: auto; - margin-top: 3 * @specialpage-separation-unit; + width: 100%; } } + + &-sidebar { + width: @specialpage-sidebar-width; + padding-right: 1em; + vertical-align: top; + } + + &-main { + vertical-align: top; + width: 100%; + + &-toolbar { + &-top { + display: table; + margin-bottom: 3 * @specialpage-separation-unit; + width: 100%; + } + + &-bottom { + display: table; + width: inherit; + margin-left: auto; + margin-right: auto; + margin-top: 3 * @specialpage-separation-unit; + } + } + } + } diff --git a/modules/styles/mw.echo.ui.PageFilterWidget.less b/modules/styles/mw.echo.ui.PageFilterWidget.less new file mode 100644 index 0000000..8f22f27 --- /dev/null +++ b/modules/styles/mw.echo.ui.PageFilterWidget.less @@ -0,0 +1,8 @@ +@import '../echo.variables'; +.mw-echo-ui-pageFilterWidget { + margin-top: 2 * @specialpage-separation-unit; + + &-title { + font-weight: bold; + } +} diff --git a/modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less b/modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less new file mode 100644 index 0000000..15b7338 --- /dev/null +++ b/modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less @@ -0,0 +1,31 @@ +@import '../echo.variables'; + +.mw-echo-ui-pageNotificationsOptionWidget { + width: 100%; + box-sizing: border-box; + + &-icon { + float: left; + .oo-ui-iconElement-icon { + display: inline-block; + } + } + + &-title { + padding: 0.2em 0; + } + + &-count { + float: right; + padding: 0.2em 0.5em; + margin-left: 0.5em; + background-color: #eee; + border-radius: 2px; + color: @grey-medium; + + .oo-ui-optionWidget-selected & { + background-color: #bbb; + } + } + +} diff --git a/modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js b/modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js new file mode 100644 index 0000000..ed2cf78 --- /dev/null +++ b/modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js @@ -0,0 +1,182 @@ +( function ( $, mw ) { + /** + * A filter for cross-wiki unread notifications + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.PendingElement + * + * @constructor + * @param {mw.echo.Controller} controller Echo controller + * @param {mw.echo.dm.FiltersModel} filtersModel Filter model + * @param {Object} [config] Configuration object + */ + mw.echo.ui.CrossWikiUnreadFilterWidget = function MwEchoUiCrossWikiUnreadFilterWidget( controller, filtersModel, config ) { + var titleWidget, subtitleWidget; + + config = config || {}; + + // Parent + mw.echo.ui.CrossWikiUnreadFilterWidget.parent.call( this, + // Sorting callback + function ( a, b ) { + var diff; + + // Local source is always first + if ( a.getSource() === mw.config.get( 'wgDBname' ) ) { + return -1; + } else if ( b.getSource() === mw.config.get( 'wgDBname' ) ) { + return 1; + } + + diff = Number( b.getTotalCount() ) - Number( a.getTotalCount() ); + if ( diff !== 0 ) { + return diff; + } + + // Fallback on Source + return b.getSource() - a.getSource(); + }, + // Config + config + ); + // Mixin + OO.ui.mixin.PendingElement.call( this, config ); + + this.controller = controller; + this.model = filtersModel; + this.previousPageSelected = null; + + titleWidget = new OO.ui.LabelWidget( { + classes: [ 'mw-echo-ui-crossWikiUnreadFilterWidget-title' ], + label: mw.msg( 'echo-specialpage-pagefilters-title' ) + } ); + subtitleWidget = new OO.ui.LabelWidget( { + classes: [ 'mw-echo-ui-crossWikiUnreadFilterWidget-subtitle' ], + label: mw.msg( 'echo-specialpage-pagefilters-subtitle' ) + } ); + + // Events + this.aggregate( { choose: 'pageFilterChoose' } ); + this.connect( this, { pageFilterChoose: 'onPageFilterChoose' } ); + + // Always have a local wiki + this.localSource = new mw.echo.ui.PageFilterWidget( + this.model.getSourcePagesModel(), + mw.config.get( 'wgDBname' ) + ); + + this.$element + .addClass( 'mw-echo-ui-crossWikiUnreadFilterWidget' ) + .append( + titleWidget.$element, + subtitleWidget.$element, + this.$group + .addClass( 'mw-echo-ui-crossWikiUnreadFilterWidget-group' ) + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.echo.ui.CrossWikiUnreadFilterWidget, mw.echo.ui.SortedListWidget ); + OO.mixinClass( mw.echo.ui.CrossWikiUnreadFilterWidget, OO.ui.mixin.PendingElement ); + + /* Events */ + + /** + * @event filter + * @param {string} source Source symbolic name + * @param {number} [pageId] Chosen page ID + * + * A source page filter was chosen + */ + + /* Methods */ + + /** + * Respond to choose event in one of the page filter widgets + * + * @param {mw.echo.ui.PageFilterWidget} widget The widget the event originated from + * @param {mw.echo.ui.PageNotificationsOptionWidget} item The chosen item + * @fires filter + */ + mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.onPageFilterChoose = function ( widget, item ) { + var source = widget.getSource(), + pageId = item && item.getData(); + + if ( item ) { + this.setItemSelected( item ); + // Emit a choice + this.emit( 'filter', source, pageId ); + } + }; + + /** + * Set the selected item + * + * @param {mw.echo.ui.PageNotificationsOptionWidget} item Item to select + */ + mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.setItemSelected = function ( item ) { + // Unselect the previous item + if ( this.previousPageSelected ) { + this.previousPageSelected.setSelected( false ); + } + item.setSelected( true ); + this.previousPageSelected = item; + }; + + /** + * Populate the sources + */ + mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.populateSources = function () { + this.pushPending(); + this.controller.fetchUnreadPagesByWiki() + .then( this.populateDataFromModel.bind( this ) ) + .always( this.popPending.bind( this ) ); + }; + + /** + * Populate the widget from the model data + */ + mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.populateDataFromModel = function () { + var i, source, widget, selectedWidget, item, + widgets = [], + sourcePageModel = this.model.getSourcePagesModel(), + selectedSource = sourcePageModel.getCurrentSource(), + selectedPage = sourcePageModel.getCurrentPage(), + sources = sourcePageModel.getSourcesArray(); + + for ( i = 0; i < sources.length; i++ ) { + source = sources[ i ]; + widget = new mw.echo.ui.PageFilterWidget( + sourcePageModel, + source, + { + title: sourcePageModel.getSourceTitle( source ), + unreadCount: sourcePageModel.getSourceTotalCount( source ), + initialSelection: this.previousPageSelected && this.previousPageSelected.getData() + } + ); + + widgets.push( widget ); + if ( source !== mw.config.get( 'wgDBname' ) ) { + this.localSource = widget; + } + } + + this.clearItems(); + this.addItems( widgets ); + + // Select the current source + selectedWidget = this.getItemFromData( selectedSource ); + if ( selectedPage ) { + // Select a specific page + item = selectedWidget.getItemFromData( selectedPage ); + } else { + // The wiki title is selected + item = selectedWidget.getTitleItem(); + } + this.setItemSelected( item ); + }; + +} )( jQuery, mediaWiki ); diff --git a/modules/ui/mw.echo.ui.DatedNotificationsWidget.js b/modules/ui/mw.echo.ui.DatedNotificationsWidget.js index b61efdd..0755cad 100644 --- a/modules/ui/mw.echo.ui.DatedNotificationsWidget.js +++ b/modules/ui/mw.echo.ui.DatedNotificationsWidget.js @@ -73,16 +73,10 @@ * Respond to model manager update event. * This event means we are repopulating the entire list and the * associated models within it. - * - * @param {Object} [models] Object of new models to populate the - * list. If not given, the method will request all models from the - * manager. */ mw.echo.ui.DatedNotificationsWidget.prototype.populateFromModel = function ( models ) { var modelId, model, subgroupWidget, groupWidgets = []; - - models = models || this.manager.getAllNotificationModels(); // Detach all attached models for ( modelId in this.models ) { diff --git a/modules/ui/mw.echo.ui.NotificationsInboxWidget.js b/modules/ui/mw.echo.ui.NotificationsInboxWidget.js index 93bded3..a36d9d4 100644 --- a/modules/ui/mw.echo.ui.NotificationsInboxWidget.js +++ b/modules/ui/mw.echo.ui.NotificationsInboxWidget.js @@ -14,6 +14,8 @@ * @cfg {jQuery} [$overlay] An overlay for the popup menus */ mw.echo.ui.NotificationsInboxWidget = function MwEchoUiNotificationsInboxWidget( controller, manager, config ) { + var $main, $sidebar; + config = config || {}; // Parent @@ -59,8 +61,15 @@ // Filter by read state this.readStateSelectWidget = new mw.echo.ui.ReadStateButtonSelectWidget(); + // Sidebar filters + this.xwikiUnreadWidget = new mw.echo.ui.CrossWikiUnreadFilterWidget( + this.controller, + this.manager.getFiltersModel() + ); + // Events this.readStateSelectWidget.connect( this, { filter: 'onReadStateFilter' } ); + this.xwikiUnreadWidget.connect( this, { filter: 'onSourcePageFilter' } ); this.manager.getFiltersModel().connect( this, { update: 'updateReadStateSelectWidget' } ); this.topPaginationWidget.connect( this, { change: 'populateNotifications' } ); this.bottomPaginationWidget.connect( this, { change: 'populateNotifications' } ); @@ -69,37 +78,41 @@ this.bottomPaginationWidget.setDisabled( true ); // Initialization - this.$element - .addClass( 'mw-echo-ui-notificationsInboxWidget' ) + $sidebar = $( '<div>' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-sidebar' ) + .append( this.xwikiUnreadWidget.$element ); + + $main = $( '<div>' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-main' ) .append( $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-top' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-main-toolbar-top' ) .append( $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-row' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-row' ) .append( $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-readState' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-cell' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-main-toolbar-readState' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-cell' ) .append( this.readStateSelectWidget.$element ), $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-top-placeholder' ), + .addClass( 'mw-echo-ui-notificationsInboxWidget-cell-placeholder' ), $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-pagination' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-cell' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-main-toolbar-pagination' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-cell' ) .append( this.topPaginationWidget.$element ) ) ), this.noticeMessageWidget.$element, this.datedListWidget.$element, $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-bottom' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-main-toolbar-bottom' ) .append( $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-row' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-row' ) .append( $( '<div>' ) - .addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-cell' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-cell' ) .append( this.bottomPaginationWidget.$element ) @@ -107,7 +120,21 @@ ) ); + this.$element + .addClass( 'mw-echo-ui-notificationsInboxWidget' ) + .append( + $( '<div>' ) + .addClass( 'mw-echo-ui-notificationsInboxWidget-row' ) + .append( + $sidebar + .addClass( 'mw-echo-ui-notificationsInboxWidget-cell' ), + $main + .addClass( 'mw-echo-ui-notificationsInboxWidget-cell' ) + ) + ); + this.updateReadStateSelectWidget(); + this.xwikiUnreadWidget.populateSources(); this.populateNotifications(); }; @@ -128,6 +155,17 @@ }; /** + * Respond to unread page filter + * + * @param {string} source Source symbolic name + * @param {number} pageId Page Id + */ + mw.echo.ui.NotificationsInboxWidget.prototype.onSourcePageFilter = function ( source, pageId ) { + this.controller.setFilter( 'sourcePage', source, pageId ); + this.populateNotifications(); + }; + + /** * Respond to read state filter event * * @param {string} readState Read state 'all', 'read' or 'unread' diff --git a/modules/ui/mw.echo.ui.PageFilterWidget.js b/modules/ui/mw.echo.ui.PageFilterWidget.js new file mode 100644 index 0000000..c8a0379 --- /dev/null +++ b/modules/ui/mw.echo.ui.PageFilterWidget.js @@ -0,0 +1,170 @@ +( function ( $, mw ) { + /** + * A widget that displays wikis and their pages to choose a filter + * + * @class + * @extends OO.ui.SelectWidget + * + * @constructor + * @param {mw.echo.dm.FiltersModel} filterModel Filters model + * @param {string} source Symbolic name for the source + * @param {Object} [config] Configuration object + * @cfg {string} [title] The title of this page group, usually + * the name of the wiki that the pages belong to + * @cfg {number} [unreadCount] Number of unread notifications + * @cfg {number} [initialSelection] The pageId of an initial selection + */ + mw.echo.ui.PageFilterWidget = function MwEchoUiPageFilterWidget( filterModel, source, config ) { + config = config || {}; + + // Parent + mw.echo.ui.PageFilterWidget.parent.call( this, config ); + + this.model = filterModel; + this.source = source; + // This is to be able to fetch and recognize this widget + // according to its source. The source is, in this case, + // unique per filter widget. + this.data = this.source; + this.totalCount = config.unreadCount || this.model.getSourceTotalCount( this.source ); + + this.initialSelection = config.initialSelection; + + // Title option + this.title = new mw.echo.ui.PageNotificationsOptionWidget( { + label: config.title, + unreadCount: this.totalCount, + data: null, + classes: [ 'mw-echo-ui-pageFilterWidget-title' ] + } ); + + // Initialization + this.populateDataFromModel(); + this.$element + .addClass( 'mw-echo-ui-pageFilterWidget' ); + }; + + /* Initialization */ + + OO.inheritClass( mw.echo.ui.PageFilterWidget, OO.ui.SelectWidget ); + + /** + * Set the total count of this group + * + * @param {number} count Total count + */ + mw.echo.ui.PageFilterWidget.prototype.setTotalCount = function ( count ) { + this.totalCount = count; + this.title.setCount( this.totalCount ); + }; + + /** + * Set the total count of this group + * + * @return {number} Total count + */ + mw.echo.ui.PageFilterWidget.prototype.getTotalCount = function () { + return this.totalCount; + }; + + /** + * Populate the widget from the model + */ + mw.echo.ui.PageFilterWidget.prototype.populateDataFromModel = function () { + var id, title, widget, + optionWidgets = [], + sourcePages = this.model.getSourcePages( this.source ); + + for ( id in sourcePages ) { + title = this.model.getPageTitle( this.source, id ); + if ( !title ) { + continue; + } + widget = new mw.echo.ui.PageNotificationsOptionWidget( { + label: title, + // TODO: Pages that are a user page should + // have a user icon + icon: 'article', + unreadCount: sourcePages[ id ].count, + // TODO: When we group pages, this should be + // an array of IDs + data: id, + classes: [ 'mw-echo-ui-pageFilterWidget-page' ] + } ); + optionWidgets.push( widget ); + + if ( this.initialSelection === title ) { + widget.setSelected( true ); + } + } + + this.setItems( optionWidgets ); + }; + + /** + * Get the source associated with this filter + * + * @return {string} Source symbolic name + */ + mw.echo.ui.PageFilterWidget.prototype.getSource = function () { + return this.source; + }; + + /** + * Get the title item + * + * @return {mw.echo.ui.PageNotificationsOptionWidget} Page title item + */ + mw.echo.ui.PageFilterWidget.prototype.getTitleItem = function () { + return this.title; + }; + + /** + * Set the page items in this widget, in order + * + * @param {mw.echo.ui.PageNotificationsOptionWidget[]} items Item widgets to order and insert + */ + mw.echo.ui.PageFilterWidget.prototype.setItems = function ( items ) { + var i, index; + + this.clearItems(); + for ( i = 0; i < items.length; i++ ) { + index = this.findInsertionIndex( items[ i ] ); + this.addItems( [ items[ i ] ], index ); + } + + // Add the title on top + this.addItems( [ this.title ], 0 ); + }; + + /** + * Find the proper insertion index for ordering when inserting items + * + * @private + * @param {mw.echo.ui.PageNotificationsOptionWidget} item Item widget + * @return {number} Insertion index + */ + mw.echo.ui.PageFilterWidget.prototype.findInsertionIndex = function ( item ) { + var widget = this; + + return OO.binarySearch( + this.items, + function ( otherItem ) { + return widget.sortingFunction( item, otherItem ); + }, + true + ); + }; + + /** + * Sorting function for item insertion + * + * @private + * @param {mw.echo.ui.PageNotificationsOptionWidget} item Item widget + * @param {mw.echo.ui.PageNotificationsOptionWidget} otherItem Another item widget + * @return {number} Ordering value + */ + mw.echo.ui.PageFilterWidget.prototype.sortingFunction = function ( item, otherItem ) { + return Number( otherItem.getCount() ) - Number( item.getCount() ); + }; +} )( jQuery, mediaWiki ); diff --git a/modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js b/modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js new file mode 100644 index 0000000..e3283f4 --- /dev/null +++ b/modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js @@ -0,0 +1,76 @@ +( function ( $, mw ) { + /** + * An option widget for the page filter in PageFilterWidget + * + * @class + * @extends OO.ui.OptionWidget + * @mixins OO.ui.mixin.IconElement + * + * @constructor + * @param {Object} [config] Configuration object + * @cfg {number} [unreadCount] Number of unread notifications + */ + mw.echo.ui.PageNotificationsOptionWidget = function MwEchoUiPageNotificationsOptionWidget( config ) { + config = config || {}; + + // Parent + mw.echo.ui.PageNotificationsOptionWidget.parent.call( this, config ); + // Mixin constructors + OO.ui.mixin.IconElement.call( this, config ); + + this.count = config.unreadCount || 0; + + this.$label + .addClass( 'mw-echo-ui-pageNotificationsOptionWidget-title-label' ); + + this.unreadCountLabel = new OO.ui.LabelWidget( { + classes: [ 'mw-echo-ui-pageNotificationsOptionWidget-label-count' ], + label: String( this.count ) + } ); + + // Initialization + this.$element + .addClass( 'mw-echo-ui-pageNotificationsOptionWidget' ) + .append( + $( '<div>' ) + .addClass( 'mw-echo-ui-pageNotificationsOptionWidget-count' ) + .append( this.unreadCountLabel.$element ), + $( '<div>' ) + .addClass( 'mw-echo-ui-pageNotificationsOptionWidget-title' ) + .append( this.$label ) + ); + + if ( this.getIcon() ) { + this.$element.prepend( + $( '<div>' ) + .addClass( 'mw-echo-ui-pageNotificationsOptionWidget-icon' ) + .append( this.$icon ) + ); + } + }; + + /* Initialization */ + + OO.inheritClass( mw.echo.ui.PageNotificationsOptionWidget, OO.ui.OptionWidget ); + OO.mixinClass( mw.echo.ui.PageNotificationsOptionWidget, OO.ui.mixin.IconElement ); + + /** + * Set the page count + * + * @param {number} count Page count + */ + mw.echo.ui.PageNotificationsOptionWidget.prototype.setCount = function ( count ) { + this.count = count; + this.unreadCountLabel.setLabel( this.count ); + }; + + /** + * Get the page count + * + * @return {number} Page count + */ + mw.echo.ui.PageNotificationsOptionWidget.prototype.getCount = function () { + return this.count; + }; + +} )( jQuery, mediaWiki ); diff --git a/modules/ui/mw.echo.ui.SortedListWidget.js b/modules/ui/mw.echo.ui.SortedListWidget.js index 99fde36..9316d78 100644 --- a/modules/ui/mw.echo.ui.SortedListWidget.js +++ b/modules/ui/mw.echo.ui.SortedListWidget.js @@ -81,6 +81,26 @@ }; /** + * Get an item by its data. + * + * @param {string} data Item data to search for + * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists + */ + mw.echo.ui.SortedListWidget.prototype.getItemFromData = function ( data ) { + var i, len, item, + hash = OO.getHash( data ); + + for ( i = 0, len = this.items.length; i < len; i++ ) { + item = this.items[ i ]; + if ( hash === OO.getHash( item.getData() ) ) { + return item; + } + } + + return null; + }; + + /** * Remove items. * * @param {OO.EventEmitter[]} items Items to remove diff --git a/modules/ui/mw.echo.ui.SubGroupListWidget.js b/modules/ui/mw.echo.ui.SubGroupListWidget.js index 1ccd13f..ec8de33 100644 --- a/modules/ui/mw.echo.ui.SubGroupListWidget.js +++ b/modules/ui/mw.echo.ui.SubGroupListWidget.js @@ -262,13 +262,13 @@ }; /** - * Get the group id, which is represented by its source. + * Get the group id, which is represented by its model symbolic name. * This is meant for sorting callbacks that fallback on * sorting by IDs. * * @return {string} Group source */ mw.echo.ui.SubGroupListWidget.prototype.getId = function () { - return this.getSource(); + return this.model.getName(); }; } )( mediaWiki, jQuery ); -- To view, visit https://gerrit.wikimedia.org/r/292600 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I57d827a47f80274d75364c2099a9624049a26834 Gerrit-PatchSet: 24 Gerrit-Project: mediawiki/extensions/Echo Gerrit-Branch: master Gerrit-Owner: Mooeypoo <mor...@gmail.com> Gerrit-Reviewer: Catrope <roan.katt...@gmail.com> Gerrit-Reviewer: Mooeypoo <mor...@gmail.com> Gerrit-Reviewer: Sbisson <sbis...@wikimedia.org> Gerrit-Reviewer: Siebrand <siebr...@kitano.nl> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits