Commit 0ae2b78f authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Allow selecting which user's annotations to export

parent fe0618e0
import { Button, CardActions, Input } from '@hypothesis/frontend-shared'; import {
import { useMemo, useState } from 'preact/hooks'; Button,
CardActions,
Input,
Select,
} from '@hypothesis/frontend-shared';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { downloadJSONFile } from '../../../shared/download-json-file'; import { downloadJSONFile } from '../../../shared/download-json-file';
import { isReply } from '../../helpers/annotation-metadata'; import type { APIAnnotationData } from '../../../types/api';
import { annotationDisplayName } from '../../helpers/annotation-user';
import { annotationsByUser } from '../../helpers/annotations-by-user';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
import type { AnnotationsExporter } from '../../services/annotations-exporter'; import type { AnnotationsExporter } from '../../services/annotations-exporter';
import type { ToastMessengerService } from '../../services/toast-messenger'; import type { ToastMessengerService } from '../../services/toast-messenger';
...@@ -29,11 +36,22 @@ function ExportAnnotations({ ...@@ -29,11 +36,22 @@ function ExportAnnotations({
const exportReady = group && !store.isLoading(); const exportReady = group && !store.isLoading();
const exportableAnnotations = store.savedAnnotations(); const exportableAnnotations = store.savedAnnotations();
const replyCount = useMemo( const defaultAuthority = store.defaultAuthority();
() => exportableAnnotations.filter(ann => isReply(ann)).length, const displayNamesEnabled = store.isFeatureEnabled('client_display_names');
[exportableAnnotations], const getDisplayName = useCallback(
(ann: APIAnnotationData) =>
annotationDisplayName(ann, defaultAuthority, displayNamesEnabled),
[defaultAuthority, displayNamesEnabled],
); );
const nonReplyCount = exportableAnnotations.length - replyCount; const userList = useMemo(
() =>
annotationsByUser({ annotations: exportableAnnotations, getDisplayName }),
[exportableAnnotations, getDisplayName],
);
// User whose annotations are going to be exported. Preselect current user
const currentUser = store.profile().userid;
const [selectedUser, setSelectedUser] = useState(currentUser);
const draftCount = store.countDrafts(); const draftCount = store.countDrafts();
...@@ -51,10 +69,12 @@ function ExportAnnotations({ ...@@ -51,10 +69,12 @@ function ExportAnnotations({
e.preventDefault(); e.preventDefault();
try { try {
const annotationsToExport =
userList.find(item => item.userid === selectedUser)?.annotations ??
exportableAnnotations;
const filename = `${customFilename ?? defaultFilename}.json`; const filename = `${customFilename ?? defaultFilename}.json`;
const exportData = annotationsExporter.buildExportContent( const exportData =
exportableAnnotations, annotationsExporter.buildExportContent(annotationsToExport);
);
downloadJSONFile(exportData, filename); downloadJSONFile(exportData, filename);
} catch (e) { } catch (e) {
toastMessenger.error('Exporting annotations failed'); toastMessenger.error('Exporting annotations failed');
...@@ -75,19 +95,7 @@ function ExportAnnotations({ ...@@ -75,19 +95,7 @@ function ExportAnnotations({
{exportableAnnotations.length > 0 ? ( {exportableAnnotations.length > 0 ? (
<> <>
<label data-testid="export-count" htmlFor="export-filename"> <label data-testid="export-count" htmlFor="export-filename">
Export{' '} Name of export file:
<strong>
{nonReplyCount}{' '}
{pluralize(nonReplyCount, 'annotation', 'annotations')}
</strong>{' '}
{replyCount > 0
? `(and ${replyCount} ${pluralize(
replyCount,
'reply',
'replies',
)}) `
: ''}
in a file named:
</label> </label>
<Input <Input
data-testid="export-filename" data-testid="export-filename"
...@@ -100,6 +108,28 @@ function ExportAnnotations({ ...@@ -100,6 +108,28 @@ function ExportAnnotations({
required required
maxLength={250} maxLength={250}
/> />
<label htmlFor="export-user" className="block">
Select which user{"'"}s annotations to export:
</label>
<Select
id="export-user"
onChange={e =>
setSelectedUser((e.target as HTMLSelectElement).value || null)
}
>
<option value="" selected={!selectedUser}>
All annotations ({exportableAnnotations.length})
</option>
{userList.map(userInfo => (
<option
key={userInfo.userid}
value={userInfo.userid}
selected={userInfo.userid === selectedUser}
>
{userInfo.displayName} ({userInfo.annotations.length})
</option>
))}
</Select>
</> </>
) : ( ) : (
<p data-testid="no-annotations-message"> <p data-testid="no-annotations-message">
......
...@@ -2,8 +2,8 @@ import { Button, CardActions, Select } from '@hypothesis/frontend-shared'; ...@@ -2,8 +2,8 @@ import { Button, CardActions, Select } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useId, useMemo, useState } from 'preact/hooks'; import { useCallback, useEffect, useId, useMemo, useState } from 'preact/hooks';
import type { APIAnnotationData } from '../../../types/api'; import type { APIAnnotationData } from '../../../types/api';
import { isReply } from '../../helpers/annotation-metadata';
import { annotationDisplayName } from '../../helpers/annotation-user'; import { annotationDisplayName } from '../../helpers/annotation-user';
import { annotationsByUser } from '../../helpers/annotations-by-user';
import { readExportFile } from '../../helpers/import'; import { readExportFile } from '../../helpers/import';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
import type { ImportAnnotationsService } from '../../services/import-annotations'; import type { ImportAnnotationsService } from '../../services/import-annotations';
...@@ -11,43 +11,6 @@ import { useSidebarStore } from '../../store'; ...@@ -11,43 +11,6 @@ import { useSidebarStore } from '../../store';
import FileInput from './FileInput'; import FileInput from './FileInput';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
/** Details of a user and their annotations that are available to import. */
type UserAnnotations = {
userid: string;
displayName: string;
annotations: APIAnnotationData[];
};
/**
* Generate an alphabetized list of authors and their importable annotations.
*/
function annotationsByUser(
anns: APIAnnotationData[],
getDisplayName: (ann: APIAnnotationData) => string,
): UserAnnotations[] {
const userInfo = new Map<string, UserAnnotations>();
for (const ann of anns) {
if (isReply(ann)) {
// We decided to exclude replies from the initial implementation of
// annotation import, to simplify the feature.
continue;
}
let info = userInfo.get(ann.user);
if (!info) {
info = {
userid: ann.user,
displayName: getDisplayName(ann),
annotations: [],
};
userInfo.set(ann.user, info);
}
info.annotations.push(ann);
}
const userInfos = [...userInfo.values()];
userInfos.sort((a, b) => a.displayName.localeCompare(b.displayName));
return userInfos;
}
export type ImportAnnotationsProps = { export type ImportAnnotationsProps = {
importAnnotationsService: ImportAnnotationsService; importAnnotationsService: ImportAnnotationsService;
}; };
...@@ -83,7 +46,16 @@ function ImportAnnotations({ ...@@ -83,7 +46,16 @@ function ImportAnnotations({
[defaultAuthority, displayNamesEnabled], [defaultAuthority, displayNamesEnabled],
); );
const userList = useMemo( const userList = useMemo(
() => (annotations ? annotationsByUser(annotations, getDisplayName) : null), () =>
annotations
? annotationsByUser({
annotations,
getDisplayName,
// We decided to exclude replies from the initial implementation of
// annotation import, to simplify the feature.
excludeReplies: true,
})
: null,
[annotations, getDisplayName], [annotations, getDisplayName],
); );
......
...@@ -2,6 +2,7 @@ import { mount } from 'enzyme'; ...@@ -2,6 +2,7 @@ import { mount } from 'enzyme';
import { checkAccessibility } from '../../../../test-util/accessibility'; import { checkAccessibility } from '../../../../test-util/accessibility';
import { mockImportedComponents } from '../../../../test-util/mock-imported-components'; import { mockImportedComponents } from '../../../../test-util/mock-imported-components';
import { waitForElement } from '../../../../test-util/wait';
import * as fixtures from '../../../test/annotation-fixtures'; import * as fixtures from '../../../test/annotation-fixtures';
import ExportAnnotations, { $imports } from '../ExportAnnotations'; import ExportAnnotations, { $imports } from '../ExportAnnotations';
...@@ -35,6 +36,9 @@ describe('ExportAnnotations', () => { ...@@ -35,6 +36,9 @@ describe('ExportAnnotations', () => {
}; };
fakeDownloadJSONFile = sinon.stub(); fakeDownloadJSONFile = sinon.stub();
fakeStore = { fakeStore = {
defaultAuthority: sinon.stub().returns('example.com'),
isFeatureEnabled: sinon.stub().returns(true),
profile: sinon.stub().returns({ userid: 'acct:john@example.com' }),
countDrafts: sinon.stub().returns(0), countDrafts: sinon.stub().returns(0),
focusedGroup: sinon.stub().returns(fakePrivateGroup), focusedGroup: sinon.stub().returns(fakePrivateGroup),
isLoading: sinon.stub().returns(false), isLoading: sinon.stub().returns(false),
...@@ -84,39 +88,75 @@ describe('ExportAnnotations', () => { ...@@ -84,39 +88,75 @@ describe('ExportAnnotations', () => {
}); });
[ [
{ // A mix of annotations and replies.
annotations: [fixtures.oldAnnotation()],
message: 'Export 1 annotation in a file',
},
{
annotations: [fixtures.oldAnnotation(), fixtures.oldAnnotation()],
message: 'Export 2 annotations in a file',
},
{ {
annotations: [ annotations: [
fixtures.oldAnnotation(), {
fixtures.oldAnnotation(), id: 'abc',
fixtures.oldReply(), user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
{
id: 'def',
user: 'acct:brian@example.com',
user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
},
{
id: 'xyz',
user: 'acct:brian@example.com',
user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
references: ['abc'],
},
],
userEntries: [
{ value: '', text: 'All annotations (3)' }, // "No user selected" entry
{ value: 'acct:brian@example.com', text: 'Brian Smith (2)' },
{ value: 'acct:john@example.com', text: 'John Smith (1)' },
], ],
message: 'Export 2 annotations (and 1 reply) in a file',
}, },
// A single reply.
{ {
annotations: [ annotations: [
fixtures.oldAnnotation(), {
fixtures.oldAnnotation(), id: 'xyz',
fixtures.oldReply(), user: 'acct:brian@example.com',
fixtures.oldReply(), user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
references: ['abc'],
},
],
userEntries: [
{ value: '', text: 'All annotations (1)' }, // "No user selected" entry
{ value: 'acct:brian@example.com', text: 'Brian Smith (1)' },
], ],
message: 'Export 2 annotations (and 2 replies) in a file',
}, },
].forEach(({ annotations, message }) => { ].forEach(({ annotations, userEntries }) => {
it('shows a count of annotations for export', () => { it('displays a list with users who annotated the document', async () => {
fakeStore.savedAnnotations.returns(annotations); fakeStore.savedAnnotations.returns(annotations);
const wrapper = createComponent(); const wrapper = createComponent();
assert.include(
wrapper.find('[data-testid="export-count"]').text(), const userList = await waitForElement(wrapper, 'Select');
message, const users = userList.find('option');
); assert.equal(users.length, userEntries.length);
for (const [i, entry] of userEntries.entries()) {
assert.equal(users.at(i).prop('value'), entry.value);
assert.equal(users.at(i).text(), entry.text);
}
}); });
}); });
...@@ -132,7 +172,7 @@ describe('ExportAnnotations', () => { ...@@ -132,7 +172,7 @@ describe('ExportAnnotations', () => {
const submitExportForm = wrapper => const submitExportForm = wrapper =>
wrapper.find('[data-testid="export-form"]').simulate('submit'); wrapper.find('[data-testid="export-form"]').simulate('submit');
it('builds an export file from the non-draft annotations', () => { it('builds an export file from all non-draft annotations', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const annotationsToExport = [ const annotationsToExport = [
fixtures.oldAnnotation(), fixtures.oldAnnotation(),
...@@ -150,6 +190,59 @@ describe('ExportAnnotations', () => { ...@@ -150,6 +190,59 @@ describe('ExportAnnotations', () => {
assert.notCalled(fakeToastMessenger.error); assert.notCalled(fakeToastMessenger.error);
}); });
it('builds an export file from selected user annotations', async () => {
const selectedUserAnnotations = [
{
id: 'abc',
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
{
id: 'xyz',
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
references: ['def'],
},
];
const allAnnotations = [
...selectedUserAnnotations,
{
id: 'def',
user: 'acct:brian@example.com',
user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
},
];
fakeStore.savedAnnotations.returns(allAnnotations);
const wrapper = createComponent();
// Select the user whose annotations we want to export
const userList = await waitForElement(wrapper, 'Select');
userList.prop('onChange')({
target: {
value: 'acct:john@example.com',
},
});
wrapper.update();
submitExportForm(wrapper);
assert.calledOnce(fakeAnnotationsExporter.buildExportContent);
assert.calledWith(
fakeAnnotationsExporter.buildExportContent,
selectedUserAnnotations,
);
});
it('downloads a file using user-entered filename appended with `.json`', () => { it('downloads a file using user-entered filename appended with `.json`', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const filenameInput = wrapper.find( const filenameInput = wrapper.find(
......
import type { APIAnnotationData } from '../../types/api';
import { isReply } from './annotation-metadata';
/**
* Details of a user and their annotations that are available to import or export.
*/
export type UserAnnotations = {
userid: string;
displayName: string;
annotations: APIAnnotationData[];
};
export type AnnotationsByUserOptions = {
annotations: APIAnnotationData[];
getDisplayName: (ann: APIAnnotationData) => string;
/** If true, replies will be excluded from returned annotations */
excludeReplies?: boolean;
};
/**
* Generate an alphabetized list of authors and their importable/exportable
* annotations.
*/
export function annotationsByUser({
annotations,
getDisplayName,
excludeReplies = false,
}: AnnotationsByUserOptions): UserAnnotations[] {
const userInfo = new Map<string, UserAnnotations>();
for (const ann of annotations) {
if (excludeReplies && isReply(ann)) {
continue;
}
let info = userInfo.get(ann.user);
if (!info) {
info = {
userid: ann.user,
displayName: getDisplayName(ann),
annotations: [],
};
userInfo.set(ann.user, info);
}
info.annotations.push(ann);
}
const userInfos = [...userInfo.values()];
userInfos.sort((a, b) => a.displayName.localeCompare(b.displayName));
return userInfos;
}
import { annotationsByUser } from '../annotations-by-user';
describe('annotationsByUser', () => {
const annotations = [
{
id: 'abc',
user: 'acct:john@example.com',
text: 'Test annotation',
},
{
id: 'def',
user: 'acct:brian@example.com',
text: 'Test annotation',
},
{
id: 'xyz',
user: 'acct:brian@example.com',
text: 'Test annotation',
references: ['abc'],
},
];
it('groups annotations by user and result is sorted', () => {
const getDisplayName = ann => ann.user;
const [first, second, ...rest] = annotationsByUser({
annotations,
getDisplayName,
});
// It should only return the first two users
assert.equal(rest.length, 0);
assert.deepEqual(first, {
userid: 'acct:brian@example.com',
displayName: 'acct:brian@example.com',
annotations: [
{
id: 'def',
user: 'acct:brian@example.com',
text: 'Test annotation',
},
{
id: 'xyz',
user: 'acct:brian@example.com',
text: 'Test annotation',
references: ['abc'],
},
],
});
assert.deepEqual(second, {
userid: 'acct:john@example.com',
displayName: 'acct:john@example.com',
annotations: [
{
id: 'abc',
user: 'acct:john@example.com',
text: 'Test annotation',
},
],
});
});
it('allows replies to be excluded', () => {
const getDisplayName = ann => ann.user;
const [first, second, ...rest] = annotationsByUser({
annotations,
getDisplayName,
excludeReplies: true,
});
// It should only return the first two users
assert.equal(rest.length, 0);
assert.deepEqual(first, {
userid: 'acct:brian@example.com',
displayName: 'acct:brian@example.com',
annotations: [
{
id: 'def',
user: 'acct:brian@example.com',
text: 'Test annotation',
},
],
});
assert.deepEqual(second, {
userid: 'acct:john@example.com',
displayName: 'acct:john@example.com',
annotations: [
{
id: 'abc',
user: 'acct:john@example.com',
text: 'Test annotation',
},
],
});
});
});
import type { Annotation, APIAnnotationData } from '../../types/api'; import type { APIAnnotationData } from '../../types/api';
import { stripInternalProperties } from '../helpers/strip-internal-properties'; import { stripInternalProperties } from '../helpers/strip-internal-properties';
import { VersionData } from '../helpers/version-data'; import { VersionData } from '../helpers/version-data';
import type { SidebarStore } from '../store'; import type { SidebarStore } from '../store';
...@@ -23,7 +23,7 @@ export class AnnotationsExporter { ...@@ -23,7 +23,7 @@ export class AnnotationsExporter {
} }
buildExportContent( buildExportContent(
annotations: Annotation[], annotations: APIAnnotationData[],
/* istanbul ignore next - test seam */ /* istanbul ignore next - test seam */
now = new Date(), now = new Date(),
): ExportContent { ): ExportContent {
......
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