Unverified Commit 32b086a8 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #2074 from hypothesis/convert-annotation-viewer-content

Convert single annotation page content to Preact
parents 8a0c0852 4feb9fee
import { createElement } from 'preact';
import { useEffect } from 'preact/hooks';
import propTypes from 'prop-types';
import useStore from '../store/use-store';
import { withServices } from '../util/service-context';
import ThreadList from './thread-list';
/** /**
* Fetch all annotations in the same thread as `id`. * The main content for the single annotation page (aka. https://hypothes.is/a/<annotation ID>)
*
* @return Promise<Array<Annotation>>
*/ */
function fetchThread(api, id) { function AnnotationViewerContent({
let annot;
return api.annotation
.get({ id: id })
.then(function (annot) {
if (annot.references && annot.references.length) {
// This is a reply, fetch the top-level annotation
return api.annotation.get({ id: annot.references[0] });
} else {
return annot;
}
})
.then(function (annot_) {
annot = annot_;
return api.search({ references: annot.id });
})
.then(function (searchResult) {
return [annot].concat(searchResult.rows);
});
}
// @ngInject
function AnnotationViewerContentController(
store,
api, api,
rootThread, rootThread: rootThreadService,
streamer, streamer,
streamFilter streamFilter,
) { }) {
store.clearAnnotations(); const addAnnotations = useStore(store => store.addAnnotations);
const annotationId = useStore(store => store.routeParams().id);
const annotationId = store.routeParams().id; const clearAnnotations = useStore(store => store.clearAnnotations);
const highlightAnnotations = useStore(store => store.highlightAnnotations);
const rootThread = useStore(store =>
rootThreadService.thread(store.getState())
);
const setCollapsed = useStore(store => store.setCollapsed);
this.rootThread = () => rootThread.thread(store.getState()); useEffect(() => {
clearAnnotations();
this.setCollapsed = function (id, collapsed) { // TODO - Handle exceptions during the `fetchThread` call.
store.setCollapsed(id, collapsed); fetchThread(api, annotationId).then(annots => {
}; addAnnotations(annots);
this.ready = fetchThread(api, annotationId).then(function (annots) { // Find the top-level annotation in the thread that `annotationId` is
store.addAnnotations(annots); // part of. This will be different to `annotationId` if `annotationId`
// is a reply.
const topLevelAnnot = annots.filter(function (annot) { const topLevelAnnot = annots.filter(
return (annot.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`
// is part of (note that `annotationId` may refer to a reply) but
// couldn't find a top-level (non-reply) annotation in that thread.
//
// This might happen if the top-level annotation was deleted or
// moderated or had its permissions changed.
//
// We need to decide what what be the most useful behavior in this case
// and implement it.
/* istanbul ignore next */
return; return;
} }
// Configure the connection to the real-time update service to send us
// 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();
annots.forEach(function (annot) { // Make the full thread of annotations visible. By default replies are
store.setCollapsed(annot.id, false); // not shown until the user expands the thread.
}); annots.forEach(annot => setCollapsed(annot.id, false));
// FIXME - This should show a visual indication of which reply the
// annotation ID in the URL refers to. That isn't currently working.
if (topLevelAnnot.id !== annotationId) { if (topLevelAnnot.id !== annotationId) {
store.highlightAnnotations([annotationId]); highlightAnnotations([annotationId]);
} }
}); });
}, [
annotationId,
// Static dependencies.
addAnnotations,
api,
clearAnnotations,
highlightAnnotations,
setCollapsed,
streamFilter,
streamer,
]);
return <ThreadList thread={rootThread} />;
} }
export default { AnnotationViewerContent.propTypes = {
controller: AnnotationViewerContentController, // Injected.
controllerAs: 'vm', api: propTypes.object,
bindings: {}, rootThread: propTypes.object,
template: require('../templates/annotation-viewer-content.html'), streamer: propTypes.object,
streamFilter: propTypes.object,
}; };
AnnotationViewerContent.injectedProps = [
'api',
'rootThread',
'streamer',
'streamFilter',
];
// NOTE: The function below is intentionally at the bottom of the file.
//
// Putting it at the top resulted in an issue where the `createElement` import
// wasn't correctly referenced in the body of `AnnotationViewerContent` in
// the compiled JS, causing a runtime error.
/**
* Fetch all annotations in the same thread as `id`.
*
* @param {Object} api - API client
* @param {string} id - Annotation ID. This may be an annotation or a reply.
* @return Promise<Annotation[]> - The annotation, followed by any replies.
*/
async function fetchThread(api, id) {
let annot = await api.annotation.get({ id });
if (annot.references && annot.references.length) {
// This is a reply, fetch the top-level annotation
annot = await api.annotation.get({ id: annot.references[0] });
}
// Fetch all replies to the top-level annotation.
const replySearchResult = await api.search({ references: annot.id });
return [annot, ...replySearchResult.rows];
}
export default withServices(AnnotationViewerContent);
import angular from 'angular'; import { createElement } from 'preact';
import { mount } from 'enzyme';
import annotationViewerContent from '../annotation-viewer-content'; import { waitFor } from '../../../test-util/wait';
import mockImportedComponents from '../../../test-util/mock-imported-components';
// Fake implementation of the API for fetching annotations and replies to import AnnotationViewerContent, {
// annotations. $imports,
function FakeApi(annots) { } from '../annotation-viewer-content';
this.annots = annots;
/**
* Fake implementation of the `api` service.
*/
class FakeApi {
constructor(annots) {
this.annotations = annots;
this.annotation = { this.annotation = {
get: function (query) { get: async query => this.annotations.find(a => a.id === query.id),
let result;
if (query.id) {
result = annots.find(function (a) {
return a.id === query.id;
});
}
return Promise.resolve(result);
},
}; };
}
this.search = function (query) { async search(query) {
let result; let matches = [];
if (query.references) { if (query.references) {
result = annots.filter(function (a) { matches = this.annotations.filter(
return a.references && a.references.indexOf(query.references) !== -1; a => a.references && a.references.includes(query.references)
}); );
}
return { rows: matches };
} }
return Promise.resolve({ rows: result });
};
} }
describe('annotationViewerContent', function () { describe('AnnotationViewerContent', () => {
before(function () { let fakeStore;
angular let fakeRootThread;
.module('h', []) let fakeStreamer;
.component('annotationViewerContent', annotationViewerContent); let fakeStreamFilter;
});
beforeEach(angular.mock.module('h'));
function createController(opts) { beforeEach(() => {
const locals = { fakeStore = {
store: {
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
clearAnnotations: sinon.stub(), clearAnnotations: sinon.stub(),
setCollapsed: sinon.stub(), 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' }),
subscribe: sinon.stub(), setCollapsed: sinon.stub(),
}, };
api: opts.api,
rootThread: { thread: sinon.stub() }, fakeRootThread = { thread: sinon.stub().returns({}) };
streamer: {
setConfig: function () {}, fakeStreamer = {
connect: function () {}, setConfig: () => {},
}, connect: () => {},
streamFilter: { };
addClause: function () {
fakeStreamFilter = {
addClause: () => {
return { return {
addClause: function () {}, addClause: () => {},
}; };
}, },
getFilter: function () {}, getFilter: () => {},
},
}; };
let $componentController; $imports.$mock(mockImportedComponents());
angular.mock.inject(function (_$componentController_) { $imports.$mock({
$componentController = _$componentController_; '../store/use-store': callback => callback(fakeStore),
}); });
locals.ctrl = $componentController('annotationViewerContent', locals, {
search: {},
}); });
return locals;
afterEach(() => {
$imports.$restore();
});
function createComponent({ api }) {
return mount(
<AnnotationViewerContent
api={api}
rootThread={fakeRootThread}
streamer={fakeStreamer}
streamFilter={fakeStreamFilter}
/>
);
}
function waitForAnnotationsToLoad() {
return waitFor(() => fakeStore.addAnnotations.called);
} }
describe('the standalone view for a top-level annotation', function () { describe('the standalone view for a top-level annotation', () => {
it('loads the annotation and all replies', function () { it('loads the annotation and all replies', async () => {
const fakeApi = new FakeApi([ const fakeApi = new FakeApi([
{ id: 'test_annotation_id' }, { id: 'test_annotation_id' },
{ id: 'test_reply_id', references: ['test_annotation_id'] }, { id: 'test_reply_id', references: ['test_annotation_id'] },
]); ]);
const controller = createController({ api: fakeApi }); createComponent({ api: fakeApi });
return controller.ctrl.ready.then(function () {
assert.calledOnce(controller.store.addAnnotations); await waitForAnnotationsToLoad();
assert.calledOnce(fakeStore.addAnnotations);
assert.calledWith( assert.calledWith(
controller.store.addAnnotations, fakeStore.addAnnotations,
sinon.match(fakeApi.annots) sinon.match(fakeApi.annotations)
); );
}); });
});
it('does not highlight any annotations', function () { it('does not highlight any annotations', async () => {
const fakeApi = new FakeApi([ const fakeApi = new FakeApi([
{ id: 'test_annotation_id' }, { id: 'test_annotation_id' },
{ id: 'test_reply_id', references: ['test_annotation_id'] }, { id: 'test_reply_id', references: ['test_annotation_id'] },
]); ]);
const controller = createController({ api: fakeApi }); createComponent({ api: fakeApi });
return controller.ctrl.ready.then(function () {
assert.notCalled(controller.store.highlightAnnotations); await waitForAnnotationsToLoad();
});
assert.notCalled(fakeStore.highlightAnnotations);
}); });
}); });
describe('the standalone view for a reply', function () { describe('the standalone view for a reply', () => {
it('loads the top-level annotation and all replies', function () { it('loads the top-level annotation and all replies', async () => {
const fakeApi = new FakeApi([ const fakeApi = new FakeApi([
{ id: 'parent_id' }, { id: 'parent_id' },
{ id: 'test_annotation_id', references: ['parent_id'] }, { id: 'test_annotation_id', references: ['parent_id'] },
]); ]);
const controller = createController({ api: fakeApi }); createComponent({ api: fakeApi });
return controller.ctrl.ready.then(function () {
await waitForAnnotationsToLoad();
assert.calledWith( assert.calledWith(
controller.store.addAnnotations, fakeStore.addAnnotations,
sinon.match(fakeApi.annots) sinon.match(fakeApi.annotations)
); );
}); });
});
it('expands the thread', function () { it('expands the thread', async () => {
const fakeApi = new FakeApi([ const fakeApi = new FakeApi([
{ id: 'parent_id' }, { id: 'parent_id' },
{ id: 'test_annotation_id', references: ['parent_id'] }, { id: 'test_annotation_id', references: ['parent_id'] },
]); ]);
const controller = createController({ api: fakeApi });
return controller.ctrl.ready.then(function () { createComponent({ api: fakeApi });
assert.calledWith(controller.store.setCollapsed, 'parent_id', false);
assert.calledWith( await waitForAnnotationsToLoad();
controller.store.setCollapsed,
'test_annotation_id', assert.calledWith(fakeStore.setCollapsed, 'parent_id', false);
false assert.calledWith(fakeStore.setCollapsed, 'test_annotation_id', false);
);
});
}); });
it('highlights the reply', function () { it('highlights the reply', async () => {
const fakeApi = new FakeApi([ const fakeApi = new FakeApi([
{ id: 'parent_id' }, { id: 'parent_id' },
{ id: 'test_annotation_id', references: ['parent_id'] }, { id: 'test_annotation_id', references: ['parent_id'] },
]); ]);
const controller = createController({ api: fakeApi }); createComponent({ api: fakeApi });
return controller.ctrl.ready.then(function () {
await waitForAnnotationsToLoad();
assert.calledWith( assert.calledWith(
controller.store.highlightAnnotations, fakeStore.highlightAnnotations,
sinon.match(['test_annotation_id']) sinon.match(['test_annotation_id'])
); );
}); });
}); });
});
}); });
...@@ -119,6 +119,7 @@ registerIcons(iconSet); ...@@ -119,6 +119,7 @@ registerIcons(iconSet);
// Preact UI components that are wrapped for use within Angular templates. // Preact UI components that are wrapped for use within Angular templates.
import Annotation from './components/annotation'; import Annotation from './components/annotation';
import AnnotationViewerContent from './components/annotation-viewer-content';
import FocusedModeHeader from './components/focused-mode-header'; import FocusedModeHeader from './components/focused-mode-header';
import HelpPanel from './components/help-panel'; import HelpPanel from './components/help-panel';
import LoggedOutMessage from './components/logged-out-message'; import LoggedOutMessage from './components/logged-out-message';
...@@ -136,7 +137,6 @@ import TopBar from './components/top-bar'; ...@@ -136,7 +137,6 @@ import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular. // Remaining UI components that are still built with Angular.
import annotationViewerContent from './components/annotation-viewer-content';
import hypothesisApp from './components/hypothesis-app'; import hypothesisApp from './components/hypothesis-app';
import sidebarContent from './components/sidebar-content'; import sidebarContent from './components/sidebar-content';
import streamContent from './components/stream-content'; import streamContent from './components/stream-content';
...@@ -257,7 +257,10 @@ function startAngularApp(config) { ...@@ -257,7 +257,10 @@ function startAngularApp(config) {
// UI components // UI components
.component('annotation', wrapComponent(Annotation)) .component('annotation', wrapComponent(Annotation))
.component('annotationViewerContent', annotationViewerContent) .component(
'annotationViewerContent',
wrapComponent(AnnotationViewerContent)
)
.component('helpPanel', wrapComponent(HelpPanel)) .component('helpPanel', wrapComponent(HelpPanel))
.component('loginPromptPanel', wrapComponent(LoginPromptPanel)) .component('loginPromptPanel', wrapComponent(LoginPromptPanel))
.component('loggedOutMessage', wrapComponent(LoggedOutMessage)) .component('loggedOutMessage', wrapComponent(LoggedOutMessage))
......
<thread-list
on-change-collapsed="vm.setCollapsed(id, collapsed)"
on-force-visible="vm.forceVisible(thread)"
show-document-info="true"
thread="vm.rootThread()">
</thread-list>
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