Unverified Commit 2ed42cfb authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1219 from hypothesis/annotation-header-preact

Convert `AnnotationHeader` and its allies to preact
parents d8efffcf c673c916
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="48px" height="56px" viewBox="0 0 48 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="48px" height="56px" viewBox="0 0 48 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 3.6.1 (26313) - http://www.bohemiancoding.com/sketch --> <!-- Generator: Sketch 3.6.1 (26313) - http://www.bohemiancoding.com/sketch -->
<title>Group 4 Copy 3</title> <title>Only me</title>
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<defs></defs> <defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
......
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const annotationMetadata = require('../annotation-metadata');
/**
* Render some metadata about an annotation's document and link to it
* if a link is available.
*/
function AnnotationDocumentInfo({ annotation }) {
const documentInfo = annotationMetadata.domainAndTitle(annotation);
// If there's no document title, nothing to do here
if (!documentInfo.titleText) {
return null;
}
return (
<div className="annotation-document-info">
<div className="annotation-document-info__title">
on &quot;
{documentInfo.titleLink ? (
<a href={documentInfo.titleLink}>{documentInfo.titleText}</a>
) : (
<span>{documentInfo.titleText}</span>
)}
&quot;
</div>
{documentInfo.domain && (
<div className="annotation-document-info__domain">
({documentInfo.domain})
</div>
)}
</div>
);
}
AnnotationDocumentInfo.propTypes = {
/* Annotation for which the document metadata will be rendered */
annotation: propTypes.object.isRequired,
};
module.exports = AnnotationDocumentInfo;
'use strict'; 'use strict';
const annotationMetadata = require('../annotation-metadata'); const propTypes = require('prop-types');
const memoize = require('../util/memoize'); const { createElement } = require('preact');
const { isThirdPartyUser, username } = require('../util/account-id');
// @ngInject const AnnotationDocumentInfo = require('./annotation-document-info');
function AnnotationHeaderController(features, groups, settings, serviceUrl) { const AnnotationShareInfo = require('./annotation-share-info');
const self = this; const AnnotationUser = require('./annotation-user');
const Timestamp = require('./timestamp');
this.user = function() {
return self.annotation.user;
};
this.displayName = () => {
const userInfo = this.annotation.user_info;
const isThirdPartyUser_ = isThirdPartyUser(
this.annotation.user,
settings.authDomain
);
if (features.flagEnabled('client_display_names') || isThirdPartyUser_) {
// userInfo is undefined if the api_render_user_info feature flag is off.
if (userInfo) {
// display_name is null if the user doesn't have a display name.
if (userInfo.display_name) {
return userInfo.display_name;
}
}
}
return username(this.annotation.user);
};
this.isThirdPartyUser = function() {
return isThirdPartyUser(self.annotation.user, settings.authDomain);
};
this.thirdPartyUsernameLink = function() {
return settings.usernameUrl
? settings.usernameUrl + username(this.annotation.user)
: null;
};
this.serviceUrl = serviceUrl;
this.group = function() {
return groups.get(self.annotation.group);
};
const documentMeta = memoize(annotationMetadata.domainAndTitle);
this.documentMeta = function() {
return documentMeta(self.annotation);
};
this.updated = function() {
return self.annotation.updated;
};
this.htmlLink = function() {
if (self.annotation.links && self.annotation.links.html) {
return self.annotation.links.html;
}
return '';
};
}
/** /**
* Header component for an annotation card. * Render an annotation's header summary, including metadata about its user,
* * sharing status, document and timestamp. It also allows the user to
* Header which displays the username, last update timestamp and other key * toggle sub-threads/replies in certain cases.
* metadata about an annotation.
*/ */
module.exports = { function AnnotationHeader({
controller: AnnotationHeaderController, annotation,
controllerAs: 'vm', isEditing,
bindings: { isHighlight,
/** isPrivate,
* The saved annotation onReplyCountClick,
*/ replyCount,
annotation: '<', showDocumentInfo,
}) {
/** const annotationLink = annotation.links ? annotation.links.html : '';
* True if the annotation is private or will become private when the user const replyPluralized = !replyCount || replyCount > 1 ? 'replies' : 'reply';
* saves their changes.
*/ return (
isPrivate: '<', <header className="annotation-header">
<div className="annotation-header__row">
/** True if the user is currently editing the annotation. */ <AnnotationUser annotation={annotation} />
isEditing: '<', <div className="annotation-collapsed-replies">
<a className="annotation-link" onClick={onReplyCountClick}>
{replyCount} {replyPluralized}
</a>
</div>
{!isEditing && annotation.updated && (
<div className="annotation-header__timestamp">
<Timestamp
className="annotation-header__timestamp-link"
href={annotationLink}
timestamp={annotation.updated}
/>
</div>
)}
</div>
<div className="annotation-header__row">
<AnnotationShareInfo annotation={annotation} isPrivate={isPrivate} />
{!isEditing && isHighlight && (
<div className="annotation-header__highlight">
<i
className="h-icon-border-color"
title="This is a highlight. Click 'edit' to add a note or tag."
/>
</div>
)}
{showDocumentInfo && <AnnotationDocumentInfo annotation={annotation} />}
</div>
</header>
);
}
AnnotationHeader.propTypes = {
/* The annotation */
annotation: propTypes.object.isRequired,
/* Whether the annotation is actively being edited */
isEditing: propTypes.bool,
/* Whether the annotation is a highlight */
isHighlight: propTypes.bool,
/* Whether the annotation is an "only me" (private) annotation */
isPrivate: propTypes.bool,
/* Callback for when the toggle-replies element is clicked */
onReplyCountClick: propTypes.func.isRequired,
/* How many replies this annotation currently has */
replyCount: propTypes.number,
/** /**
* True if the annotation is a highlight. * Should document metadata be rendered? Hint: this is enabled for single-
* FIXME: This should determined in AnnotationHeaderController * annotation and stream views
*/ */
isHighlight: '<', showDocumentInfo: propTypes.bool,
onReplyCountClick: '&',
replyCount: '<',
/** True if document metadata should be shown. */
showDocumentInfo: '<',
},
template: require('../templates/annotation-header.html'),
}; };
module.exports = AnnotationHeader;
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const SvgIcon = require('./svg-icon');
const useStore = require('../store/use-store');
/**
* Render information about what group an annotation is in and
* whether it is private to the current user (only me)
*/
function AnnotationShareInfo({ annotation, isPrivate }) {
const group = useStore(store => store.getGroup(annotation.group));
// We may not have access to the group object beyond its ID
const hasGroup = !!group;
// Only show the name of the group and link to it if there is a
// URL (link) returned by the API for this group. Some groups do not have links
const linkToGroup = hasGroup && group.links && group.links.html;
return (
<div className="annotation-share-info">
{linkToGroup && (
<a
className="annotation-share-info__group"
href={group.links.html}
target="_blank"
rel="noopener noreferrer"
>
{group.type === 'open' ? (
<SvgIcon className="annotation-share-info__icon" name="public" />
) : (
<SvgIcon className="annotation-share-info__icon" name="groups" />
)}
<span className="annotation-share-info__group-info">
{group.name}
</span>
</a>
)}
{isPrivate && (
<span
className="annotation-share-info__private"
title="This annotation is visible only to you."
>
{/* Show the lock icon in all cases when the annotation is private... */}
<SvgIcon className="annotation-share-info__icon" name="lock" />
{/* but only render the "Only Me" text if we're not showing/linking a group name */}
{!linkToGroup && (
<span className="annotation-share-info__private-info">Only me</span>
)}
</span>
)}
</div>
);
}
AnnotationShareInfo.propTypes = {
/** The current annotation object for which sharing info will be rendered */
annotation: propTypes.object.isRequired,
/** Is this an "only me" (private) annotation? */
isPrivate: propTypes.bool.isRequired,
};
module.exports = AnnotationShareInfo;
...@@ -30,8 +30,9 @@ function AnnotationUser({ annotation, features, serviceUrl, settings }) { ...@@ -30,8 +30,9 @@ function AnnotationUser({ annotation, features, serviceUrl, settings }) {
if (shouldLinkToActivity) { if (shouldLinkToActivity) {
return ( return (
<div className="annotation-user">
<a <a
className="annotation-user" className="annotation-user__link"
href={ href={
isFirstPartyUser isFirstPartyUser
? serviceUrl('user', { user }) ? serviceUrl('user', { user })
...@@ -40,12 +41,17 @@ function AnnotationUser({ annotation, features, serviceUrl, settings }) { ...@@ -40,12 +41,17 @@ function AnnotationUser({ annotation, features, serviceUrl, settings }) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{displayName} <span className="annotation-user__user-name">{displayName}</span>
</a> </a>
</div>
); );
} }
return <div className="annotation-user">{displayName}</div>; return (
<div className="annotation-user">
<span className="annotation-user__user-name">{displayName}</span>
</div>
);
} }
AnnotationUser.propTypes = { AnnotationUser.propTypes = {
......
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const fixtures = require('../../test/annotation-fixtures');
const AnnotationDocumentInfo = require('../annotation-document-info');
describe('AnnotationDocumentInfo', () => {
let fakeDomainAndTitle;
let fakeMetadata;
const createAnnotationDocumentInfo = props => {
return shallow(
<AnnotationDocumentInfo
annotation={fixtures.defaultAnnotation()}
{...props}
/>
);
};
beforeEach(() => {
fakeDomainAndTitle = sinon.stub();
fakeMetadata = { domainAndTitle: fakeDomainAndTitle };
AnnotationDocumentInfo.$imports.$mock({
'../annotation-metadata': fakeMetadata,
});
});
afterEach(() => {
AnnotationDocumentInfo.$imports.$restore();
});
it('should not render if there is no document title', () => {
fakeDomainAndTitle.returns({});
const wrapper = createAnnotationDocumentInfo();
const info = wrapper.find('.annotation-document-info');
assert.notOk(info.exists());
});
it('should render the document title', () => {
fakeDomainAndTitle.returns({ titleText: 'I have a title' });
const wrapper = createAnnotationDocumentInfo();
const info = wrapper.find('.annotation-document-info');
assert.isOk(info.exists());
});
it('should render a link if available', () => {
fakeDomainAndTitle.returns({
titleText: 'I have a title',
titleLink: 'https://www.example.com',
});
const wrapper = createAnnotationDocumentInfo();
const link = wrapper.find('.annotation-document-info__title a');
assert.equal(link.prop('href'), 'https://www.example.com');
});
it('should render domain if available', () => {
fakeDomainAndTitle.returns({
titleText: 'I have a title',
domain: 'www.example.com',
});
const wrapper = createAnnotationDocumentInfo();
const domain = wrapper.find('.annotation-document-info__domain');
assert.equal(domain.text(), '(www.example.com)');
});
});
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const fixtures = require('../../test/annotation-fixtures');
const AnnotationShareInfo = require('../annotation-share-info');
describe('AnnotationShareInfo', () => {
let fakeGroup;
let fakeStore;
let fakeGetGroup;
const createAnnotationShareInfo = props => {
return shallow(
<AnnotationShareInfo
annotation={fixtures.defaultAnnotation()}
isPrivate={false}
{...props}
/>
);
};
beforeEach(() => {
fakeGroup = {
name: 'My Group',
links: {
html: 'https://www.example.com',
},
type: 'private',
};
fakeGetGroup = sinon.stub().returns(fakeGroup);
fakeStore = { getGroup: fakeGetGroup };
AnnotationShareInfo.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
});
});
afterEach(() => {
AnnotationShareInfo.$imports.$restore();
});
describe('group link', () => {
it('should show a link to the group for extant, first-party groups', () => {
const wrapper = createAnnotationShareInfo();
const groupLink = wrapper.find('.annotation-share-info__group');
const groupName = wrapper.find('.annotation-share-info__group-info');
assert.equal(groupLink.prop('href'), fakeGroup.links.html);
assert.equal(groupName.text(), fakeGroup.name);
});
it('should display a group icon for private and restricted groups', () => {
const wrapper = createAnnotationShareInfo();
const groupIcon = wrapper.find(
'.annotation-share-info__group .annotation-share-info__icon'
);
assert.equal(groupIcon.prop('name'), 'groups');
});
it('should display a public/world icon for open groups', () => {
fakeGroup.type = 'open';
const wrapper = createAnnotationShareInfo();
const groupIcon = wrapper.find(
'.annotation-share-info__group .annotation-share-info__icon'
);
assert.equal(groupIcon.prop('name'), 'public');
});
it('should not show a link to third-party groups', () => {
// Third-party groups have no `html` link
fakeGetGroup.returns({ name: 'A Group', links: {} });
const wrapper = createAnnotationShareInfo();
const groupLink = wrapper.find('.annotation-share-info__group');
assert.notOk(groupLink.exists());
});
it('should not show a link if no group available', () => {
fakeGetGroup.returns(undefined);
const wrapper = createAnnotationShareInfo();
const groupLink = wrapper.find('.annotation-share-info__group');
assert.notOk(groupLink.exists());
});
});
describe('"only you" information', () => {
it('should not show privacy information if annotation is not private', () => {
const wrapper = createAnnotationShareInfo({ isPrivate: false });
const privacy = wrapper.find('.annotation-share-info__private');
assert.notOk(privacy.exists());
});
context('private annotation', () => {
it('should show privacy icon', () => {
const wrapper = createAnnotationShareInfo({ isPrivate: true });
const privacyIcon = wrapper.find(
'.annotation-share-info__private .annotation-share-info__icon'
);
assert.isOk(privacyIcon.exists());
assert.equal(privacyIcon.prop('name'), 'lock');
});
it('should not show "only me" text for first-party group', () => {
const wrapper = createAnnotationShareInfo({ isPrivate: true });
const privacyText = wrapper.find(
'.annotation-share-info__private-info'
);
assert.notOk(privacyText.exists());
});
it('should show "only me" text for annotation in third-party group', () => {
fakeGetGroup.returns({ name: 'Some Name' });
const wrapper = createAnnotationShareInfo({ isPrivate: true });
const privacyText = wrapper.find(
'.annotation-share-info__private-info'
);
assert.isOk(privacyText.exists());
assert.equal(privacyText.text(), 'Only me');
});
});
});
});
...@@ -141,7 +141,10 @@ function startAngularApp(config) { ...@@ -141,7 +141,10 @@ function startAngularApp(config) {
// UI components // UI components
.component('annotation', require('./components/annotation')) .component('annotation', require('./components/annotation'))
.component('annotationHeader', require('./components/annotation-header')) .component(
'annotationHeader',
wrapReactComponent(require('./components/annotation-header'))
)
.component( .component(
'annotationActionButton', 'annotationActionButton',
wrapReactComponent(require('./components/annotation-action-button')) wrapReactComponent(require('./components/annotation-action-button'))
......
<header class="annotation-header">
<!-- User -->
<span ng-if="vm.user()">
<annotation-user annotation="vm.annotation"></annotation-user>
<span class="annotation-collapsed-replies">
<a class="annotation-link" href=""
ng-click="vm.onReplyCountClick()"
ng-pluralize count="vm.replyCount"
when="{'0': '', 'one': '1 reply', 'other': '{} replies'}"></a>
</span>
<br />
<span class="annotation-header__share-info">
<a class="annotation-header__group"
target="_blank" ng-if="vm.group() && vm.group().links.html" href="{{vm.group().links.html}}">
<i class="h-icon-group"></i><span class="annotation-header__group-name">{{vm.group().name}}</span>
</a>
<span ng-show="vm.isPrivate"
title="This annotation is visible only to you.">
<i class="h-icon-lock"></i><span class="annotation-header__group-name" ng-show="!vm.group().links.html">Only me</span>
</span>
<i class="h-icon-border-color" ng-show="vm.isHighlight && !vm.isEditing" title="This is a highlight. Click 'edit' to add a note or tag."></i>
<span ng-if="::vm.showDocumentInfo">
<span class="annotation-citation" ng-if="vm.documentMeta().titleLink">
on "<a ng-href="{{vm.documentMeta().titleLink}}">{{vm.documentMeta().titleText}}</a>"
</span>
<span class="annotation-citation" ng-if="!vm.documentMeta().titleLink">
on "{{vm.documentMeta().titleText}}"
</span>
<span class="annotation-citation-domain"
ng-if="vm.documentMeta().domain">({{vm.documentMeta().domain}})</span>
</span>
</span>
</span>
<span class="u-flex-spacer"></span>
<timestamp
class-name="'annotation-header__timestamp'"
timestamp="vm.updated()"
href="vm.htmlLink()"
ng-if="!vm.editing() && vm.updated()"></timestamp>
</header>
.annotation-document-info {
font-size: 13px;
color: $grey-5;
display: flex;
&__title {
margin-right: 5px;
}
&__domain {
margin-right: 5px;
font-size: 12px;
}
}
.annotation-header {
@include pie-clearfix;
// Margin between top of x-height of username and
// top of the annotation card should be ~15px
margin-top: -$layout-h-margin + 10px;
color: $grey-5;
&__row {
display: flex;
align-items: baseline;
}
&__timestamp {
margin-left: auto;
}
&__timestamp-link {
@include font-small;
color: $grey-4;
}
&__highlight {
margin-right: 5px;
}
}
.annotation-share-info {
display: flex;
align-items: baseline;
&__group,
&__private {
display: flex;
align-items: baseline;
font-size: $body1-font-size;
color: $grey-5;
}
&__group-info {
margin-right: 5px;
}
&__private-info {
margin-right: 5px;
}
&__icon {
margin-right: 5px;
width: 10px;
height: 10px;
}
}
.annotation-user, .annotation-user {
.annotation-user a { &__user-name {
@include font-normal; @include font-normal;
color: $grey-7; color: $grey-7;
font-weight: bold; font-weight: bold;
...@@ -11,4 +11,5 @@ ...@@ -11,4 +11,5 @@
.is-highlighted & { .is-highlighted & {
color: $grey-7; color: $grey-7;
} }
}
} }
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
// Highlight quote of annotation whenever its thread is hovered // Highlight quote of annotation whenever its thread is hovered
.thread-list__card:hover .annotation-quote { .thread-list__card:hover .annotation-quote {
border-left: $highlight 3px solid; border-left: $highlight 3px solid;
color: $grey-5;
} }
// When hovering a top-level annotation, show the footer in a hovered state. // When hovering a top-level annotation, show the footer in a hovered state.
...@@ -17,7 +16,7 @@ ...@@ -17,7 +16,7 @@
color: $grey-6; color: $grey-6;
} }
.annotation-header__timestamp { .annotation-header__timestamp-link {
color: $grey-5; color: $grey-5;
} }
} }
...@@ -60,35 +59,10 @@ ...@@ -60,35 +59,10 @@
} }
.annotation-quote-list, .annotation-quote-list,
.annotation-header,
.annotation-footer { .annotation-footer {
@include pie-clearfix; @include pie-clearfix;
} }
.annotation-header {
display: flex;
flex-direction: row;
align-items: baseline;
// Margin between top of x-height of username and
// top of the annotation card should be ~15px
margin-top: -$layout-h-margin + 10px;
}
.annotation-header__share-info {
color: $grey-5;
@include font-normal;
}
.annotation-header__group {
color: $color-gray;
font-size: $body1-font-size;
}
.annotation-header__group-name {
display: inline-block;
margin-left: 5px;
}
.annotation-body { .annotation-body {
@include font-normal; @include font-normal;
color: $grey-6; color: $grey-6;
...@@ -164,11 +138,6 @@ ...@@ -164,11 +138,6 @@
color: $grey-5; color: $grey-5;
} }
.annotation-header__timestamp {
@include font-small;
color: $grey-4;
}
.annotation-actions { .annotation-actions {
float: right; float: right;
margin-top: 0; margin-top: 0;
......
...@@ -19,7 +19,10 @@ $base-line-height: 20px; ...@@ -19,7 +19,10 @@ $base-line-height: 20px;
// Components // Components
// ---------- // ----------
@import './components/annotation'; @import './components/annotation';
@import './components/annotation-document-info';
@import './components/annotation-header';
@import './components/annotation-share-dialog'; @import './components/annotation-share-dialog';
@import './components/annotation-share-info';
@import './components/annotation-publish-control'; @import './components/annotation-publish-control';
@import './components/annotation-thread'; @import './components/annotation-thread';
@import './components/annotation-user'; @import './components/annotation-user';
......
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