Commit d6c00fd0 authored by Robert Knight's avatar Robert Knight

Merge branch 'master' into move-pending-update-state-to-store

Adapt to conversion of `<top-bar>` component to Preact.
parents 343237bb e080b8b9
{
"extends": [
"hypothesis",
"plugin:react/recommended"
],
"extends": ["hypothesis", "plugin:react/recommended"],
"globals": {
"Set": false
},
......@@ -10,6 +7,7 @@
"mocha/no-exclusive-tests": "error",
"no-var": "error",
"indent": "off",
"react/self-closing-comp": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
},
......@@ -19,11 +17,7 @@
"jsx": true
}
},
"plugins": [
"mocha",
"react",
"react-hooks"
],
"plugins": ["mocha", "react", "react-hooks"],
"settings": {
"react": {
"pragma": "createElement",
......
<?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">
<!-- 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>
<defs></defs>
<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';
const annotationMetadata = require('../annotation-metadata');
const memoize = require('../util/memoize');
const { isThirdPartyUser, username } = require('../util/account-id');
const propTypes = require('prop-types');
const { createElement } = require('preact');
// @ngInject
function AnnotationHeaderController(features, groups, settings, serviceUrl) {
const self = this;
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 '';
};
}
const AnnotationDocumentInfo = require('./annotation-document-info');
const AnnotationShareInfo = require('./annotation-share-info');
const AnnotationUser = require('./annotation-user');
const Timestamp = require('./timestamp');
/**
* Header component for an annotation card.
*
* Header which displays the username, last update timestamp and other key
* metadata about an annotation.
* Render an annotation's header summary, including metadata about its user,
* sharing status, document and timestamp. It also allows the user to
* toggle sub-threads/replies in certain cases.
*/
module.exports = {
controller: AnnotationHeaderController,
controllerAs: 'vm',
bindings: {
/**
* The saved annotation
*/
annotation: '<',
/**
* True if the annotation is private or will become private when the user
* saves their changes.
*/
isPrivate: '<',
/** True if the user is currently editing the annotation. */
isEditing: '<',
/**
* True if the annotation is a highlight.
* FIXME: This should determined in AnnotationHeaderController
*/
isHighlight: '<',
onReplyCountClick: '&',
replyCount: '<',
function AnnotationHeader({
annotation,
isEditing,
isHighlight,
isPrivate,
onReplyCountClick,
replyCount,
showDocumentInfo,
}) {
const annotationLink = annotation.links ? annotation.links.html : '';
const replyPluralized = !replyCount || replyCount > 1 ? 'replies' : 'reply';
return (
<header className="annotation-header">
<div className="annotation-header__row">
<AnnotationUser annotation={annotation} />
<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>
);
}
/** True if document metadata should be shown. */
showDocumentInfo: '<',
},
template: require('../templates/annotation-header.html'),
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,
/**
* Should document metadata be rendered? Hint: this is enabled for single-
* annotation and stream views
*/
showDocumentInfo: propTypes.bool,
};
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,22 +30,28 @@ function AnnotationUser({ annotation, features, serviceUrl, settings }) {
if (shouldLinkToActivity) {
return (
<a
className="annotation-user"
href={
isFirstPartyUser
? serviceUrl('user', { user })
: `${settings.usernameUrl}${username_}`
}
target="_blank"
rel="noopener noreferrer"
>
{displayName}
</a>
<div className="annotation-user">
<a
className="annotation-user__link"
href={
isFirstPartyUser
? serviceUrl('user', { user })
: `${settings.usernameUrl}${username_}`
}
target="_blank"
rel="noopener noreferrer"
>
<span className="annotation-user__user-name">{displayName}</span>
</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 = {
......
......@@ -28,7 +28,6 @@ function fetchThread(api, id) {
// @ngInject
function AnnotationViewerContentController(
$location,
$routeParams,
store,
api,
......@@ -43,12 +42,6 @@ function AnnotationViewerContentController(
const id = $routeParams.id;
this.$onInit = () => {
this.search.update = function(query) {
$location.path('/stream').search('q', query);
};
};
store.subscribe(function() {
self.rootThread = rootThread.thread(store.getState());
});
......@@ -87,8 +80,6 @@ function AnnotationViewerContentController(
module.exports = {
controller: AnnotationViewerContentController,
controllerAs: 'vm',
bindings: {
search: '<',
},
bindings: {},
template: require('../templates/annotation-viewer-content.html'),
};
......@@ -35,7 +35,6 @@ function authStateFromProfile(profile) {
// @ngInject
function HypothesisAppController(
$document,
$location,
$rootScope,
$route,
$scope,
......@@ -183,15 +182,6 @@ function HypothesisAppController(
session.logout();
};
this.search = {
query: function() {
return store.getState().filterQuery;
},
update: function(query) {
store.setFilterQuery(query);
},
};
}
module.exports = {
......
'use strict';
module.exports = {
controllerAs: 'vm',
//@ngInject
controller: function() {},
bindings: {
/**
* An object representing the current authentication status.
*/
auth: '<',
/**
* Called when the user clicks on the "About this version" text.
*/
onLogin: '&',
/**
* Called when the user clicks on the "Sign Up" text.
*/
onSignUp: '&',
/**
* Called when the user clicks on the "Log out" text.
*/
onLogout: '&',
},
template: require('../templates/login-control.html'),
};
'use strict';
const annotationMetadata = require('../annotation-metadata');
// @ngInject
function ModerationBannerController(store, flash, api) {
const self = this;
const { createElement } = require('preact');
const classnames = require('classnames');
const propTypes = require('prop-types');
this.flagCount = function() {
return annotationMetadata.flagCount(self.annotation);
};
const annotationMetadata = require('../annotation-metadata');
const useStore = require('../store/use-store');
const { withServices } = require('../util/service-context');
this.isHidden = function() {
return self.annotation.hidden;
};
/**
* Banner allows moderators to hide/unhide the flagged
* annotation from other users.
*/
function ModerationBanner({ annotation, api, flash }) {
// actions
const store = useStore(store => ({
hide: store.hideAnnotation,
unhide: store.unhideAnnotation,
}));
this.isHiddenOrFlagged = function() {
const flagCount = self.flagCount();
return flagCount !== null && (flagCount > 0 || self.isHidden());
};
const flagCount = annotationMetadata.flagCount(annotation);
this.isReply = function() {
return annotationMetadata.isReply(self.annotation);
};
const isHiddenOrFlagged =
flagCount !== null && (flagCount > 0 || annotation.hidden);
/**
* Hide an annotation from non-moderator users.
*/
this.hideAnnotation = function() {
const hideAnnotation = () => {
api.annotation
.hide({ id: self.annotation.id })
.then(function() {
store.hideAnnotation(self.annotation.id);
.hide({ id: annotation.id })
.then(() => {
store.hide(annotation.id);
})
.catch(function() {
.catch(() => {
flash.error('Failed to hide annotation');
});
};
......@@ -40,28 +41,66 @@ function ModerationBannerController(store, flash, api) {
/**
* Un-hide an annotation from non-moderator users.
*/
this.unhideAnnotation = function() {
const unhideAnnotation = () => {
api.annotation
.unhide({ id: self.annotation.id })
.then(function() {
store.unhideAnnotation(self.annotation.id);
.unhide({ id: annotation.id })
.then(() => {
store.unhide(annotation.id);
})
.catch(function() {
.catch(() => {
flash.error('Failed to unhide annotation');
});
};
const toggleButtonProps = (() => {
const props = {};
if (annotation.hidden) {
props.onClick = unhideAnnotation;
props.title = 'Make this annotation visible to everyone';
} else {
props.onClick = hideAnnotation;
props.title = 'Hide this annotation from non-moderators';
}
return props;
})();
const bannerClasses = classnames('moderation-banner', {
'is-flagged': flagCount > 0,
'is-hidden': annotation.hidden,
'is-reply': annotationMetadata.isReply(annotation),
});
if (!isHiddenOrFlagged) {
return null;
}
return (
<div className={bannerClasses}>
{!!flagCount && !annotation.hidden && (
<span>Flagged for review x{flagCount}</span>
)}
{annotation.hidden && (
<span>Hidden from users. Flagged x{flagCount}</span>
)}
<span className="u-stretch" />
<button {...toggleButtonProps}>
{annotation.hidden ? 'Unhide' : 'Hide'}
</button>
</div>
);
}
/**
* Banner shown above flagged annotations to allow moderators to hide/unhide the
* annotation from other users.
*/
ModerationBanner.propTypes = {
/**
* The annotation object for this banner. This contains
* state about the flag count or its hidden value.
*/
annotation: propTypes.object.isRequired,
module.exports = {
controller: ModerationBannerController,
controllerAs: 'vm',
bindings: {
annotation: '<',
},
template: require('../templates/moderation-banner.html'),
// Injected services.
api: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
};
ModerationBanner.injectedProps = ['api', 'flash'];
module.exports = withServices(ModerationBanner);
......@@ -30,10 +30,7 @@ function SearchInput({ alwaysExpanded, query, onSearch }) {
const onSubmit = e => {
e.preventDefault();
// TODO - When the parent components are converted to React, the signature
// of the callback can be simplified to `onSearch(query)` rather than
// `onSearch({ $query: query })`.
onSearch({ $query: input.current.value });
onSearch(input.current.value);
};
// When the active query changes outside of this component, update the input
......@@ -61,9 +58,10 @@ function SearchInput({ alwaysExpanded, query, onSearch }) {
<button
type="button"
className="search-input__icon top-bar__btn"
title="Search"
onClick={() => input.current.focus()}
>
<i className="h-icon-search"></i>
<i className="h-icon-search" />
</button>
)}
{isLoading && <Spinner className="top-bar__btn" title="Loading…" />}
......
......@@ -3,7 +3,6 @@
// @ngInject
function StreamContentController(
$scope,
$location,
$route,
$routeParams,
annotationMapper,
......@@ -55,6 +54,10 @@ function StreamContentController(
}
});
// In case this route loaded after a client-side route change (eg. from
// '/a/:id'), clear any existing annotations.
store.clearAnnotations();
// Perform the initial search
fetch(20);
......@@ -68,18 +71,11 @@ function StreamContentController(
store.setSortKey('Newest');
this.loadMore = fetch;
this.$onInit = () => {
this.search.query = () => $routeParams.q || '';
this.search.update = q => $location.search({ q });
};
}
module.exports = {
controller: StreamContentController,
controllerAs: 'vm',
bindings: {
search: '<',
},
bindings: {},
template: require('../templates/stream-content.html'),
};
'use strict';
const { createElement } = require('preact');
const { useEffect, useState } = require('preact/hooks');
const propTypes = require('prop-types');
const { withServices } = require('../util/service-context');
const SearchInput = require('./search-input');
/**
* Search input for the single annotation view and stream.
*
* This displays and updates the "q" query param in the URL.
*/
function StreamSearchInput({ $location, $rootScope }) {
const [query, setQuery] = useState($location.search().q);
const search = query => {
$rootScope.$apply(() => {
// Re-route the user to `/stream` if they are on `/a/:id` and then set
// the search query.
$location.path('/stream').search({ q: query });
});
};
useEffect(() => {
$rootScope.$on('$locationChangeSuccess', () => {
setQuery($location.search().q);
});
});
return <SearchInput query={query} onSearch={search} alwaysExpanded={true} />;
}
StreamSearchInput.propTypes = {
$location: propTypes.object,
$rootScope: propTypes.object,
};
StreamSearchInput.injectedProps = ['$location', '$rootScope'];
module.exports = withServices(StreamSearchInput);
'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');
});
});
});
});
......@@ -238,10 +238,8 @@ describe('annotationThread', function() {
const element = util.createDirective(document, 'annotationThread', {
thread: thread,
});
const moderationBanner = element
.find('moderation-banner')
.controller('moderationBanner');
assert.deepEqual(moderationBanner, { annotation: ann });
assert.ok(element[0].querySelector('moderation-banner'));
assert.ok(element[0].querySelector('annotation'));
});
it('does not render the annotation or moderation banner if there is no annotation', function() {
......
......@@ -19,7 +19,6 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeFeatures = null;
let fakeFlash = null;
let fakeFrameSync = null;
let fakeLocation = null;
let fakeParams = null;
let fakeServiceConfig = null;
let fakeSession = null;
......@@ -95,10 +94,6 @@ describe('sidebar.components.hypothesis-app', function() {
connect: sandbox.spy(),
};
fakeLocation = {
search: sandbox.stub().returns({}),
};
fakeParams = { id: 'test' };
fakeSession = {
......@@ -138,7 +133,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('bridge', fakeBridge);
$provide.value('groups', fakeGroups);
$provide.value('$route', fakeRoute);
$provide.value('$location', fakeLocation);
$provide.value('$routeParams', fakeParams);
$provide.value('$window', fakeWindow);
})
......
'use strict';
const angular = require('angular');
const util = require('../../directive/test/util');
const loginControl = require('../login-control');
describe('loginControl', function() {
before(function() {
angular.module('app', []).component('loginControl', loginControl);
});
beforeEach(function() {
angular.mock.module('app', {});
});
describe('sign up and log in links', () => {
it('should render empty login and signup element if user auth status is unknown', () => {
const el = util.createDirective(document, 'loginControl', {
auth: {
username: 'someUsername',
status: 'unknown',
},
newStyle: true,
});
const loginEl = el.find('.login-text');
const links = loginEl.find('a');
assert.lengthOf(loginEl, 1);
assert.lengthOf(links, 0);
});
it('should render login and signup links if user is logged out', () => {
const el = util.createDirective(document, 'loginControl', {
auth: {
username: 'someUsername',
status: 'logged-out',
},
newStyle: true,
});
const loginEl = el.find('.login-text');
const links = loginEl.find('a');
assert.lengthOf(loginEl, 1);
assert.lengthOf(links, 2);
});
it('should not render login and signup element if user is logged in', () => {
const el = util.createDirective(document, 'loginControl', {
auth: {
username: 'someUsername',
status: 'logged-in',
},
newStyle: true,
});
const loginEl = el.find('.login-text');
assert.lengthOf(loginEl, 0);
});
});
describe('user menu', () => {
it('should render a user menu if the user is logged in', () => {
const el = util.createDirective(document, 'loginControl', {
auth: {
username: 'someUsername',
status: 'logged-in',
},
newStyle: true,
});
const menuEl = el.find('user-menu');
assert.lengthOf(menuEl, 1);
});
it('should not render a user menu if user is not logged in', () => {
const el = util.createDirective(document, 'loginControl', {
auth: {
username: 'someUsername',
status: 'logged-out',
},
newStyle: true,
});
const menuEl = el.find('user-menu');
assert.lengthOf(menuEl, 0);
});
});
});
'use strict';
const angular = require('angular');
const { shallow } = require('enzyme');
const { createElement } = require('preact');
const util = require('../../directive/test/util');
const ModerationBanner = require('../moderation-banner');
const fixtures = require('../../test/annotation-fixtures');
const unroll = require('../../../shared/test/util').unroll;
const moderatedAnnotation = fixtures.moderatedAnnotation;
describe('moderationBanner', function() {
let bannerEl;
let fakeStore;
let fakeFlash;
describe('ModerationBanner', () => {
let fakeApi;
let fakeFlash;
before(function() {
angular
.module('app', [])
.component('moderationBanner', require('../moderation-banner'));
});
beforeEach(function() {
fakeStore = {
hideAnnotation: sinon.stub(),
unhideAnnotation: sinon.stub(),
};
function createComponent(props) {
return shallow(
<ModerationBanner api={fakeApi} flash={fakeFlash} {...props} />
).dive(); // dive() needed because this component uses `withServices`
}
beforeEach(() => {
fakeFlash = {
error: sinon.stub(),
};
......@@ -37,31 +31,29 @@ describe('moderationBanner', function() {
},
};
angular.mock.module('app', {
store: fakeStore,
api: fakeApi,
flash: fakeFlash,
ModerationBanner.$imports.$mock({
'../store/use-store': callback =>
callback({
hide: sinon.stub(),
unhide: sinon.stub(),
}),
});
});
afterEach(function() {
bannerEl.remove();
afterEach(() => {
ModerationBanner.$imports.$restore();
});
function createBanner(inputs) {
const el = util.createDirective(document, 'moderationBanner', inputs);
bannerEl = el[0];
return bannerEl;
}
unroll(
'displays if user is a moderator and annotation is hidden or flagged',
function(testCase) {
const banner = createBanner({ annotation: testCase.ann });
const wrapper = createComponent({
annotation: testCase.ann,
});
if (testCase.expectVisible) {
assert.notEqual(banner.textContent.trim(), '');
assert.notEqual(wrapper.text().trim(), '');
} else {
assert.equal(banner.textContent.trim(), '');
assert.isFalse(wrapper.exists());
}
},
[
......@@ -72,9 +64,10 @@ describe('moderationBanner', function() {
},
{
// Hidden, but user is not a moderator
ann: Object.assign(fixtures.defaultAnnotation(), {
ann: {
...fixtures.defaultAnnotation(),
hidden: true,
}),
},
expectVisible: false,
},
{
......@@ -98,42 +91,62 @@ describe('moderationBanner', function() {
it('displays the number of flags the annotation has received', function() {
const ann = fixtures.moderatedAnnotation({ flagCount: 10 });
const banner = createBanner({ annotation: ann });
assert.include(banner.textContent, 'Flagged for review x10');
const wrapper = createComponent({ annotation: ann });
assert.include(wrapper.text(), 'Flagged for review x10');
});
it('displays in a more compact form if the annotation is a reply', function() {
const ann = Object.assign(fixtures.oldReply(), {
moderation: {
flagCount: 10,
const wrapper = createComponent({
annotation: {
...fixtures.oldReply(),
moderation: {
flagCount: 10,
},
},
});
wrapper.exists('.is-reply');
});
it('does not display in a more compact form if the annotation is not a reply', function() {
const wrapper = createComponent({
annotation: {
...fixtures.moderatedAnnotation({}),
moderation: {
flagCount: 10,
},
},
});
const banner = createBanner({ annotation: ann });
assert.ok(banner.querySelector('.is-reply'));
assert.isFalse(wrapper.exists('.is-reply'));
});
it('reports if the annotation was hidden', function() {
const ann = moderatedAnnotation({
flagCount: 1,
hidden: true,
const wrapper = createComponent({
annotation: fixtures.moderatedAnnotation({
flagCount: 1,
hidden: true,
}),
});
const banner = createBanner({ annotation: ann });
assert.include(banner.textContent, 'Hidden from users');
assert.include(wrapper.text(), 'Hidden from users');
});
it('hides the annotation if "Hide" is clicked', function() {
const ann = moderatedAnnotation({ flagCount: 10 });
const banner = createBanner({ annotation: ann });
banner.querySelector('button').click();
const wrapper = createComponent({
annotation: fixtures.moderatedAnnotation({
flagCount: 10,
}),
});
wrapper.find('button').simulate('click');
assert.calledWith(fakeApi.annotation.hide, { id: 'ann-id' });
});
it('reports an error if hiding the annotation fails', function(done) {
const ann = moderatedAnnotation({ flagCount: 10 });
const banner = createBanner({ annotation: ann });
const wrapper = createComponent({
annotation: moderatedAnnotation({
flagCount: 10,
}),
});
fakeApi.annotation.hide.returns(Promise.reject(new Error('Network Error')));
banner.querySelector('button').click();
wrapper.find('button').simulate('click');
setTimeout(function() {
assert.calledWith(fakeFlash.error, 'Failed to hide annotation');
......@@ -142,29 +155,27 @@ describe('moderationBanner', function() {
});
it('unhides the annotation if "Unhide" is clicked', function() {
const ann = moderatedAnnotation({
flagCount: 1,
hidden: true,
const wrapper = createComponent({
annotation: moderatedAnnotation({
flagCount: 1,
hidden: true,
}),
});
const banner = createBanner({ annotation: ann });
banner.querySelector('button').click();
wrapper.find('button').simulate('click');
assert.calledWith(fakeApi.annotation.unhide, { id: 'ann-id' });
});
it('reports an error if unhiding the annotation fails', function(done) {
const ann = moderatedAnnotation({
flagCount: 1,
hidden: true,
const wrapper = createComponent({
annotation: moderatedAnnotation({
flagCount: 1,
hidden: true,
}),
});
const banner = createBanner({ annotation: ann });
fakeApi.annotation.unhide.returns(
Promise.reject(new Error('Network Error'))
);
banner.querySelector('button').click();
wrapper.find('button').simulate('click');
setTimeout(function() {
assert.calledWith(fakeFlash.error, 'Failed to unhide annotation');
done();
......
......@@ -59,7 +59,7 @@ describe('SearchInput', () => {
const wrapper = createSearchInput({ query: 'foo', onSearch });
typeQuery(wrapper, 'new-query');
wrapper.find('form').simulate('submit');
assert.calledWith(onSearch, { $query: 'new-query' });
assert.calledWith(onSearch, 'new-query');
});
it('renders loading indicator when app is in a "loading" state', () => {
......
......@@ -94,18 +94,14 @@ describe('StreamContentController', function() {
});
function createController() {
return $componentController(
'streamContent',
{},
{
search: {
query: sinon.stub(),
update: sinon.stub(),
},
}
);
return $componentController('streamContent', {}, {});
}
it('clears any existing annotations when the /stream route is loaded', () => {
createController();
assert.calledOnce(fakeStore.clearAnnotations);
});
it('calls the search API with `_separate_replies: true`', function() {
createController();
assert.equal(fakeApi.search.firstCall.args[0]._separate_replies, true);
......@@ -144,7 +140,10 @@ describe('StreamContentController', function() {
it('does not reload the route if the query did not change', function() {
fakeRouteParams.q = 'test query';
createController();
fakeStore.clearAnnotations.resetHistory();
$rootScope.$broadcast('$routeUpdate');
assert.notCalled(fakeStore.clearAnnotations);
assert.notCalled(fakeRoute.reload);
});
......
'use strict';
const { shallow } = require('enzyme');
const { createElement } = require('preact');
const { act } = require('preact/test-utils');
const StreamSearchInput = require('../stream-search-input');
describe('StreamSearchInput', () => {
let fakeLocation;
let fakeRootScope;
beforeEach(() => {
fakeLocation = {
path: sinon.stub().returnsThis(),
search: sinon.stub().returns({ q: 'the-query' }),
};
fakeRootScope = {
$apply: callback => callback(),
$on: sinon.stub(),
};
});
function createSearchInput(props = {}) {
return shallow(
<StreamSearchInput
$location={fakeLocation}
$rootScope={fakeRootScope}
{...props}
/>
).dive(); // Dive through `withServices` wrapper.
}
it('displays current "q" search param', () => {
const wrapper = createSearchInput();
assert.equal(wrapper.find('SearchInput').prop('query'), 'the-query');
});
it('sets path and query when user searches', () => {
const wrapper = createSearchInput();
act(() => {
wrapper
.find('SearchInput')
.props()
.onSearch('new-query');
});
assert.calledWith(fakeLocation.path, '/stream');
assert.calledWith(fakeLocation.search, { q: 'new-query' });
});
it('updates query when changed in URL', () => {
fakeLocation.search.returns({ q: 'query-b' });
const wrapper = createSearchInput();
assert.calledOnce(fakeRootScope.$on);
assert.calledWith(fakeRootScope.$on, '$locationChangeSuccess');
act(() => {
fakeRootScope.$on.lastCall.callback();
});
// Check that new query is displayed.
wrapper.update();
assert.equal(wrapper.find('SearchInput').prop('query'), 'query-b');
});
});
This diff is collapsed.
'use strict';
const { Fragment, createElement } = require('preact');
const classnames = require('classnames');
const propTypes = require('prop-types');
const useStore = require('../store/use-store');
const { applyTheme } = require('../util/theme');
const isThirdPartyService = require('../util/is-third-party-service');
const { withServices } = require('../util/service-context');
const GroupList = require('./group-list');
const SearchInput = require('./search-input');
const StreamSearchInput = require('./stream-search-input');
const SortMenu = require('./sort-menu');
const SvgIcon = require('./svg-icon');
const UserMenu = require('./user-menu');
/**
* The toolbar which appears at the top of the sidebar providing actions
* to switch groups, view account information, sort/filter annotations etc.
*/
function TopBar({
auth,
isSidebar,
onLogin,
onLogout,
onSharePage,
onShowHelpPanel,
onSignUp,
settings,
streamer,
}) {
const useCleanTheme = settings.theme === 'clean';
const showSharePageButton = !isThirdPartyService(settings);
const loginLinkStyle = applyTheme(['accentColor'], settings);
const filterQuery = useStore(store => store.filterQuery());
const setFilterQuery = useStore(store => store.setFilterQuery);
const pendingUpdateCount = useStore(store => store.pendingUpdateCount());
const applyPendingUpdates = () => streamer.applyPendingUpdates();
const loginControl = (
<Fragment>
{auth.status === 'unknown' && (
<span className="top-bar__login-links"></span>
)}
{auth.status === 'logged-out' && (
<span className="top-bar__login-links">
<a href="#" onClick={onSignUp} target="_blank" style={loginLinkStyle}>
Sign up
</a>{' '}
/{' '}
<a href="#" onClick={onLogin} style={loginLinkStyle}>
Log in
</a>
</span>
)}
{auth.status === 'logged-in' && (
<UserMenu auth={auth} onLogout={onLogout} />
)}
</Fragment>
);
return (
<div
className={classnames('top-bar', useCleanTheme && 'top-bar--theme-clean')}
>
{/* Single-annotation and stream views. */}
{!isSidebar && (
<div className="top-bar__inner content">
<StreamSearchInput />
<div className="top-bar__expander" />
<button
className="top-bar__btn top-bar__help-btn"
onClick={onShowHelpPanel}
title="Help"
aria-label="Help"
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
{loginControl}
</div>
)}
{/* Sidebar view */}
{isSidebar && (
<div className="top-bar__inner content">
<GroupList className="GroupList" auth={auth} />
<div className="top-bar__expander" />
{pendingUpdateCount > 0 && (
<a
className="top-bar__apply-update-btn"
onClick={applyPendingUpdates}
title={`Show ${pendingUpdateCount} new/updated ${
pendingUpdateCount === 1 ? 'annotation' : 'annotations'
}`}
>
<SvgIcon className="top-bar__apply-icon" name="refresh" />
</a>
)}
<SearchInput query={filterQuery} onSearch={setFilterQuery} />
<SortMenu />
{showSharePageButton && (
<button
className="top-bar__btn"
onClick={onSharePage}
title="Share this page"
aria-label="Share this page"
>
<i className="h-icon-annotation-share" />
</button>
)}
<button
className="top-bar__btn top-bar__help-btn"
onClick={onShowHelpPanel}
title="Help"
aria-label="Help"
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
{loginControl}
</div>
)}
</div>
);
}
TopBar.propTypes = {
/**
* Object containing current authentication status.
*/
auth: propTypes.shape({
status: propTypes.string.isRequired,
module.exports = {
controllerAs: 'vm',
//@ngInject
controller: function(settings, store, streamer) {
if (settings.theme && settings.theme === 'clean') {
this.isThemeClean = true;
} else {
this.isThemeClean = false;
}
this.applyPendingUpdates = streamer.applyPendingUpdates;
this.pendingUpdateCount = store.pendingUpdateCount;
this.showSharePageButton = function() {
return !isThirdPartyService(settings);
};
},
bindings: {
auth: '<',
isSidebar: '<',
onShowHelpPanel: '&',
onLogin: '&',
onLogout: '&',
onSharePage: '&',
onSignUp: '&',
searchController: '<',
},
template: require('../templates/top-bar.html'),
// Additional properties when user is logged in.
displayName: propTypes.string,
userid: propTypes.string,
username: propTypes.string,
}),
/**
* Flag indicating whether the app is the sidebar or a top-level page.
*/
isSidebar: propTypes.bool,
/**
* Callback invoked when user clicks "Help" button.
*/
onShowHelpPanel: propTypes.func,
/**
* Callback invoked when user clicks "Login" button.
*/
onLogin: propTypes.func,
/** Callback invoked when user clicks "Logout" action in account menu. */
onLogout: propTypes.func,
/** Callback invoked when user clicks "Share" toolbar action. */
onSharePage: propTypes.func,
/** Callback invoked when user clicks "Sign up" button. */
onSignUp: propTypes.func,
// Services
settings: propTypes.object,
streamer: propTypes.object,
};
TopBar.injectedProps = ['settings', 'streamer'];
module.exports = withServices(TopBar);
......@@ -79,13 +79,12 @@ function configureRoutes($routeProvider) {
// The `vm.{auth,search}` properties used in these templates come from the
// `<hypothesis-app>` component which hosts the router's container element.
$routeProvider.when('/a/:id', {
template:
'<annotation-viewer-content search="vm.search"></annotation-viewer-content>',
template: '<annotation-viewer-content></annotation-viewer-content>',
reloadOnSearch: false,
resolve: resolve,
});
$routeProvider.when('/stream', {
template: '<stream-content search="vm.search"></stream-content>',
template: '<stream-content></stream-content>',
reloadOnSearch: false,
resolve: resolve,
});
......@@ -141,7 +140,10 @@ function startAngularApp(config) {
// UI components
.component('annotation', require('./components/annotation'))
.component('annotationHeader', require('./components/annotation-header'))
.component(
'annotationHeader',
wrapReactComponent(require('./components/annotation-header'))
)
.component(
'annotationActionButton',
wrapReactComponent(require('./components/annotation-action-button'))
......@@ -164,24 +166,18 @@ function startAngularApp(config) {
require('./components/annotation-viewer-content')
)
.component('excerpt', require('./components/excerpt'))
.component(
'groupList',
wrapReactComponent(require('./components/group-list'))
)
.component(
'helpLink',
wrapReactComponent(require('./components/help-link'))
)
.component('helpPanel', require('./components/help-panel'))
.component('loggedoutMessage', require('./components/loggedout-message'))
.component('loginControl', require('./components/login-control'))
.component('markdown', require('./components/markdown'))
.component('moderationBanner', require('./components/moderation-banner'))
.component('newNoteBtn', require('./components/new-note-btn'))
.component(
'searchInput',
wrapReactComponent(require('./components/search-input'))
'moderationBanner',
wrapReactComponent(require('./components/moderation-banner'))
)
.component('newNoteBtn', require('./components/new-note-btn'))
.component('searchStatusBar', require('./components/search-status-bar'))
.component('selectionTabs', require('./components/selection-tabs'))
.component('sidebarContent', require('./components/sidebar-content'))
......@@ -191,11 +187,6 @@ function startAngularApp(config) {
)
.component('sidebarTutorial', require('./components/sidebar-tutorial'))
.component('shareDialog', require('./components/share-dialog'))
.component(
'sortMenu',
wrapReactComponent(require('./components/sort-menu'))
)
.component('spinner', wrapReactComponent(require('./components/spinner')))
.component('streamContent', require('./components/stream-content'))
.component('svgIcon', wrapReactComponent(require('./components/svg-icon')))
.component('tagEditor', require('./components/tag-editor'))
......@@ -204,12 +195,7 @@ function startAngularApp(config) {
'timestamp',
wrapReactComponent(require('./components/timestamp'))
)
.component('topBar', require('./components/top-bar'))
.component(
'userMenu',
wrapReactComponent(require('./components/user-menu'))
)
.component('topBar', wrapReactComponent(require('./components/top-bar')))
.directive('hAutofocus', require('./directive/h-autofocus'))
.directive('hBranding', require('./directive/h-branding'))
.directive('hOnTouch', require('./directive/h-on-touch'))
......
......@@ -74,11 +74,6 @@ class SearchClient extends EventEmitter {
return;
}
self.emit('error', err);
})
.then(function() {
if (self._canceled) {
return;
}
self.emit('end');
});
}
......
......@@ -14,6 +14,10 @@ function init() {
* The number of API requests that have started and not yet completed.
*/
activeApiRequests: 0,
/**
* The number of annotation fetches that have started and not yet completed.
*/
activeAnnotationFetches: 0,
},
};
}
......@@ -44,6 +48,32 @@ const update = {
},
};
},
ANNOTATION_FETCH_STARTED(state) {
const { activity } = state;
return {
activity: {
...activity,
activeAnnotationFetches: activity.activeAnnotationFetches + 1,
},
};
},
ANNOTATION_FETCH_FINISHED(state) {
const { activity } = state;
if (activity.activeAnnotationFetches === 0) {
throw new Error(
'ANNOTATION_FETCH_FINISHED action when no annotation fetches were active'
);
}
return {
activity: {
...activity,
activeAnnotationFetches: activity.activeAnnotationFetches - 1,
},
};
},
};
const actions = actionTypes(update);
......@@ -56,6 +86,14 @@ function apiRequestFinished() {
return { type: actions.API_REQUEST_FINISHED };
}
function annotationFetchStarted() {
return { type: actions.ANNOTATION_FETCH_STARTED };
}
function annotationFetchFinished() {
return { type: actions.ANNOTATION_FETCH_FINISHED };
}
/**
* Return true when any activity is happening in the app that needs to complete
* before the UI will be idle.
......@@ -64,6 +102,13 @@ function isLoading(state) {
return state.activity.activeApiRequests > 0;
}
/**
* Return true when annotations are actively being fetched.
*/
function isFetchingAnnotations(state) {
return state.activity.activeAnnotationFetches > 0;
}
module.exports = {
init,
update,
......@@ -71,9 +116,12 @@ module.exports = {
actions: {
apiRequestStarted,
apiRequestFinished,
annotationFetchStarted,
annotationFetchFinished,
},
selectors: {
isLoading,
isFetchingAnnotations,
},
};
......@@ -348,6 +348,10 @@ const getFirstSelectedAnnotationId = createSelector(
selected => (selected ? Object.keys(selected)[0] : null)
);
function filterQuery(state) {
return state.filterQuery;
}
module.exports = {
init: init,
update: update,
......@@ -369,6 +373,7 @@ module.exports = {
selectors: {
hasSelectedAnnotations,
filterQuery,
isAnnotationSelected,
getFirstSelectedAnnotationId,
},
......
......@@ -33,6 +33,63 @@ describe('sidebar/store/modules/activity', () => {
});
});
describe('isFetchingAnnotations', () => {
it('returns false with the initial state', () => {
assert.equal(store.isFetchingAnnotations(), false);
});
it('returns true when API requests are in flight', () => {
store.annotationFetchStarted();
assert.equal(store.isFetchingAnnotations(), true);
});
it('returns false when all requests end', () => {
store.annotationFetchStarted();
store.annotationFetchStarted();
store.annotationFetchFinished();
assert.equal(store.isFetchingAnnotations(), true);
store.annotationFetchFinished();
assert.equal(store.isFetchingAnnotations(), false);
});
});
it('defaults `activeAnnotationFetches` counter to zero', () => {
assert.equal(store.getState().activity.activeAnnotationFetches, 0);
});
describe('annotationFetchFinished', () => {
it('triggers an error if no requests are in flight', () => {
assert.throws(() => {
store.annotationFetchFinished();
});
});
it('increments `activeAnnotationFetches` counter when a new annotation fetch is started', () => {
store.annotationFetchStarted();
assert.equal(store.getState().activity.activeAnnotationFetches, 1);
});
});
describe('annotationFetchStarted', () => {
it('triggers an error if no requests are in flight', () => {
assert.throws(() => {
store.annotationFetchFinished();
});
});
it('decrements `activeAnnotationFetches` counter when an annotation fetch is finished', () => {
store.annotationFetchStarted();
store.annotationFetchFinished();
assert.equal(store.getState().activity.activeAnnotationFetches, 0);
});
});
describe('#apiRequestFinished', () => {
it('triggers an error if no requests are in flight', () => {
assert.throws(() => {
......
<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>
......@@ -10,8 +10,9 @@
<div class="annotation-thread__thread-line"></div>
</div>
<div class="annotation-thread__content">
<moderation-banner annotation="vm.thread.annotation"
ng-if="vm.thread.annotation">
<moderation-banner
annotation="vm.thread.annotation"
ng-if="vm.thread.annotation">
</moderation-banner>
<annotation ng-class="vm.annotationClasses()"
annotation="vm.thread.annotation"
......
......@@ -5,12 +5,12 @@
<div ng-keydown="vm.onKeydown($event)" ng-if="vm.user()">
<annotation-header annotation="vm.annotation"
is-editing="vm.editing()"
is-highlight="vm.isHighlight()"
is-private="vm.state().isPrivate"
on-reply-count-click="vm.onReplyCountClick()"
reply-count="vm.replyCount"
show-document-info="vm.showDocumentInfo">
is-editing="vm.editing()"
is-highlight="vm.isHighlight()"
is-private="vm.state().isPrivate"
on-reply-count-click="vm.onReplyCountClick()"
reply-count="vm.replyCount"
show-document-info="vm.showDocumentInfo">
</annotation-header>
<!-- Excerpts -->
......
......@@ -6,8 +6,7 @@
on-logout="vm.logout()"
on-share-page="vm.share()"
on-show-help-panel="vm.showHelpPanel()"
is-sidebar="::vm.isSidebar"
search-controller="vm.search">
is-sidebar="::vm.isSidebar">
</top-bar>
<div class="content">
......
<!-- New controls -->
<span class="login-text"
ng-if="vm.auth.status === 'unknown'"></span>
<span class="login-text"
ng-if="vm.auth.status === 'logged-out'">
<a href="" ng-click="vm.onSignUp()" target="_blank" h-branding="accentColor">Sign up</a>
/ <a href="" ng-click="vm.onLogin()" h-branding="accentColor">Log in</a>
</span>
<user-menu auth="vm.auth" on-logout="vm.onLogout()" ng-if="vm.auth.status === 'logged-in'"/>
<div class="moderation-banner"
ng-if="vm.isHiddenOrFlagged()"
ng-class="{'is-flagged': vm.flagCount() > 0,
'is-hidden': vm.isHidden(),
'is-reply': vm.isReply()}">
<span ng-if="vm.flagCount() > 0 && !vm.isHidden()">
Flagged for review x{{ vm.flagCount() }}
</span>
<span ng-if="vm.isHidden()">
Hidden from users. Flagged x{{ vm.flagCount() }}
</span>
<span class="u-stretch"></span>
<button ng-if="!vm.isHidden()"
ng-click="vm.hideAnnotation()"
title="Hide this annotation from non-moderators">
Hide
</button>
<button ng-if="vm.isHidden()"
ng-click="vm.unhideAnnotation()"
title="Make this annotation visible to everyone">
Unhide
</button>
</div>
<!-- top bar for the sidebar and the stream.
!-->
<div class="top-bar"
ng-class="{'top-bar--theme-clean' : vm.isThemeClean }">
<!-- Legacy design for top bar, as used in the stream !-->
<div class="top-bar__inner content" ng-if="::!vm.isSidebar">
<search-input
class="search-input"
query="vm.searchController.query()"
on-search="vm.searchController.update($query)"
always-expanded="true">
</search-input>
<div class="top-bar__expander"></div>
<button class="top-bar__btn top-bar__help-btn"
ng-click="vm.onShowHelpPanel()"
title="Help"
aria-label="Help">
<svg-icon name="'help'" class-name="'top-bar__help-icon'"></svg-icon>
</button>
<login-control
class="login-control"
auth="vm.auth"
on-login="vm.onLogin()"
on-logout="vm.onLogout()"
on-sign-up="vm.onSignUp()">
</login-control>
</div>
<!-- New design for the top bar, as used in the sidebar.
The inner div is styled with 'content' to center it in
the stream view.
!-->
<div class="top-bar__inner content" ng-if="::vm.isSidebar">
<group-list class="group-list" auth="vm.auth"></group-list>
<div class="top-bar__expander"></div>
<a class="top-bar__apply-update-btn"
ng-if="vm.pendingUpdateCount() > 0"
ng-click="vm.applyPendingUpdates()"
h-tooltip
tooltip-direction="up"
aria-label="Show {{vm.pendingUpdateCount()}} new/updated annotation(s)">
<svg-icon class="top-bar__apply-icon" name="'refresh'"></svg-icon>
</a>
<search-input
class="search-input"
query="vm.searchController.query()"
on-search="vm.searchController.update($query)"
title="Filter the annotation list">
</search-input>
<sort-menu></sort-menu>
<button class="top-bar__btn"
ng-click="vm.onSharePage()"
ng-if="vm.showSharePageButton()"
title="Share this page"
aria-label="Share this page">
<i class="h-icon-annotation-share"></i>
</button>
<button class="top-bar__btn top-bar__help-btn"
ng-click="vm.onShowHelpPanel()"
title="Help"
aria-label="Help">
<svg-icon name="'help'" class-name="'top-bar__help-icon'"></svg-icon>
</button>
<login-control
class="login-control"
auth="vm.auth"
on-login="vm.onLogin()"
on-logout="vm.onLogout()"
on-sign-up="vm.onSignUp()">
</login-control>
</div>
</div>
......@@ -8,7 +8,7 @@ function awaitEvent(emitter, event) {
});
}
describe('SearchClient', function() {
describe('SearchClient', () => {
const RESULTS = [
{ id: 'one' },
{ id: 'two' },
......@@ -18,7 +18,7 @@ describe('SearchClient', function() {
let fakeSearchFn;
beforeEach(function() {
beforeEach(() => {
fakeSearchFn = sinon.spy(function(params) {
return Promise.resolve({
rows: RESULTS.slice(params.offset, params.offset + params.limit),
......@@ -27,34 +27,45 @@ describe('SearchClient', function() {
});
});
it('emits "results"', function() {
it('emits "results"', () => {
const client = new SearchClient(fakeSearchFn);
const onResults = sinon.stub();
client.on('results', onResults);
client.get({ uri: 'http://example.com' });
return awaitEvent(client, 'end').then(function() {
return awaitEvent(client, 'end').then(() => {
assert.calledWith(onResults, RESULTS);
});
});
it('emits "results" with chunks in incremental mode', function() {
it('emits "end" only once', () => {
const client = new SearchClient(fakeSearchFn, { chunkSize: 2 });
client.on('results', sinon.stub());
let emitEndCounter = 0;
client.on('end', () => {
emitEndCounter += 1;
assert.equal(emitEndCounter, 1);
});
client.get({ uri: 'http://example.com' });
});
it('emits "results" with chunks in incremental mode', () => {
const client = new SearchClient(fakeSearchFn, { chunkSize: 2 });
const onResults = sinon.stub();
client.on('results', onResults);
client.get({ uri: 'http://example.com' });
return awaitEvent(client, 'end').then(function() {
return awaitEvent(client, 'end').then(() => {
assert.calledWith(onResults, RESULTS.slice(0, 2));
assert.calledWith(onResults, RESULTS.slice(2, 4));
});
});
it('stops fetching chunks if the results array is empty', function() {
it('stops fetching chunks if the results array is empty', () => {
// Simulate a situation where the `total` count for the server is incorrect
// and we appear to have reached the end of the result list even though
// `total` implies that there should be more results available.
//
// In that case the client should stop trying to fetch additional pages.
fakeSearchFn = sinon.spy(function() {
fakeSearchFn = sinon.spy(() => {
return Promise.resolve({
rows: [],
total: 1000,
......@@ -66,13 +77,13 @@ describe('SearchClient', function() {
client.get({ uri: 'http://example.com' });
return awaitEvent(client, 'end').then(function() {
return awaitEvent(client, 'end').then(() => {
assert.calledWith(onResults, []);
assert.calledOnce(fakeSearchFn);
});
});
it('emits "results" once in non-incremental mode', function() {
it('emits "results" once in non-incremental mode', () => {
const client = new SearchClient(fakeSearchFn, {
chunkSize: 2,
incremental: false,
......@@ -80,13 +91,13 @@ describe('SearchClient', function() {
const onResults = sinon.stub();
client.on('results', onResults);
client.get({ uri: 'http://example.com' });
return awaitEvent(client, 'end').then(function() {
return awaitEvent(client, 'end').then(() => {
assert.calledOnce(onResults);
assert.calledWith(onResults, RESULTS);
});
});
it('does not emit "results" if canceled', function() {
it('does not emit "results" if canceled', () => {
const client = new SearchClient(fakeSearchFn);
const onResults = sinon.stub();
const onEnd = sinon.stub();
......@@ -94,22 +105,22 @@ describe('SearchClient', function() {
client.on('end', onEnd);
client.get({ uri: 'http://example.com' });
client.cancel();
return Promise.resolve().then(function() {
return Promise.resolve().then(() => {
assert.notCalled(onResults);
assert.called(onEnd);
});
});
it('emits "error" event if search fails', function() {
it('emits "error" event if search fails', () => {
const err = new Error('search failed');
fakeSearchFn = function() {
fakeSearchFn = () => {
return Promise.reject(err);
};
const client = new SearchClient(fakeSearchFn);
const onError = sinon.stub();
client.on('error', onError);
client.get({ uri: 'http://example.com' });
return awaitEvent(client, 'end').then(function() {
return awaitEvent(client, 'end').then(() => {
assert.calledWith(onError, err);
});
});
......
.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 a {
@include font-normal;
color: $grey-7;
font-weight: bold;
.annotation-user {
&__user-name {
@include font-normal;
color: $grey-7;
font-weight: bold;
.is-dimmed & {
color: $grey-5;
}
.is-dimmed & {
color: $grey-5;
}
.is-highlighted & {
color: $grey-7;
.is-highlighted & {
color: $grey-7;
}
}
}
......@@ -3,7 +3,6 @@
// Highlight quote of annotation whenever its thread is hovered
.thread-list__card:hover .annotation-quote {
border-left: $highlight 3px solid;
color: $grey-5;
}
// When hovering a top-level annotation, show the footer in a hovered state.
......@@ -17,7 +16,7 @@
color: $grey-6;
}
.annotation-header__timestamp {
.annotation-header__timestamp-link {
color: $grey-5;
}
}
......@@ -60,35 +59,10 @@
}
.annotation-quote-list,
.annotation-header,
.annotation-footer {
@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 {
@include font-normal;
color: $grey-6;
......@@ -164,11 +138,6 @@
color: $grey-5;
}
.annotation-header__timestamp {
@include font-small;
color: $grey-4;
}
.annotation-actions {
float: right;
margin-top: 0;
......
......@@ -6,6 +6,11 @@
align-items: center;
color: $grey-6;
display: flex;
// Prevent label from wrapping if top bar is too narrow to fit all of its
// items.
flex-shrink: 0;
font-size: $body2-font-size;
font-weight: bold;
}
......
.login-control {
flex-shrink: 0;
}
.login-text {
font-size: $body2-font-size;
padding-left: 6px;
}
......@@ -21,6 +21,12 @@
border-bottom: none;
}
.top-bar__login-links {
flex-shrink: 0;
font-size: $body2-font-size;
padding-left: 6px;
}
.top-bar__inner {
// the edges of the top-bar's contents should be aligned
// with the edges of annotation cards displayed below
......
......@@ -19,7 +19,10 @@ $base-line-height: 20px;
// Components
// ----------
@import './components/annotation';
@import './components/annotation-document-info';
@import './components/annotation-header';
@import './components/annotation-share-dialog';
@import './components/annotation-share-info';
@import './components/annotation-publish-control';
@import './components/annotation-thread';
@import './components/annotation-user';
......@@ -28,7 +31,6 @@ $base-line-height: 20px;
@import './components/group-list-item';
@import './components/help-panel';
@import './components/loggedout-message';
@import './components/login-control';
@import './components/markdown';
@import './components/menu';
@import './components/menu-item';
......
This diff is collapsed.
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