Unverified Commit d50476db authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #2078 from hypothesis/convert-stream-content

Convert stream content to Preact
parents bd7cc1c3 b742694a
import { watch } from '../util/watch'; import { createElement } from 'preact';
import { useCallback, useEffect } from 'preact/hooks';
import propTypes from 'prop-types';
// @ngInject import { withServices } from '../util/service-context';
function StreamContentController($scope, store, api, rootThread, searchFilter) { import useStore from '../store/use-store';
/** `offset` parameter for the next search API call. */
let offset = 0;
/** Load annotations fetched from the API into the app. */ import ThreadList from './thread-list';
const load = function (result) {
offset += result.rows.length;
const annots = [...result.rows, ...result.replies];
store.addAnnotations(annots);
};
const currentQuery = () => store.routeParams().q; /**
* The main content of the "stream" route (https://hypothes.is/stream)
*/
function StreamContent({
api,
rootThread: rootThreadService,
searchFilter,
toastMessenger,
}) {
const addAnnotations = useStore(store => store.addAnnotations);
const clearAnnotations = useStore(store => store.clearAnnotations);
const currentQuery = useStore(store => store.routeParams().q);
const setSortKey = useStore(store => store.setSortKey);
/** /**
* Fetch the next `limit` annotations starting from `offset` from the API. * Fetch annotations from the API and display them in the stream.
*
* @param {string} query - The user-supplied search query
*/ */
const fetch = function (limit) { const loadAnnotations = useCallback(
const query = Object.assign( async query => {
{ const queryParams = {
_separate_replies: true, _separate_replies: true,
offset: offset,
limit: limit,
},
searchFilter.toObject(currentQuery())
);
api // nb. There is currently no way to load anything except the first
.search(query) // 20 matching annotations in the UI.
.then(load) offset: 0,
.catch(function (err) { limit: 20,
console.error(err);
});
};
function clearAndFetch() { ...searchFilter.toObject(query),
// In case this route loaded after a client-side route change (eg. from };
// '/a/:id'), clear any existing annotations. const results = await api.search(queryParams);
store.clearAnnotations(); addAnnotations([...results.rows, ...results.replies]);
},
[addAnnotations, api, searchFilter]
);
// Fetch initial batch of annotations. // Update the stream when this route is initially displayed and whenever
offset = 0; // the search query is updated.
fetch(20); useEffect(() => {
} // Sort the stream so that the newest annotations are at the top
setSortKey('Newest');
clearAnnotations();
loadAnnotations(currentQuery).catch(err => {
toastMessenger.error(`Unable to fetch annotations: ${err.message}`);
});
}, [
clearAnnotations,
currentQuery,
loadAnnotations,
setSortKey,
toastMessenger,
]);
const unsubscribe = watch(store.subscribe, currentQuery, () => { const rootThread = useStore(store =>
clearAndFetch(); rootThreadService.thread(store.getState())
}); );
$scope.$on('$destroy', unsubscribe);
clearAndFetch(); return <ThreadList thread={rootThread} />;
this.setCollapsed = store.setCollapsed;
this.rootThread = () => rootThread.thread(store.getState());
// Sort the stream so that the newest annotations are at the top
store.setSortKey('Newest');
} }
export default { StreamContent.propTypes = {
controller: StreamContentController, // Injected services.
controllerAs: 'vm', api: propTypes.object,
bindings: {}, rootThread: propTypes.object,
template: require('../templates/stream-content.html'), searchFilter: propTypes.object,
toastMessenger: propTypes.object,
}; };
StreamContent.injectedProps = [
'api',
'rootThread',
'searchFilter',
'toastMessenger',
];
export default withServices(StreamContent);
import angular from 'angular'; import { mount } from 'enzyme';
import EventEmitter from 'tiny-emitter'; import { createElement } from 'preact';
import streamContent from '../stream-content'; import mockImportedComponents from '../../../test-util/mock-imported-components';
import { waitFor } from '../../../test-util/wait';
class FakeRootThread extends EventEmitter { import StreamContent, { $imports } from '../stream-content';
constructor() {
super();
this.thread = sinon.stub();
}
}
describe('StreamContentController', function () { describe('StreamContent', () => {
let $componentController; let fakeApi;
let fakeStore;
let fakeRootThread; let fakeRootThread;
let fakeSearchFilter; let fakeSearchFilter;
let fakeApi; let fakeStore;
let fakeStreamer; let fakeToastMessenger;
let fakeStreamFilter;
before(function () { beforeEach(() => {
angular.module('h', []).component('streamContent', streamContent); fakeApi = {
}); search: sinon.stub().resolves({ rows: [], replies: [], total: 0 }),
};
beforeEach(function () { fakeRootThread = {
fakeStore = { thread: sinon.stub().returns({}),
addAnnotations: sinon.stub(),
clearAnnotations: sinon.spy(),
routeParams: sinon.stub().returns({ id: 'test' }),
setCollapsed: sinon.spy(),
setForceVisible: sinon.spy(),
setSortKey: sinon.spy(),
subscribe: sinon.spy(),
}; };
fakeSearchFilter = { fakeSearchFilter = {
generateFacetedFilter: sinon.stub(),
toObject: sinon.stub().returns({}), toObject: sinon.stub().returns({}),
}; };
fakeApi = { fakeStore = {
search: sinon.stub().resolves({ rows: [], replies: [], total: 0 }), addAnnotations: sinon.stub(),
}; clearAnnotations: sinon.spy(),
getState: sinon.stub().returns({}),
fakeStreamer = { routeParams: sinon.stub().returns({ id: 'test' }),
open: sinon.spy(), setSortKey: sinon.spy(),
close: sinon.spy(),
setConfig: sinon.spy(),
connect: sinon.spy(),
}; };
fakeStreamFilter = { fakeToastMessenger = {
resetFilter: sinon.stub().returnsThis(), error: sinon.stub(),
setMatchPolicyIncludeAll: sinon.stub().returnsThis(),
getFilter: sinon.stub(),
}; };
fakeRootThread = new FakeRootThread(); $imports.$mock(mockImportedComponents());
$imports.$mock({
angular.mock.module('h', { '../store/use-store': callback => callback(fakeStore),
store: fakeStore,
api: fakeApi,
rootThread: fakeRootThread,
searchFilter: fakeSearchFilter,
streamFilter: fakeStreamFilter,
streamer: fakeStreamer,
}); });
});
angular.mock.inject(function (_$componentController_) { afterEach(() => {
$componentController = _$componentController_; $imports.$restore();
});
}); });
function createController() { function createComponent() {
return $componentController('streamContent', {}, {}); return mount(
<StreamContent
api={fakeApi}
rootThread={fakeRootThread}
searchFilter={fakeSearchFilter}
toastMessenger={fakeToastMessenger}
/>
);
} }
it('clears any existing annotations when the /stream route is loaded', () => { it('clears any existing annotations when the /stream route is loaded', () => {
createController(); createComponent();
assert.calledOnce(fakeStore.clearAnnotations); assert.calledOnce(fakeStore.clearAnnotations);
}); });
it('calls the search API with `_separate_replies: true`', function () { it('calls the search API with `_separate_replies: true`', () => {
createController(); createComponent();
assert.equal(fakeApi.search.firstCall.args[0]._separate_replies, true); assert.equal(fakeApi.search.firstCall.args[0]._separate_replies, true);
}); });
it('passes the annotations and replies from search to loadAnnotations()', function () { it('loads the annotations and replies into the store', async () => {
fakeApi.search = function () { fakeApi.search.resolves({
return Promise.resolve({ rows: ['annotation_1', 'annotation_2'],
rows: ['annotation_1', 'annotation_2'], replies: ['reply_1', 'reply_2', 'reply_3'],
replies: ['reply_1', 'reply_2', 'reply_3'],
});
};
createController();
return Promise.resolve().then(function () {
assert.calledOnce(fakeStore.addAnnotations);
assert.calledWith(fakeStore.addAnnotations, [
'annotation_1',
'annotation_2',
'reply_1',
'reply_2',
'reply_3',
]);
}); });
createComponent();
await waitFor(() => fakeStore.addAnnotations.called);
assert.calledOnce(fakeStore.addAnnotations);
assert.calledWith(fakeStore.addAnnotations, [
'annotation_1',
'annotation_2',
'reply_1',
'reply_2',
'reply_3',
]);
});
it('displays an error if fetching annotations fails', async () => {
fakeApi.search.rejects(new Error('Server error'));
createComponent();
await waitFor(() => fakeToastMessenger.error.called);
assert.calledWith(
fakeToastMessenger.error,
'Unable to fetch annotations: Server error'
);
}); });
context('when route parameters change', function () { context('when route parameters change', () => {
it('updates annotations if the query changed', function () { it('updates annotations if the query changed', () => {
fakeStore.routeParams.returns({ q: 'test query' }); fakeStore.routeParams.returns({ q: 'test query' });
createController(); const wrapper = createComponent();
fakeStore.clearAnnotations.resetHistory(); fakeStore.clearAnnotations.resetHistory();
fakeApi.search.resetHistory(); fakeApi.search.resetHistory();
fakeStore.routeParams.returns({ q: 'new query' }); fakeStore.routeParams.returns({ q: 'new query' });
fakeStore.subscribe.lastCall.callback(); // Force update. `useStore` handles this in the real app.
wrapper.setProps({});
assert.called(fakeStore.clearAnnotations); assert.called(fakeStore.clearAnnotations);
assert.called(fakeApi.search); assert.called(fakeApi.search);
}); });
it('does not clear annotations if the query did not change', function () { it('does not clear annotations if the query did not change', () => {
fakeStore.routeParams.returns({ q: 'test query' }); fakeStore.routeParams.returns({ q: 'test query' });
createController(); const wrapper = createComponent();
fakeApi.search.resetHistory(); fakeApi.search.resetHistory();
fakeStore.clearAnnotations.resetHistory(); fakeStore.clearAnnotations.resetHistory();
fakeStore.subscribe.lastCall.callback(); fakeStore.routeParams.returns({ q: 'test query', other_param: 'foo' });
// Force update. `useStore` handles this in the real app.
wrapper.setProps({});
assert.notCalled(fakeStore.clearAnnotations); assert.notCalled(fakeStore.clearAnnotations);
assert.notCalled(fakeApi.search); assert.notCalled(fakeApi.search);
......
...@@ -129,6 +129,7 @@ import SearchStatusBar from './components/search-status-bar'; ...@@ -129,6 +129,7 @@ import SearchStatusBar from './components/search-status-bar';
import SelectionTabs from './components/selection-tabs'; import SelectionTabs from './components/selection-tabs';
import ShareAnnotationsPanel from './components/share-annotations-panel'; import ShareAnnotationsPanel from './components/share-annotations-panel';
import SidebarContentError from './components/sidebar-content-error'; import SidebarContentError from './components/sidebar-content-error';
import StreamContent from './components/stream-content';
import SvgIcon from '../shared/components/svg-icon'; import SvgIcon from '../shared/components/svg-icon';
import Thread from './components/thread'; import Thread from './components/thread';
import ThreadList from './components/thread-list'; import ThreadList from './components/thread-list';
...@@ -139,7 +140,6 @@ import TopBar from './components/top-bar'; ...@@ -139,7 +140,6 @@ import TopBar from './components/top-bar';
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';
// Services. // Services.
...@@ -271,7 +271,7 @@ function startAngularApp(config) { ...@@ -271,7 +271,7 @@ function startAngularApp(config) {
.component('sidebarContent', sidebarContent) .component('sidebarContent', sidebarContent)
.component('sidebarContentError', wrapComponent(SidebarContentError)) .component('sidebarContentError', wrapComponent(SidebarContentError))
.component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel)) .component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel))
.component('streamContent', streamContent) .component('streamContent', wrapComponent(StreamContent))
.component('svgIcon', wrapComponent(SvgIcon)) .component('svgIcon', wrapComponent(SvgIcon))
.component('thread', wrapComponent(Thread)) .component('thread', wrapComponent(Thread))
.component('threadList', wrapComponent(ThreadList)) .component('threadList', wrapComponent(ThreadList))
......
<thread-list
on-change-collapsed="vm.setCollapsed(id, collapsed)"
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