Unverified Commit 2d6b5f8a authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1554 from hypothesis/annotation-action-bar

Extract `AnnotationActionBar` Subcomponent from `Annotation`
parents 1bb4191c ed3e31d5
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { withServices } = require('../util/service-context');
const { isShareable, shareURI } = require('../util/annotation-sharing');
const AnnotationActionButton = require('./annotation-action-button');
const AnnotationShareControl = require('./annotation-share-control');
/**
* A collection of `AnnotationActionButton`s in the footer area of an annotation.
*/
function AnnotationActionBar({
annotation,
isPrivate,
onDelete,
onEdit,
onFlag,
onReply,
groups,
permissions,
session,
settings,
}) {
// Is the current user allowed to take the given `action` on this annotation?
const userIsAuthorizedTo = action => {
return permissions.permits(
annotation.permissions,
action,
session.state.userid
);
};
const showDeleteAction = userIsAuthorizedTo('delete');
const showEditAction = userIsAuthorizedTo('update');
// Anyone may flag an annotation except the annotation's author.
// This option is even presented to anonymous users
const showFlagAction = session.state.userid !== annotation.user;
const showShareAction = isShareable(annotation, settings);
const annotationGroup = groups.get(annotation.group);
return (
<div className="annotation-action-bar">
{showEditAction && (
<AnnotationActionButton icon="edit" label="Edit" onClick={onEdit} />
)}
{showDeleteAction && (
<AnnotationActionButton
icon="trash"
label="Delete"
onClick={onDelete}
/>
)}
<AnnotationActionButton icon="reply" label="Reply" onClick={onReply} />
{showShareAction && (
<AnnotationShareControl
group={annotationGroup}
isPrivate={isPrivate}
shareUri={shareURI(annotation)}
/>
)}
{showFlagAction && !annotation.flagged && (
<AnnotationActionButton
icon="flag"
label="Report this annotation to moderators"
onClick={onFlag}
/>
)}
{showFlagAction && annotation.flagged && (
<AnnotationActionButton
isActive={true}
icon="flag--active"
label="Annotation has been reported to the moderators"
/>
)}
</div>
);
}
AnnotationActionBar.propTypes = {
annotation: propTypes.object.isRequired,
/** Is this annotation shared at the group level or marked as "only me"/private? */
isPrivate: propTypes.bool.isRequired,
/** Callbacks for when action buttons are clicked/tapped */
onEdit: propTypes.func.isRequired,
onDelete: propTypes.func.isRequired,
onFlag: propTypes.func.isRequired,
onReply: propTypes.func.isRequired,
// Injected services
groups: propTypes.object.isRequired,
permissions: propTypes.object.isRequired,
session: propTypes.object.isRequired,
settings: propTypes.object.isRequired,
};
AnnotationActionBar.injectedProps = [
'groups',
'permissions',
'session',
'settings',
];
module.exports = withServices(AnnotationActionBar);
......@@ -6,18 +6,21 @@ const { createElement } = require('preact');
const SvgIcon = require('./svg-icon');
/**
* A simple icon-only button for actions applicable to annotations
*/
function AnnotationActionButton({
className = '',
icon,
isDisabled,
isActive = false,
label,
onClick,
onClick = () => null,
}) {
return (
<button
className={classnames('annotation-action-button', className)}
className={classnames('annotation-action-button', {
'is-active': isActive,
})}
onClick={onClick}
disabled={isDisabled}
aria-label={label}
title={label}
>
......@@ -27,12 +30,14 @@ function AnnotationActionButton({
}
AnnotationActionButton.propTypes = {
className: propTypes.string,
/** The name of the SVGIcon to render */
icon: propTypes.string.isRequired,
isDisabled: propTypes.bool.isRequired,
/** Is this button currently in an "active" or "on" state? */
isActive: propTypes.bool,
/** a label used for the `title` and `aria-label` attributes */
label: propTypes.string.isRequired,
onClick: propTypes.func.isRequired,
/** optional callback for clicks */
onClick: propTypes.func,
};
module.exports = AnnotationActionButton;
......@@ -8,7 +8,6 @@ const {
} = require('../util/annotation-metadata');
const events = require('../events');
const { isThirdPartyUser } = require('../util/account-id');
const serviceConfig = require('../service-config');
/**
* Return a copy of `annotation` with changes made in the editor applied.
......@@ -26,23 +25,6 @@ function updateModel(annotation, changes, permissions) {
});
}
/**
* Return true if share links are globally enabled.
*
* Share links will only be shown on annotation cards if this is true and if
* these links are included in API responses.
*/
function shouldEnableShareLinks(settings) {
const serviceConfig_ = serviceConfig(settings);
if (serviceConfig_ === null) {
return true;
}
if (typeof serviceConfig_.enableShareLinks !== 'boolean') {
return true;
}
return serviceConfig_.enableShareLinks;
}
// @ngInject
function AnnotationController(
$document,
......@@ -65,8 +47,6 @@ function AnnotationController(
const self = this;
let newlyCreatedByHighlightButton;
const enableShareLinks = shouldEnableShareLinks(settings);
/** Save an annotation to the server. */
function save(annot) {
let saved;
......@@ -218,14 +198,6 @@ function AnnotationController(
}
}
this.authorize = function(action) {
return permissions.permits(
self.annotation.permissions,
action,
session.state.userid
);
};
/**
* @ngdoc method
* @name annotation.AnnotationController#flag
......@@ -517,28 +489,10 @@ function AnnotationController(
return self.annotation.hidden;
};
this.canFlag = function() {
// Users can flag any annotations except their own.
return session.state.userid !== self.annotation.user;
};
this.isFlagged = function() {
return self.annotation.flagged;
};
this.isReply = function() {
return isReply(self.annotation);
};
this.incontextLink = function() {
if (enableShareLinks && self.annotation.links) {
return (
self.annotation.links.incontext || self.annotation.links.html || ''
);
}
return '';
};
/**
* Sets whether or not the controls for expanding/collapsing the body of
* lengthy annotations should be shown.
......
'use strict';
const { createElement } = require('preact');
const { mount } = require('enzyme');
const AnnotationActionBar = require('../annotation-action-bar');
const mockImportedComponents = require('./mock-imported-components');
describe('AnnotationActionBar', () => {
let fakeAnnotation;
let fakeOnDelete;
let fakeOnEdit;
let fakeOnFlag;
let fakeOnReply;
// Fake services
let fakeGroups;
let fakePermissions;
let fakeSession;
let fakeSettings;
// Fake dependencies
let fakeIsShareable;
function createComponent(props = {}) {
return mount(
<AnnotationActionBar
annotation={fakeAnnotation}
isPrivate={false}
onDelete={fakeOnDelete}
onEdit={fakeOnEdit}
onReply={fakeOnReply}
onFlag={fakeOnFlag}
groups={fakeGroups}
permissions={fakePermissions}
session={fakeSession}
settings={fakeSettings}
{...props}
/>
);
}
const allowOnly = action => {
fakePermissions.permits.returns(false);
fakePermissions.permits
.withArgs(sinon.match.any, action, sinon.match.any)
.returns(true);
};
const disallowOnly = action => {
fakePermissions.permits
.withArgs(sinon.match.any, action, sinon.match.any)
.returns(false);
};
const getButton = (wrapper, iconName) => {
return wrapper.find('AnnotationActionButton').filter({ icon: iconName });
};
beforeEach(() => {
fakeAnnotation = {
group: 'fakegroup',
permissions: {},
user: 'acct:bar@foo.com',
};
fakeSession = {
state: {
userid: 'acct:foo@bar.com',
},
};
fakeOnEdit = sinon.stub();
fakeOnDelete = sinon.stub();
fakeOnReply = sinon.stub();
fakeOnFlag = sinon.stub();
fakeGroups = {
get: sinon.stub(),
};
fakePermissions = {
permits: sinon.stub().returns(true),
};
fakeSettings = {};
fakeIsShareable = sinon.stub().returns(true);
AnnotationActionBar.$imports.$mock(mockImportedComponents());
AnnotationActionBar.$imports.$mock({
'../util/annotation-sharing': {
isShareable: fakeIsShareable,
shareURI: sinon.stub().returns('http://share.me'),
},
});
});
afterEach(() => {
AnnotationActionBar.$imports.$restore();
});
describe('edit action button', () => {
it('shows edit button if permissions allow', () => {
allowOnly('update');
const wrapper = createComponent();
assert.isTrue(getButton(wrapper, 'edit').exists());
});
it('invokes `onEdit` callback when edit button clicked', () => {
allowOnly('update');
const button = getButton(createComponent(), 'edit');
button.props().onClick();
assert.calledOnce(fakeOnEdit);
});
it('does not show edit button if permissions do not allow', () => {
disallowOnly('update');
const wrapper = createComponent();
assert.isFalse(getButton(wrapper, 'edit').exists());
});
});
describe('delete action button', () => {
it('shows delete button if permissions allow', () => {
allowOnly('delete');
const wrapper = createComponent();
assert.isTrue(getButton(wrapper, 'trash').exists());
});
it('invokes `onDelete` callback when delete button clicked', () => {
allowOnly('delete');
const button = getButton(createComponent(), 'trash');
button.props().onClick();
assert.calledOnce(fakeOnDelete);
});
it('does not show edit button if permissions do not allow', () => {
disallowOnly('delete');
const wrapper = createComponent();
assert.isFalse(getButton(wrapper, 'trash').exists());
});
});
describe('reply action button', () => {
it('shows the reply button (in all cases)', () => {
const wrapper = createComponent();
assert.isTrue(getButton(wrapper, 'reply').exists());
});
it('invokes `onReply` callback when reply button clicked', () => {
const button = getButton(createComponent(), 'reply');
button.props().onClick();
assert.calledOnce(fakeOnReply);
});
});
describe('share action button', () => {
it('shows share action button if annotation is shareable', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('AnnotationShareControl').exists());
});
it('does not show share action button if annotation is not shareable', () => {
fakeIsShareable.returns(false);
const wrapper = createComponent();
assert.isFalse(wrapper.find('AnnotationShareControl').exists());
});
});
describe('flag action button', () => {
it('shows flag button if user is not author', () => {
const wrapper = createComponent();
assert.isTrue(getButton(wrapper, 'flag').exists());
});
it('invokes `onFlag` callback when flag button clicked', () => {
const button = getButton(createComponent(), 'flag');
button.props().onClick();
assert.calledOnce(fakeOnFlag);
});
it('does not show flag action button if user is author', () => {
fakeAnnotation.user = 'acct:foo@bar.com';
const wrapper = createComponent();
assert.isFalse(getButton(wrapper, 'flag').exists());
});
context('previously-flagged annotation', () => {
beforeEach(() => {
fakeAnnotation.flagged = true;
});
it('renders an active-state flag action button', () => {
const wrapper = createComponent();
assert.isTrue(getButton(wrapper, 'flag--active').exists());
});
it('does not set an `onClick` property for the flag action button', () => {
const button = getButton(createComponent(), 'flag--active');
assert.isUndefined(button.props().onClick);
});
});
});
});
......@@ -30,8 +30,20 @@ describe('AnnotationActionButton', () => {
AnnotationActionButton.$imports.$restore();
});
it('applies any provided className to the button', () => {
const wrapper = createComponent({ className: 'my-class' });
assert.isTrue(wrapper.hasClass('my-class'));
it('adds active className if `isActive` is `true`', () => {
const wrapper = createComponent({ isActive: true });
assert.isTrue(wrapper.find('button').hasClass('is-active'));
});
it('renders `SvgIcon` if icon property set', () => {
const wrapper = createComponent();
assert.equal(wrapper.find('SvgIcon').prop('name'), 'fakeIcon');
});
it('invokes `onClick` callback when pressed', () => {
const wrapper = createComponent();
wrapper.find('button').simulate('click');
assert.calledOnce(fakeOnClick);
});
});
......@@ -33,25 +33,6 @@ const groupFixtures = {
},
};
/**
* Returns the controller for the action button with the given `label`.
*
* @param {Element} annotationEl - Annotation element
* @param {string} label - Button label
*/
function findActionButton(annotationEl, label) {
const btns = Array.from(
annotationEl[0].querySelectorAll('annotation-action-button')
);
const match = btns.find(function(btn) {
const ctrl = angular.element(btn).controller('annotationActionButton');
return ctrl.label === label;
});
return match
? angular.element(match).controller('annotationActionButton')
: null;
}
describe('annotation', function() {
describe('updateModel()', function() {
const updateModel = require('../annotation').updateModel;
......@@ -877,20 +858,6 @@ describe('annotation', function() {
});
});
describe('#canFlag', function() {
it('returns false if the user signed in is the same as the author of the annotation', function() {
const ann = fixtures.defaultAnnotation();
const controller = createDirective(ann).controller;
assert.isFalse(controller.canFlag());
});
it('returns true if the user signed in is different from the author of the annotation', function() {
const ann = fixtures.thirdPartyAnnotation();
const controller = createDirective(ann).controller;
assert.isTrue(controller.canFlag());
});
});
describe('#shouldShowLicense', function() {
[
{
......@@ -937,28 +904,6 @@ describe('annotation', function() {
});
});
describe('#authorize', function() {
it('passes the current permissions and logged-in user ID to the permissions service', function() {
const ann = fixtures.defaultAnnotation();
ann.permissions = {
read: [fakeSession.state.userid],
delete: [fakeSession.state.userid],
update: [fakeSession.state.userid],
};
const controller = createDirective(ann).controller;
['update', 'delete'].forEach(function(action) {
controller.authorize(action);
assert.calledWith(
fakePermissions.permits,
ann.permissions,
action,
fakeSession.state.userid
);
});
});
});
describe('saving a new annotation', function() {
let annotation;
......@@ -1186,53 +1131,6 @@ describe('annotation', function() {
});
});
describe('annotation links', function() {
it('uses the in-context links when available', function() {
const annotation = Object.assign({}, fixtures.defaultAnnotation(), {
links: {
incontext: 'https://hpt.is/deadbeef',
},
});
const controller = createDirective(annotation).controller;
assert.equal(controller.incontextLink(), annotation.links.incontext);
});
it('falls back to the HTML link when in-context links are missing', function() {
const annotation = Object.assign({}, fixtures.defaultAnnotation(), {
links: {
html: 'https://test.hypothes.is/a/deadbeef',
},
});
const controller = createDirective(annotation).controller;
assert.equal(controller.incontextLink(), annotation.links.html);
});
it('in-context link is blank when unknown', function() {
const annotation = fixtures.defaultAnnotation();
const controller = createDirective(annotation).controller;
assert.equal(controller.incontextLink(), '');
});
[true, false].forEach(enableShareLinks => {
it('does not render links if share links are globally disabled', () => {
const annotation = Object.assign({}, fixtures.defaultAnnotation(), {
links: {
incontext: 'https://hpt.is/deadbeef',
},
});
fakeSettings.services = [
{
enableShareLinks,
},
];
const controller = createDirective(annotation).controller;
const hasIncontextLink =
controller.incontextLink() === annotation.links.incontext;
assert.equal(hasIncontextLink, enableShareLinks);
});
});
});
[
{
context: 'for moderators',
......@@ -1262,32 +1160,5 @@ describe('annotation', function() {
);
});
});
it('flags the annotation when the user clicks the "Flag" button', function() {
fakeAnnotationMapper.flagAnnotation.returns(Promise.resolve());
const ann = Object.assign(fixtures.defaultAnnotation(), {
user: 'acct:notCurrentUser@localhost',
});
const el = createDirective(ann).element;
const flagBtn = findActionButton(
el,
'Report this annotation to the moderators'
);
flagBtn.onClick();
assert.called(fakeAnnotationMapper.flagAnnotation);
});
it('highlights the "Flag" button if the annotation is flagged', function() {
const ann = Object.assign(fixtures.defaultAnnotation(), {
flagged: true,
user: 'acct:notCurrentUser@localhost',
});
const el = createDirective(ann).element;
const flaggedBtn = findActionButton(
el,
'Annotation has been reported to the moderators'
);
assert.ok(flaggedBtn);
});
});
});
......@@ -139,8 +139,8 @@ function startAngularApp(config) {
wrapReactComponent(require('./components/annotation-header'))
)
.component(
'annotationActionButton',
wrapReactComponent(require('./components/annotation-action-button'))
'annotationActionBar',
wrapReactComponent(require('./components/annotation-action-bar'))
)
.component(
'annotationPublishControl',
......@@ -150,10 +150,6 @@ function startAngularApp(config) {
'annotationQuote',
wrapReactComponent(require('./components/annotation-quote'))
)
.component(
'annotationShareControl',
wrapReactComponent(require('./components/annotation-share-control'))
)
.component('annotationThread', require('./components/annotation-thread'))
.component(
'annotationViewerContent',
......
......@@ -86,46 +86,13 @@
</div>
<div class="annotation-actions" ng-if="!vm.isSaving && !vm.editing() && vm.id()">
<div ng-show="vm.isSaving">Saving…</div>
<annotation-action-button
icon="'edit'"
is-disabled="vm.isDeleted()"
label="'Edit'"
ng-show="vm.authorize('update') && !vm.isSaving"
on-click="vm.edit()"
></annotation-action-button>
<annotation-action-button
icon="'trash'"
is-disabled="vm.isDeleted()"
label="'Delete'"
ng-show="vm.authorize('delete')"
on-click="vm.delete()"
></annotation-action-button>
<annotation-action-button
icon="'reply'"
is-disabled="vm.isDeleted()"
label="'Reply'"
on-click="vm.reply()"
></annotation-action-button>
<span class="annotation-share-dialog-wrapper" ng-if="vm.incontextLink()">
<annotation-share-control group="vm.group()" is-private="vm.state().isPrivate" share-uri="vm.incontextLink()"></annotation-share-control>
</span>
<span ng-if="vm.canFlag()">
<annotation-action-button
icon="'flag'"
is-disabled="vm.isDeleted()"
label="'Report this annotation to the moderators'"
ng-if="!vm.isFlagged()"
on-click="vm.flag()"
></annotation-action-button>
<annotation-action-button
class-name="'annotation-action-button--active'"
icon="'flag--active'"
is-disabled="vm.isDeleted()"
label="'Annotation has been reported to the moderators'"
ng-if="vm.isFlagged()"
></annotation-action-button>
</span>
<annotation-action-bar
annotation="vm.annotation"
is-private="vm.state().isPrivate"
on-delete="vm.delete()"
on-flag="vm.flag()"
on-edit="vm.edit()"
on-reply="vm.reply()"></annotation-action-bar>
</div>
</footer>
</div>
'use strict';
const serviceConfig = require('../service-config');
/**
* Is the sharing of annotations enabled? Check for any defined `serviceConfig`,
* but default to `true` if none found.
*
* @param {object} settings
* @return {boolean}
*/
function sharingEnabled(settings) {
const serviceConfig_ = serviceConfig(settings);
if (serviceConfig_ === null) {
return true;
}
if (typeof serviceConfig_.enableShareLinks !== 'boolean') {
return true;
}
return serviceConfig_.enableShareLinks;
}
/**
* Return any defined standalone URI for this `annotation`, preferably the
* `incontext` URI, but fallback to `html` link if not present.
*
* @param {object} annotation
* @return {string|undefined}
*/
function shareURI(annotation) {
const links = annotation.links;
return links && (links.incontext || links.html);
}
/**
* For an annotation to be "shareable", sharing links need to be enabled overall
* and the annotation itself needs to have a sharing URI.
*
* @param {object} annotation
* @param {object} settings
* @return {boolean}
*/
function isShareable(annotation, settings) {
return !!(sharingEnabled(settings) && shareURI(annotation));
}
module.exports = {
sharingEnabled,
shareURI,
isShareable,
};
'use strict';
const sharingUtil = require('../annotation-sharing');
describe('sidebar.util.annotation-sharing', () => {
let fakeAnnotation;
let fakeServiceConfig;
let fakeServiceSettings;
beforeEach(() => {
fakeServiceSettings = {};
fakeServiceConfig = sinon.stub().returns(fakeServiceSettings);
fakeAnnotation = {
links: {
incontext: 'https://www.example.com',
html: 'https://www.example2.com',
},
};
sharingUtil.$imports.$mock({
'../service-config': fakeServiceConfig,
});
});
afterEach(() => {
sharingUtil.$imports.$restore();
});
describe('#annotationSharingEnabled', () => {
it('returns true if no service settings present', () => {
fakeServiceConfig.returns(null);
assert.isTrue(sharingUtil.sharingEnabled({}));
});
it('returns true if service settings do not have a `enableShareLinks` prop', () => {
// service config is an empty object
assert.isTrue(sharingUtil.sharingEnabled({}));
});
it('returns true if service settings `enableShareLinks` is non-boolean', () => {
fakeServiceConfig.returns({ enableShareLinks: 'foo' });
assert.isTrue(sharingUtil.sharingEnabled({}));
});
it('returns false if service settings really sets it to nope', () => {
fakeServiceConfig.returns({ enableShareLinks: false });
assert.isFalse(sharingUtil.sharingEnabled({}));
});
});
describe('#shareURI', () => {
it('returns `incontext` link if set on annotation', () => {
assert.equal(
sharingUtil.shareURI(fakeAnnotation),
'https://www.example.com'
);
});
it('returns `html` link if `incontext` link not set on annotation', () => {
delete fakeAnnotation.links.incontext;
assert.equal(
sharingUtil.shareURI(fakeAnnotation),
'https://www.example2.com'
);
});
it('returns `undefined` if links empty', () => {
delete fakeAnnotation.links.incontext;
delete fakeAnnotation.links.html;
assert.isUndefined(sharingUtil.shareURI(fakeAnnotation));
});
it('returns `undefined` if no links on annotation', () => {
delete fakeAnnotation.links;
assert.isUndefined(sharingUtil.shareURI(fakeAnnotation));
});
});
describe('#isShareable', () => {
it('returns `true` if sharing enabled and there is a share link available', () => {
fakeServiceConfig.returns(null);
assert.isTrue(sharingUtil.isShareable(fakeAnnotation, {}));
});
it('returns `false` if sharing not enabled', () => {
fakeServiceConfig.returns({ enableShareLinks: false });
assert.isFalse(sharingUtil.isShareable(fakeAnnotation, {}));
});
it('returns `false` if no sharing link available on annotation', () => {
fakeServiceConfig.returns(null);
delete fakeAnnotation.links;
assert.isFalse(sharingUtil.isShareable(fakeAnnotation, {}));
});
});
});
.annotation-action-bar {
display: flex;
}
......@@ -4,7 +4,7 @@
.annotation-action-button {
@include buttons.button-base;
&--active {
&.is-active {
color: var.$brand;
&:hover {
......
......@@ -25,6 +25,7 @@
// Components
// ----------
@use './components/action-button';
@use './components/annotation-action-bar';
@use './components/annotation-action-button';
@use './components/annotation';
@use './components/annotation-body';
......
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