Implement SortedList in Echo notifications

This is especially important for combined notifications and
notification lists from different sources; the model list should
be sorted to reflect items by timestamp and unread status.

Note: The dm.List and dm.SortedList now mirror the structures
OO.EmitterList and OO.SortedEmitterList that are awaiting to be
added for oojs in Ib94e4e4a49 and  I3fd569691549 respectively.
Once those are available, the dm.List and dm.SortedList can
be removed, and the model can mixin OO.SortedEmitterList instead.

Change-Id: I97e1ecbe5dccc478be527a94f037500f78f74b14
This commit is contained in:
Moriel Schottlender 2015-10-28 13:47:54 -07:00
parent f4deb6829c
commit 02530f19e1
7 changed files with 487 additions and 180 deletions

View file

@ -88,6 +88,7 @@ $wgResourceModules += array(
'viewmodel/mw.echo.dm.AbstractAPIHandler.js', 'viewmodel/mw.echo.dm.AbstractAPIHandler.js',
'viewmodel/mw.echo.dm.APIHandler.js', 'viewmodel/mw.echo.dm.APIHandler.js',
'viewmodel/mw.echo.dm.List.js', 'viewmodel/mw.echo.dm.List.js',
'viewmodel/mw.echo.dm.SortedList.js',
'viewmodel/mw.echo.dm.NotificationList.js', 'viewmodel/mw.echo.dm.NotificationList.js',
'viewmodel/mw.echo.dm.NotificationsModel.js', 'viewmodel/mw.echo.dm.NotificationsModel.js',
), ),

View file

@ -1,4 +1,4 @@
( function ( mw, $ ) { ( function ( mw ) {
/** /**
* Notification widget for echo popup. * Notification widget for echo popup.
* *
@ -45,32 +45,24 @@
/** /**
* Respond to model add event * Respond to model add event
* *
* @param {mw.echo.dm.NotificationItem[]} Added notification items * @param {mw.echo.dm.NotificationItem} Added notification item
* @param {number} index Index to add the item
*/ */
mw.echo.ui.NotificationsWidget.prototype.onModelNotificationAdd = function ( notificationItems, index ) { mw.echo.ui.NotificationsWidget.prototype.onModelNotificationAdd = function ( notificationItem, index ) {
var i, len, widget, var widget = new mw.echo.ui.NotificationOptionWidget(
$elements = $(), notificationItem,
optionWidgets = [];
for ( i = 0, len = notificationItems.length; i < len; i++ ) {
widget = new mw.echo.ui.NotificationOptionWidget(
notificationItems[ i ],
{ {
markReadWhenSeen: this.markReadWhenSeen markReadWhenSeen: this.markReadWhenSeen
} }
); );
optionWidgets.push( widget );
// Collect the elements for the hook firing
$elements = $elements.add( widget.$element );
}
// Fire hook for gadgets to update the option list // Fire hook for gadgets to update the option list
mw.hook( 'ext.echo.overlay.beforeShowingOverlay' ).fire( $elements ); mw.hook( 'ext.echo.overlay.beforeShowingOverlay' ).fire( widget.$element );
// Remove dummy option // Remove dummy option
this.removeItems( [ this.loadingOptionWidget ] ); this.removeItems( [ this.loadingOptionWidget ] );
this.addItems( optionWidgets, index ); this.addItems( [ widget ], index );
}; };
/** /**
@ -99,22 +91,18 @@
/** /**
* Respond to model add event * Respond to model add event
* *
* @param {mw.echo.dm.NotificationItem[]} Removed notification items * @param {mw.echo.dm.NotificationItem} notificationItem Removed notification items
*/ */
mw.echo.ui.NotificationsWidget.prototype.onModelNotificationRemove = function ( notificationItems ) { mw.echo.ui.NotificationsWidget.prototype.onModelNotificationRemove = function ( notificationItem ) {
var i, len, widget, items, var widget, items;
removalWidgets = [];
for ( i = 0, len = notificationItems.length; i < len; i++ ) { widget = this.getItemFromData( notificationItem.getId() );
widget = this.getItemById( notificationItems[ i ].getId() ); if ( widget && typeof widget.destroy === 'function' ) {
if ( widget && typeof widget.destroy === 'function' ) { // Destroy all widgets that can be destroyed
// Destroy all widgets that can be destroyed widget.destroy();
widget.destroy();
}
removalWidgets.push( widget );
} }
this.removeItems( removalWidgets ); this.removeItems( [ widget ] );
items = this.getItems(); items = this.getItems();
if ( !items.length ) { if ( !items.length ) {
@ -148,4 +136,4 @@
this.loadingOptionWidget.setLabel( label || '' ); this.loadingOptionWidget.setLabel( label || '' );
this.addItems( [ this.loadingOptionWidget ] ); this.addItems( [ this.loadingOptionWidget ] );
}; };
} )( mediaWiki, jQuery ); } )( mediaWiki );

View file

@ -22,18 +22,31 @@
/* Events */ /* Events */
/** /**
* @event add Items have been added * Item has been added
* @param {mw.echo.dm.NotificationItem[]} items Added items *
* @event add
* @param {OO.EventEmitter} item Added item
* @param {number} index Index items were added at * @param {number} index Index items were added at
*/ */
/** /**
* @event remove Items have been removed * Item has been moved to a new index
* @param {mw.echo.dm.NotificationItem[]} items Removed items *
* @event move
* @param {OO.EventEmitter} item Moved item
* @param {number} index Index item was moved to
*/ */
/** /**
* @event clear All items have been removed * Item has been removed
*
* @event remove
* @param {OO.EventEmitter} item Removed item
* @param {number} index Index the item was removed from
*/
/**
* @event clear The list has been cleared of items
*/ */
/* Methods */ /* Methods */
@ -100,7 +113,7 @@
* @throws {Error} An error is thrown if aggregation already exists. * @throws {Error} An error is thrown if aggregation already exists.
*/ */
mw.echo.dm.List.prototype.aggregate = function ( events ) { mw.echo.dm.List.prototype.aggregate = function ( events ) {
var i, len, item, add, remove, itemEvent, groupEvent; var i, item, add, remove, itemEvent, groupEvent;
for ( itemEvent in events ) { for ( itemEvent in events ) {
groupEvent = events[ itemEvent ]; groupEvent = events[ itemEvent ];
@ -112,7 +125,7 @@
throw new Error( 'Duplicate item event aggregation for ' + itemEvent ); throw new Error( 'Duplicate item event aggregation for ' + itemEvent );
} }
// Remove event aggregation from existing items // Remove event aggregation from existing items
for ( i = 0, len = this.items.length; i < len; i++ ) { for ( i = 0; i < this.items.length; i++ ) {
item = this.items[ i ]; item = this.items[ i ];
if ( item.connect && item.disconnect ) { if ( item.connect && item.disconnect ) {
remove = {}; remove = {};
@ -129,7 +142,7 @@
// Make future items aggregate event // Make future items aggregate event
this.aggregateItemEvents[ itemEvent ] = groupEvent; this.aggregateItemEvents[ itemEvent ] = groupEvent;
// Add event aggregation to existing items // Add event aggregation to existing items
for ( i = 0, len = this.items.length; i < len; i++ ) { for ( i = 0; i < this.items.length; i++ ) {
item = this.items[ i ]; item = this.items[ i ];
if ( item.connect && item.disconnect ) { if ( item.connect && item.disconnect ) {
add = {}; add = {};
@ -142,63 +155,121 @@
}; };
/** /**
* Add items * Add items.
* *
* @param {mw.echo.dm.NotificationItem[]} items Items to add * @param {mw.echo.dm.NotificationItem|mw.echo.dm.NotificationItem[]} items Item to add or
* @param {number} index Index to add items at * an array of items to add
* @param {number} [index] Index to add items at. If no index is
* given, or if the index that is given is invalid, the item
* will be added at the end of the list.
* @chainable * @chainable
* @fires add * @fires add
* @fires move
*/ */
mw.echo.dm.List.prototype.addItems = function ( items, index ) { mw.echo.dm.List.prototype.addItems = function ( items, index ) {
var i, len, item, event, events, currentIndex, existingItem, at; var i;
if ( !Array.isArray( items ) ) {
items = [ items ];
}
if ( items.length === 0 ) { if ( items.length === 0 ) {
return this; return this;
} }
// Support adding existing items at new locations index = this.normalizeIndex( index );
for ( i = 0, len = items.length; i < len; i++ ) { for ( i = 0; i < items.length; i++ ) {
item = items[ i ]; if ( this.items.indexOf( items[ i ] ) !== -1 ) {
existingItem = this.getItemById( item.getId() ); // Move item to new index
index = this.moveItem( items[ i ], index );
// Check if item exists then remove it first, effectively "moving" it this.emit( 'move', items[ i ], index );
currentIndex = this.items.indexOf( existingItem ); } else {
if ( currentIndex >= 0 ) { // insert item at index
this.removeItems( [ existingItem ] ); index = this.insertItem( items[ i ], index );
// Adjust index to compensate for removal this.emit( 'add', items[ i ], index );
if ( currentIndex < index ) {
index--;
}
} }
index++;
// Add the item
if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) {
events = {};
for ( event in this.aggregateItemEvents ) {
events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ];
}
item.connect( this, events );
}
// Add by reference
this.itemsById[ item.getId() ] = items[ i ];
} }
if ( index === undefined || index < 0 || index >= this.items.length ) {
at = this.items.length;
this.items.push.apply( this.items, items );
} else if ( index === 0 ) {
at = 0;
this.items.unshift.apply( this.items, items );
} else {
at = index;
this.items.splice.apply( this.items, [ index, 0 ].concat( items ) );
}
this.emit( 'add', items, at );
return this; return this;
}; };
/**
* Move an item from its current position to a new index.
*
* @param {mw.echo.dm.NotificationItem} item Items to add
* @param {number} newIndex Index to move the item to
* @private
* @return {number} The index the item was moved to
*/
mw.echo.dm.List.prototype.moveItem = function ( item, newIndex ) {
var existingIndex = this.items.indexOf( item );
newIndex = this.normalizeIndex( newIndex );
if ( existingIndex === -1 ) {
return this;
}
// Remove the item from the current index
this.items.splice( existingIndex, 1 );
// Adjust new index after removal
newIndex--;
// Move the item to the new index
this.items.splice( newIndex, 0, item );
return newIndex;
};
/**
* Normalize requested index to fit into the array.
*
* @private
* @param {number} index Requested index
* @return {number} Normalized index
*/
mw.echo.dm.List.prototype.normalizeIndex = function ( index ) {
return ( index === undefined || index < 0 || index >= this.items.length ) ?
this.items.length :
index;
};
/**
* Utility method to insert an item into the list, and
* connect it to aggregate events.
*
* Don't call this directly unless you know what you're doing.
* Use #addItems instead.
*
* @param {mw.echo.dm.NotificationItem} item Items to add
* @param {number} index Index to add items at
* @private
* @return {number} The index the item was added at
*/
mw.echo.dm.List.prototype.insertItem = function ( item, index ) {
var events, event;
// Add the item to event aggregation
if ( item.connect && item.disconnect ) {
events = {};
for ( event in this.aggregateItemEvents ) {
events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ];
}
item.connect( this, events );
}
index = this.normalizeIndex( index );
// Insert into items array
this.items.splice( index, 0, item );
// Store by id
this.itemsById[ item.getId() ] = item;
return index;
};
/** /**
* Remove items * Remove items
* *
@ -207,33 +278,32 @@
* @fires remove * @fires remove
*/ */
mw.echo.dm.List.prototype.removeItems = function ( items ) { mw.echo.dm.List.prototype.removeItems = function ( items ) {
var i, len, item, index, remove, itemEvent, var i, item, index;
removed = [];
if ( !Array.isArray( items ) ) {
items = [ items ];
}
if ( items.length === 0 ) { if ( items.length === 0 ) {
return this; return this;
} }
// Remove specific items // Remove specific items
for ( i = 0, len = items.length; i < len; i++ ) { for ( i = 0; i < items.length; i++ ) {
item = items[ i ]; item = items[ i ];
index = this.items.indexOf( item ); index = this.items.indexOf( item );
if ( index !== -1 ) { if ( index !== -1 ) {
if ( if ( item.connect && item.disconnect ) {
item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) // Disconnect all listeners from the item
) { item.disconnect( this );
remove = {};
if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
}
item.disconnect( this, remove );
} }
// Remove from id cache
delete this.itemsById[ index ];
// Remove from items
this.items.splice( index, 1 ); this.items.splice( index, 1 );
// Remove reference by Id this.emit( 'remove', item, index );
delete this.itemsById[ item.getId() ];
} }
} }
this.emit( 'remove', removed );
return this; return this;
}; };
@ -244,23 +314,17 @@
* @fires clear * @fires clear
*/ */
mw.echo.dm.List.prototype.clearItems = function () { mw.echo.dm.List.prototype.clearItems = function () {
var i, len, item, remove, itemEvent; var i, item,
items = this.items.splice( 0, this.items.length );
// Remove all items // Remove all items
for ( i = 0, len = this.items.length; i < len; i++ ) { for ( i = 0; i < items.length; i++ ) {
item = this.items[ i ]; item = items[ i ];
if ( if ( item.connect && item.disconnect ) {
item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) item.disconnect( this );
) {
remove = {};
if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
}
item.disconnect( this, remove );
} }
} }
this.items = [];
this.itemsById = {}; this.itemsById = {};
this.emit( 'clear' ); this.emit( 'clear' );

View file

@ -32,7 +32,7 @@
// Mixin constructor // Mixin constructor
OO.EventEmitter.call( this ); OO.EventEmitter.call( this );
this.id = id || null; this.id = id !== undefined ? id : null;
// TODO: We should work on the API to release and work with actual // TODO: We should work on the API to release and work with actual
// data here, rather than getting a pre-made html content of the // data here, rather than getting a pre-made html content of the
@ -45,7 +45,7 @@
this.toggleRead( !!config.read ); this.toggleRead( !!config.read );
this.toggleSeen( !!config.seen ); this.toggleSeen( !!config.seen );
this.setTimestamp( config.timestamp || fallbackMWDate ); this.timestamp = config.timestamp || fallbackMWDate;
this.setPrimaryUrl( config.primaryUrl ); this.setPrimaryUrl( config.primaryUrl );
}; };
@ -129,6 +129,7 @@
if ( this.read !== read ) { if ( this.read !== read ) {
this.read = read; this.read = read;
this.emit( 'read', this.read ); this.emit( 'read', this.read );
this.emit( 'sortChange' );
} }
}; };
@ -153,6 +154,7 @@
*/ */
mw.echo.dm.NotificationItem.prototype.setTimestamp = function ( timestamp ) { mw.echo.dm.NotificationItem.prototype.setTimestamp = function ( timestamp ) {
this.timestamp = Number( timestamp ); this.timestamp = Number( timestamp );
this.emit( 'sortChange' );
}; };
/** /**

View file

@ -20,7 +20,7 @@
OO.EventEmitter.call( this ); OO.EventEmitter.call( this );
// Mixin constructor // Mixin constructor
mw.echo.dm.List.call( this ); mw.echo.dm.SortedList.call( this );
this.type = config.type || 'alert'; this.type = config.type || 'alert';
@ -42,13 +42,32 @@
itemSeen: 'onItemSeen', itemSeen: 'onItemSeen',
itemRead: 'onItemRead' itemRead: 'onItemRead'
} ); } );
this.setSortingCallback( function ( a, b ) {
var diff;
if ( !a.isRead() && b.isRead() ) {
return -1; // Unread items are always above read items
} else if ( a.isRead() && !b.isRead() ) {
return 1;
} else {
// Reverse sorting
diff = b.getTimestamp() - a.getTimestamp();
if ( diff !== 0 ) {
return diff;
}
// Fallback on IDs
return b.getId() - a.getId();
}
} );
}; };
/* Initialization */ /* Initialization */
OO.initClass( mw.echo.dm.NotificationsModel ); OO.initClass( mw.echo.dm.NotificationsModel );
OO.mixinClass( mw.echo.dm.NotificationsModel, OO.EventEmitter ); OO.mixinClass( mw.echo.dm.NotificationsModel, OO.EventEmitter );
OO.mixinClass( mw.echo.dm.NotificationsModel, mw.echo.dm.List ); OO.mixinClass( mw.echo.dm.NotificationsModel, mw.echo.dm.SortedList );
/* Events */ /* Events */
@ -112,6 +131,7 @@
var id = item && item.getId(), var id = item && item.getId(),
unreadItem = id && this.unreadNotifications.getItemById( id ); unreadItem = id && this.unreadNotifications.getItemById( id );
// Update unread status and emit events
if ( unreadItem ) { if ( unreadItem ) {
if ( isRead ) { if ( isRead ) {
this.markItemReadInApi( id ); this.markItemReadInApi( id );
@ -337,9 +357,8 @@
* Update the unread and unseen tracking lists when we add items * Update the unread and unseen tracking lists when we add items
* *
* @param {mw.echo.dm.NotificationItem[]} items Items to add * @param {mw.echo.dm.NotificationItem[]} items Items to add
* @param {number} index Index to add items at
*/ */
mw.echo.dm.NotificationsModel.prototype.addItems = function ( items, index ) { mw.echo.dm.NotificationsModel.prototype.addItems = function ( items ) {
var i, len; var i, len;
for ( i = 0, len = items.length; i < len; i++ ) { for ( i = 0, len = items.length; i < len; i++ ) {
@ -352,7 +371,7 @@
} }
// Parent // Parent
mw.echo.dm.List.prototype.addItems.call( this, items, index ); mw.echo.dm.SortedList.prototype.addItems.call( this, items );
}; };
/** /**
@ -370,7 +389,7 @@
} }
// Parent // Parent
mw.echo.dm.List.prototype.removeItems.call( this, items ); mw.echo.dm.SortedList.prototype.removeItems.call( this, items );
}; };
/** /**
@ -381,7 +400,7 @@
this.unseenNotifications.clearItems(); this.unseenNotifications.clearItems();
// Parent // Parent
mw.echo.dm.List.prototype.clearItems.call( this ); mw.echo.dm.SortedList.prototype.clearItems.call( this );
}; };
/** /**

View file

@ -0,0 +1,164 @@
( function ( mw, oo ) {
/**
* @class mw.echo.dm.SortedList
* Contains and a sorted mw.echo.dm.List
*
* @constructor
*/
mw.echo.dm.SortedList = function OoSortedEmitterList() {
// Mixin constructors
mw.echo.dm.List.call( this );
this.sortingCallback = function ( a, b ) {
return a.getTimestamp() - b.getTimestamp();
};
// Listen to sortChange event and make sure
// we re-sort the changed item when that happens
this.aggregate( {
sortChange: 'itemSortChange'
} );
this.connect( this, {
itemSortChange: 'onItemSortChange'
} );
};
oo.mixinClass( mw.echo.dm.SortedList, mw.echo.dm.List );
/**
* Handle a case where an item changed a property that relates
* to its sorted order
*
* @param {mw.echo.dm.NotificationItem} item Item in the list
*/
mw.echo.dm.SortedList.prototype.onItemSortChange = function ( item ) {
// Remove the item
this.removeItems( item );
// Re-add the item so it is in the correct place
this.addItems( item );
};
/**
* Set the sorting callback for this sorted list.
*
* @param {Function} sortingCallback Sorting callback
*/
mw.echo.dm.SortedList.prototype.setSortingCallback = function ( sortingCallback ) {
var items = this.getItems();
this.sortingCallback = sortingCallback;
// Empty the list
this.clearItems();
// Re-add the items in the new order
this.addItems( items );
};
/**
* Add items to the sorted list.
*
* @param {OO.EventEmitter|OO.EventEmitter[]} items Item to add or
* an array of items to add
*/
mw.echo.dm.SortedList.prototype.addItems = function ( items ) {
var index, i, insertionIndex;
if ( !Array.isArray( items ) ) {
items = [ items ];
}
if ( items.length === 0 ) {
return this;
}
// Call parent mixin
for ( i = 0; i < items.length; i++ ) {
// Find insertion index
insertionIndex = this.findInsertionIndex( items[ i ] );
// Check if the item exists using the sorting callback
// and remove it first if it exists
if (
// First make sure the insertion index is not at the end
// of the list (which means it does not point to any actual
// items)
insertionIndex <= this.items.length &&
// Make sure there actually is an item in this index
this.items[ insertionIndex ] &&
// The callback returns 0 if the items are equal
this.sortingCallback( this.items[ insertionIndex ], items[ i ] ) === 0
) {
// Remove the existing item
this.removeItems( this.items[ insertionIndex ] );
}
// Insert item at the insertion index
index = this.insertItem( items[ i ], insertionIndex );
this.emit( 'add', items[ i ], insertionIndex );
}
return this;
};
/**
* Normalize requested index to fit into the array.
* In the case of a sorted list, the index
*
* @param {OO.EventEmitter} item Items to insert
* @return {number} The index the item should be inserted into
*/
mw.echo.dm.SortedList.prototype.findInsertionIndex = function ( item ) {
var list = this;
return this.binarySearchIndex(
this.items,
// Fake a this.sortingCallback.bind( null, item ) call here
// otherwise this doesn't pass tests in phantomJS
function ( otherItem ) {
return list.sortingCallback( item, otherItem );
},
true
);
};
/**
* Use binary search to locate an element in a sorted array.
*
* searchFunc is given an element from the array. `searchFunc(elem)` must return a number
* above 0 if the element we're searching for is to the right of (has a higher index than) elem,
* below 0 if it is to the left of elem, or zero if it's equal to elem.
*
* To search for a specific value with a comparator function (a `function cmp(a,b)` that returns
* above 0 if `a > b`, below 0 if `a < b`, and 0 if `a == b`), you can use
* `searchFunc = cmp.bind( null, value )`.
*
* @param {Array} arr Array to search in
* @param {Function} searchFunc Search function
* @param {boolean} [forInsertion] If not found, return index where val could be inserted
* @return {number|null} Index where val was found, or null if not found
*/
mw.echo.dm.SortedList.prototype.binarySearchIndex = function ( arr, searchFunc, forInsertion ) {
// TODO: Replace this with OO.binarySearch
// See https://gerrit.wikimedia.org/r/#/c/246813/
var mid, cmpResult,
left = 0,
right = arr.length;
while ( left < right ) {
// Equivalent to Math.floor( ( left + right ) / 2 ) but much faster
/*jshint bitwise:false */
mid = ( left + right ) >> 1;
cmpResult = searchFunc( arr[ mid ] );
if ( cmpResult < 0 ) {
right = mid;
} else if ( cmpResult > 0 ) {
left = mid + 1;
} else {
return mid;
}
}
return forInsertion ? right : null;
};
}( mediaWiki, OO ) );

View file

@ -1,4 +1,4 @@
( function ( mw ) { ( function ( mw, $ ) {
QUnit.module( 'ext.echo.dm mw.echo.dm.NotificationsModel' ); QUnit.module( 'ext.echo.dm mw.echo.dm.NotificationsModel' );
function runPreparation( model, testPrepare ) { function runPreparation( model, testPrepare ) {
@ -9,88 +9,110 @@
} }
} }
// Helper method to get an array of item ids for testing
function getIdArray( arr ) {
return arr.map( function ( item ) {
return item.getId();
} );
}
// Set up a dummy API handler to avoid sending requests to the API during tests
function TestApiHandler() {
// Parent constructor
TestApiHandler.parent.call( this );
}
/* Setup */
OO.inheritClass( TestApiHandler, mw.echo.dm.AbstractAPIHandler );
// Override api call
TestApiHandler.prototype.markItemRead = function () {
return $.Deferred().resolve( 0 );
};
QUnit.test( 'Adding notifications', function ( assert ) { QUnit.test( 'Adding notifications', function ( assert ) {
var i, ilen, model, actual, test, var initialItems = [
new mw.echo.dm.NotificationItem( 0, { timestamp: '20150828173000', read: false } ),
new mw.echo.dm.NotificationItem( 1, { timestamp: '20150828173100', read: false } ),
new mw.echo.dm.NotificationItem( 2, { timestamp: '20150828173200', read: false } )
],
cases = [ cases = [
{ {
prepare: [ items: initialItems,
{ expected: [ 2, 1, 0 ],
method: 'addItems', msg: 'Inserting items in timestamp order.'
params: [
[
new mw.echo.dm.NotificationItem( 1, {
content: '1',
timestamp: '20150828172900'
} ),
new mw.echo.dm.NotificationItem( 2, { content: '2', timestamp: '20150828172900' } )
]
]
}
],
run: {
method: 'getItemCount'
},
expect: 2,
message: 'Adding items'
}, },
{ {
prepare: [ items: [
initialItems[ 0 ],
initialItems[ 1 ],
initialItems[ 2 ],
initialItems[ 0 ]
],
expected: [ 2, 1, 0 ],
msg: 'Reinserting an item to its rightful position.'
},
{
items: [
new mw.echo.dm.NotificationItem( 0, { timestamp: '20150828173000', read: false } ),
new mw.echo.dm.NotificationItem( 1, { timestamp: '20150828173100', read: false } ),
new mw.echo.dm.NotificationItem( 2, { timestamp: '20150828173200', read: false } )
],
run: [
{ {
method: 'addItems', item: 0,
params: [ method: 'setTimestamp',
[ args: [ '20150830173000' ] // Newer timestamp
new mw.echo.dm.NotificationItem( 1, {
content: '1',
timestamp: '20150828172900'
} ),
new mw.echo.dm.NotificationItem( 2, { content: '2', timestamp: '20150828172900' } )
// TODO: This should actually work, but due to a bug in List, the 'don't add items twice'
// only works when we explicitly request to add the item a separate second time.
// This should be fixed in List and upstreamed to OOUI GroupElement which is where
// it came from.
// new mw.echo.dm.NotificationItem( 1, { content: '1', timestamp: '20150828172900' } )
]
]
},
{
method: 'addItems',
params: [
[
new mw.echo.dm.NotificationItem( 1, { content: '1', timestamp: '20150828172900' } )
]
]
} }
], ],
run: { expected: [ 0, 2, 1 ],
method: 'getItemCount' msg: 'Changing timestamp on an item.'
}, },
expect: 2, {
message: 'Do not re-add items with existing ids' items: [
new mw.echo.dm.NotificationItem( 0, { timestamp: '20150828173000', read: false } ),
new mw.echo.dm.NotificationItem( 1, { timestamp: '20150828173100', read: false } ),
new mw.echo.dm.NotificationItem( 2, { timestamp: '20150828173200', read: false } )
],
run: [
{
item: 1,
method: 'toggleRead',
args: [ true ] // Item is read
}
],
expected: [ 2, 0, 1 ],
msg: 'Changing read status of an item.'
} }
]; ];
assert.expect( cases.length ); QUnit.expect( cases.length );
for ( i = 0, ilen = cases.length; i < ilen; i++ ) { cases.forEach( function ( test ) {
model = new mw.echo.dm.NotificationsModel( { var r, runCase, runItem,
type: 'alert', model = new mw.echo.dm.NotificationsModel( new TestApiHandler(), {
limit: 25, type: 'alert',
userLang: 'en' limit: 25,
} ); userLang: 'en'
} );
test = cases[ i ]; model.addItems( test.items );
// Run preparation if ( test.add ) {
runPreparation( model, test.prepare ); model.addItems( test.add.items );
}
if ( test.run ) {
for ( r = 0; r < test.run.length; r++ ) {
runCase = test.run[ r ];
runItem = test.items[ runCase.item ];
runItem[ runCase.method ].apply( runItem, runCase.args );
}
}
// Test assert.deepEqual( getIdArray( model.getItems() ), test.expected, test.msg );
actual = model[ test.run.method ].apply( model, cases[ i ].run.params ); }, this );
assert.equal( actual, cases[ i ].expect, cases[ i ].message );
}
} ); } );
QUnit.test( 'Deleting notifications', 2, function ( assert ) { QUnit.test( 'Deleting notifications', 2, function ( assert ) {
var model = new mw.echo.dm.NotificationsModel( { var model = new mw.echo.dm.NotificationsModel( new TestApiHandler(), {
type: 'alert', type: 'alert',
limit: 25, limit: 25,
userLang: 'en' userLang: 'en'
@ -153,7 +175,7 @@
assert.expect( cases.length ); assert.expect( cases.length );
for ( i = 0, ilen = cases.length; i < ilen; i++ ) { for ( i = 0, ilen = cases.length; i < ilen; i++ ) {
model = new mw.echo.dm.NotificationsModel( { model = new mw.echo.dm.NotificationsModel( new TestApiHandler(), {
type: 'alert', type: 'alert',
limit: 25, limit: 25,
userLang: 'en' userLang: 'en'
@ -170,4 +192,51 @@
} }
} ); } );
} )( mediaWiki ); QUnit.test( 'Changing read/unread status', function ( assert ) {
var i,
initialItems = [
new mw.echo.dm.NotificationItem( 0, { timestamp: '20150828173000', read: false } ),
new mw.echo.dm.NotificationItem( 1, { timestamp: '20150828173100', read: false } ),
new mw.echo.dm.NotificationItem( 2, { timestamp: '20150828173200', read: false } ),
new mw.echo.dm.NotificationItem( 3, { timestamp: '20150828173300', read: false } ),
// Notice that in item 4 the timestamp is earlier
new mw.echo.dm.NotificationItem( 4, { timestamp: '20150828172900', read: true } ),
new mw.echo.dm.NotificationItem( 5, { timestamp: '20150828173500', read: true } )
],
cases = [
{
items: initialItems,
expected: [ 3, 2, 1, 0, 5, 4 ],
msg: 'Items organized by read/unread groups'
},
{
items: initialItems,
markRead: [ initialItems[ 1 ], initialItems[ 3 ] ],
expected: [ 2, 0, 5, 3, 1, 4 ],
msg: 'Items marked as read are pushed to the end'
}
];
QUnit.expect( cases.length );
cases.forEach( function ( test ) {
var apiHandler = new TestApiHandler(),
model = new mw.echo.dm.NotificationsModel( apiHandler, {
type: 'alert',
limit: 25,
userLang: 'en'
} );
model.addItems( test.items );
if ( test.markRead ) {
for ( i = 0; i < test.markRead.length; i++ ) {
test.markRead[ i ].toggleRead( true );
}
}
assert.deepEqual( getIdArray( model.getItems() ), test.expected, test.msg );
}, this );
} );
} )( mediaWiki, jQuery );