Skip to content

Commit ff6fdb6

Browse files
committed
Add AnnotationDocument component
1 parent 7572f23 commit ff6fdb6

File tree

7 files changed

+431
-0
lines changed

7 files changed

+431
-0
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { domainAndTitle } from '../helpers/annotation-metadata';
2+
import type { Annotation } from '../helpers/annotation-metadata';
3+
import AnnotationDocumentInfo from './AnnotationDocumentInfo';
4+
5+
export type AnnotationDocumentProps = {
6+
annotation: Annotation;
7+
pageNumber?: string;
8+
};
9+
10+
/**
11+
* Renders annotation document information, like document title, document URL
12+
* and a page number if provided.
13+
*/
14+
export default function AnnotationDocument({
15+
annotation,
16+
pageNumber,
17+
}: AnnotationDocumentProps) {
18+
const documentInfo = domainAndTitle(annotation);
19+
const annotationURL = annotation.links?.html || '';
20+
const documentLink =
21+
annotationURL && documentInfo.titleLink ? documentInfo.titleLink : '';
22+
23+
return (
24+
<span className="flex">
25+
{documentInfo.titleText && (
26+
<AnnotationDocumentInfo
27+
domain={documentInfo.domain}
28+
link={documentLink}
29+
title={documentInfo.titleText}
30+
/>
31+
)}
32+
{pageNumber && (
33+
<span className="text-grey-6" data-testid="page-number">
34+
{documentInfo.titleText && ', '}p. {pageNumber}
35+
</span>
36+
)}
37+
</span>
38+
);
39+
}

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { default as AnnotationDocument } from './AnnotationDocument';
12
export { default as AnnotationDocumentInfo } from './AnnotationDocumentInfo';
23
export { default as AnnotationGroupInfo } from './AnnotationGroupInfo';
34
export { default as AnnotationTimestamps } from './AnnotationTimestamps';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
checkAccessibility,
3+
mockImportedComponents,
4+
mount,
5+
} from '@hypothesis/frontend-testing';
6+
7+
import AnnotationDocument, { $imports } from '../AnnotationDocument';
8+
9+
describe('AnnotationDocument', () => {
10+
let fakeAnnotation;
11+
let fakeDomainAndTitle;
12+
13+
beforeEach(() => {
14+
fakeAnnotation = {};
15+
fakeDomainAndTitle = sinon.stub().returns({
16+
titleText: 'This document',
17+
titleLink: 'http://www.example.com',
18+
domain: 'www.foo.com',
19+
});
20+
21+
$imports.$mock(mockImportedComponents());
22+
$imports.$mock({
23+
'../helpers/annotation-metadata': {
24+
domainAndTitle: fakeDomainAndTitle,
25+
},
26+
});
27+
});
28+
29+
function createComponent() {
30+
return mount(<AnnotationDocument annotation={fakeAnnotation} />);
31+
}
32+
33+
it('should not render document info if document does not have a title', () => {
34+
fakeDomainAndTitle.returns({});
35+
36+
const wrapper = createComponent();
37+
const documentInfo = wrapper.find('AnnotationDocumentInfo');
38+
39+
assert.isFalse(documentInfo.exists());
40+
});
41+
42+
it('should set document properties as props to `AnnotationDocumentInfo`', () => {
43+
const wrapper = createComponent();
44+
45+
const documentInfo = wrapper.find('AnnotationDocumentInfo');
46+
47+
assert.isTrue(documentInfo.exists());
48+
assert.equal(documentInfo.props().title, 'This document');
49+
assert.equal(documentInfo.props().domain, 'www.foo.com');
50+
});
51+
52+
it('should provide document link for document info if annotation has an HTML link/URL', () => {
53+
fakeAnnotation.links = { html: 'http://www.whatever' };
54+
const wrapper = createComponent();
55+
56+
const documentInfo = wrapper.find('AnnotationDocumentInfo');
57+
58+
assert.equal(documentInfo.props().link, 'http://www.example.com');
59+
});
60+
61+
it(
62+
'should pass a11y checks',
63+
checkAccessibility({
64+
content: () => createComponent(),
65+
}),
66+
);
67+
});

src/helpers/annotation-metadata.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
export type Annotation = {
2+
uri: string;
3+
document: { title: string };
4+
links: {
5+
/** A "bouncer" URL that takes the user to see the annotation in context */
6+
incontext?: string;
7+
/** URL to view the annotation by itself. */
8+
html?: string;
9+
};
10+
};
11+
12+
export type DocumentMetadata = {
13+
uri: string;
14+
domain: string;
15+
title: string;
16+
};
17+
18+
/**
19+
* Extract document metadata from an annotation.
20+
*/
21+
export function documentMetadata(annotation: Annotation): DocumentMetadata {
22+
const uri = annotation.uri;
23+
24+
let domain;
25+
try {
26+
domain = new URL(uri).hostname;
27+
} catch {
28+
// Annotation URI parsing on the backend is very liberal compared to the URL
29+
// constructor. There is also some historic invalid data in h (eg [1]).
30+
// Hence, we must handle URL parsing failures in the client.
31+
//
32+
// [1] https://github.com/hypothesis/client/issues/3666
33+
domain = '';
34+
}
35+
if (domain === 'localhost') {
36+
domain = '';
37+
}
38+
39+
let title = domain;
40+
if (annotation.document && annotation.document.title) {
41+
title = annotation.document.title[0];
42+
}
43+
44+
return { uri, domain, title };
45+
}
46+
47+
export type DomainAndTitle = {
48+
domain: string;
49+
titleText: string;
50+
titleLink: string | null;
51+
};
52+
53+
/**
54+
* Return the domain and title of an annotation for display on an annotation
55+
* card.
56+
*/
57+
export function domainAndTitle(annotation: Annotation): DomainAndTitle {
58+
return {
59+
domain: domainTextFromAnnotation(annotation),
60+
titleText: titleTextFromAnnotation(annotation),
61+
titleLink: titleLinkFromAnnotation(annotation),
62+
};
63+
}
64+
65+
function titleLinkFromAnnotation(annotation: Annotation): string | null {
66+
let titleLink: string | null = annotation.uri;
67+
68+
if (
69+
titleLink &&
70+
!(titleLink.indexOf('http://') === 0 || titleLink.indexOf('https://') === 0)
71+
) {
72+
// We only link to http(s) URLs.
73+
titleLink = null;
74+
}
75+
76+
if (annotation.links && annotation.links.incontext) {
77+
titleLink = annotation.links.incontext;
78+
}
79+
80+
return titleLink;
81+
}
82+
/**
83+
* Returns the domain text from an annotation.
84+
*/
85+
function domainTextFromAnnotation(annotation: Annotation): string {
86+
const document = documentMetadata(annotation);
87+
88+
let domainText = '';
89+
if (document.uri && document.uri.indexOf('file://') === 0 && document.title) {
90+
const parts = document.uri.split('/');
91+
const filename = parts[parts.length - 1];
92+
if (filename) {
93+
domainText = filename;
94+
}
95+
} else if (document.domain && document.domain !== document.title) {
96+
domainText = document.domain;
97+
}
98+
99+
return domainText;
100+
}
101+
102+
/**
103+
* Returns the title text from an annotation and crops it to 30 chars
104+
* if needed.
105+
*/
106+
function titleTextFromAnnotation(annotation: Annotation): string {
107+
const document = documentMetadata(annotation);
108+
109+
let titleText = document.title;
110+
if (titleText.length > 30) {
111+
titleText = titleText.slice(0, 30) + '…';
112+
}
113+
114+
return titleText;
115+
}

src/helpers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
export { documentMetadata } from './annotation-metadata';
12
export { processAndReplaceMentionElements } from './mentions';
23

4+
export type { DocumentMetadata, DomainAndTitle } from './annotation-metadata';
35
export type { Group, GroupType } from './groups';
46
export type { Mention, MentionMode, InvalidMentionContent } from './mentions';

0 commit comments

Comments
 (0)