Commit 0aa62e16 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Do not link to documents for third-party annotations

- Refactor `ShowDocumentInfo`: don't link to documents for third-party
  annotations. This is a possibly-temporary measure to prevent linking
  to documents that are not directly accessible, e.g. in an LMS context.
- Remove unnecessary forwarding of `showDocumentInfo` through several
  components.
- Centralize some logic in `AnnotationHeader` and make `ShowDocumentInfo`
  a dumb component.
parent 72208f96
...@@ -26,7 +26,6 @@ import AnnotationReplyToggle from './AnnotationReplyToggle'; ...@@ -26,7 +26,6 @@ import AnnotationReplyToggle from './AnnotationReplyToggle';
* @prop {boolean} isReply * @prop {boolean} isReply
* @prop {VoidFunction} onToggleReplies - Callback to expand/collapse reply threads * @prop {VoidFunction} onToggleReplies - Callback to expand/collapse reply threads
* @prop {number} replyCount - Number of replies to this annotation's thread * @prop {number} replyCount - Number of replies to this annotation's thread
* @prop {boolean} showDocumentInfo - Should extended document info be rendered (e.g. in non-sidebar contexts)?
* @prop {boolean} threadIsCollapsed - Is the thread to which this annotation belongs currently collapsed? * @prop {boolean} threadIsCollapsed - Is the thread to which this annotation belongs currently collapsed?
* @prop {Object} annotationsService - Injected service * @prop {Object} annotationsService - Injected service
*/ */
...@@ -42,7 +41,6 @@ function Annotation({ ...@@ -42,7 +41,6 @@ function Annotation({
isReply, isReply,
onToggleReplies, onToggleReplies,
replyCount, replyCount,
showDocumentInfo,
threadIsCollapsed, threadIsCollapsed,
annotationsService, annotationsService,
}) { }) {
...@@ -76,7 +74,6 @@ function Annotation({ ...@@ -76,7 +74,6 @@ function Annotation({
annotation={annotation} annotation={annotation}
isEditing={isEditing} isEditing={isEditing}
replyCount={replyCount} replyCount={replyCount}
showDocumentInfo={showDocumentInfo}
threadIsCollapsed={threadIsCollapsed} threadIsCollapsed={threadIsCollapsed}
/> />
...@@ -130,7 +127,6 @@ Annotation.propTypes = { ...@@ -130,7 +127,6 @@ Annotation.propTypes = {
isReply: propTypes.bool, isReply: propTypes.bool,
onToggleReplies: propTypes.func, onToggleReplies: propTypes.func,
replyCount: propTypes.number.isRequired, replyCount: propTypes.number.isRequired,
showDocumentInfo: propTypes.bool.isRequired,
threadIsCollapsed: propTypes.bool.isRequired, threadIsCollapsed: propTypes.bool.isRequired,
annotationsService: propTypes.object.isRequired, annotationsService: propTypes.object.isRequired,
}; };
......
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import * as annotationMetadata from '../../helpers/annotation-metadata';
/** @typedef {import("../../../types/api").Annotation} Annotation */ /** @typedef {import("../../../types/api").Annotation} Annotation */
/** /**
* @typedef AnnotationDocumentInfoProps * @typedef AnnotationDocumentInfoProps
* @prop {Annotation} annotation - Annotation for which the document metadata will be rendered * @prop {string} [domain] - The domain associated with the document
* @prop {string} [link] - A link to the document (directly)
* @prop {string} title - The document's title
*/ */
/** /**
...@@ -15,27 +15,17 @@ import * as annotationMetadata from '../../helpers/annotation-metadata'; ...@@ -15,27 +15,17 @@ import * as annotationMetadata from '../../helpers/annotation-metadata';
* *
* @param {AnnotationDocumentInfoProps} props * @param {AnnotationDocumentInfoProps} props
*/ */
export default function AnnotationDocumentInfo({ annotation }) { export default function AnnotationDocumentInfo({ domain, link, title }) {
const documentInfo = annotationMetadata.domainAndTitle(annotation);
// If there's no document title, nothing to do here
if (!documentInfo.titleText) {
return null;
}
return ( return (
<div className="AnnotationDocumentInfo u-layout-row u-horizontal-rhythm"> <div className="AnnotationDocumentInfo u-layout-row u-horizontal-rhythm">
<div className="AnnotationDocumentInfo__title u-color-text--muted"> <div className="AnnotationDocumentInfo__title u-color-text--muted">
on &quot; on &quot;
{documentInfo.titleLink ? ( {link ? <a href={link}>{title}</a> : <span>{title}</span>}
<a href={documentInfo.titleLink}>{documentInfo.titleText}</a>
) : (
<span>{documentInfo.titleText}</span>
)}
&quot; &quot;
</div> </div>
{documentInfo.domain && ( {domain && (
<div className="AnnotationDocumentInfo__domain u-color-text--muted"> <div className="AnnotationDocumentInfo__domain u-color-text--muted">
({documentInfo.domain}) ({domain})
</div> </div>
)} )}
</div> </div>
...@@ -43,5 +33,7 @@ export default function AnnotationDocumentInfo({ annotation }) { ...@@ -43,5 +33,7 @@ export default function AnnotationDocumentInfo({ annotation }) {
} }
AnnotationDocumentInfo.propTypes = { AnnotationDocumentInfo.propTypes = {
annotation: propTypes.object.isRequired, domain: propTypes.string,
link: propTypes.string,
title: propTypes.string.isRequired,
}; };
...@@ -4,6 +4,7 @@ import propTypes from 'prop-types'; ...@@ -4,6 +4,7 @@ import propTypes from 'prop-types';
import { useStoreProxy } from '../../store/use-store'; import { useStoreProxy } from '../../store/use-store';
import { import {
domainAndTitle,
isHighlight, isHighlight,
isReply, isReply,
hasBeenEdited, hasBeenEdited,
...@@ -26,9 +27,6 @@ import AnnotationUser from './AnnotationUser'; ...@@ -26,9 +27,6 @@ import AnnotationUser from './AnnotationUser';
* @prop {Annotation} annotation * @prop {Annotation} annotation
* @prop {boolean} [isEditing] - Whether the annotation is actively being edited * @prop {boolean} [isEditing] - Whether the annotation is actively being edited
* @prop {number} replyCount - How many replies this annotation currently has * @prop {number} replyCount - How many replies this annotation currently has
* @prop {boolean} [showDocumentInfo] -
* Should document metadata be rendered? Hint: this is enabled for single annotation
* and stream views.
* @prop {boolean} threadIsCollapsed - Is this thread currently collapsed? * @prop {boolean} threadIsCollapsed - Is this thread currently collapsed?
*/ */
...@@ -43,7 +41,6 @@ export default function AnnotationHeader({ ...@@ -43,7 +41,6 @@ export default function AnnotationHeader({
annotation, annotation,
isEditing, isEditing,
replyCount, replyCount,
showDocumentInfo,
threadIsCollapsed, threadIsCollapsed,
}) { }) {
const store = useStoreProxy(); const store = useStoreProxy();
...@@ -61,6 +58,13 @@ export default function AnnotationHeader({ ...@@ -61,6 +58,13 @@ export default function AnnotationHeader({
const showReplyButton = replyCount > 0 && isCollapsedReply; const showReplyButton = replyCount > 0 && isCollapsedReply;
const showExtendedInfo = !isReply(annotation); const showExtendedInfo = !isReply(annotation);
const annotationUrl = annotation.links?.html || '';
const documentInfo = domainAndTitle(annotation);
const documentLink =
annotationUrl && documentInfo.titleLink ? documentInfo.titleLink : '';
const showDocumentInfo =
store.route() !== 'sidebar' && documentInfo.titleText;
const onReplyCountClick = () => const onReplyCountClick = () =>
// If an annotation has replies it must have been saved and therefore have // If an annotation has replies it must have been saved and therefore have
// an ID. // an ID.
...@@ -110,7 +114,11 @@ export default function AnnotationHeader({ ...@@ -110,7 +114,11 @@ export default function AnnotationHeader({
</div> </div>
)} )}
{showDocumentInfo && ( {showDocumentInfo && (
<AnnotationDocumentInfo annotation={annotation} /> <AnnotationDocumentInfo
domain={documentInfo.domain}
link={documentLink}
title={documentInfo.titleText}
/>
)} )}
</div> </div>
)} )}
...@@ -122,6 +130,5 @@ AnnotationHeader.propTypes = { ...@@ -122,6 +130,5 @@ AnnotationHeader.propTypes = {
annotation: propTypes.object.isRequired, annotation: propTypes.object.isRequired,
isEditing: propTypes.bool, isEditing: propTypes.bool,
replyCount: propTypes.number, replyCount: propTypes.number,
showDocumentInfo: propTypes.bool,
threadIsCollapsed: propTypes.bool.isRequired, threadIsCollapsed: propTypes.bool.isRequired,
}; };
...@@ -35,7 +35,6 @@ describe('Annotation', () => { ...@@ -35,7 +35,6 @@ describe('Annotation', () => {
isReply={false} isReply={false}
onToggleReplies={fakeOnToggleReplies} onToggleReplies={fakeOnToggleReplies}
replyCount={0} replyCount={0}
showDocumentInfo={false}
threadIsCollapsed={true} threadIsCollapsed={true}
{...props} {...props}
/> />
......
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import * as fixtures from '../../../test/annotation-fixtures';
import { checkAccessibility } from '../../../../test-util/accessibility'; import { checkAccessibility } from '../../../../test-util/accessibility';
import mockImportedComponents from '../../../../test-util/mock-imported-components';
import AnnotationDocumentInfo, { $imports } from '../AnnotationDocumentInfo'; import AnnotationDocumentInfo from '../AnnotationDocumentInfo';
describe('AnnotationDocumentInfo', () => { describe('AnnotationDocumentInfo', () => {
let fakeDomainAndTitle;
let fakeMetadata;
const createAnnotationDocumentInfo = props => { const createAnnotationDocumentInfo = props => {
return mount( return mount(
<AnnotationDocumentInfo <AnnotationDocumentInfo
annotation={fixtures.defaultAnnotation()} domain="www.foo.bar"
link="http://www.baz"
title="Turtles"
{...props} {...props}
/> />
); );
}; };
beforeEach(() => {
fakeDomainAndTitle = sinon.stub();
fakeMetadata = { domainAndTitle: fakeDomainAndTitle };
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../helpers/annotation-metadata': fakeMetadata,
});
});
afterEach(() => {
$imports.$restore();
});
it('should not render if there is no document title', () => {
fakeDomainAndTitle.returns({});
const wrapper = createAnnotationDocumentInfo();
const info = wrapper.find('.AnnotationDocumentInfo');
assert.notOk(info.exists());
});
it('should render the document title', () => { it('should render the document title', () => {
fakeDomainAndTitle.returns({ titleText: 'I have a title' });
const wrapper = createAnnotationDocumentInfo(); const wrapper = createAnnotationDocumentInfo();
const info = wrapper.find('.AnnotationDocumentInfo'); const info = wrapper.find('.AnnotationDocumentInfo__title');
assert.isOk(info.exists()); assert.equal(info.text(), 'on "Turtles"');
}); });
it('should render a link if available', () => { it('should render a link if available', () => {
fakeDomainAndTitle.returns({
titleText: 'I have a title',
titleLink: 'https://www.example.com',
});
const wrapper = createAnnotationDocumentInfo(); const wrapper = createAnnotationDocumentInfo();
const link = wrapper.find('.AnnotationDocumentInfo__title a'); const link = wrapper.find('.AnnotationDocumentInfo__title a');
assert.equal(link.prop('href'), 'https://www.example.com'); assert.equal(link.prop('href'), 'http://www.baz');
}); });
it('should render domain if available', () => { it('should render domain if available', () => {
fakeDomainAndTitle.returns({
titleText: 'I have a title',
domain: 'www.example.com',
});
const wrapper = createAnnotationDocumentInfo(); const wrapper = createAnnotationDocumentInfo();
const domain = wrapper.find('.AnnotationDocumentInfo__domain'); const domain = wrapper.find('.AnnotationDocumentInfo__domain');
assert.equal(domain.text(), '(www.example.com)'); assert.equal(domain.text(), '(www.foo.bar)');
}); });
it( it(
'should pass a11y checks', 'should pass a11y checks',
checkAccessibility({ checkAccessibility({
content: () => { content: () => {
fakeDomainAndTitle.returns({
titleText: 'I have a title',
domain: 'www.example.com',
});
return createAnnotationDocumentInfo(); return createAnnotationDocumentInfo();
}, },
}) })
......
...@@ -8,6 +8,7 @@ import mockImportedComponents from '../../../../test-util/mock-imported-componen ...@@ -8,6 +8,7 @@ import mockImportedComponents from '../../../../test-util/mock-imported-componen
import AnnotationHeader, { $imports } from '../AnnotationHeader'; import AnnotationHeader, { $imports } from '../AnnotationHeader';
describe('AnnotationHeader', () => { describe('AnnotationHeader', () => {
let fakeDomainAndTitle;
let fakeIsHighlight; let fakeIsHighlight;
let fakeIsReply; let fakeIsReply;
let fakeHasBeenEdited; let fakeHasBeenEdited;
...@@ -20,7 +21,6 @@ describe('AnnotationHeader', () => { ...@@ -20,7 +21,6 @@ describe('AnnotationHeader', () => {
annotation={fixtures.defaultAnnotation()} annotation={fixtures.defaultAnnotation()}
isEditing={false} isEditing={false}
replyCount={0} replyCount={0}
showDocumentInfo={false}
threadIsCollapsed={false} threadIsCollapsed={false}
{...props} {...props}
/> />
...@@ -28,12 +28,14 @@ describe('AnnotationHeader', () => { ...@@ -28,12 +28,14 @@ describe('AnnotationHeader', () => {
}; };
beforeEach(() => { beforeEach(() => {
fakeDomainAndTitle = sinon.stub().returns({});
fakeIsHighlight = sinon.stub().returns(false); fakeIsHighlight = sinon.stub().returns(false);
fakeIsReply = sinon.stub().returns(false); fakeIsReply = sinon.stub().returns(false);
fakeHasBeenEdited = sinon.stub().returns(false); fakeHasBeenEdited = sinon.stub().returns(false);
fakeIsPrivate = sinon.stub(); fakeIsPrivate = sinon.stub();
fakeStore = { fakeStore = {
route: sinon.stub().returns('sidebar'),
setExpanded: sinon.stub(), setExpanded: sinon.stub(),
}; };
...@@ -41,6 +43,7 @@ describe('AnnotationHeader', () => { ...@@ -41,6 +43,7 @@ describe('AnnotationHeader', () => {
$imports.$mock({ $imports.$mock({
'../../store/use-store': { useStoreProxy: () => fakeStore }, '../../store/use-store': { useStoreProxy: () => fakeStore },
'../../helpers/annotation-metadata': { '../../helpers/annotation-metadata': {
domainAndTitle: fakeDomainAndTitle,
isHighlight: fakeIsHighlight, isHighlight: fakeIsHighlight,
isReply: fakeIsReply, isReply: fakeIsReply,
hasBeenEdited: fakeHasBeenEdited, hasBeenEdited: fakeHasBeenEdited,
...@@ -251,21 +254,84 @@ describe('AnnotationHeader', () => { ...@@ -251,21 +254,84 @@ describe('AnnotationHeader', () => {
}); });
describe('annotation document info', () => { describe('annotation document info', () => {
it('should render document info if `showDocumentInfo` is enabled', () => { const fakeDocumentInfo = {
const wrapper = createAnnotationHeader({ showDocumentInfo: true }); titleText: 'This document',
titleLink: 'http://www.example.com',
domain: 'www.foo.com',
};
beforeEach(() => {
fakeDomainAndTitle.returns(fakeDocumentInfo);
});
it('should not render document info if on sidebar route', () => {
fakeStore.route.returns('sidebar');
const wrapper = createAnnotationHeader();
const documentInfo = wrapper.find('AnnotationDocumentInfo'); const documentInfo = wrapper.find('AnnotationDocumentInfo');
assert.isTrue(documentInfo.exists()); assert.isFalse(documentInfo.exists());
}); });
it('should not render document info if `showDocumentInfo` is not enabled', () => { it('should not render document info if document does not have a title', () => {
const wrapper = createAnnotationHeader({ showDocumentInfo: false }); fakeStore.route.returns('notebook');
fakeDomainAndTitle.returns({});
const wrapper = createAnnotationHeader();
const documentInfo = wrapper.find('AnnotationDocumentInfo'); const documentInfo = wrapper.find('AnnotationDocumentInfo');
assert.isFalse(documentInfo.exists()); assert.isFalse(documentInfo.exists());
}); });
[
{
route: 'notebook',
documentInfo: fakeDocumentInfo,
expectedPresence: true,
},
{ route: 'notebook', documentInfo: {}, expectedPresence: false },
{
route: 'sidebar',
documentInfo: fakeDocumentInfo,
expectedPresence: false,
},
].forEach(testCase => {
it('should render document info if document info available and not on sidebar route', () => {
fakeStore.route.returns(testCase.route);
fakeDomainAndTitle.returns(testCase.documentInfo);
const wrapper = createAnnotationHeader();
const documentInfo = wrapper.find('AnnotationDocumentInfo');
assert.equal(documentInfo.exists(), testCase.expectedPresence);
});
});
it('should set document properties as props to `AnnotationDocumentInfo`', () => {
fakeStore.route.returns('notebook');
const wrapper = createAnnotationHeader();
const documentInfo = wrapper.find('AnnotationDocumentInfo');
assert.isTrue(documentInfo.exists());
assert.equal(documentInfo.props().title, 'This document');
// Link is not set because Annotation prop (default fixture) doesn't
// have a URL (html link)
assert.equal(documentInfo.props().link, '');
assert.equal(documentInfo.props().domain, 'www.foo.com');
});
it('should provide document link for document info if annotation has an HTML link/URL', () => {
const annotation = fixtures.defaultAnnotation();
annotation.links = { html: 'http://www.whatever' };
fakeStore.route.returns('notebook');
const wrapper = createAnnotationHeader({ annotation });
const documentInfo = wrapper.find('AnnotationDocumentInfo');
assert.equal(documentInfo.props().link, 'http://www.example.com');
});
}); });
}); });
......
...@@ -14,7 +14,6 @@ import ModerationBanner from './ModerationBanner'; ...@@ -14,7 +14,6 @@ import ModerationBanner from './ModerationBanner';
/** /**
* @typedef ThreadProps * @typedef ThreadProps
* @prop {boolean} [showDocumentInfo]
* @prop {Thread} thread * @prop {Thread} thread
* @prop {Object} threadsService - Injected service * @prop {Object} threadsService - Injected service
*/ */
...@@ -26,7 +25,7 @@ import ModerationBanner from './ModerationBanner'; ...@@ -26,7 +25,7 @@ import ModerationBanner from './ModerationBanner';
* *
* @param {ThreadProps} props * @param {ThreadProps} props
*/ */
function Thread({ showDocumentInfo = false, thread, threadsService }) { function Thread({ thread, threadsService }) {
// Render this thread's replies only if the thread is expanded // Render this thread's replies only if the thread is expanded
const showChildren = !thread.collapsed; const showChildren = !thread.collapsed;
...@@ -68,7 +67,6 @@ function Thread({ showDocumentInfo = false, thread, threadsService }) { ...@@ -68,7 +67,6 @@ function Thread({ showDocumentInfo = false, thread, threadsService }) {
isReply={!!thread.parent} isReply={!!thread.parent}
onToggleReplies={onToggleReplies} onToggleReplies={onToggleReplies}
replyCount={thread.replyCount} replyCount={thread.replyCount}
showDocumentInfo={showDocumentInfo}
threadIsCollapsed={thread.collapsed} threadIsCollapsed={thread.collapsed}
/> />
</> </>
...@@ -76,7 +74,6 @@ function Thread({ showDocumentInfo = false, thread, threadsService }) { ...@@ -76,7 +74,6 @@ function Thread({ showDocumentInfo = false, thread, threadsService }) {
[ [
hasAppliedFilter, hasAppliedFilter,
onToggleReplies, onToggleReplies,
showDocumentInfo,
thread.annotation, thread.annotation,
thread.parent, thread.parent,
thread.replyCount, thread.replyCount,
...@@ -129,7 +126,6 @@ function Thread({ showDocumentInfo = false, thread, threadsService }) { ...@@ -129,7 +126,6 @@ function Thread({ showDocumentInfo = false, thread, threadsService }) {
} }
Thread.propTypes = { Thread.propTypes = {
showDocumentInfo: propTypes.bool,
thread: propTypes.object.isRequired, thread: propTypes.object.isRequired,
// Injected // Injected
......
...@@ -28,7 +28,6 @@ function ThreadCard({ frameSync, thread }) { ...@@ -28,7 +28,6 @@ function ThreadCard({ frameSync, thread }) {
const store = useStoreProxy(); const store = useStoreProxy();
const threadTag = thread.annotation && thread.annotation.$tag; const threadTag = thread.annotation && thread.annotation.$tag;
const isFocused = store.isAnnotationFocused(threadTag); const isFocused = store.isAnnotationFocused(threadTag);
const showDocumentInfo = store.route() !== 'sidebar';
const focusThreadAnnotation = useMemo( const focusThreadAnnotation = useMemo(
() => () =>
debounce(tag => { debounce(tag => {
...@@ -57,10 +56,7 @@ function ThreadCard({ frameSync, thread }) { ...@@ -57,10 +56,7 @@ function ThreadCard({ frameSync, thread }) {
// Memoize threads to reduce avoid re-rendering when something changes in a // Memoize threads to reduce avoid re-rendering when something changes in a
// parent component but the `Thread` itself has not changed. // parent component but the `Thread` itself has not changed.
const threadContent = useMemo( const threadContent = useMemo(() => <Thread thread={thread} />, [thread]);
() => <Thread thread={thread} showDocumentInfo={showDocumentInfo} />,
[thread, showDocumentInfo]
);
return ( return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
......
...@@ -70,7 +70,6 @@ describe('Thread', () => { ...@@ -70,7 +70,6 @@ describe('Thread', () => {
const createComponent = props => { const createComponent = props => {
return mount( return mount(
<Thread <Thread
showDocumentInfo={false}
thread={createThread()} thread={createThread()}
threadsService={fakeThreadsService} threadsService={fakeThreadsService}
{...props} {...props}
......
...@@ -58,22 +58,6 @@ describe('ThreadCard', () => { ...@@ -58,22 +58,6 @@ describe('ThreadCard', () => {
assert(wrapper.find('.ThreadCard').hasClass('is-focused')); assert(wrapper.find('.ThreadCard').hasClass('is-focused'));
}); });
it('shows document info if current route is not sidebar', () => {
fakeStore.route.returns('whatever');
const wrapper = createComponent();
assert.isTrue(wrapper.find('Thread').props().showDocumentInfo);
});
it('does not show document info if current route is sidebar', () => {
fakeStore.route.returns('sidebar');
const wrapper = createComponent();
assert.isFalse(wrapper.find('Thread').props().showDocumentInfo);
});
describe('mouse and click events', () => { describe('mouse and click events', () => {
it('scrolls to the annotation when the `ThreadCard` is clicked', () => { it('scrolls to the annotation when the `ThreadCard` is clicked', () => {
const wrapper = createComponent(); const wrapper = createComponent();
......
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