Unverified Commit 61f44ff4 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #2183 from hypothesis/single-annotation-handle-404

Improve handling of annotation fetch error in single annotation view
parents ea35d0e5 23a13091
import { createElement } from 'preact'; import { Fragment, createElement } from 'preact';
import { useEffect } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import useStore from '../store/use-store'; import useStore from '../store/use-store';
import { withServices } from '../util/service-context'; import { withServices } from '../util/service-context';
import ThreadList from './thread-list'; import ThreadList from './thread-list';
import SidebarContentError from './sidebar-content-error';
/** /**
* The main content for the single annotation page (aka. https://hypothes.is/a/<annotation ID>) * The main content for the single annotation page (aka. https://hypothes.is/a/<annotation ID>)
*/ */
function AnnotationViewerContent({ function AnnotationViewerContent({
api, api,
onLogin,
rootThread: rootThreadService, rootThread: rootThreadService,
streamer, streamer,
streamFilter, streamFilter,
...@@ -24,12 +26,16 @@ function AnnotationViewerContent({ ...@@ -24,12 +26,16 @@ function AnnotationViewerContent({
rootThreadService.thread(store.getState()) rootThreadService.thread(store.getState())
); );
const setCollapsed = useStore(store => store.setCollapsed); const setCollapsed = useStore(store => store.setCollapsed);
const userid = useStore(store => store.profile().userid);
const [fetchError, setFetchError] = useState(false);
useEffect(() => { useEffect(() => {
setFetchError(false);
clearAnnotations(); clearAnnotations();
// TODO - Handle exceptions during the `fetchThread` call. fetchThread(api, annotationId)
fetchThread(api, annotationId).then(annots => { .then(annots => {
addAnnotations(annots); addAnnotations(annots);
// Find the top-level annotation in the thread that `annotationId` is // Find the top-level annotation in the thread that `annotationId` is
...@@ -70,10 +76,17 @@ function AnnotationViewerContent({ ...@@ -70,10 +76,17 @@ function AnnotationViewerContent({
if (topLevelAnnot.id !== annotationId) { if (topLevelAnnot.id !== annotationId) {
highlightAnnotations([annotationId]); highlightAnnotations([annotationId]);
} }
})
.catch(() => {
setFetchError(true);
}); });
}, [ }, [
annotationId, annotationId,
// This is not used by the effect but ensures that the annotation is
// fetched after the user logs in/out, in case the annotation is private.
userid,
// Static dependencies. // Static dependencies.
addAnnotations, addAnnotations,
api, api,
...@@ -84,10 +97,22 @@ function AnnotationViewerContent({ ...@@ -84,10 +97,22 @@ function AnnotationViewerContent({
streamer, streamer,
]); ]);
return <ThreadList thread={rootThread} />; return (
<Fragment>
{fetchError && (
// This is the same error shown if a direct-linked annotation cannot
// be fetched in the sidebar. Fortunately the error message makes sense
// for this scenario as well.
<SidebarContentError errorType="annotation" onLoginRequest={onLogin} />
)}
<ThreadList thread={rootThread} />
</Fragment>
);
} }
AnnotationViewerContent.propTypes = { AnnotationViewerContent.propTypes = {
onLogin: propTypes.func.isRequired,
// Injected. // Injected.
api: propTypes.object, api: propTypes.object,
rootThread: propTypes.object, rootThread: propTypes.object,
......
...@@ -168,7 +168,9 @@ function HypothesisApp({ ...@@ -168,7 +168,9 @@ function HypothesisApp({
{route && ( {route && (
<main> <main>
{route === 'annotation' && <AnnotationViewerContent />} {route === 'annotation' && (
<AnnotationViewerContent onLogin={login} />
)}
{route === 'stream' && <StreamContent />} {route === 'stream' && <StreamContent />}
{route === 'sidebar' && ( {route === 'sidebar' && (
<SidebarContent onLogin={login} onSignUp={signUp} /> <SidebarContent onLogin={login} onSignUp={signUp} />
......
...@@ -7,9 +7,14 @@ import Button from './button'; ...@@ -7,9 +7,14 @@ import Button from './button';
import SvgIcon from '../../shared/components/svg-icon'; import SvgIcon from '../../shared/components/svg-icon';
/** /**
* An error message to display in the sidebar. * Show an error indicating that an annotation or group referenced in the URL
* could not be fetched.
*/ */
export default function SidebarContentError({ errorType, onLoginRequest }) { export default function SidebarContentError({
errorType,
onLoginRequest,
showClearSelection = false,
}) {
const clearSelection = useStore(store => store.clearSelection); const clearSelection = useStore(store => store.clearSelection);
const isLoggedIn = useStore(store => store.isLoggedIn()); const isLoggedIn = useStore(store => store.isLoggedIn());
...@@ -42,12 +47,14 @@ export default function SidebarContentError({ errorType, onLoginRequest }) { ...@@ -42,12 +47,14 @@ export default function SidebarContentError({ errorType, onLoginRequest }) {
<div className="sidebar-content-error__content"> <div className="sidebar-content-error__content">
<p>{errorMessage}</p> <p>{errorMessage}</p>
<div className="sidebar-content-error__actions"> <div className="sidebar-content-error__actions">
{showClearSelection && (
<Button <Button
buttonText="Show all annotations" buttonText="Show all annotations"
className="sidebar-content-error__button" className="sidebar-content-error__button"
onClick={clearSelection} onClick={clearSelection}
usePrimaryStyle={isLoggedIn} usePrimaryStyle={isLoggedIn}
/> />
)}
{!isLoggedIn && ( {!isLoggedIn && (
<Button <Button
buttonText="Log in" buttonText="Log in"
...@@ -64,6 +71,12 @@ export default function SidebarContentError({ errorType, onLoginRequest }) { ...@@ -64,6 +71,12 @@ export default function SidebarContentError({ errorType, onLoginRequest }) {
SidebarContentError.propTypes = { SidebarContentError.propTypes = {
errorType: propTypes.oneOf(['annotation', 'group']), errorType: propTypes.oneOf(['annotation', 'group']),
/**
* Whether to show a "Clear selection" button.
*/
showClearSelection: propTypes.bool,
/* A function that will launch the login flow for the user. */ /* A function that will launch the login flow for the user. */
onLoginRequest: propTypes.func.isRequired, onLoginRequest: propTypes.func.isRequired,
}; };
...@@ -131,7 +131,11 @@ function SidebarContent({ ...@@ -131,7 +131,11 @@ function SidebarContent({
{isFocusedMode && <FocusedModeHeader />} {isFocusedMode && <FocusedModeHeader />}
<LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} /> <LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
{hasDirectLinkedAnnotationError && ( {hasDirectLinkedAnnotationError && (
<SidebarContentError errorType="annotation" onLoginRequest={onLogin} /> <SidebarContentError
errorType="annotation"
onLoginRequest={onLogin}
showClearSelection={true}
/>
)} )}
{hasDirectLinkedGroupError && ( {hasDirectLinkedGroupError && (
<SidebarContentError errorType="group" onLoginRequest={onLogin} /> <SidebarContentError errorType="group" onLoginRequest={onLogin} />
......
...@@ -44,6 +44,7 @@ describe('AnnotationViewerContent', () => { ...@@ -44,6 +44,7 @@ describe('AnnotationViewerContent', () => {
getState: sinon.stub().returns({}), getState: sinon.stub().returns({}),
highlightAnnotations: sinon.stub(), highlightAnnotations: sinon.stub(),
routeParams: sinon.stub().returns({ id: 'test_annotation_id' }), routeParams: sinon.stub().returns({ id: 'test_annotation_id' }),
profile: sinon.stub().returns({ userid: null }),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
}; };
...@@ -73,10 +74,11 @@ describe('AnnotationViewerContent', () => { ...@@ -73,10 +74,11 @@ describe('AnnotationViewerContent', () => {
$imports.$restore(); $imports.$restore();
}); });
function createComponent({ api }) { function createComponent({ api, onLogin = sinon.stub() }) {
return mount( return mount(
<AnnotationViewerContent <AnnotationViewerContent
api={api} api={api}
onLogin={onLogin}
rootThread={fakeRootThread} rootThread={fakeRootThread}
streamer={fakeStreamer} streamer={fakeStreamer}
streamFilter={fakeStreamFilter} streamFilter={fakeStreamFilter}
...@@ -116,6 +118,36 @@ describe('AnnotationViewerContent', () => { ...@@ -116,6 +118,36 @@ describe('AnnotationViewerContent', () => {
assert.notCalled(fakeStore.highlightAnnotations); assert.notCalled(fakeStore.highlightAnnotations);
}); });
it('shows an error if the annotation could not be fetched', async () => {
const fakeApi = new FakeApi([]);
const onLogin = sinon.stub();
const wrapper = createComponent({ api: fakeApi, onLogin });
// Initially the annotation is not available to the user, so an error
// should be shown.
await waitFor(() => {
wrapper.update();
return wrapper.exists('SidebarContentError');
});
// Simulate clicking the "Login" button in the error.
const onLoginRequest = wrapper
.find('SidebarContentError')
.prop('onLoginRequest');
onLoginRequest();
assert.called(onLogin);
// After the user logs in, the annotation should be shown.
fakeApi.annotations = [{ id: 'test_annotation_id' }];
fakeStore.profile.returns({ userid: 'acct:jimsmith@hypothes.is' });
// Force re-render. `useStore` would do this in the actual app.
wrapper.setProps({});
await waitForAnnotationsToLoad();
assert.isFalse(wrapper.exists('SidebarContentError'));
});
}); });
describe('the standalone view for a reply', () => { describe('the standalone view for a reply', () => {
......
...@@ -47,7 +47,10 @@ describe('SidebarContentError', () => { ...@@ -47,7 +47,10 @@ describe('SidebarContentError', () => {
it('should provide a button to clear the selection (show all annotations)', () => { it('should provide a button to clear the selection (show all annotations)', () => {
const fakeOnLogin = sinon.stub(); const fakeOnLogin = sinon.stub();
const wrapper = createComponent({ onLoginRequest: fakeOnLogin }); const wrapper = createComponent({
onLoginRequest: fakeOnLogin,
showClearSelection: true,
});
const clearButton = findButtonByText(wrapper, 'Show all annotations'); const clearButton = findButtonByText(wrapper, 'Show all annotations');
assert.isTrue(clearButton.exists()); assert.isTrue(clearButton.exists());
......
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