Commit 50595672 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Emit and display `ResultSizeError` on too-many-annotations

Emit a `ResultSizeError` when encountering too many annotations to load.
Update `NotebookView` to display a message when this happens.
parent f936fffa
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import scrollIntoView from 'scroll-into-view';
import { ResultSizeError } from '../search-client';
import { withServices } from '../service-context';
import useRootThread from './hooks/use-root-thread';
import { useStoreProxy } from '../store/use-store';
......@@ -8,6 +9,7 @@ import { useStoreProxy } from '../store/use-store';
import NotebookFilters from './NotebookFilters';
import NotebookResultCount from './NotebookResultCount';
import Panel from './Panel';
import PaginatedThreadList from './PaginatedThreadList';
/**
......@@ -46,11 +48,22 @@ function NotebookView({ loadAnnotationsService }) {
const lastPaginationPage = useRef(1);
const [paginationPage, setPaginationPage] = useState(1);
// Load all annotations; re-load if `focusedGroup` changes
useEffect(() => {
const [hasTooManyAnnotationsError, setHasTooManyAnnotationsError] = useState(
false
);
// Load all annotations in the group, unless there are more than 5000
// of them: this is a performance safety valve.
const maxResults = 5000;
const onLoadError = error => {
if (error instanceof ResultSizeError) {
setHasTooManyAnnotationsError(true);
}
};
// Load all annotations; re-load if `focusedGroup` changes
useEffect(() => {
// NB: In current implementation, this will only happen/load once (initial
// annotation fetch on application startup), as there is no mechanism
// within the Notebook to change the `focusedGroup`. If the focused group
......@@ -60,8 +73,6 @@ function NotebookView({ loadAnnotationsService }) {
if (groupId) {
loadAnnotationsService.load({
groupId,
maxResults: 5000,
// Load annotations in reverse-chronological order because that is how
// threads are sorted in the notebook view. By aligning the fetch
// order with the thread display order we reduce the changes in visible
......@@ -74,6 +85,8 @@ function NotebookView({ loadAnnotationsService }) {
// the top-level threads.
sortBy: 'updated',
sortOrder: 'desc',
maxResults,
onError: onLoadError,
});
}
}, [loadAnnotationsService, groupId, store]);
......@@ -115,6 +128,20 @@ function NotebookView({ loadAnnotationsService }) {
/>
</div>
<div className="NotebookView__items">
{hasTooManyAnnotationsError && (
<div className="NotebookView__messages">
<Panel title="Too many results to show">
This preview of the Notebook can show{' '}
<strong>up to {maxResults} results</strong> at a time (there are{' '}
{resultCount} to show here).{' '}
<a href="mailto:support@hypothes.is?subject=Hypothesis%20Notebook&body=Please%20notify%20me%20when%20the%20Hypothesis%20Notebook%20is%20updated%20to%20support%20more%20than%205000%20annotations">
Contact us
</a>{' '}
if you would like to be notified when support for more annotations
is available.
</Panel>
</div>
)}
<PaginatedThreadList
currentPage={paginationPage}
isLoading={isLoading}
......
......@@ -3,6 +3,7 @@ import { act } from 'preact/test-utils';
import mockImportedComponents from '../../../test-util/mock-imported-components';
import { ResultSizeError } from '../../search-client';
import NotebookView, { $imports } from '../NotebookView';
describe('NotebookView', () => {
......@@ -60,6 +61,7 @@ describe('NotebookView', () => {
maxResults: 5000,
sortBy: 'updated',
sortOrder: 'desc',
onError: sinon.match.func,
})
);
assert.calledWith(fakeStore.setSortKey, 'Newest');
......@@ -78,6 +80,7 @@ describe('NotebookView', () => {
maxResults: 5000,
sortBy: 'updated',
sortOrder: 'desc',
onError: sinon.match.func,
})
);
});
......@@ -91,6 +94,20 @@ describe('NotebookView', () => {
assert.notCalled(fakeLoadAnnotationsService.load);
});
it('shows a message if too many annotations to load', () => {
// Simulate the loading service emitting an error indicating
// too many annotations to load
fakeLoadAnnotationsService.load.callsFake(options => {
options.onError(new ResultSizeError(5000));
});
fakeStore.focusedGroup.returns({ id: 'hallothere', name: 'Hallo' });
const wrapper = createComponent();
const message = wrapper.find('.NotebookView__messages');
assert.include(message.text(), 'up to 5000 results at a time');
assert.isTrue(message.exists());
});
it('renders the current group name', () => {
fakeStore.focusedGroup.returns({ id: 'hallothere', name: 'Hallo' });
const wrapper = createComponent();
......
......@@ -4,13 +4,27 @@ import { TinyEmitter } from 'tiny-emitter';
* @typedef {import('../types/api').Annotation} Annotation
* @typedef {import('../types/api').SearchQuery} SearchQuery
* @typedef {import('../types/api').SearchResult} SearchResult
*
*/
/**
* Indicates that there are more annotations matching the current API
* search request than the interface can currently handle displaying
* (Notebook).
*/
export class ResultSizeError extends Error {
/**
* @param {number} limit
*/
constructor(limit) {
super(`Results size exceeds ${limit}`);
}
}
/**
* @typedef {'created'|'updated'} SortOrder
* @typedef {'asc'|'desc'} SortBy
*/
/**
* Default callback used to get the page size for iterating through annotations.
*
......@@ -31,7 +45,7 @@ function defaultPageSize(index) {
*
* [1] https://h.readthedocs.io/en/latest/api-reference/#tag/annotations/paths/~1search/get
*/
export default class SearchClient extends TinyEmitter {
export class SearchClient extends TinyEmitter {
/**
* @param {(query: SearchQuery) => Promise<SearchResult>} searchFn - Function for querying the search API
* @param {Object} options
......@@ -116,6 +130,12 @@ export default class SearchClient extends TinyEmitter {
return;
}
if (this._resultCount === null) {
// Emit the result count (total) on first encountering it
this._resultCount = results.total;
this.emit('resultCount', this._resultCount);
}
// For now, abort loading of annotations if `maxResults` is set and the
// number of annotations in the results set exceeds that value.
//
......@@ -126,23 +146,14 @@ export default class SearchClient extends TinyEmitter {
//
// This change has no effect on loading annotations in the SidebarView,
// where the `maxResults` option is not used.
//
// TODO: Implement pagination
if (this._maxResults && results.total > this._maxResults) {
this.emit(
'error',
new Error('Results size exceeds maximum allowed annotations')
);
this.emit('error', new ResultSizeError(this._maxResults));
this.emit('end');
return;
}
const page = results.rows.concat(results.replies || []);
if (this._resultCount === null) {
// Emit the result count (total) on first encountering it
this._resultCount = results.total;
this.emit('resultCount', this._resultCount);
}
if (this._incremental) {
this.emit('results', page);
} else {
......
import SearchClient from '../search-client';
import { ResultSizeError, SearchClient } from '../search-client';
function awaitEvent(emitter, event) {
return new Promise(function (resolve) {
......@@ -218,10 +218,7 @@ describe('SearchClient', () => {
await awaitEvent(client, 'end');
assert.calledOnce(onError);
assert.equal(
onError.getCall(0).args[0].message,
'Results size exceeds maximum allowed annotations'
);
assert.instanceOf(onError.getCall(0).args[0], ResultSizeError);
});
it('does not emit an error if results size is <= `maxResults`', async () => {
......
......@@ -52,5 +52,9 @@
justify-self: end;
align-self: flex-end;
}
&__messages {
padding: 1em 0;
}
}
}
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