Commit 061048b6 authored by Robert Knight's avatar Robert Knight

Implement annotation import via Import tab of share panel

 - Add `ImportAnnotationsService` service to handle import process for
   annotations and show messages when done.

 - Add `importsPending` store fields to track the number of imports in
   flight.

 - When the `import_annotations` feature flag is enabled, show an Import tab
   in the share dialog.

 - Add `ImportAnnotations` component which implements the UI for the
   Import tab and initiates import when the user clicks "Import"

Part of https://github.com/hypothesis/client/issues/5694
parent 2c9592e4
import { Button, CardActions, Select } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useId, useMemo, useState } from 'preact/hooks';
import { isObject } from '../../../shared/is-object';
import { readJSONFile } from '../../../shared/read-json-file';
import type { APIAnnotationData } from '../../../types/api';
import { annotationDisplayName } from '../../helpers/annotation-user';
import { withServices } from '../../service-context';
import type { ExportContent } from '../../services/annotations-exporter';
import type { ImportAnnotationsService } from '../../services/import-annotations';
import { useSidebarStore } from '../../store';
import LoadingSpinner from './LoadingSpinner';
/**
* Parse a file generated by the annotation exporter and return the extracted
* annotations.
*/
async function parseExportContent(file: File): Promise<APIAnnotationData[]> {
let json;
try {
json = await readJSONFile(file);
} catch (err) {
throw new Error('Not a valid JSON file');
}
// Perform some very rudimentary validation of the file content, just enough
// to catch issues where the user picked the wrong kind of JSON file.
if (!isObject(json) || !Array.isArray((json as any).annotations)) {
throw new Error('Not a valid Hypothesis JSON file');
}
return (json as ExportContent).annotations;
}
type UserAnnotationCount = {
userid: string;
displayName: string;
/** Number of annotations made by this user. */
count: number;
};
/**
* Generate an alphabetized list of authors and their annotation counts.
*/
function annotationCountsByUser(
anns: APIAnnotationData[],
getDisplayName: (ann: APIAnnotationData) => string,
): UserAnnotationCount[] {
const userInfo = new Map<string, UserAnnotationCount>();
for (const ann of anns) {
let info = userInfo.get(ann.user);
if (!info) {
info = { userid: ann.user, displayName: getDisplayName(ann), count: 0 };
userInfo.set(ann.user, info);
}
info.count += 1;
}
const userInfos = [...userInfo.values()];
userInfos.sort((a, b) => a.displayName.localeCompare(b.displayName));
return userInfos;
}
export type ImportAnnotationsProps = {
importAnnotationsService: ImportAnnotationsService;
};
/**
* Content of "Import" tab of annotation share dialog.
*
* This allows the user to select a previously exported file of annotations
* and initiate an import via {@link ImportAnnotationsService}.
*/
function ImportAnnotations({
importAnnotationsService,
}: ImportAnnotationsProps) {
const [file, setFile] = useState<File | null>(null);
// Annotations extracted from `file`.
const [annotations, setAnnotations] = useState<APIAnnotationData[] | null>(
null,
);
const [error, setError] = useState<string | null>(null);
// User whose annotations are going to be imported.
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const store = useSidebarStore();
const currentUser = store.profile().userid;
const defaultAuthority = store.defaultAuthority();
const displayNamesEnabled = store.isFeatureEnabled('client_display_names');
const getDisplayName = useCallback(
(ann: APIAnnotationData) =>
annotationDisplayName(ann, defaultAuthority, displayNamesEnabled),
[defaultAuthority, displayNamesEnabled],
);
const userList = useMemo(
() =>
annotations ? annotationCountsByUser(annotations, getDisplayName) : null,
[annotations, getDisplayName],
);
const importsPending = store.importsPending();
// Parse input file, extract annotations and update the user list.
useEffect(() => {
if (!currentUser || !file) {
return;
}
setAnnotations(null);
setError(null);
setSelectedUser(null);
parseExportContent(file)
.then(annotations => {
setAnnotations(annotations);
// Pre-select the current user in the list, if at least one of the
// annotations was authored by them.
const userMatch = annotations.some(ann => ann.user === currentUser);
setSelectedUser(userMatch ? currentUser : null);
})
.catch(err => {
setError(err.message);
});
}, [currentUser, file]);
let importAnnotations;
if (annotations && selectedUser) {
importAnnotations = async () => {
const annsToImport = annotations.filter(ann => ann.user === selectedUser);
// nb. In the event of an error, `import` will report that directly via
// a toast message, so we don't do that ourselves.
importAnnotationsService.import(annsToImport);
};
}
const fileInputId = useId();
const userSelectId = useId();
if (!currentUser) {
// TODO - Make "Log in" a link.
return (
<p data-testid="log-in-message">You must log in to import annotations.</p>
);
}
// True if we're validating a JSON file after it has been selected.
const parseInProgress = file && !annotations && !error;
// True if we're validating or importing.
const busy = parseInProgress || importsPending > 0;
return (
<>
<label htmlFor={fileInputId}>
<p>Select Hypothesis export file:</p>
</label>
<input
id={fileInputId}
accept=".json"
type="file"
disabled={busy}
onChange={e => {
const files = (e.target as HTMLInputElement)!.files;
if (files !== null && files.length > 0) {
setFile(files[0]);
}
}}
/>
{userList && (
<>
<label htmlFor={userSelectId}>
<p>Select which user&apos;s annotations to import:</p>
</label>
<Select
id={userSelectId}
data-testid="user-list"
disabled={busy}
onChange={e =>
setSelectedUser((e.target as HTMLSelectElement).value || null)
}
>
<option value="" selected={!selectedUser} />
{userList.map(userInfo => (
<option
key={userInfo.userid}
value={userInfo.userid}
selected={userInfo.userid === selectedUser}
>
{userInfo.displayName} ({userInfo.count})
</option>
))}
</Select>
</>
)}
{busy && <LoadingSpinner />}
{error && (
// TODO - Add a support link here.
<p data-testid="error-info">
<b>Unable to find annotations to import:</b> {error}
</p>
)}
<CardActions>
<Button
data-testid="import-button"
disabled={!importAnnotations || busy}
onClick={importAnnotations}
variant="primary"
>
Import
</Button>
</CardActions>
</>
);
}
export default withServices(ImportAnnotations, ['importAnnotationsService']);
......@@ -4,6 +4,7 @@ import { useState } from 'preact/hooks';
import { useSidebarStore } from '../../store';
import SidebarPanel from '../SidebarPanel';
import ExportAnnotations from './ExportAnnotations';
import ImportAnnotations from './ImportAnnotations';
import ShareAnnotations from './ShareAnnotations';
import TabHeader from './TabHeader';
import TabPanel from './TabPanel';
......@@ -20,8 +21,12 @@ export default function ShareDialog() {
const groupName = (focusedGroup && focusedGroup.name) || '...';
const panelTitle = `Share Annotations in ${groupName}`;
const tabbedDialog = store.isFeatureEnabled('export_annotations');
const [selectedTab, setSelectedTab] = useState<'share' | 'export'>('share');
const showExportTab = store.isFeatureEnabled('export_annotations');
const showImportTab = store.isFeatureEnabled('import_annotations');
const tabbedDialog = showExportTab || showImportTab;
const [selectedTab, setSelectedTab] = useState<'share' | 'export' | 'import'>(
'share',
);
return (
<SidebarPanel
......@@ -42,6 +47,7 @@ export default function ShareDialog() {
>
Share
</Tab>
{showExportTab && (
<Tab
id="export-panel-tab"
aria-controls="export-panel"
......@@ -52,6 +58,19 @@ export default function ShareDialog() {
>
Export
</Tab>
)}
{showImportTab && (
<Tab
id="import-panel-tab"
aria-controls="import-panel"
variant="tab"
selected={selectedTab === 'import'}
onClick={() => setSelectedTab('import')}
textContent="Import"
>
Import
</Tab>
)}
</TabHeader>
<Card>
<TabPanel
......@@ -70,6 +89,14 @@ export default function ShareDialog() {
>
<ExportAnnotations />
</TabPanel>
<TabPanel
id="import-panel"
active={selectedTab === 'import'}
aria-labelledby="import-panel-tab"
title={`Import into ${focusedGroup?.name ?? '...'}`}
>
<ImportAnnotations />
</TabPanel>
</Card>
</>
)}
......
import { mount } from 'enzyme';
import { checkAccessibility } from '../../../../test-util/accessibility';
import { waitFor, waitForElement } from '../../../../test-util/wait';
import ImportAnnotations, { $imports } from '../ImportAnnotations';
describe('ImportAnnotations', () => {
let fakeImportAnnotationsService;
let fakeStore;
beforeEach(() => {
fakeImportAnnotationsService = {
import: sinon.stub().resolves([]),
};
fakeStore = {
profile: sinon.stub().returns({ userid: 'acct:john@example.com' }),
defaultAuthority: sinon.stub().returns('example.com'),
isFeatureEnabled: sinon.stub().returns(true),
importsPending: sinon.stub().returns(0),
};
$imports.$mock({
'../../store': { useSidebarStore: () => fakeStore },
});
});
afterEach(() => {
$imports.$restore();
});
function createImportAnnotations() {
return mount(
<ImportAnnotations
store={fakeStore}
importAnnotationsService={fakeImportAnnotationsService}
/>,
);
}
it('shows a notice if the user is not logged in', () => {
fakeStore.profile.returns({ userid: null });
const wrapper = createImportAnnotations();
assert.isTrue(wrapper.exists('[data-testid="log-in-message"]'));
assert.isFalse(wrapper.exists('input[type="file"]'));
});
function getImportButton(wrapper) {
return wrapper.find('button[data-testid="import-button"]');
}
function selectFile(wrapper, data) {
const fileInput = wrapper.find('input[type="file"]');
const fileContent = typeof data === 'string' ? data : JSON.stringify(data);
const file = new File([fileContent], 'export.json');
// `HTMLInputElement.files` can be assigned, but is a `FileList`, which
// can't be constructed, so we just stub the property instead.
Object.defineProperty(fileInput.getDOMNode(), 'files', {
get: () => [file],
});
fileInput.simulate('change');
}
it('disables "Import" button when no file is selected', () => {
const wrapper = createImportAnnotations();
assert.isTrue(getImportButton(wrapper).prop('disabled'));
});
it('displays user list when a valid file is selected', async () => {
const wrapper = createImportAnnotations();
selectFile(wrapper, {
annotations: [
{
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
{
user: 'acct:brian@example.com',
user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
},
],
});
const userList = await waitForElement(wrapper, 'Select');
const users = userList.find('option');
assert.equal(users.length, 3);
assert.equal(users.at(0).prop('value'), '');
assert.equal(users.at(0).text(), '');
assert.equal(users.at(1).prop('value'), 'acct:brian@example.com');
assert.equal(users.at(1).text(), 'Brian Smith (1)');
assert.equal(users.at(2).prop('value'), 'acct:john@example.com');
assert.equal(users.at(2).text(), 'John Smith (1)');
});
// TODO - Check handling of errors when reading file fails
[
{
content: 'foobar',
reason: 'Not a valid JSON file',
},
{
content: {},
reason: 'Not a valid Hypothesis JSON file',
},
].forEach(({ content, reason }) => {
it('displays error if file is invalid', async () => {
const wrapper = createImportAnnotations();
selectFile(wrapper, content);
const error = await waitForElement(wrapper, '[data-testid="error-info"]');
assert.equal(
error.text(),
`Unable to find annotations to import: ${reason}`,
);
});
});
it('selects user matching logged in user if found', async () => {
const wrapper = createImportAnnotations();
const annotations = [
{
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
];
selectFile(wrapper, { annotations });
const userList = await waitForElement(wrapper, 'Select');
assert.equal(userList.getDOMNode().value, 'acct:john@example.com');
});
it('does not select a user if no user matches logged-in user', async () => {
fakeStore.profile.returns({ userid: 'acct:brian@example.com' });
const wrapper = createImportAnnotations();
const annotations = [
{
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
];
selectFile(wrapper, { annotations });
const userList = await waitForElement(wrapper, 'Select');
assert.equal(userList.getDOMNode().value, '');
});
it('imports annotations when "Import" button is clicked', async () => {
const wrapper = createImportAnnotations();
const annotations = [
{
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
{
user: 'acct:brian@example.com',
user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
},
];
selectFile(wrapper, { annotations });
const userList = await waitForElement(wrapper, 'select');
userList.getDOMNode().value = 'acct:brian@example.com';
userList.simulate('change');
const importButton = getImportButton(wrapper).getDOMNode();
await waitFor(() => !importButton.disabled);
importButton.click();
assert.calledWith(
fakeImportAnnotationsService.import,
annotations.filter(ann => ann.user === 'acct:brian@example.com'),
);
});
it('shows loading spinner during import', () => {
fakeStore.importsPending.returns(2);
const wrapper = createImportAnnotations();
assert.isTrue(wrapper.exists('LoadingSpinner'));
fakeStore.importsPending.returns(0);
wrapper.setProps({}); // Force re-render
assert.isFalse(wrapper.exists('LoadingSpinner'));
});
it(
'should pass a11y checks',
checkAccessibility({
content: () =>
mount(
// re. outer div, see https://github.com/hypothesis/client/issues/5690
<div>
<ImportAnnotations
store={fakeStore}
importAnnotationsService={fakeImportAnnotationsService}
/>
</div>,
),
}),
);
});
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { checkAccessibility } from '../../../../test-util/accessibility';
import { mockImportedComponents } from '../../../../test-util/mock-imported-components';
......@@ -59,70 +58,71 @@ describe('ShareDialog', () => {
});
});
describe('tabbed dialog panel', () => {
it('does not render a tabbed dialog if export feature flag is not enabled', () => {
function enableFeature(feature) {
fakeStore.isFeatureEnabled.withArgs(feature).returns(true);
}
function selectTab(wrapper, name) {
wrapper
.find(`Tab[aria-controls="${name}-panel"]`)
.find('button')
.simulate('click');
}
function getActiveTab(wrapper) {
return wrapper.find('Tab').filter({ selected: true });
}
function activeTabPanel(wrapper) {
return wrapper.find('TabPanel').filter({ active: true });
}
it('does not render a tabbed dialog if import/export feature flags are not enabled', () => {
const wrapper = createComponent();
assert.isFalse(wrapper.find('TabHeader').exists());
});
context('export feature enabled', () => {
beforeEach(() => {
fakeStore.isFeatureEnabled.withArgs('export_annotations').returns(true);
});
['export_annotations', 'import_annotations'].forEach(feature => {
it(`renders a tabbed dialog when ${feature} feature is enabled`, () => {
enableFeature('export_annotations');
it('renders a tabbed dialog with share panel active', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('TabHeader').exists());
assert.isTrue(
wrapper.find('Tab[aria-controls="share-panel"]').props().selected,
);
assert.isTrue(
wrapper.find('TabPanel[id="share-panel"]').props().active,
);
assert.isTrue(wrapper.find('TabPanel[id="share-panel"]').props().active);
});
});
it('shows the export tab panel when export tab clicked', () => {
const wrapper = createComponent();
const shareTabSelector = 'Tab[aria-controls="share-panel"]';
const exportTabSelector = 'Tab[aria-controls="export-panel"]';
it('shows correct tab panel when each tab is clicked', () => {
enableFeature('export_annotations');
enableFeature('import_annotations');
act(() => {
wrapper
.find(exportTabSelector)
.getDOMNode()
.dispatchEvent(new Event('click'));
});
wrapper.update();
const wrapper = createComponent();
const selectedTab = wrapper.find('Tab').filter({ selected: true });
selectTab(wrapper, 'export');
let selectedTab = getActiveTab(wrapper);
assert.equal(selectedTab.text(), 'Export');
assert.equal(selectedTab.props()['aria-controls'], 'export-panel');
assert.equal(activeTabPanel(wrapper).props().id, 'export-panel');
const activeTabPanel = wrapper
.find('TabPanel')
.filter({ active: true });
assert.equal(activeTabPanel.props().id, 'export-panel');
selectTab(wrapper, 'import');
selectedTab = getActiveTab(wrapper);
assert.equal(selectedTab.text(), 'Import');
assert.equal(selectedTab.props()['aria-controls'], 'import-panel');
assert.equal(activeTabPanel(wrapper).props().id, 'import-panel');
// Now, reselect share tab
act(() => {
wrapper
.find(shareTabSelector)
.getDOMNode()
.dispatchEvent(new Event('click'));
});
wrapper.update();
const shareTabPanel = wrapper.find('TabPanel').filter({ active: true });
assert.equal(shareTabPanel.props().id, 'share-panel');
});
});
selectTab(wrapper, 'share');
assert.equal(activeTabPanel(wrapper).props().id, 'share-panel');
});
describe('a11y', () => {
beforeEach(() => {
fakeStore.isFeatureEnabled.withArgs('export_annotations').returns(true);
enableFeature('export_annotations');
enableFeature('import_annotations');
});
it(
......
import type {
APIAnnotationData,
Annotation,
SavedAnnotation,
TextQuoteSelector,
......@@ -326,7 +327,7 @@ export function flagCount(annotation: Annotation): number | null {
/**
* Return the text quote that an annotation refers to.
*/
export function quote(annotation: Annotation): string | null {
export function quote(annotation: APIAnnotationData): string | null {
if (annotation.target.length === 0) {
return null;
}
......
......@@ -26,6 +26,7 @@ import { AuthService } from './services/auth';
import { AutosaveService } from './services/autosave';
import { FrameSyncService } from './services/frame-sync';
import { GroupsService } from './services/groups';
import { ImportAnnotationsService } from './services/import-annotations';
import { LoadAnnotationsService } from './services/load-annotations';
import { LocalStorageService } from './services/local-storage';
import { PersistedDefaultsService } from './services/persisted-defaults';
......@@ -129,6 +130,7 @@ function startApp(settings: SidebarSettings, appEl: HTMLElement) {
.register('autosaveService', AutosaveService)
.register('frameSync', FrameSyncService)
.register('groups', GroupsService)
.register('importAnnotationsService', ImportAnnotationsService)
.register('loadAnnotationsService', LoadAnnotationsService)
.register('localStorage', LocalStorageService)
.register('persistedDefaults', PersistedDefaultsService)
......
import { generateHexString } from '../../shared/random';
import type { AnnotationData } from '../../types/annotator';
import type { Annotation, SavedAnnotation } from '../../types/api';
import type {
APIAnnotationData,
Annotation,
SavedAnnotation,
} from '../../types/api';
import type { AnnotationEventType } from '../../types/config';
import * as metadata from '../helpers/annotation-metadata';
import {
......@@ -53,11 +57,16 @@ export class AnnotationsService {
}
/**
* Extend new annotation objects with defaults and permissions.
* Create a new {@link Annotation} object from a set of field values.
*
* All fields not set in `annotationData` will be populated with default
* values.
*/
private _initialize(
annotationData: Omit<AnnotationData, '$tag'>,
now: Date,
annotationFromData(
annotationData: Partial<APIAnnotationData> &
Pick<AnnotationData, 'uri' | 'target'>,
/* istanbul ignore next */
now: Date = new Date(),
): Annotation {
const defaultPrivacy = this._store.getDefault('annotationPrivacy');
const groupid = this._store.focusedGroupId();
......@@ -109,7 +118,7 @@ export class AnnotationsService {
* drafts out of the way.
*/
create(annotationData: Omit<AnnotationData, '$tag'>, now = new Date()) {
const annotation = this._initialize(annotationData, now);
const annotation = this.annotationFromData(annotationData, now);
this._store.addAnnotations([annotation]);
......
import type { Annotation, APIAnnotationData } from '../../types/api';
import { quote } from '../helpers/annotation-metadata';
import type { SidebarStore } from '../store';
import type { AnnotationsService } from './annotations';
import type { ToastMessengerService } from './toast-messenger';
/**
* The subset of annotation fields which are preserved during an import.
*/
type ImportData = Pick<
APIAnnotationData,
'document' | 'tags' | 'text' | 'target' | 'uri'
>;
/**
* Return a copy of `ann` that contains only fields which can be preserved by
* an import performed on the client.
*/
function getImportData(ann: APIAnnotationData): ImportData {
return {
target: ann.target,
tags: ann.tags,
text: ann.text,
uri: ann.uri,
document: ann.document,
};
}
/**
* Summarize the results of an import operation.
*
* Returns an object which can easily mapped to a toast message notification.
*/
function importStatus(results: ImportResult[]): {
messageType: 'success' | 'notice' | 'error';
message: string;
} {
const errorCount = results.filter(r => r.type === 'error').length;
const errorMessage = errorCount > 0 ? `${errorCount} imports failed` : '';
const importCount = results.filter(r => r.type === 'import').length;
const importMessage =
importCount > 0 ? `${importCount} annotations imported` : '';
const dupCount = results.filter(r => r.type === 'duplicate').length;
const dupMessage = dupCount > 0 ? `${dupCount} duplicates skipped` : '';
let messageType: 'success' | 'notice' | 'error';
if (errorCount === 0) {
if (importCount > 0) {
messageType = 'success';
} else {
messageType = 'notice';
}
} else if (importCount > 0) {
messageType = 'notice';
} else {
messageType = 'error';
}
const message = [importMessage, errorMessage, dupMessage]
.filter(msg => msg.length > 0)
.join(', ');
return { messageType, message };
}
function arraysEqual<T>(a: T[], b: T[]): boolean {
return a.length === b.length && a.every((x, i) => b[i] === x);
}
/**
* Return true if two annotations should be considered duplicates based on their
* content.
*/
function duplicateMatch(a: APIAnnotationData, b: APIAnnotationData): boolean {
if (a.text !== b.text) {
return false;
}
if (!arraysEqual(a.tags, b.tags)) {
return false;
}
return quote(a) === quote(b);
}
/**
* Enum of the result of an import operation for a single annotation.
*/
export type ImportResult =
/** Annotation was successfully imported. */
| { type: 'import'; annotation: Annotation }
/** Annotation was skipped because it is a duplicate. */
| { type: 'duplicate'; annotation: Annotation }
/** Annotation import failed. */
| { type: 'error'; error: Error };
/**
* Imports annotations from a Hypothesis JSON file.
*
* @inject
*/
export class ImportAnnotationsService {
private _annotationsService: AnnotationsService;
private _store: SidebarStore;
private _toastMessenger: ToastMessengerService;
constructor(
store: SidebarStore,
annotationsService: AnnotationsService,
toastMessenger: ToastMessengerService,
) {
this._store = store;
this._annotationsService = annotationsService;
this._toastMessenger = toastMessenger;
}
/**
* Import annotations.
*/
async import(anns: APIAnnotationData[]): Promise<ImportResult[]> {
this._store.beginImport(anns.length);
const existingAnns = this._store.allAnnotations();
const results: ImportResult[] = [];
for (const ann of anns) {
const existingAnn = existingAnns.find(ex => duplicateMatch(ann, ex));
if (existingAnn) {
results.push({ type: 'duplicate', annotation: existingAnn });
this._store.completeImport(1);
continue;
}
try {
// Strip out all the fields that are ignored in an import.
const importData = getImportData(ann);
// Fill out the annotation with default values for the current user and
// group.
const saveData =
this._annotationsService.annotationFromData(importData);
// Persist the annotation.
const saved = await this._annotationsService.save(saveData);
results.push({ type: 'import', annotation: saved });
} catch (error) {
results.push({ type: 'error', error });
} finally {
this._store.completeImport(1);
}
}
const { messageType, message } = importStatus(results);
if (messageType === 'success') {
this._toastMessenger.success(message);
} else if (messageType === 'notice') {
this._toastMessenger.notice(message);
} else if (messageType === 'error') {
this._toastMessenger.error(message);
}
return results;
}
}
import { ImportAnnotationsService } from '../import-annotations';
describe('ImportAnnotationsService', () => {
let counter;
let fakeStore;
let fakeToastMessenger;
let fakeAnnotationsService;
beforeEach(() => {
counter = 0;
fakeStore = {
allAnnotations: sinon.stub().returns([]),
beginImport: sinon.stub(),
completeImport: sinon.stub(),
};
fakeToastMessenger = {
success: sinon.stub(),
notice: sinon.stub(),
error: sinon.stub(),
};
fakeAnnotationsService = {
annotationFromData: sinon.stub().callsFake(data => {
return {
$tag: 'dummy',
...data,
};
}),
save: sinon.stub().resolves({}),
};
});
function createService() {
return new ImportAnnotationsService(
fakeStore,
fakeAnnotationsService,
fakeToastMessenger,
);
}
function generateAnnotation(fields = {}) {
++counter;
return {
uri: 'https://example.com',
target: [
{
source: 'https://example.com',
},
],
text: `Annotation ${counter}`,
tags: ['foo'],
document: { title: 'Example' },
...fields,
};
}
describe('#import', () => {
it('increments count of pending imports', async () => {
const svc = createService();
const done = svc.import([generateAnnotation()]);
assert.calledWith(fakeStore.beginImport, 1);
await done;
});
it('decrements count of pending imports as they complete', async () => {
const svc = createService();
const done = svc.import([generateAnnotation()]);
assert.notCalled(fakeStore.completeImport);
await done;
assert.calledWith(fakeStore.completeImport, 1);
});
it('generates annotation payloads and saves them', async () => {
const svc = createService();
const ann = generateAnnotation();
await svc.import([ann]);
assert.calledWith(fakeAnnotationsService.save, {
$tag: 'dummy',
...ann,
});
});
it('does not skip annotation if existing annotations in store differ', async () => {
const svc = createService();
const ann = generateAnnotation();
fakeStore.allAnnotations.returns([
{ ...ann, text: 'Different text' },
{ ...ann, tags: ['different-tag'] },
]);
await svc.import([ann]);
assert.calledWith(fakeToastMessenger.success, '1 annotations imported');
});
// TODO - Test for matching based on tags, text, quote.
it('skips annotations with content that matches existing annotations in the store', async () => {
const svc = createService();
const ann = generateAnnotation();
fakeStore.allAnnotations.returns([ann]);
await svc.import([ann]);
assert.calledWith(fakeToastMessenger.notice, '1 duplicates skipped');
});
it('shows a success toast if import succeeds', async () => {
const svc = createService();
await svc.import([generateAnnotation(), generateAnnotation()]);
assert.calledWith(fakeToastMessenger.success, '2 annotations imported');
});
it('shows a warning toast if some errors occurred', async () => {
const svc = createService();
fakeAnnotationsService.save.onCall(0).resolves({});
fakeAnnotationsService.save.onCall(1).rejects(new Error('Oh no'));
await svc.import([generateAnnotation(), generateAnnotation()]);
assert.calledWith(
fakeToastMessenger.notice,
'1 annotations imported, 1 imports failed',
);
});
it('shows an error toast if all imports failed', async () => {
const svc = createService();
fakeAnnotationsService.save.rejects(new Error('Something went wrong'));
await svc.import([generateAnnotation(), generateAnnotation()]);
assert.calledWith(fakeToastMessenger.error, '2 imports failed');
});
});
});
......@@ -23,6 +23,11 @@ export type State = {
* matching the most recent load/search request
*/
annotationResultCount: number | null;
/**
* Count of annotations waiting to be imported.
*/
importsPending: number;
};
const initialState: State = {
......@@ -31,6 +36,7 @@ const initialState: State = {
activeAnnotationFetches: 0,
hasFetchedAnnotations: false,
annotationResultCount: null,
importsPending: 0,
};
const reducers = {
......@@ -106,6 +112,21 @@ const reducers = {
annotationResultCount: action.resultCount,
};
},
BEGIN_IMPORT(state: State, action: { count: number }) {
return {
importsPending: state.importsPending + action.count,
};
},
COMPLETE_IMPORT(state: State, action: { count: number }) {
if (!state.importsPending) {
return state;
}
return {
importsPending: Math.max(state.importsPending - action.count, 0),
};
},
};
function annotationFetchStarted() {
......@@ -136,6 +157,14 @@ function setAnnotationResultCount(resultCount: number) {
return makeAction(reducers, 'SET_ANNOTATION_RESULT_COUNT', { resultCount });
}
function beginImport(count: number) {
return makeAction(reducers, 'BEGIN_IMPORT', { count });
}
function completeImport(count: number) {
return makeAction(reducers, 'COMPLETE_IMPORT', { count });
}
/** Selectors */
function annotationResultCount(state: State) {
......@@ -146,6 +175,10 @@ function hasFetchedAnnotations(state: State) {
return state.hasFetchedAnnotations;
}
function importsPending(state: State) {
return state.importsPending;
}
/**
* Return true when annotations are actively being fetched.
*/
......@@ -184,11 +217,14 @@ export const activityModule = createStoreModule(initialState, {
annotationSaveFinished,
apiRequestStarted,
apiRequestFinished,
beginImport,
completeImport,
setAnnotationResultCount,
},
selectors: {
hasFetchedAnnotations,
importsPending,
isLoading,
isFetchingAnnotations,
isSavingAnnotation,
......
......@@ -223,4 +223,30 @@ describe('sidebar/store/modules/activity', () => {
assert.equal(store.annotationResultCount(), 5);
});
});
describe('#beginImport', () => {
it('increments count of pending imports', () => {
assert.equal(store.importsPending(), 0);
store.beginImport(2);
assert.equal(store.importsPending(), 2);
store.beginImport(2);
assert.equal(store.importsPending(), 4);
});
});
describe('#completeImport', () => {
it('decrements count of pending imports', () => {
store.beginImport(5);
store.completeImport(2);
assert.equal(store.importsPending(), 3);
store.completeImport(1);
assert.equal(store.importsPending(), 2);
store.completeImport(2);
assert.equal(store.importsPending(), 0);
// Value can't go below 0. We could choose to throw an error here instead.
store.completeImport(1);
assert.equal(store.importsPending(), 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