Unverified Commit 13e73ec4 authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Merge pull request #1198 from hypothesis/react-moderation-banner

Add react moderation banner component
parents cbb01a67 f8318a3b
'use strict'; 'use strict';
const annotationMetadata = require('../annotation-metadata'); const { createElement } = require('preact');
const classnames = require('classnames');
// @ngInject const propTypes = require('prop-types');
function ModerationBannerController(store, flash, api) {
const self = this;
this.flagCount = function() { const annotationMetadata = require('../annotation-metadata');
return annotationMetadata.flagCount(self.annotation); const useStore = require('../store/use-store');
}; const { withServices } = require('../util/service-context');
this.isHidden = function() { /**
return self.annotation.hidden; * Banner allows moderators to hide/unhide the flagged
}; * annotation from other users.
*/
function ModerationBanner({ annotation, api, flash }) {
// actions
const store = useStore(store => ({
hide: store.hideAnnotation,
unhide: store.unhideAnnotation,
}));
this.isHiddenOrFlagged = function() { const flagCount = annotationMetadata.flagCount(annotation);
const flagCount = self.flagCount();
return flagCount !== null && (flagCount > 0 || self.isHidden());
};
this.isReply = function() { const isHiddenOrFlagged =
return annotationMetadata.isReply(self.annotation); flagCount !== null && (flagCount > 0 || annotation.hidden);
};
/** /**
* Hide an annotation from non-moderator users. * Hide an annotation from non-moderator users.
*/ */
this.hideAnnotation = function() { const hideAnnotation = () => {
api.annotation api.annotation
.hide({ id: self.annotation.id }) .hide({ id: annotation.id })
.then(function() { .then(() => {
store.hideAnnotation(self.annotation.id); store.hide(annotation.id);
}) })
.catch(function() { .catch(() => {
flash.error('Failed to hide annotation'); flash.error('Failed to hide annotation');
}); });
}; };
...@@ -40,28 +41,66 @@ function ModerationBannerController(store, flash, api) { ...@@ -40,28 +41,66 @@ function ModerationBannerController(store, flash, api) {
/** /**
* Un-hide an annotation from non-moderator users. * Un-hide an annotation from non-moderator users.
*/ */
this.unhideAnnotation = function() { const unhideAnnotation = () => {
api.annotation api.annotation
.unhide({ id: self.annotation.id }) .unhide({ id: annotation.id })
.then(function() { .then(() => {
store.unhideAnnotation(self.annotation.id); store.unhide(annotation.id);
}) })
.catch(function() { .catch(() => {
flash.error('Failed to unhide annotation'); flash.error('Failed to unhide annotation');
}); });
}; };
const toggleButtonProps = (() => {
const props = {};
if (annotation.hidden) {
props.onClick = unhideAnnotation;
props.title = 'Make this annotation visible to everyone';
} else {
props.onClick = hideAnnotation;
props.title = 'Hide this annotation from non-moderators';
}
return props;
})();
const bannerClasses = classnames('moderation-banner', {
'is-flagged': flagCount > 0,
'is-hidden': annotation.hidden,
'is-reply': annotationMetadata.isReply(annotation),
});
if (!isHiddenOrFlagged) {
return null;
}
return (
<div className={bannerClasses}>
{flagCount && !annotation.hidden && (
<span>Flagged for review x{flagCount}</span>
)}
{annotation.hidden && (
<span>Hidden from users. Flagged x{flagCount}</span>
)}
<span className="u-stretch"></span>
<button {...toggleButtonProps}>
{annotation.hidden ? 'Unhide' : 'Hide'}
</button>
</div>
);
} }
/** ModerationBanner.propTypes = {
* Banner shown above flagged annotations to allow moderators to hide/unhide the /**
* annotation from other users. * The annotation object for this banner. This contains
* state about the flag count or its hidden value.
*/ */
annotation: propTypes.object.isRequired,
module.exports = { // Injected services.
controller: ModerationBannerController, api: propTypes.object.isRequired,
controllerAs: 'vm', flash: propTypes.object.isRequired,
bindings: {
annotation: '<',
},
template: require('../templates/moderation-banner.html'),
}; };
ModerationBanner.injectedProps = ['api', 'flash'];
module.exports = withServices(ModerationBanner);
...@@ -238,10 +238,8 @@ describe('annotationThread', function() { ...@@ -238,10 +238,8 @@ describe('annotationThread', function() {
const element = util.createDirective(document, 'annotationThread', { const element = util.createDirective(document, 'annotationThread', {
thread: thread, thread: thread,
}); });
const moderationBanner = element assert.ok(element[0].querySelector('moderation-banner'));
.find('moderation-banner') assert.ok(element[0].querySelector('annotation'));
.controller('moderationBanner');
assert.deepEqual(moderationBanner, { annotation: ann });
}); });
it('does not render the annotation or moderation banner if there is no annotation', function() { it('does not render the annotation or moderation banner if there is no annotation', function() {
......
'use strict'; 'use strict';
const angular = require('angular'); const { shallow } = require('enzyme');
const { createElement } = require('preact');
const util = require('../../directive/test/util'); const ModerationBanner = require('../moderation-banner');
const fixtures = require('../../test/annotation-fixtures'); const fixtures = require('../../test/annotation-fixtures');
const unroll = require('../../../shared/test/util').unroll; const unroll = require('../../../shared/test/util').unroll;
const moderatedAnnotation = fixtures.moderatedAnnotation; const moderatedAnnotation = fixtures.moderatedAnnotation;
describe('moderationBanner', function() { describe('ModerationBanner', () => {
let bannerEl;
let fakeStore;
let fakeFlash;
let fakeApi; let fakeApi;
let fakeFlash;
before(function() { function createComponent(props) {
angular return shallow(
.module('app', []) <ModerationBanner api={fakeApi} flash={fakeFlash} {...props} />
.component('moderationBanner', require('../moderation-banner')); ).dive(); // dive() needed because this component uses `withServices`
}); }
beforeEach(function() {
fakeStore = {
hideAnnotation: sinon.stub(),
unhideAnnotation: sinon.stub(),
};
beforeEach(() => {
fakeFlash = { fakeFlash = {
error: sinon.stub(), error: sinon.stub(),
}; };
...@@ -37,31 +31,29 @@ describe('moderationBanner', function() { ...@@ -37,31 +31,29 @@ describe('moderationBanner', function() {
}, },
}; };
angular.mock.module('app', { ModerationBanner.$imports.$mock({
store: fakeStore, '../store/use-store': callback =>
api: fakeApi, callback({
flash: fakeFlash, hide: sinon.stub(),
unhide: sinon.stub(),
}),
}); });
}); });
afterEach(function() { afterEach(() => {
bannerEl.remove(); ModerationBanner.$imports.$restore();
}); });
function createBanner(inputs) {
const el = util.createDirective(document, 'moderationBanner', inputs);
bannerEl = el[0];
return bannerEl;
}
unroll( unroll(
'displays if user is a moderator and annotation is hidden or flagged', 'displays if user is a moderator and annotation is hidden or flagged',
function(testCase) { function(testCase) {
const banner = createBanner({ annotation: testCase.ann }); const wrapper = createComponent({
annotation: testCase.ann,
});
if (testCase.expectVisible) { if (testCase.expectVisible) {
assert.notEqual(banner.textContent.trim(), ''); assert.notEqual(wrapper.text().trim(), '');
} else { } else {
assert.equal(banner.textContent.trim(), ''); assert.isFalse(wrapper.exists());
} }
}, },
[ [
...@@ -72,9 +64,10 @@ describe('moderationBanner', function() { ...@@ -72,9 +64,10 @@ describe('moderationBanner', function() {
}, },
{ {
// Hidden, but user is not a moderator // Hidden, but user is not a moderator
ann: Object.assign(fixtures.defaultAnnotation(), { ann: {
...fixtures.defaultAnnotation(),
hidden: true, hidden: true,
}), },
expectVisible: false, expectVisible: false,
}, },
{ {
...@@ -98,42 +91,62 @@ describe('moderationBanner', function() { ...@@ -98,42 +91,62 @@ describe('moderationBanner', function() {
it('displays the number of flags the annotation has received', function() { it('displays the number of flags the annotation has received', function() {
const ann = fixtures.moderatedAnnotation({ flagCount: 10 }); const ann = fixtures.moderatedAnnotation({ flagCount: 10 });
const banner = createBanner({ annotation: ann }); const wrapper = createComponent({ annotation: ann });
assert.include(banner.textContent, 'Flagged for review x10'); assert.include(wrapper.text(), 'Flagged for review x10');
}); });
it('displays in a more compact form if the annotation is a reply', function() { it('displays in a more compact form if the annotation is a reply', function() {
const ann = Object.assign(fixtures.oldReply(), { const wrapper = createComponent({
annotation: {
...fixtures.oldReply(),
moderation: {
flagCount: 10,
},
},
});
wrapper.exists('.is-reply');
});
it('does not display in a more compact form if the annotation is not a reply', function() {
const wrapper = createComponent({
annotation: {
...fixtures.moderatedAnnotation({}),
moderation: { moderation: {
flagCount: 10, flagCount: 10,
}, },
},
}); });
const banner = createBanner({ annotation: ann }); assert.isFalse(wrapper.exists('.is-reply'));
assert.ok(banner.querySelector('.is-reply'));
}); });
it('reports if the annotation was hidden', function() { it('reports if the annotation was hidden', function() {
const ann = moderatedAnnotation({ const wrapper = createComponent({
annotation: fixtures.moderatedAnnotation({
flagCount: 1, flagCount: 1,
hidden: true, hidden: true,
}),
}); });
const banner = createBanner({ annotation: ann }); assert.include(wrapper.text(), 'Hidden from users');
assert.include(banner.textContent, 'Hidden from users');
}); });
it('hides the annotation if "Hide" is clicked', function() { it('hides the annotation if "Hide" is clicked', function() {
const ann = moderatedAnnotation({ flagCount: 10 }); const wrapper = createComponent({
const banner = createBanner({ annotation: ann }); annotation: fixtures.moderatedAnnotation({
banner.querySelector('button').click(); flagCount: 10,
}),
});
wrapper.find('button').simulate('click');
assert.calledWith(fakeApi.annotation.hide, { id: 'ann-id' }); assert.calledWith(fakeApi.annotation.hide, { id: 'ann-id' });
}); });
it('reports an error if hiding the annotation fails', function(done) { it('reports an error if hiding the annotation fails', function(done) {
const ann = moderatedAnnotation({ flagCount: 10 }); const wrapper = createComponent({
const banner = createBanner({ annotation: ann }); annotation: moderatedAnnotation({
flagCount: 10,
}),
});
fakeApi.annotation.hide.returns(Promise.reject(new Error('Network Error'))); fakeApi.annotation.hide.returns(Promise.reject(new Error('Network Error')));
wrapper.find('button').simulate('click');
banner.querySelector('button').click();
setTimeout(function() { setTimeout(function() {
assert.calledWith(fakeFlash.error, 'Failed to hide annotation'); assert.calledWith(fakeFlash.error, 'Failed to hide annotation');
...@@ -142,29 +155,27 @@ describe('moderationBanner', function() { ...@@ -142,29 +155,27 @@ describe('moderationBanner', function() {
}); });
it('unhides the annotation if "Unhide" is clicked', function() { it('unhides the annotation if "Unhide" is clicked', function() {
const ann = moderatedAnnotation({ const wrapper = createComponent({
annotation: moderatedAnnotation({
flagCount: 1, flagCount: 1,
hidden: true, hidden: true,
}),
}); });
const banner = createBanner({ annotation: ann }); wrapper.find('button').simulate('click');
banner.querySelector('button').click();
assert.calledWith(fakeApi.annotation.unhide, { id: 'ann-id' }); assert.calledWith(fakeApi.annotation.unhide, { id: 'ann-id' });
}); });
it('reports an error if unhiding the annotation fails', function(done) { it('reports an error if unhiding the annotation fails', function(done) {
const ann = moderatedAnnotation({ const wrapper = createComponent({
annotation: moderatedAnnotation({
flagCount: 1, flagCount: 1,
hidden: true, hidden: true,
}),
}); });
const banner = createBanner({ annotation: ann });
fakeApi.annotation.unhide.returns( fakeApi.annotation.unhide.returns(
Promise.reject(new Error('Network Error')) Promise.reject(new Error('Network Error'))
); );
wrapper.find('button').simulate('click');
banner.querySelector('button').click();
setTimeout(function() { setTimeout(function() {
assert.calledWith(fakeFlash.error, 'Failed to unhide annotation'); assert.calledWith(fakeFlash.error, 'Failed to unhide annotation');
done(); done();
......
...@@ -176,7 +176,10 @@ function startAngularApp(config) { ...@@ -176,7 +176,10 @@ function startAngularApp(config) {
.component('loggedoutMessage', require('./components/loggedout-message')) .component('loggedoutMessage', require('./components/loggedout-message'))
.component('loginControl', require('./components/login-control')) .component('loginControl', require('./components/login-control'))
.component('markdown', require('./components/markdown')) .component('markdown', require('./components/markdown'))
.component('moderationBanner', require('./components/moderation-banner')) .component(
'moderationBanner',
wrapReactComponent(require('./components/moderation-banner'))
)
.component('newNoteBtn', require('./components/new-note-btn')) .component('newNoteBtn', require('./components/new-note-btn'))
.component( .component(
'searchInput', 'searchInput',
......
...@@ -10,7 +10,8 @@ ...@@ -10,7 +10,8 @@
<div class="annotation-thread__thread-line"></div> <div class="annotation-thread__thread-line"></div>
</div> </div>
<div class="annotation-thread__content"> <div class="annotation-thread__content">
<moderation-banner annotation="vm.thread.annotation" <moderation-banner
annotation="vm.thread.annotation"
ng-if="vm.thread.annotation"> ng-if="vm.thread.annotation">
</moderation-banner> </moderation-banner>
<annotation ng-class="vm.annotationClasses()" <annotation ng-class="vm.annotationClasses()"
......
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