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 { useMemo, useState } from 'preact/hooks';
import {
Button,
CardActions,
Input,
Select,
} from '@hypothesis/frontend-shared';
import { useCallback, useMemo, useState } from 'preact/hooks';
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 type { AnnotationsExporter } from '../../services/annotations-exporter';
import type { ToastMessengerService } from '../../services/toast-messenger';
......@@ -29,11 +36,22 @@ function ExportAnnotations({
const exportReady = group && !store.isLoading();
const exportableAnnotations = store.savedAnnotations();
const replyCount = useMemo(
() => exportableAnnotations.filter(ann => isReply(ann)).length,
[exportableAnnotations],
const defaultAuthority = store.defaultAuthority();
const displayNamesEnabled = store.isFeatureEnabled('client_display_names');
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();
......@@ -51,10 +69,12 @@ function ExportAnnotations({
e.preventDefault();
try {
const annotationsToExport =
userList.find(item => item.userid === selectedUser)?.annotations ??
exportableAnnotations;
const filename = `${customFilename ?? defaultFilename}.json`;
const exportData = annotationsExporter.buildExportContent(
exportableAnnotations,
);
const exportData =
annotationsExporter.buildExportContent(annotationsToExport);
downloadJSONFile(exportData, filename);
} catch (e) {
toastMessenger.error('Exporting annotations failed');
......@@ -75,19 +95,7 @@ function ExportAnnotations({
{exportableAnnotations.length > 0 ? (
<>
<label data-testid="export-count" htmlFor="export-filename">
Export{' '}
<strong>
{nonReplyCount}{' '}
{pluralize(nonReplyCount, 'annotation', 'annotations')}
</strong>{' '}
{replyCount > 0
? `(and ${replyCount} ${pluralize(
replyCount,
'reply',
'replies',
)}) `
: ''}
in a file named:
Name of export file:
</label>
<Input
data-testid="export-filename"
......@@ -100,6 +108,28 @@ function ExportAnnotations({
required
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">
......
......@@ -2,8 +2,8 @@ import { Button, CardActions, Select } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useId, useMemo, useState } from 'preact/hooks';
import type { APIAnnotationData } from '../../../types/api';
import { isReply } from '../../helpers/annotation-metadata';
import { annotationDisplayName } from '../../helpers/annotation-user';
import { annotationsByUser } from '../../helpers/annotations-by-user';
import { readExportFile } from '../../helpers/import';
import { withServices } from '../../service-context';
import type { ImportAnnotationsService } from '../../services/import-annotations';
......@@ -11,43 +11,6 @@ import { useSidebarStore } from '../../store';
import FileInput from './FileInput';
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 = {
importAnnotationsService: ImportAnnotationsService;
};
......@@ -83,7 +46,16 @@ function ImportAnnotations({
[defaultAuthority, displayNamesEnabled],
);
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],
);
......
......@@ -2,6 +2,7 @@ import { mount } from 'enzyme';
import { checkAccessibility } from '../../../../test-util/accessibility';
import { mockImportedComponents } from '../../../../test-util/mock-imported-components';
import { waitForElement } from '../../../../test-util/wait';
import * as fixtures from '../../../test/annotation-fixtures';
import ExportAnnotations, { $imports } from '../ExportAnnotations';
......@@ -35,6 +36,9 @@ describe('ExportAnnotations', () => {
};
fakeDownloadJSONFile = sinon.stub();
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),
focusedGroup: sinon.stub().returns(fakePrivateGroup),
isLoading: sinon.stub().returns(false),
......@@ -84,39 +88,75 @@ describe('ExportAnnotations', () => {
});
[
// A mix of annotations and replies.
{
annotations: [fixtures.oldAnnotation()],
message: 'Export 1 annotation in a file',
annotations: [
{
id: 'abc',
user: 'acct:john@example.com',
user_info: {
display_name: 'John Smith',
},
text: 'Test annotation',
},
{
annotations: [fixtures.oldAnnotation(), fixtures.oldAnnotation()],
message: 'Export 2 annotations in a file',
id: 'def',
user: 'acct:brian@example.com',
user_info: {
display_name: 'Brian Smith',
},
text: 'Test annotation',
},
{
annotations: [
fixtures.oldAnnotation(),
fixtures.oldAnnotation(),
fixtures.oldReply(),
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: [
fixtures.oldAnnotation(),
fixtures.oldAnnotation(),
fixtures.oldReply(),
fixtures.oldReply(),
{
id: 'xyz',
user: 'acct:brian@example.com',
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 }) => {
it('shows a count of annotations for export', () => {
].forEach(({ annotations, userEntries }) => {
it('displays a list with users who annotated the document', async () => {
fakeStore.savedAnnotations.returns(annotations);
const wrapper = createComponent();
assert.include(
wrapper.find('[data-testid="export-count"]').text(),
message,
);
const userList = await waitForElement(wrapper, 'Select');
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', () => {
const submitExportForm = wrapper =>
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 annotationsToExport = [
fixtures.oldAnnotation(),
......@@ -150,6 +190,59 @@ describe('ExportAnnotations', () => {
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`', () => {
const wrapper = createComponent();
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 { VersionData } from '../helpers/version-data';
import type { SidebarStore } from '../store';
......@@ -23,7 +23,7 @@ export class AnnotationsExporter {
}
buildExportContent(
annotations: Annotation[],
annotations: APIAnnotationData[],
/* istanbul ignore next - test seam */
now = new Date(),
): 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