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