Commit 64ff836d authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Use SelectNext for import and export user selectors

parent 36f918df
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.16.7", "@babel/preset-typescript": "^7.16.7",
"@hypothesis/frontend-build": "^2.0.0", "@hypothesis/frontend-build": "^2.0.0",
"@hypothesis/frontend-shared": "^6.5.0", "@hypothesis/frontend-shared": "^6.8.1",
"@hypothesis/frontend-testing": "^1.0.1", "@hypothesis/frontend-testing": "^1.0.1",
"@npmcli/arborist": "^7.0.0", "@npmcli/arborist": "^7.0.0",
"@octokit/rest": "^20.0.1", "@octokit/rest": "^20.0.1",
......
...@@ -3,13 +3,14 @@ import { ...@@ -3,13 +3,14 @@ import {
CardActions, CardActions,
Link, Link,
Input, Input,
Select, SelectNext,
} from '@hypothesis/frontend-shared'; } from '@hypothesis/frontend-shared';
import { useCallback, useMemo, useState } from 'preact/hooks'; import { useCallback, useId, useMemo, useState } from 'preact/hooks';
import { downloadJSONFile } from '../../../shared/download-json-file'; import { downloadJSONFile } from '../../../shared/download-json-file';
import type { APIAnnotationData } from '../../../types/api'; import type { APIAnnotationData } from '../../../types/api';
import { annotationDisplayName } from '../../helpers/annotation-user'; import { annotationDisplayName } from '../../helpers/annotation-user';
import type { UserAnnotations } from '../../helpers/annotations-by-user';
import { annotationsByUser } from '../../helpers/annotations-by-user'; import { annotationsByUser } from '../../helpers/annotations-by-user';
import { suggestedFilename } from '../../helpers/export-annotations'; import { suggestedFilename } from '../../helpers/export-annotations';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
...@@ -17,6 +18,7 @@ import type { AnnotationsExporter } from '../../services/annotations-exporter'; ...@@ -17,6 +18,7 @@ import type { AnnotationsExporter } from '../../services/annotations-exporter';
import type { ToastMessengerService } from '../../services/toast-messenger'; import type { ToastMessengerService } from '../../services/toast-messenger';
import { useSidebarStore } from '../../store'; import { useSidebarStore } from '../../store';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
import { UserAnnotationsListItem } from './UserAnnotationsListItem';
export type ExportAnnotationsProps = { export type ExportAnnotationsProps = {
// injected // injected
...@@ -51,9 +53,23 @@ function ExportAnnotations({ ...@@ -51,9 +53,23 @@ function ExportAnnotations({
[exportableAnnotations, getDisplayName], [exportableAnnotations, getDisplayName],
); );
// User whose annotations are going to be exported. Preselect current user // User whose annotations are going to be exported.
const currentUser = store.profile().userid; const currentUser = store.profile().userid;
const [selectedUser, setSelectedUser] = useState(currentUser); const allAnnotationsOption: Omit<UserAnnotations, 'userid'> = useMemo(
() => ({
annotations: exportableAnnotations,
displayName: 'All annotations',
}),
[exportableAnnotations],
);
const [selectedUser, setSelectedUser] = useState(
// Try to preselect current user
userList.find(userInfo => userInfo.userid === currentUser) ??
allAnnotationsOption,
);
const fileInputId = useId();
const userSelectId = useId();
const draftCount = store.countDrafts(); const draftCount = store.countDrafts();
...@@ -76,8 +92,7 @@ function ExportAnnotations({ ...@@ -76,8 +92,7 @@ function ExportAnnotations({
try { try {
const annotationsToExport = const annotationsToExport =
userList.find(item => item.userid === selectedUser)?.annotations ?? selectedUser?.annotations ?? exportableAnnotations;
exportableAnnotations;
const filename = `${customFilename ?? defaultFilename}.json`; const filename = `${customFilename ?? defaultFilename}.json`;
const exportData = const exportData =
annotationsExporter.buildExportContent(annotationsToExport); annotationsExporter.buildExportContent(annotationsToExport);
...@@ -113,14 +128,14 @@ function ExportAnnotations({ ...@@ -113,14 +128,14 @@ function ExportAnnotations({
</p> </p>
<label <label
data-testid="export-count" data-testid="export-count"
htmlFor="export-filename" htmlFor={fileInputId}
className="font-medium" className="font-medium"
> >
Name of export file: Name of export file:
</label> </label>
<Input <Input
data-testid="export-filename" data-testid="export-filename"
id="export-filename" id={fileInputId}
defaultValue={defaultFilename} defaultValue={defaultFilename}
value={customFilename} value={customFilename}
onChange={e => onChange={e =>
...@@ -129,28 +144,30 @@ function ExportAnnotations({ ...@@ -129,28 +144,30 @@ function ExportAnnotations({
required required
maxLength={250} maxLength={250}
/> />
<label htmlFor="export-user" className="block font-medium"> <label htmlFor={userSelectId} className="block font-medium">
Select which user{"'"}s annotations to export: Select which user{"'"}s annotations to export:
</label> </label>
<Select <SelectNext
id="export-user" value={selectedUser}
onChange={e => onChange={setSelectedUser}
setSelectedUser((e.target as HTMLSelectElement).value || null) buttonId={userSelectId}
buttonContent={
<UserAnnotationsListItem userAnnotations={selectedUser} />
} }
> >
<option value="" selected={!selectedUser}> <SelectNext.Option value={allAnnotationsOption}>
All annotations ({exportableAnnotations.length}) {() => (
</option> <UserAnnotationsListItem
userAnnotations={allAnnotationsOption}
/>
)}
</SelectNext.Option>
{userList.map(userInfo => ( {userList.map(userInfo => (
<option <SelectNext.Option key={userInfo.userid} value={userInfo}>
key={userInfo.userid} {() => <UserAnnotationsListItem userAnnotations={userInfo} />}
value={userInfo.userid} </SelectNext.Option>
selected={userInfo.userid === selectedUser}
>
{userInfo.displayName} ({userInfo.annotations.length})
</option>
))} ))}
</Select> </SelectNext>
</> </>
) : ( ) : (
<p data-testid="no-annotations-message"> <p data-testid="no-annotations-message">
......
import { Button, CardActions, Link, Select } from '@hypothesis/frontend-shared'; import {
Button,
CardActions,
Link,
SelectNext,
} 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';
...@@ -10,6 +15,7 @@ import type { ImportAnnotationsService } from '../../services/import-annotations ...@@ -10,6 +15,7 @@ import type { ImportAnnotationsService } from '../../services/import-annotations
import { useSidebarStore } from '../../store'; import { useSidebarStore } from '../../store';
import CircularProgress from '../CircularProgress'; import CircularProgress from '../CircularProgress';
import FileInput from './FileInput'; import FileInput from './FileInput';
import { UserAnnotationsListItem } from './UserAnnotationsListItem';
export type ImportAnnotationsProps = { export type ImportAnnotationsProps = {
importAnnotationsService: ImportAnnotationsService; importAnnotationsService: ImportAnnotationsService;
...@@ -32,9 +38,6 @@ function ImportAnnotations({ ...@@ -32,9 +38,6 @@ function ImportAnnotations({
); );
const [error, setError] = useState<string | 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 store = useSidebarStore();
const currentUser = store.profile().userid; const currentUser = store.profile().userid;
...@@ -59,6 +62,13 @@ function ImportAnnotations({ ...@@ -59,6 +62,13 @@ function ImportAnnotations({
[annotations, getDisplayName], [annotations, getDisplayName],
); );
// User whose annotations are going to be imported.
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const selectedUser = useMemo(
() => userList?.find(user => user.userid === selectedUserId) ?? null,
[selectedUserId, userList],
);
// Parse input file, extract annotations and update the user list. // Parse input file, extract annotations and update the user list.
useEffect(() => { useEffect(() => {
if (!currentUser || !file) { if (!currentUser || !file) {
...@@ -66,7 +76,7 @@ function ImportAnnotations({ ...@@ -66,7 +76,7 @@ function ImportAnnotations({
} }
setAnnotations(null); setAnnotations(null);
setError(null); setError(null);
setSelectedUser(null); setSelectedUserId(null);
readExportFile(file) readExportFile(file)
.then(annotations => { .then(annotations => {
setAnnotations(annotations); setAnnotations(annotations);
...@@ -74,7 +84,7 @@ function ImportAnnotations({ ...@@ -74,7 +84,7 @@ function ImportAnnotations({
// Pre-select the current user in the list, if at least one of the // Pre-select the current user in the list, if at least one of the
// annotations was authored by them. // annotations was authored by them.
const userMatch = annotations.some(ann => ann.user === currentUser); const userMatch = annotations.some(ann => ann.user === currentUser);
setSelectedUser(userMatch ? currentUser : null); setSelectedUserId(userMatch ? currentUser : null);
}) })
.catch(err => { .catch(err => {
setError(err.message); setError(err.message);
...@@ -82,17 +92,11 @@ function ImportAnnotations({ ...@@ -82,17 +92,11 @@ function ImportAnnotations({
}, [currentUser, file]); }, [currentUser, file]);
let importAnnotations; let importAnnotations;
if (annotations && selectedUser && userList) { if (selectedUser) {
importAnnotations = async () => { importAnnotations = async () => {
const userEntry = userList.find(item => item.userid === selectedUser);
/* istanbul ignore next - This should never be triggered */
if (!userEntry) {
return;
}
// nb. In the event of an error, `import` will report that directly via // nb. In the event of an error, `import` will report that directly via
// a toast message, so we don't do that ourselves. // a toast message, so we don't do that ourselves.
importAnnotationsService.import(userEntry.annotations); importAnnotationsService.import(selectedUser.annotations);
}; };
} }
...@@ -159,25 +163,24 @@ function ImportAnnotations({ ...@@ -159,25 +163,24 @@ function ImportAnnotations({
<label htmlFor={userSelectId} className="block font-medium"> <label htmlFor={userSelectId} className="block font-medium">
Select which user&apos;s annotations to import: Select which user&apos;s annotations to import:
</label> </label>
<Select <SelectNext
id={userSelectId} value={selectedUser}
data-testid="user-list" onChange={newValue => setSelectedUserId(newValue?.userid ?? null)}
disabled={busy} buttonId={userSelectId}
onChange={e => buttonContent={
setSelectedUser((e.target as HTMLSelectElement).value || null) selectedUser ? (
<UserAnnotationsListItem userAnnotations={selectedUser} />
) : (
<span className="text-grey-6">Select a user...</span>
)
} }
> >
<option value="" selected={!selectedUser} />
{userList.map(userInfo => ( {userList.map(userInfo => (
<option <SelectNext.Option key={userInfo.userid} value={userInfo}>
key={userInfo.userid} {() => <UserAnnotationsListItem userAnnotations={userInfo} />}
value={userInfo.userid} </SelectNext.Option>
selected={userInfo.userid === selectedUser}
>
{userInfo.displayName} ({userInfo.annotations.length})
</option>
))} ))}
</Select> </SelectNext>
</> </>
)} )}
{error && ( {error && (
......
import type { UserAnnotations } from '../../helpers/annotations-by-user';
export type UserAnnotationsListItemProps = {
userAnnotations: Omit<UserAnnotations, 'userid'>;
};
/**
* UserAnnotations representation to use inside `SelectNext.Option`.
*/
export function UserAnnotationsListItem({
userAnnotations,
}: UserAnnotationsListItemProps) {
return (
<div className="flex gap-x-2">
{userAnnotations.displayName}
<div className="rounded px-1 bg-grey-7 text-white">
{userAnnotations.annotations.length}
</div>
</div>
);
}
import { SelectNext } from '@hypothesis/frontend-shared';
import { import {
checkAccessibility, checkAccessibility,
mockImportedComponents, mockImportedComponents,
waitForElement, waitForElement,
} from '@hypothesis/frontend-testing'; } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import * as fixtures from '../../../test/annotation-fixtures'; import * as fixtures from '../../../test/annotation-fixtures';
import ExportAnnotations, { $imports } from '../ExportAnnotations'; import ExportAnnotations, { $imports } from '../ExportAnnotations';
...@@ -66,9 +68,12 @@ describe('ExportAnnotations', () => { ...@@ -66,9 +68,12 @@ describe('ExportAnnotations', () => {
'../../store': { useSidebarStore: () => fakeStore }, '../../store': { useSidebarStore: () => fakeStore },
}); });
// Restore this very simple component to get it test coverage
$imports.$restore({ $imports.$restore({
// Restore this very simple component to get it test coverage
'./LoadingSpinner': true, './LoadingSpinner': true,
// Restore UserAnnotationsListItem, as it's used as some buttons' content
// and is needed to make a11y tests pass
'./UserAnnotationsListItem': true,
}); });
}); });
...@@ -126,9 +131,21 @@ describe('ExportAnnotations', () => { ...@@ -126,9 +131,21 @@ describe('ExportAnnotations', () => {
}, },
], ],
userEntries: [ userEntries: [
{ value: '', text: 'All annotations (3)' }, // "No user selected" entry {
{ value: 'acct:brian@example.com', text: 'Brian Smith (2)' }, // "No user selected" entry
{ value: 'acct:john@example.com', text: 'John Smith (1)' }, displayName: 'All annotations',
annotationsCount: 3,
},
{
userid: 'acct:brian@example.com',
displayName: 'Brian Smith',
annotationsCount: 2,
},
{
userid: 'acct:john@example.com',
displayName: 'John Smith',
annotationsCount: 1,
},
], ],
}, },
...@@ -146,8 +163,16 @@ describe('ExportAnnotations', () => { ...@@ -146,8 +163,16 @@ describe('ExportAnnotations', () => {
}, },
], ],
userEntries: [ userEntries: [
{ value: '', text: 'All annotations (1)' }, // "No user selected" entry {
{ value: 'acct:brian@example.com', text: 'Brian Smith (1)' }, // "No user selected" entry
displayName: 'All annotations',
annotationsCount: 1,
},
{
userid: 'acct:brian@example.com',
displayName: 'Brian Smith',
annotationsCount: 1,
},
], ],
}, },
].forEach(({ annotations, userEntries }) => { ].forEach(({ annotations, userEntries }) => {
...@@ -156,13 +181,16 @@ describe('ExportAnnotations', () => { ...@@ -156,13 +181,16 @@ describe('ExportAnnotations', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, SelectNext);
const users = userList.find('option'); const users = userList.find(SelectNext.Option);
assert.equal(users.length, userEntries.length); assert.equal(users.length, userEntries.length);
for (const [i, entry] of userEntries.entries()) { for (const [i, entry] of userEntries.entries()) {
assert.equal(users.at(i).prop('value'), entry.value); const value = users.at(i).prop('value');
assert.equal(users.at(i).text(), entry.text);
assert.equal(value.userid, entry.userid);
assert.equal(value.displayName, entry.displayName);
assert.equal(value.annotations.length, entry.annotationsCount);
} }
}); });
}); });
...@@ -237,11 +265,15 @@ describe('ExportAnnotations', () => { ...@@ -237,11 +265,15 @@ describe('ExportAnnotations', () => {
const wrapper = createComponent(); const wrapper = createComponent();
// Select the user whose annotations we want to export // Select the user whose annotations we want to export
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, SelectNext);
userList.prop('onChange')({ const option = userList
target: { .find(SelectNext.Option)
value: 'acct:john@example.com', .filterWhere(
}, option => option.prop('value').userid === 'acct:john@example.com',
)
.first();
act(() => {
userList.prop('onChange')(option.prop('value'));
}); });
wrapper.update(); wrapper.update();
......
import { SelectNext } from '@hypothesis/frontend-shared';
import { import {
checkAccessibility, checkAccessibility,
waitFor, waitFor,
...@@ -99,8 +100,8 @@ describe('ImportAnnotations', () => { ...@@ -99,8 +100,8 @@ describe('ImportAnnotations', () => {
}, },
]; ];
selectFile(wrapper, annotations); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'select'); const userList = await waitForElement(wrapper, SelectNext);
assert.ok(userList.getDOMNode().value); // Current user should be auto-selected assert.ok(userList.prop('value')); // Current user should be auto-selected
// Import button should be disabled since we don't have the things we need // Import button should be disabled since we don't have the things we need
// to perform the import. // to perform the import.
...@@ -159,9 +160,16 @@ describe('ImportAnnotations', () => { ...@@ -159,9 +160,16 @@ describe('ImportAnnotations', () => {
}, },
], ],
userEntries: [ userEntries: [
{ value: '', text: '' }, // "No user selected" entry {
{ value: 'acct:brian@example.com', text: 'Brian Smith (1)' }, userid: 'acct:brian@example.com',
{ value: 'acct:john@example.com', text: 'John Smith (1)' }, displayName: 'Brian Smith',
annotationsCount: 1,
},
{
userid: 'acct:john@example.com',
displayName: 'John Smith',
annotationsCount: 1,
},
], ],
}, },
...@@ -178,9 +186,7 @@ describe('ImportAnnotations', () => { ...@@ -178,9 +186,7 @@ describe('ImportAnnotations', () => {
references: ['abc'], references: ['abc'],
}, },
], ],
userEntries: [ userEntries: [],
{ value: '', text: '' }, // "No user selected" entry
],
}, },
].forEach(({ annotations, userEntries }) => { ].forEach(({ annotations, userEntries }) => {
it('displays user list when a valid file is selected', async () => { it('displays user list when a valid file is selected', async () => {
...@@ -188,13 +194,17 @@ describe('ImportAnnotations', () => { ...@@ -188,13 +194,17 @@ describe('ImportAnnotations', () => {
selectFile(wrapper, annotations); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, SelectNext);
const users = userList.find('option'); const users = userList.find(SelectNext.Option);
assert.equal(users.length, userEntries.length); assert.equal(users.length, userEntries.length);
for (const [i, entry] of userEntries.entries()) { for (const [i, entry] of userEntries.entries()) {
assert.equal(users.at(i).prop('value'), entry.value); const optionValue = users.at(i).prop('value');
assert.equal(users.at(i).text(), entry.text);
assert.equal(optionValue.userid, entry.userid);
assert.equal(optionValue.displayName, entry.displayName);
assert.equal(optionValue.annotations.length, entry.annotationsCount);
} }
}); });
}); });
...@@ -225,8 +235,8 @@ describe('ImportAnnotations', () => { ...@@ -225,8 +235,8 @@ describe('ImportAnnotations', () => {
]; ];
selectFile(wrapper, annotations); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, SelectNext);
assert.equal(userList.getDOMNode().value, 'acct:john@example.com'); assert.equal(userList.props().value.userid, 'acct:john@example.com');
}); });
it('does not select a user if no user matches logged-in user', async () => { it('does not select a user if no user matches logged-in user', async () => {
...@@ -243,8 +253,8 @@ describe('ImportAnnotations', () => { ...@@ -243,8 +253,8 @@ describe('ImportAnnotations', () => {
]; ];
selectFile(wrapper, annotations); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'Select'); const userList = await waitForElement(wrapper, SelectNext);
assert.equal(userList.getDOMNode().value, ''); assert.equal(userList.prop('value'), null);
}); });
it('imports annotations when "Import" button is clicked', async () => { it('imports annotations when "Import" button is clicked', async () => {
...@@ -281,9 +291,16 @@ describe('ImportAnnotations', () => { ...@@ -281,9 +291,16 @@ describe('ImportAnnotations', () => {
selectFile(wrapper, annotations); selectFile(wrapper, annotations);
const userList = await waitForElement(wrapper, 'select'); const userList = await waitForElement(wrapper, SelectNext);
userList.getDOMNode().value = 'acct:brian@example.com'; const option = userList
userList.simulate('change'); .find(SelectNext.Option)
.filterWhere(
option => option.prop('value').userid === 'acct:brian@example.com',
)
.first();
userList.prop('onChange')(option.prop('value'));
wrapper.update();
const importButton = getImportButton(wrapper).getDOMNode(); const importButton = getImportButton(wrapper).getDOMNode();
await waitFor(() => !importButton.disabled); await waitFor(() => !importButton.disabled);
......
...@@ -2059,15 +2059,15 @@ __metadata: ...@@ -2059,15 +2059,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@hypothesis/frontend-shared@npm:^6.5.0": "@hypothesis/frontend-shared@npm:^6.8.1":
version: 6.8.0 version: 6.8.1
resolution: "@hypothesis/frontend-shared@npm:6.8.0" resolution: "@hypothesis/frontend-shared@npm:6.8.1"
dependencies: dependencies:
highlight.js: ^11.6.0 highlight.js: ^11.6.0
wouter-preact: ^2.10.0-alpha.1 wouter-preact: ^2.10.0-alpha.1
peerDependencies: peerDependencies:
preact: ^10.4.0 preact: ^10.4.0
checksum: e2981d395929d9ddd5b6b78f35b4aebe5422ebbdaa48f6e65684cb1514534186e98559b2aadbaad77f3dc02652b73128c5bf3714d86b14f4104d5162d9d79466 checksum: d2f78b52c75930a9e4e337874835977b8569320ca64c2e75df125a9f45427fb4b93267d7b153ee3e2a7021ce9f49c54fcd0ad7a9cc44ad55cf531ddaaa184b7e
languageName: node languageName: node
linkType: hard linkType: hard
...@@ -7403,7 +7403,7 @@ __metadata: ...@@ -7403,7 +7403,7 @@ __metadata:
"@babel/preset-react": ^7.0.0 "@babel/preset-react": ^7.0.0
"@babel/preset-typescript": ^7.16.7 "@babel/preset-typescript": ^7.16.7
"@hypothesis/frontend-build": ^2.0.0 "@hypothesis/frontend-build": ^2.0.0
"@hypothesis/frontend-shared": ^6.5.0 "@hypothesis/frontend-shared": ^6.8.1
"@hypothesis/frontend-testing": ^1.0.1 "@hypothesis/frontend-testing": ^1.0.1
"@npmcli/arborist": ^7.0.0 "@npmcli/arborist": ^7.0.0
"@octokit/rest": ^20.0.1 "@octokit/rest": ^20.0.1
......
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