Commit 23a13091 authored by Robert Knight's avatar Robert Knight

Improve handling of annotation fetch error in single annotation view

Previously if the user visited a `/a/<ID>` URL and the request to fetch
the annotation failed with a 404 then the user would see a blank page.
If the fetch failed because the annotation was private, logging in did
not cause the annotation to be fetched.

This commit resolves the two issues:

 - When the logged-in user changes, re-fetch the annotation
 - If the annotation fetch fails, an "Annotation unavailable" message is
   shown. This currently repurposes the error that is shown when a
   direct-linked annotation fetch fails, but without the "Clear
   selection" button.

This commit also fixes an issue where a direct link to a group that
could not be fetched would show a "Clear selection" button even though
there was no active selection to clear. That button is only needed when
following a direct link to an annotation.
parent ea35d0e5
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,56 +26,67 @@ function AnnotationViewerContent({ ...@@ -24,56 +26,67 @@ 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
// part of. This will be different to `annotationId` if `annotationId` // part of. This will be different to `annotationId` if `annotationId`
// is a reply. // is a reply.
const topLevelAnnot = annots.filter( const topLevelAnnot = annots.filter(
ann => (ann.references || []).length === 0 ann => (ann.references || []).length === 0
)[0]; )[0];
if (!topLevelAnnot) { if (!topLevelAnnot) {
// We were able to fetch annotations in the thread that `annotationId` // We were able to fetch annotations in the thread that `annotationId`
// is part of (note that `annotationId` may refer to a reply) but // is part of (note that `annotationId` may refer to a reply) but
// couldn't find a top-level (non-reply) annotation in that thread. // couldn't find a top-level (non-reply) annotation in that thread.
// //
// This might happen if the top-level annotation was deleted or // This might happen if the top-level annotation was deleted or
// moderated or had its permissions changed. // moderated or had its permissions changed.
// //
// We need to decide what what be the most useful behavior in this case // We need to decide what what be the most useful behavior in this case
// and implement it. // and implement it.
/* istanbul ignore next */ /* istanbul ignore next */
return; return;
} }
// Configure the connection to the real-time update service to send us // Configure the connection to the real-time update service to send us
// updates to any of the annotations in the thread. // updates to any of the annotations in the thread.
streamFilter streamFilter
.addClause('/references', 'one_of', topLevelAnnot.id, true) .addClause('/references', 'one_of', topLevelAnnot.id, true)
.addClause('/id', 'equals', topLevelAnnot.id, true); .addClause('/id', 'equals', topLevelAnnot.id, true);
streamer.setConfig('filter', { filter: streamFilter.getFilter() }); streamer.setConfig('filter', { filter: streamFilter.getFilter() });
streamer.connect(); streamer.connect();
// Make the full thread of annotations visible. By default replies are // Make the full thread of annotations visible. By default replies are
// not shown until the user expands the thread. // not shown until the user expands the thread.
annots.forEach(annot => setCollapsed(annot.id, false)); annots.forEach(annot => setCollapsed(annot.id, false));
// FIXME - This should show a visual indication of which reply the // FIXME - This should show a visual indication of which reply the
// annotation ID in the URL refers to. That isn't currently working. // annotation ID in the URL refers to. That isn't currently working.
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">
<Button {showClearSelection && (
buttonText="Show all annotations" <Button
className="sidebar-content-error__button" buttonText="Show all annotations"
onClick={clearSelection} className="sidebar-content-error__button"
usePrimaryStyle={isLoggedIn} onClick={clearSelection}
/> 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