Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
0ae2b78f
Commit
0ae2b78f
authored
Aug 31, 2023
by
Alejandro Celaya
Committed by
Alejandro Celaya
Aug 31, 2023
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Allow selecting which user's annotations to export
parent
fe0618e0
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
329 additions
and
88 deletions
+329
-88
ExportAnnotations.tsx
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
+53
-23
ImportAnnotations.tsx
src/sidebar/components/ShareDialog/ImportAnnotations.tsx
+11
-39
ExportAnnotations-test.js
...bar/components/ShareDialog/test/ExportAnnotations-test.js
+117
-24
annotations-by-user.ts
src/sidebar/helpers/annotations-by-user.ts
+49
-0
annotations-by-user-test.js
src/sidebar/helpers/test/annotations-by-user-test.js
+97
-0
annotations-exporter.ts
src/sidebar/services/annotations-exporter.ts
+2
-2
No files found.
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
View file @
0ae2b78f
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">
...
...
src/sidebar/components/ShareDialog/ImportAnnotations.tsx
View file @
0ae2b78f
...
...
@@ -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
],
);
...
...
src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js
View file @
0ae2b78f
...
...
@@ -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
(
...
...
src/sidebar/helpers/annotations-by-user.ts
0 → 100644
View file @
0ae2b78f
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
;
}
src/sidebar/helpers/test/annotations-by-user-test.js
0 → 100644
View file @
0ae2b78f
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'
,
},
],
});
});
});
src/sidebar/services/annotations-exporter.ts
View file @
0ae2b78f
import
type
{
A
nnotation
,
A
PIAnnotationData
}
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
:
A
nnotation
[],
annotations
:
A
PIAnnotationData
[],
/* istanbul ignore next - test seam */
now
=
new
Date
(),
):
ExportContent
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment