Commit 7c3ba4ce authored by Sean Hammond's avatar Sean Hammond Committed by GitHub

Merge pull request #336 from hypothesis/update-moderation-api-use

Update moderation API use
parents 8de91da2 b71a6ffa
......@@ -180,9 +180,21 @@ function location(annotation) {
return Number.POSITIVE_INFINITY;
}
/**
* Return the number of times the annotation has been flagged
* by other users. If moderation data is unavailable, returns 0.
*/
function flagCount(ann) {
if (!ann.moderation) {
return 0;
}
return ann.moderation.flag_count;
}
module.exports = {
documentMetadata: documentMetadata,
domainAndTitle: domainAndTitle,
flagCount: flagCount,
isAnnotation: isAnnotation,
isNew: isNew,
isOrphan: isOrphan,
......
......@@ -39,7 +39,6 @@ var thunk = require('redux-thunk').default;
var reducers = require('./reducers');
var annotationsReducer = require('./reducers/annotations');
var framesReducer = require('./reducers/frames');
var moderationReducer = require('./reducers/moderation');
var selectionReducer = require('./reducers/selection');
var sessionReducer = require('./reducers/session');
var viewerReducer = require('./reducers/viewer');
......@@ -96,7 +95,6 @@ module.exports = function ($rootScope, settings) {
var actionCreators = redux.bindActionCreators(Object.assign({},
annotationsReducer.actions,
framesReducer.actions,
moderationReducer.actions,
selectionReducer.actions,
sessionReducer.actions,
viewerReducer.actions
......@@ -119,9 +117,6 @@ module.exports = function ($rootScope, settings) {
frames: framesReducer.frames,
isHiddenByModerator: moderationReducer.isHiddenByModerator,
flagCount: moderationReducer.flagCount,
isSidebar: viewerReducer.isSidebar,
}, store.getState);
......
......@@ -479,7 +479,7 @@ function AnnotationController(
};
vm.isHiddenByModerator = function () {
return annotationUI.isHiddenByModerator(vm.annotation.id);
return vm.annotation.hidden;
};
vm.isFlagged = function() {
......
'use strict';
var annotationMetadata = require('../annotation-metadata');
// @ngInject
function ModerationBannerController(annotationUI, flash, store) {
var self = this;
this.flagCount = function () {
return annotationUI.flagCount(this.annotationId);
return annotationMetadata.flagCount(self.annotation);
};
this.isHidden = function () {
return annotationUI.isHiddenByModerator(this.annotationId);
return self.annotation.hidden;
};
this.isReply = function () {
return annotationMetadata.isReply(self.annotation);
};
/**
* Hide an annotation from non-moderator users.
*/
this.hideAnnotation = function () {
store.annotation.hide({id: this.annotationId}).then(function () {
annotationUI.annotationHiddenChanged(this.annotationId, true);
}).catch(function (err) {
flash.error(err.message);
store.annotation.hide({id: self.annotation.id}).then(function () {
annotationUI.hideAnnotation(self.annotation.id);
}).catch(function () {
flash.error('Failed to hide annotation');
});
};
......@@ -25,10 +33,10 @@ function ModerationBannerController(annotationUI, flash, store) {
* Un-hide an annotation from non-moderator users.
*/
this.unhideAnnotation = function () {
store.annotation.unhide({id: this.annotationId}).then(function () {
annotationUI.annotationHiddenChanged(this.annotationId, false);
}).catch(function (err) {
flash.error(err.message);
store.annotation.unhide({id: self.annotation.id}).then(function () {
annotationUI.unhideAnnotation(self.annotation.id);
}).catch(function () {
flash.error('Failed to unhide annotation');
});
};
}
......@@ -42,15 +50,7 @@ module.exports = {
controller: ModerationBannerController,
controllerAs: 'vm',
bindings: {
/**
* The ID of the annotation whose moderation status the banner should
* reflect.
*/
annotationId: '<',
/**
* `true` if this annotation is a reply.
*/
isReply: '<',
annotation: '<',
},
template: require('../templates/moderation_banner.html'),
};
......@@ -145,7 +145,6 @@ describe('annotation', function() {
};
fakeAnnotationUI = {
isHiddenByModerator: sandbox.stub().returns(false),
updateFlagStatus: sandbox.stub().returns(true),
};
......@@ -999,8 +998,7 @@ describe('annotation', function() {
});
it('renders hidden annotations with a custom text class', function () {
var ann = fixtures.defaultAnnotation();
fakeAnnotationUI.isHiddenByModerator.returns(true);
var ann = fixtures.moderatedAnnotation({ hidden: true });
var el = createDirective(ann).element;
assert.deepEqual(el.find('markdown').controller('markdown'), {
customTextClass: {
......
......@@ -3,6 +3,8 @@
var angular = require('angular');
var annotationThread = require('../annotation-thread');
var moderationBanner = require('../moderation-banner');
var fixtures = require('../../test/annotation-fixtures');
var util = require('../../directive/test/util');
function PageObject(element) {
......@@ -27,10 +29,7 @@ describe('annotationThread', function () {
angular.module('app', [])
.component('annotationThread', annotationThread)
.component('moderationBanner', {
bindings: {
annotationId: '<',
isReply: '<',
},
bindings: moderationBanner.bindings,
});
});
......@@ -184,7 +183,9 @@ describe('annotationThread', function () {
});
it('renders the moderation banner', function () {
var ann = fixtures.moderatedAnnotation({ flagCount: 1 });
var thread = {
annotation: ann,
id: '123',
parent: null,
children: [],
......@@ -195,9 +196,6 @@ describe('annotationThread', function () {
var moderationBanner = element
.find('moderation-banner')
.controller('moderationBanner');
assert.deepEqual(moderationBanner, {
isReply: false,
annotationId: '123',
});
assert.deepEqual(moderationBanner, { annotation: ann });
});
});
......@@ -3,6 +3,9 @@
var angular = require('angular');
var util = require('../../directive/test/util');
var fixtures = require('../../test/annotation-fixtures');
var moderatedAnnotation = fixtures.moderatedAnnotation;
describe('moderationBanner', function () {
var bannerEl;
......@@ -17,10 +20,8 @@ describe('moderationBanner', function () {
beforeEach(function () {
fakeAnnotationUI = {
flagCount: sinon.stub().returns(0),
isHiddenByModerator: sinon.stub().returns(false),
annotationHiddenChanged: sinon.stub(),
hideAnnotation: sinon.stub(),
unhideAnnotation: sinon.stub(),
};
fakeFlash = {
......@@ -46,75 +47,76 @@ describe('moderationBanner', function () {
});
function createBanner(inputs) {
inputs.isReply = inputs.isReply || false;
var el = util.createDirective(document, 'moderationBanner', inputs);
bannerEl = el[0];
return bannerEl;
}
it('does not display if annotation is not flagged or hidden', function () {
fakeAnnotationUI.flagCount.returns(0);
fakeAnnotationUI.isHiddenByModerator.returns(false);
var banner = createBanner({ annotationId: 'not-flagged-or-hidden-id' });
var banner = createBanner({ annotation: fixtures.defaultAnnotation() });
assert.equal(banner.textContent.trim(), '');
});
it('displays the number of flags the annotation has received', function () {
fakeAnnotationUI.flagCount.returns(10);
var banner = createBanner({ annotationId: 'flagged-id' });
var ann = fixtures.moderatedAnnotation({ flagCount: 10 });
var banner = createBanner({ annotation: ann });
assert.include(banner.textContent, 'Flagged for review x10');
});
it('displays in a more compact form if the annotation is a reply', function () {
fakeAnnotationUI.flagCount.returns(1);
var banner = createBanner({ annotationId: 'reply-id', isReply: true });
var ann = Object.assign(fixtures.oldReply(), {
moderation: {
flag_count: 10,
},
});
var banner = createBanner({ annotation: ann });
assert.ok(banner.querySelector('.is-reply'));
});
it('reports if the annotation was hidden', function () {
fakeAnnotationUI.isHiddenByModerator.returns(true);
var banner = createBanner({ annotationId: 'hidden-id' });
var ann = moderatedAnnotation({ hidden: true });
var banner = createBanner({ annotation: ann });
assert.include(banner.textContent, 'Hidden from users');
});
it('hides the annotation if "Hide" is clicked', function () {
fakeAnnotationUI.flagCount.returns(10);
var banner = createBanner({ annotationId: 'flagged-id'} );
var ann = moderatedAnnotation({ flagCount: 10 });
var banner = createBanner({ annotation: ann });
banner.querySelector('button').click();
assert.calledWith(fakeStore.annotation.hide, {id: 'flagged-id'});
assert.calledWith(fakeStore.annotation.hide, {id: 'ann-id'});
});
it('reports an error if hiding the annotation fails', function (done) {
fakeAnnotationUI.flagCount.returns(10);
var banner = createBanner({ annotationId: 'flagged-id'} );
var ann = moderatedAnnotation({ flagCount: 10 });
var banner = createBanner({ annotation: ann });
fakeStore.annotation.hide.returns(Promise.reject(new Error('Network Error')));
banner.querySelector('button').click();
setTimeout(function () {
assert.calledWith(fakeFlash.error, 'Network Error');
assert.calledWith(fakeFlash.error, 'Failed to hide annotation');
done();
}, 0);
});
it('unhides the annotation if "Unhide" is clicked', function () {
fakeAnnotationUI.isHiddenByModerator.returns(true);
var banner = createBanner({ annotationId: 'hidden-id'} );
var ann = moderatedAnnotation({ hidden: true });
var banner = createBanner({ annotation: ann });
banner.querySelector('button').click();
assert.calledWith(fakeStore.annotation.unhide, {id: 'hidden-id'});
assert.calledWith(fakeStore.annotation.unhide, {id: 'ann-id'});
});
it('reports an error if unhiding the annotation fails', function (done) {
fakeAnnotationUI.isHiddenByModerator.returns(true);
var banner = createBanner({ annotationId: 'hidden-id'} );
var ann = moderatedAnnotation({ hidden: true });
var banner = createBanner({ annotation: ann });
fakeStore.annotation.unhide.returns(Promise.reject(new Error('Network Error')));
banner.querySelector('button').click();
setTimeout(function () {
assert.calledWith(fakeFlash.error, 'Network Error');
assert.calledWith(fakeFlash.error, 'Failed to unhide annotation');
done();
}, 0);
});
......
......@@ -176,6 +176,26 @@ var update = {
});
return {annotations: annotations};
},
HIDE_ANNOTATION: function (state, action) {
var anns = state.annotations.map(function (ann) {
if (ann.id !== action.id) {
return ann;
}
return Object.assign({}, ann, { hidden: true });
});
return {annotations: anns};
},
UNHIDE_ANNOTATION: function (state, action) {
var anns = state.annotations.map(function (ann) {
if (ann.id !== action.id) {
return ann;
}
return Object.assign({}, ann, { hidden: false });
});
return {annotations: anns};
},
};
var actions = util.actionTypes(update);
......@@ -282,6 +302,32 @@ function updateAnchorStatus(id, tag, isOrphan) {
};
}
/**
* Update the local hidden state of an annotation.
*
* This updates an annotation to reflect the fact that it has been hidden from
* non-moderators.
*/
function hideAnnotation(id) {
return {
type: actions.HIDE_ANNOTATION,
id: id,
};
}
/**
* Update the local hidden state of an annotation.
*
* This updates an annotation to reflect the fact that it has been made visible
* to non-moderators.
*/
function unhideAnnotation(id) {
return {
type: actions.UNHIDE_ANNOTATION,
id: id,
};
}
/**
* Return all loaded annotations which have been saved to the server.
*
......@@ -319,6 +365,13 @@ function findIDsForTags(state, tags) {
return ids;
}
/**
* Return the annotation with the given ID.
*/
function findAnnotationByID(state, id) {
return findByID(state.annotations, id);
}
module.exports = {
init: init,
update: update,
......@@ -328,10 +381,13 @@ module.exports = {
removeAnnotations: removeAnnotations,
updateAnchorStatus: updateAnchorStatus,
updateFlagStatus: updateFlagStatus,
hideAnnotation: hideAnnotation,
unhideAnnotation: unhideAnnotation,
},
// Selectors
annotationExists: annotationExists,
findAnnotationByID: findAnnotationByID,
findIDsForTags: findIDsForTags,
savedAnnotations: savedAnnotations,
};
......@@ -19,7 +19,6 @@
var annotations = require('./annotations');
var frames = require('./frames');
var moderation = require('./moderation');
var selection = require('./selection');
var session = require('./session');
var viewer = require('./viewer');
......@@ -30,7 +29,6 @@ function init(settings) {
{},
annotations.init(),
frames.init(),
moderation.init(),
selection.init(settings),
session.init(),
viewer.init()
......@@ -40,7 +38,6 @@ function init(settings) {
var update = util.createReducer(Object.assign(
annotations.update,
frames.update,
moderation.update,
selection.update,
session.update,
viewer.update
......
'use strict';
/**
* This module defines application state and actions related to flagging and
* moderation status of annotations.
*/
var toSet = require('../util/array-util').toSet;
var util = require('./util');
function init() {
return {
// Map of ID -> number of times annotation has been flagged by users.
flagCounts: {},
// IDs of annotations hidden by a moderator.
hiddenByModerator: {},
};
}
/**
* Return a copy of `map` with `key` added or removed.
*
* @param {Object} map
* @param {string} key
* @param {boolean} enable
*/
function toggle(map, key, enable) {
var newMap = Object.assign({}, map);
if (enable) {
newMap[key] = true;
} else {
delete newMap[key];
}
return newMap;
}
var update = {
ANNOTATION_HIDDEN_CHANGED: function (state, action) {
return {
hiddenByModerator: toggle(state.hiddenByModerator, action.id, action.hidden),
};
},
FETCHED_FLAG_COUNTS: function (state, action) {
return { flagCounts: action.flagCounts };
},
FETCHED_HIDDEN_IDS: function (state, action) {
return { hiddenByModerator: toSet(action.ids) };
},
};
var actions = util.actionTypes(update);
/**
* Update the flag counts for annotations.
*
* @param {Object} flagCounts - Map from ID to count of flags
*/
function fetchedFlagCounts(flagCounts) {
return {
type: actions.FETCHED_FLAG_COUNTS,
flagCounts: flagCounts,
};
}
/**
* Update the set of annotations hidden by a moderator.
*
* @param {string[]} ids
*/
function fetchedHiddenByModeratorIds(ids) {
return {
type: actions.FETCHED_HIDDEN_IDS,
ids: ids,
};
}
/**
* An annotation was hidden or unhidden by a moderator.
*
* @param {string} id
* @param {boolean} hidden
*/
function annotationHiddenChanged(id, hidden) {
return {
type: actions.ANNOTATION_HIDDEN_CHANGED,
id: id,
hidden: hidden,
};
}
/**
* Return the number of items an annotation with a given `id` has been flagged
* by members of the annotation's group.
*
* @param {Object} state
* @param {string} id
*/
function flagCount(state, id) {
return state.flagCounts[id] || 0;
}
/**
* Return `true` if an annotation was hidden by a moderator.
*
* @param {Object} state
* @param {string} id
*/
function isHiddenByModerator(state, id) {
return !!state.hiddenByModerator[id];
}
module.exports = {
init: init,
update: update,
actions: {
annotationHiddenChanged: annotationHiddenChanged,
fetchedFlagCounts: fetchedFlagCounts,
fetchedHiddenByModeratorIds: fetchedHiddenByModeratorIds,
},
// Selectors
isHiddenByModerator: isHiddenByModerator,
flagCount: flagCount,
};
'use strict';
var redux = require('redux');
// `.default` is needed because 'redux-thunk' is built as an ES2015 module
var thunk = require('redux-thunk').default;
var annotations = require('../annotations');
var fixtures = require('../../test/annotation-fixtures');
var util = require('../util');
var actions = annotations.actions;
/**
* Create a Redux store which only handles annotation actions.
*/
function createStore() {
// Thunk middleware is needed for the ADD_ANNOTATIONS action.
var enhancer = redux.applyMiddleware(thunk);
var reducer = util.createReducer(annotations.update);
return redux.createStore(reducer, annotations.init(), enhancer);
}
// Tests for most of the functionality in reducers/annotations.js are currently
// in the tests for the whole Redux store
......@@ -37,4 +54,26 @@ describe('annotations reducer', function () {
assert.deepEqual(findIDsForTags(state, ['t1']), []);
});
});
describe('#hideAnnotation', function () {
it('sets the `hidden` state to `true`', function () {
var store = createStore();
var ann = fixtures.moderatedAnnotation({ hidden: false });
store.dispatch(actions.addAnnotations([ann]));
store.dispatch(actions.hideAnnotation(ann.id));
var storeAnn = annotations.findAnnotationByID(store.getState(), ann.id);
assert.equal(storeAnn.hidden, true);
});
});
describe('#unhideAnnotation', function () {
it('sets the `hidden` state to `false`', function () {
var store = createStore();
var ann = fixtures.moderatedAnnotation({ hidden: true });
store.dispatch(actions.addAnnotations([ann]));
store.dispatch(actions.unhideAnnotation(ann.id));
var storeAnn = annotations.findAnnotationByID(store.getState(), ann.id);
assert.equal(storeAnn.hidden, false);
});
});
});
'use strict';
var moderation = require('../moderation');
var util = require('../util');
var init = moderation.init;
var actions = moderation.actions;
var update = util.createReducer(moderation.update);
describe('moderation reducer', function () {
describe('#fetchedFlagCounts', function () {
it('updates the flag counts', function () {
var state = update(init(), actions.fetchedFlagCounts({ 'flagged-id': 1, 'also-flagged-id': 2 }));
assert.deepEqual(state.flagCounts, { 'flagged-id': 1, 'also-flagged-id': 2});
});
});
describe('#flagCount', function () {
it('returns the number of times the annotation was flagged', function () {
var state = update(init(), actions.fetchedFlagCounts({ 'flagged-id': 1, 'also-flagged-id': 2 }));
assert.equal(moderation.flagCount(state, 'flagged-id'), 1);
assert.equal(moderation.flagCount(state, 'not-flagged-id'), 0);
});
});
describe('#fetchedHiddenByModeratorIds', function () {
it('updates the set of moderated IDs', function () {
var state = update(init(), actions.fetchedHiddenByModeratorIds(['hidden-id']));
assert.deepEqual(state.hiddenByModerator, {'hidden-id': true});
});
});
describe('#isHiddenByModerator', function () {
var state = update(init(), actions.fetchedHiddenByModeratorIds(['hidden-id']));
it('returns true if the annotation was hidden', function () {
assert.isTrue(moderation.isHiddenByModerator(state, 'hidden-id'));
});
it('returns false if the annotation was not hidden', function () {
assert.isFalse(moderation.isHiddenByModerator(state, 'not-hidden-id'));
});
});
describe('#annotationHiddenChanged', function () {
it('alters the hidden status of the annotation', function () {
var state = update(init(), actions.fetchedHiddenByModeratorIds(['hidden-id']));
state = update(state, actions.annotationHiddenChanged('hidden-id', false));
assert.isFalse(moderation.isHiddenByModerator(state, 'hidden-id'));
});
});
});
......@@ -171,6 +171,8 @@ function store($http, $q, auth, settings) {
get: apiCall('annotation.read'),
update: apiCall('annotation.update'),
flag: apiCall('annotation.flag'),
hide: apiCall('annotation.hide'),
unhide: apiCall('annotation.unhide'),
},
profile: {
read: apiCall('profile.read'),
......
......@@ -10,8 +10,7 @@
<div class="annotation-thread__thread-line"></div>
</div>
<div class="annotation-thread__content">
<moderation-banner is-reply="!vm.isTopLevelThread()"
annotation-id="vm.thread.id">
<moderation-banner annotation="vm.thread.annotation">
</moderation-banner>
<annotation ng-class="vm.annotationClasses()"
annotation="vm.thread.annotation"
......
......@@ -2,7 +2,7 @@
ng-if="vm.flagCount() > 0 || vm.isHidden()"
ng-class="{'is-flagged': vm.flagCount() > 0,
'is-hidden': vm.isHidden(),
'is-reply': vm.isReply}">
'is-reply': vm.isReply()}">
<span ng-if="vm.flagCount() > 0 && !vm.isHidden()">
Flagged for review x{{ vm.flagCount() }}
</span>
......
......@@ -137,9 +137,32 @@ function oldReply() {
};
}
/**
* @typedef ModerationState
* @property {boolean} hidden
* @property {number} flagCount
*/
/**
* Return an annotation with the given moderation state.
*
* @param {ModerationState} modInfo
*/
function moderatedAnnotation(modInfo) {
return Object.assign(defaultAnnotation(), {
id: 'ann-id',
hidden: !!modInfo.hidden,
moderation: {
flag_count: modInfo.flagCount || 0,
},
});
}
module.exports = {
defaultAnnotation: defaultAnnotation,
publicAnnotation: publicAnnotation,
moderatedAnnotation: moderatedAnnotation,
newAnnotation: newAnnotation,
newEmptyAnnotation: newEmptyAnnotation,
newHighlight: newHighlight,
......
......@@ -316,4 +316,17 @@ describe('annotation-metadata', function () {
assert.isFalse(isWaitingToAnchor(pending));
});
});
describe('.flagCount', function () {
var flagCount = annotationMetadata.flagCount;
it('returns 0 if the user is not a moderator', function () {
assert.equal(flagCount(fixtures.defaultAnnotation()), 0);
});
it('returns the flag count if present', function () {
var ann = fixtures.moderatedAnnotation({ flagCount: 10});
assert.equal(flagCount(ann), 10);
});
});
});
......@@ -51,6 +51,9 @@ describe('store', function () {
store = _store_;
$httpBackend.expectGET('http://example.com/api').respond({
// Return an API route directory.
// This should mirror the structure (but not the exact URLs) of
// https://hypothes.is/api/.
links: {
annotation: {
create: {
......@@ -70,6 +73,14 @@ describe('store', function () {
method: 'PUT',
url: 'http://example.com/api/annotations/:id/flag',
},
hide: {
method: 'PUT',
url: 'http://example.com/api/annotations/:id/hide',
},
unhide: {
method: 'DELETE',
url: 'http://example.com/api/annotations/:id/hide',
},
},
search: {
method: 'GET',
......@@ -139,6 +150,30 @@ describe('store', function () {
$httpBackend.flush();
});
it('hides an annotation', function (done) {
store.annotation.hide({id: 'an-id'}).then(function () {
done();
});
$httpBackend.expectPUT('http://example.com/api/annotations/an-id/hide')
.respond(function () {
return [204, {}, {}];
});
$httpBackend.flush();
});
it('unhides an annotation', function (done) {
store.annotation.unhide({id: 'an-id'}).then(function () {
done();
});
$httpBackend.expectDELETE('http://example.com/api/annotations/an-id/hide')
.respond(function () {
return [204, {}, {}];
});
$httpBackend.flush();
});
it('removes internal properties before sending data to the server', function (done) {
var annotation = {
$highlight: true,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment