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 angular = require('angular');
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const unroll = require('../../../shared/test/util').unroll;
const fixtures = require('../../test/annotation-fixtures');
const annotationHeader = require('../annotation-header');
const fakeDocumentMeta = {
domain: 'docs.io',
titleLink: 'http://docs.io/doc.html',
titleText: 'Dummy title',
};
describe('sidebar.components.annotation-header', function() {
let $componentController;
let fakeFeatures;
let fakeGroups;
let fakeAccountID;
const fakeSettings = { usernameUrl: 'http://www.example.org/' };
let fakeServiceUrl;
beforeEach('Initialize fakeAccountID', () => {
fakeAccountID = {
isThirdPartyUser: sinon.stub().returns(false),
username: sinon.stub().returns('TEST_USERNAME'),
};
});
beforeEach('Import and register the annotationHeader component', function() {
annotationHeader.$imports.$mock({
'../annotation-metadata': {
// eslint-disable-next-line no-unused-vars
domainAndTitle: function(ann) {
return fakeDocumentMeta;
},
},
'../util/account-id': fakeAccountID,
const AnnotationHeader = require('../annotation-header');
const AnnotationDocumentInfo = require('../annotation-document-info');
const Timestamp = require('../timestamp');
describe('AnnotationHeader', () => {
const createAnnotationHeader = props => {
return shallow(
<AnnotationHeader
annotation={fixtures.defaultAnnotation()}
isEditing={false}
isHighlight={false}
isPrivate={false}
onReplyCountClick={sinon.stub()}
replyCount={0}
showDocumentInfo={false}
{...props}
/>
);
};
describe('collapsed replies', () => {
it('should have a callback', () => {
const fakeCallback = sinon.stub();
const wrapper = createAnnotationHeader({
onReplyCountClick: fakeCallback,
});
const replyCollapseLink = wrapper.find('.annotation-link');
assert.equal(replyCollapseLink.prop('onClick'), fakeCallback);
});
angular.module('app', []).component('annotationHeader', annotationHeader);
});
afterEach(() => {
annotationHeader.$imports.$restore();
unroll(
'it should render the annotation reply count',
testCase => {
const wrapper = createAnnotationHeader({
replyCount: testCase.replyCount,
});
const replyCollapseLink = wrapper.find('.annotation-link');
assert.equal(replyCollapseLink.text(), testCase.expected);
},
[
{
replyCount: 0,
expected: '0 replies',
},
{
replyCount: 1,
expected: '1 reply',
},
{
replyCount: 2,
expected: '2 replies',
},
]
);
});
beforeEach('Initialize and register fake AngularJS dependencies', function() {
fakeFeatures = {
flagEnabled: sinon.stub().returns(false),
};
describe('timestamp', () => {
it('should render a timestamp if annotation has an `updated` value', () => {
const wrapper = createAnnotationHeader();
const timestamp = wrapper.find(Timestamp);
angular.mock.module('app', {
features: fakeFeatures,
groups: fakeGroups,
settings: fakeSettings,
serviceUrl: fakeServiceUrl,
assert.isTrue(timestamp.exists());
});
angular.mock.inject(function(_$componentController_) {
$componentController = _$componentController_;
it('should not render a timestamp if annotation does not have an `updated` value', () => {
const wrapper = createAnnotationHeader({
annotation: fixtures.newAnnotation(),
});
const timestamp = wrapper.find(Timestamp);
assert.isFalse(timestamp.exists());
});
});
describe('sidebar.components.AnnotationHeaderController', function() {
describe('#htmlLink()', function() {
it('returns the HTML link when available', function() {
const ann = fixtures.defaultAnnotation();
ann.links = { html: 'https://annotation.service/123' };
const ctrl = $componentController(
'annotationHeader',
{},
{
annotation: ann,
}
);
assert.equal(ctrl.htmlLink(), ann.links.html);
describe('annotation is-highlight icon', () => {
it('should display is-highlight icon if annotation is a highlight', () => {
const wrapper = createAnnotationHeader({
isEditing: false,
isHighlight: true,
});
const highlightIcon = wrapper.find('.annotation-header__highlight');
it('returns an empty string when no HTML link is available', function() {
const ann = fixtures.defaultAnnotation();
ann.links = {};
const ctrl = $componentController(
'annotationHeader',
{},
{
annotation: ann,
}
);
assert.equal(ctrl.htmlLink(), '');
});
assert.isTrue(highlightIcon.exists());
});
describe('#documentMeta()', function() {
it('returns the domain, title link and text for the annotation', function() {
const ann = fixtures.defaultAnnotation();
const ctrl = $componentController(
'annotationHeader',
{},
{
annotation: ann,
}
);
assert.deepEqual(ctrl.documentMeta(), fakeDocumentMeta);
it('should not display the is-highlight icon if annotation is not a highlight', () => {
const wrapper = createAnnotationHeader({
isEditing: false,
isHighlight: false,
});
const highlightIcon = wrapper.find('.annotation-header__highlight');
assert.isFalse(highlightIcon.exists());
});
});
describe('#displayName', () => {
[
{
context:
'when the api_render_user_info feature flag is turned off in h',
it: 'returns the username',
user_info: undefined,
client_display_names: false,
isThirdPartyUser: false,
expectedResult: 'TEST_USERNAME',
},
{
context:
'when the api_render_user_info feature flag is turned off in h',
it:
'returns the username even if the client_display_names feature flag is on',
user_info: undefined,
client_display_names: true,
isThirdPartyUser: false,
expectedResult: 'TEST_USERNAME',
},
{
context: 'when the client_display_names feature flag is off in h',
it: 'returns the username',
user_info: { display_name: null },
client_display_names: false,
isThirdPartyUser: false,
expectedResult: 'TEST_USERNAME',
},
{
context: 'when the client_display_names feature flag is off in h',
it: 'returns the username even if the user has a display name',
user_info: { display_name: 'Bill Jones' },
client_display_names: false,
isThirdPartyUser: false,
expectedResult: 'TEST_USERNAME',
},
{
context:
'when both feature flags api_render_user_info and ' +
'client_display_names are on',
it: 'returns the username, if the user has no display_name',
user_info: { display_name: null },
client_display_names: true,
isThirdPartyUser: false,
expectedResult: 'TEST_USERNAME',
},
{
context:
'when both feature flags api_render_user_info and ' +
'client_display_names are on',
it: 'returns the display_name, if the user has one',
user_info: { display_name: 'Bill Jones' },
client_display_names: true,
isThirdPartyUser: false,
expectedResult: 'Bill Jones',
},
{
context:
'when the client_display_names feature flag is off but ' +
'the user is a third-party user',
it: 'returns display_name even though client_display_names is off',
user_info: { display_name: 'Bill Jones' },
client_display_names: false,
isThirdPartyUser: true,
expectedResult: 'Bill Jones',
},
{
context:
'when client_display_names is on and the user is a ' +
'third-party user',
it: 'returns the display_name',
user_info: { display_name: 'Bill Jones' },
client_display_names: true,
isThirdPartyUser: true,
expectedResult: 'Bill Jones',
},
{
context:
'when the user is a third-party user but the ' +
'api_render_user_info feature flag is turned off in h',
it: 'returns the username',
user_info: undefined,
client_display_names: true,
isThirdPartyUser: true,
expectedResult: 'TEST_USERNAME',
},
{
context:
"when the user is a third-party user but doesn't have a " +
'display_name',
it: 'returns the username',
user_info: { display_name: null },
client_display_names: true,
isThirdPartyUser: true,
expectedResult: 'TEST_USERNAME',
},
].forEach(test => {
context(test.context, () => {
it(test.it, () => {
// Make features.flagEnabled('client_display_names') return true
// or false, depending on the test case.
fakeFeatures.flagEnabled = flag => {
if (flag === 'client_display_names') {
return test.client_display_names;
}
return false;
};
describe('annotation document info', () => {
it('should render document info if `showDocumentInfo` is enabled', () => {
const wrapper = createAnnotationHeader({ showDocumentInfo: true });
// Make isThirdPartyUser() return true or false,
// depending on the test case.
fakeAccountID.isThirdPartyUser.returns(test.isThirdPartyUser);
const documentInfo = wrapper.find(AnnotationDocumentInfo);
const ann = fixtures.defaultAnnotation();
ann.user_info = test.user_info;
assert.isTrue(documentInfo.exists());
});
const ctrl = $componentController(
'annotationHeader',
{},
{
annotation: ann,
}
);
it('should not render document info if `showDocumentInfo` is not enabled', () => {
const wrapper = createAnnotationHeader({ showDocumentInfo: false });
assert.equal(ctrl.displayName(), test.expectedResult);
});
});
});
});
const documentInfo = wrapper.find(AnnotationDocumentInfo);
describe('#thirdPartyUsernameLink', () => {
it('returns the custom username link if set', () => {
let ann;
let ctrl;
assert.isFalse(documentInfo.exists());
});
});
fakeSettings.usernameUrl = 'http://www.example.org/';
ann = fixtures.defaultAnnotation();
ctrl = $componentController(
'annotationHeader',
{},
{
annotation: ann,
}
);
assert.deepEqual(
ctrl.thirdPartyUsernameLink(),
'http://www.example.org/TEST_USERNAME'
);
context('user is editing annotation', () => {
it('should not display timestamp', () => {
const wrapper = createAnnotationHeader({
annotation: fixtures.defaultAnnotation(),
isEditing: true,
});
it('returns null if no custom username link is set in the settings object', () => {
let ann;
let ctrl;
const timestamp = wrapper.find(Timestamp);
fakeSettings.usernameUrl = null;
ann = fixtures.defaultAnnotation();
ctrl = $componentController(
'annotationHeader',
{},
{
annotation: ann,
}
);
assert.deepEqual(ctrl.thirdPartyUsernameLink(), null);
assert.isFalse(timestamp.exists());
});
it('should not display is-highlight icon', () => {
const wrapper = createAnnotationHeader({
annotation: fixtures.defaultAnnotation(),
isEditing: true,
isHighlight: true,
});
const highlight = wrapper.find('.annotation-header__highlight');
assert.isFalse(highlight.exists());
});
});
});
'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');
});
});
'use strict';
const angular = require('angular');
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const topBar = require('../top-bar');
const util = require('../../directive/test/util');
const GroupList = require('../group-list');
const SearchInput = require('../search-input');
const StreamSearchInput = require('../stream-search-input');
const SortMenu = require('../sort-menu');
const TopBar = require('../top-bar');
const UserMenu = require('../user-menu');
describe('topBar', function() {
describe('TopBar', () => {
const fakeSettings = {};
let fakeStore;
let fakeStreamer;
let fakeIsThirdPartyService;
before(function() {
angular
.module('app', [])
.component('topBar', topBar)
.component('loginControl', {
bindings: require('../login-control').bindings,
})
.component('searchInput', {
bindings: {
alwaysExpanded: '<',
query: '<',
onSearch: '&',
},
});
});
beforeEach(() => {
fakeIsThirdPartyService = sinon.stub().returns(false);
beforeEach(function() {
fakeStore = {
filterQuery: sinon.stub().returns(null),
pendingUpdateCount: sinon.stub().returns(0),
setFilterQuery: sinon.stub(),
};
fakeStreamer = {
applyPendingUpdates: sinon.stub(),
};
angular.mock.module('app', {
settings: fakeSettings,
store: fakeStore,
streamer: fakeStreamer,
});
fakeIsThirdPartyService = sinon.stub().returns(false);
topBar.$imports.$mock({
TopBar.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/is-third-party-service': fakeIsThirdPartyService,
});
});
afterEach(() => {
topBar.$imports.$restore();
TopBar.$imports.$restore();
});
function applyUpdateBtn(el) {
return el.querySelector('.top-bar__apply-update-btn');
function applyUpdateBtn(wrapper) {
return wrapper.find('.top-bar__apply-update-btn');
}
function helpBtn(el) {
return el.querySelector('.top-bar__help-btn');
function helpBtn(wrapper) {
return wrapper.find('.top-bar__help-btn');
}
function createTopBar(inputs) {
const defaultInputs = {
isSidebar: true,
};
return util.createDirective(
document,
'topBar',
Object.assign(defaultInputs, inputs)
);
function createTopBar(props = {}) {
const auth = { status: 'unknown' };
return shallow(
<TopBar
auth={auth}
isSidebar={true}
settings={fakeSettings}
streamer={fakeStreamer}
{...props}
/>
).dive(); // Dive through `withServices` wrapper.
}
it('shows the pending update count', function() {
it('shows the pending update count', () => {
fakeStore.pendingUpdateCount.returns(1);
const el = createTopBar();
const applyBtn = applyUpdateBtn(el[0]);
assert.ok(applyBtn);
const wrapper = createTopBar();
const applyBtn = applyUpdateBtn(wrapper);
assert.isTrue(applyBtn.exists());
});
it('does not show the pending update count when there are no updates', function() {
fakeStore.pendingUpdateCount.returns(0);
const el = createTopBar();
const applyBtn = applyUpdateBtn(el[0]);
assert.notOk(applyBtn);
it('does not show the pending update count when there are no updates', () => {
const wrapper = createTopBar();
const applyBtn = applyUpdateBtn(wrapper);
assert.isFalse(applyBtn.exists());
});
it('applies updates when clicked', function() {
it('applies updates when clicked', () => {
fakeStore.pendingUpdateCount.returns(1);
const el = createTopBar();
const applyBtn = applyUpdateBtn(el[0]);
applyBtn.click();
const wrapper = createTopBar();
const applyBtn = applyUpdateBtn(wrapper);
applyBtn.simulate('click');
assert.called(fakeStreamer.applyPendingUpdates);
});
it('shows help when help icon clicked', function() {
it('shows Help Panel when help icon is clicked', () => {
const onShowHelpPanel = sinon.stub();
const el = createTopBar({
const wrapper = createTopBar({
onShowHelpPanel: onShowHelpPanel,
});
const help = helpBtn(el[0]);
help.click();
const help = helpBtn(wrapper);
help.simulate('click');
assert.called(onShowHelpPanel);
});
it('displays the login control and propagates callbacks', function() {
const onShowHelpPanel = sinon.stub();
const onLogin = sinon.stub();
const onLogout = sinon.stub();
const el = createTopBar({
onShowHelpPanel: onShowHelpPanel,
onLogin: onLogin,
onLogout: onLogout,
describe('login/account actions', () => {
const getLoginText = wrapper => wrapper.find('.top-bar__login-links');
it('Shows ellipsis when login state is unknown', () => {
const wrapper = createTopBar({ auth: { status: 'unknown' } });
const loginText = getLoginText(wrapper);
assert.isTrue(loginText.exists());
assert.equal(loginText.text(), '⋯');
});
it('Shows "Log in" and "Sign up" links when user is logged out', () => {
const onLogin = sinon.stub();
const onSignUp = sinon.stub();
const wrapper = createTopBar({
auth: { status: 'logged-out' },
onLogin,
onSignUp,
});
const loginText = getLoginText(wrapper);
const links = loginText.find('a');
assert.equal(links.length, 2);
assert.equal(links.at(0).text(), 'Sign up');
links.at(0).simulate('click');
assert.called(onSignUp);
assert.equal(links.at(1).text(), 'Log in');
links.at(1).simulate('click');
assert.called(onLogin);
});
const loginControl = el.find('login-control').controller('loginControl');
loginControl.onLogin();
assert.called(onLogin);
it('Shows user menu when logged in', () => {
const onLogout = sinon.stub();
const auth = { status: 'logged-in' };
const wrapper = createTopBar({ auth, onLogout });
assert.isFalse(getLoginText(wrapper).exists());
loginControl.onLogout();
assert.called(onLogout);
const userMenu = wrapper.find(UserMenu);
assert.isTrue(userMenu.exists());
assert.include(userMenu.props(), { auth, onLogout });
});
});
it("checks whether we're using a third-party service", function() {
it("checks whether we're using a third-party service", () => {
createTopBar();
assert.called(fakeIsThirdPartyService);
assert.alwaysCalledWithExactly(fakeIsThirdPartyService, fakeSettings);
});
context('when using a first-party service', function() {
it('shows the share page button', function() {
let el = createTopBar();
// I want the DOM element, not AngularJS's annoying angular.element
// wrapper object.
el = el[0];
assert.isNotNull(el.querySelector('[title="Share this page"]'));
context('when using a first-party service', () => {
it('shows the share page button', () => {
const wrapper = createTopBar();
assert.isTrue(wrapper.exists('[title="Share this page"]'));
});
});
context('when using a third-party service', function() {
beforeEach(function() {
context('when using a third-party service', () => {
beforeEach(() => {
fakeIsThirdPartyService.returns(true);
});
it("doesn't show the share page button", function() {
let el = createTopBar();
// I want the DOM element, not AngularJS's annoying angular.element
// wrapper object.
el = el[0];
assert.isNull(el.querySelector('[title="Share this page"]'));
it("doesn't show the share page button", () => {
const wrapper = createTopBar();
assert.isFalse(wrapper.exists('[title="Share this page"]'));
});
});
it('displays the share page when "Share this page" is clicked', function() {
it('displays the share page when "Share this page" is clicked', () => {
const onSharePage = sinon.stub();
const el = createTopBar({ onSharePage: onSharePage });
el.find('[title="Share this page"]').click();
const wrapper = createTopBar({ onSharePage });
wrapper.find('[title="Share this page"]').simulate('click');
assert.called(onSharePage);
});
it('displays the search input and propagates query changes', function() {
const onSearch = sinon.stub();
const el = createTopBar({
searchController: {
query: sinon.stub().returns('query'),
update: onSearch,
},
});
const searchInput = el.find('search-input').controller('searchInput');
it('displays search input in the sidebar', () => {
fakeStore.filterQuery.returns('test-query');
const wrapper = createTopBar();
assert.equal(wrapper.find(SearchInput).prop('query'), 'test-query');
});
assert.equal(searchInput.query, 'query');
it('updates current filter when changing search query in the sidebar', () => {
const wrapper = createTopBar();
wrapper.find('SearchInput').prop('onSearch')('new-query');
assert.calledWith(fakeStore.setFilterQuery, 'new-query');
});
searchInput.onSearch({ $query: 'new-query' });
assert.calledWith(onSearch, 'new-query');
it('displays search input in the single annotation view / stream', () => {
const wrapper = createTopBar({ isSidebar: false });
const searchInput = wrapper.find(StreamSearchInput);
assert.ok(searchInput.exists());
});
it('shows the clean theme when settings contains the clean theme option', function() {
angular.mock.module('app', {
settings: { theme: 'clean' },
it('shows the clean theme when settings contains the clean theme option', () => {
fakeSettings.theme = 'clean';
const wrapper = createTopBar();
assert.isTrue(wrapper.exists('.top-bar--theme-clean'));
});
context('in the stream and single annotation pages', () => {
it('does not render the group list, sort menu or share menu', () => {
const wrapper = createTopBar({ isSidebar: false });
assert.isFalse(wrapper.exists(GroupList));
assert.isFalse(wrapper.exists(SortMenu));
assert.isFalse(wrapper.exists('button[title="Share this page"]'));
});
const el = createTopBar();
assert.ok(el[0].querySelector('.top-bar--theme-clean'));
it('does show the Help menu and user menu', () => {
const wrapper = createTopBar({
isSidebar: false,
auth: { status: 'logged-in' },
});
assert.isTrue(wrapper.exists('button[title="Help"]'));
assert.isTrue(wrapper.exists(UserMenu));
});
});
});
'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';
......
......@@ -10,17 +10,17 @@
"@babel/highlight" "^7.0.0"
"@babel/core@^7.1.6":
version "7.4.5"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.4.5.tgz#081f97e8ffca65a9b4b0fdc7e274e703f000c06a"
integrity sha512-OvjIh6aqXtlsA8ujtGKfC7LYWksYSX8yQcM8Ay3LuvVeQ63lcOKgoZWVqcpFwkd29aYU9rVx7jxhfhiEDV9MZA==
version "7.5.0"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.5.0.tgz#6ed6a2881ad48a732c5433096d96d1b0ee5eb734"
integrity sha512-6Isr4X98pwXqHvtigw71CKgmhL1etZjPs5A67jL/w0TkLM9eqmFR40YrnJvEc1WnMZFsskjsmid8bHZyxKEAnw==
dependencies:
"@babel/code-frame" "^7.0.0"
"@babel/generator" "^7.4.4"
"@babel/helpers" "^7.4.4"
"@babel/parser" "^7.4.5"
"@babel/generator" "^7.5.0"
"@babel/helpers" "^7.5.0"
"@babel/parser" "^7.5.0"
"@babel/template" "^7.4.4"
"@babel/traverse" "^7.4.5"
"@babel/types" "^7.4.4"
"@babel/traverse" "^7.5.0"
"@babel/types" "^7.5.0"
convert-source-map "^1.1.0"
debug "^4.1.0"
json5 "^2.1.0"
......@@ -29,12 +29,12 @@
semver "^5.4.1"
source-map "^0.5.0"
"@babel/generator@^7.4.0", "@babel/generator@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.4.4.tgz#174a215eb843fc392c7edcaabeaa873de6e8f041"
integrity sha512-53UOLK6TVNqKxf7RUh8NE851EHRxOOeVXKbK2bivdb+iziMyk03Sr4eaE9OELCbyZAAafAKPDwF2TPUES5QbxQ==
"@babel/generator@^7.4.0", "@babel/generator@^7.5.0":
version "7.5.0"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.5.0.tgz#f20e4b7a91750ee8b63656073d843d2a736dca4a"
integrity sha512-1TTVrt7J9rcG5PMjvO7VEG3FrEoEJNHxumRq66GemPmzboLWtIjjcJgk8rokuAS7IiRSpgVSu5Vb9lc99iJkOA==
dependencies:
"@babel/types" "^7.4.4"
"@babel/types" "^7.5.0"
jsesc "^2.5.1"
lodash "^4.17.11"
source-map "^0.5.0"
......@@ -239,14 +239,14 @@
"@babel/traverse" "^7.1.0"
"@babel/types" "^7.0.0"
"@babel/helpers@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.4.4.tgz#868b0ef59c1dd4e78744562d5ce1b59c89f2f2a5"
integrity sha512-igczbR/0SeuPR8RFfC7tGrbdTbFL3QTvH6D+Z6zNxnTe//GyqmtHmDkzrqDmyZ3eSwPqB/LhyKoU5DXsp+Vp2A==
"@babel/helpers@^7.5.0":
version "7.5.1"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.5.1.tgz#65407c741a56ddd59dd86346cd112da3de912db3"
integrity sha512-rVOTDv8sH8kNI72Unenusxw6u+1vEepZgLxeV+jHkhsQlYhzVhzL1EpfoWT7Ub3zpWSv2WV03V853dqsnyoQzA==
dependencies:
"@babel/template" "^7.4.4"
"@babel/traverse" "^7.4.4"
"@babel/types" "^7.4.4"
"@babel/traverse" "^7.5.0"
"@babel/types" "^7.5.0"
"@babel/highlight@^7.0.0":
version "7.0.0"
......@@ -257,10 +257,10 @@
esutils "^2.0.2"
js-tokens "^4.0.0"
"@babel/parser@^7.4.3", "@babel/parser@^7.4.4", "@babel/parser@^7.4.5":
version "7.4.5"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.4.5.tgz#04af8d5d5a2b044a2a1bffacc1e5e6673544e872"
integrity sha512-9mUqkL1FF5T7f0WDFfAoDdiMVPWsdD1gZYzSnaXsxUCUqzuch/8of9G3VUSNiZmMBoRxT3neyVsqeiL/ZPcjew==
"@babel/parser@^7.4.3", "@babel/parser@^7.4.4", "@babel/parser@^7.5.0":
version "7.5.0"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.0.tgz#3e0713dff89ad6ae37faec3b29dcfc5c979770b7"
integrity sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA==
"@babel/plugin-proposal-async-generator-functions@^7.2.0":
version "7.2.0"
......@@ -688,25 +688,25 @@
"@babel/parser" "^7.4.4"
"@babel/types" "^7.4.4"
"@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.4", "@babel/traverse@^7.4.5":
version "7.4.5"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.4.5.tgz#4e92d1728fd2f1897dafdd321efbff92156c3216"
integrity sha512-Vc+qjynwkjRmIFGxy0KYoPj4FdVDxLej89kMHFsWScq999uX+pwcX4v9mWRjW0KcAYTPAuVQl2LKP1wEVLsp+A==
"@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.0":
version "7.5.0"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.0.tgz#4216d6586854ef5c3c4592dab56ec7eb78485485"
integrity sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg==
dependencies:
"@babel/code-frame" "^7.0.0"
"@babel/generator" "^7.4.4"
"@babel/generator" "^7.5.0"
"@babel/helper-function-name" "^7.1.0"
"@babel/helper-split-export-declaration" "^7.4.4"
"@babel/parser" "^7.4.5"
"@babel/types" "^7.4.4"
"@babel/parser" "^7.5.0"
"@babel/types" "^7.5.0"
debug "^4.1.0"
globals "^11.1.0"
lodash "^4.17.11"
"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.4.4.tgz#8db9e9a629bb7c29370009b4b779ed93fe57d5f0"
integrity sha512-dOllgYdnEFOebhkKCjzSVFqw/PmmB8pH6RGOWkY4GsboQNd47b1fBThBSwlHAq9alF9vc1M3+6oqR47R50L0tQ==
"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.5.0":
version "7.5.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.5.0.tgz#e47d43840c2e7f9105bc4d3a2c371b4d0c7832ab"
integrity sha512-UFpDVqRABKsW01bvw7/wSUe56uy6RXM5+VJibVVAybDGxEW25jdwiFJEf7ASvSaC7sN7rbE/l3cLp2izav+CtQ==
dependencies:
esutils "^2.0.2"
lodash "^4.17.11"
......@@ -1295,17 +1295,17 @@ autofill-event@0.0.1:
integrity sha1-w4LPmJshth/0oSs1l+GUNHHTz3o=
autoprefixer@^9.4.7:
version "9.6.0"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.6.0.tgz#0111c6bde2ad20c6f17995a33fad7cf6854b4c87"
integrity sha512-kuip9YilBqhirhHEGHaBTZKXL//xxGnzvsD0FtBQa6z+A69qZD6s/BAX9VzDF1i9VKDquTJDQaPLSEhOnL6FvQ==
version "9.6.1"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.6.1.tgz#51967a02d2d2300bb01866c1611ec8348d355a47"
integrity sha512-aVo5WxR3VyvyJxcJC3h4FKfwCQvQWb1tSI5VHNibddCVWrcD1NvlxEweg3TSgiPztMnWfjpy2FURKA2kvDE+Tw==
dependencies:
browserslist "^4.6.1"
caniuse-lite "^1.0.30000971"
browserslist "^4.6.3"
caniuse-lite "^1.0.30000980"
chalk "^2.4.2"
normalize-range "^0.1.2"
num2fraction "^1.2.2"
postcss "^7.0.16"
postcss-value-parser "^3.3.1"
postcss "^7.0.17"
postcss-value-parser "^4.0.0"
aws-sdk@^2.345.0:
version "2.400.0"
......@@ -1656,9 +1656,9 @@ browserify-zlib@~0.2.0:
pako "~1.0.5"
browserify@^16.1.0, browserify@^16.2.3:
version "16.2.3"
resolved "https://registry.yarnpkg.com/browserify/-/browserify-16.2.3.tgz#7ee6e654ba4f92bce6ab3599c3485b1cc7a0ad0b"
integrity sha512-zQt/Gd1+W+IY+h/xX2NYMW4orQWhqSwyV+xsblycTtpOuB27h1fZhhNQuipJ4t79ohw4P4mMem0jp/ZkISQtjQ==
version "16.3.0"
resolved "https://registry.yarnpkg.com/browserify/-/browserify-16.3.0.tgz#4d414466e0b07492fff493a009ea883a9f2db230"
integrity sha512-BWaaD7alyGZVEBBwSTYx4iJF5DswIGzK17o8ai9w4iKRbYpk3EOiprRHMRRA8DCZFmFeOdx7A385w2XdFvxWmg==
dependencies:
JSONStream "^1.0.3"
assert "^1.4.0"
......@@ -1709,13 +1709,13 @@ browserify@^16.1.0, browserify@^16.2.3:
vm-browserify "^1.0.0"
xtend "^4.0.0"
browserslist@^4.6.0, browserslist@^4.6.1:
version "4.6.2"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.6.2.tgz#574c665950915c2ac73a4594b8537a9eba26203f"
integrity sha512-2neU/V0giQy9h3XMPwLhEY3+Ao0uHSwHvU8Q1Ea6AgLVL1sXbX3dzPrJ8NWe5Hi4PoTkCYXOtVR9rfRLI0J/8Q==
browserslist@^4.6.0, browserslist@^4.6.3:
version "4.6.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.6.3.tgz#0530cbc6ab0c1f3fc8c819c72377ba55cf647f05"
integrity sha512-CNBqTCq22RKM8wKJNowcqihHJ4SkI8CGeK7KOR9tPboXUuS5Zk5lQgzzTbs4oxD8x+6HUshZUa2OyNI9lR93bQ==
dependencies:
caniuse-lite "^1.0.30000974"
electron-to-chromium "^1.3.150"
caniuse-lite "^1.0.30000975"
electron-to-chromium "^1.3.164"
node-releases "^1.1.23"
btoa-lite@^1.0.0:
......@@ -1836,10 +1836,10 @@ camelcase@^5.0.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42"
integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==
caniuse-lite@^1.0.30000971, caniuse-lite@^1.0.30000974:
version "1.0.30000974"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000974.tgz#b7afe14ee004e97ce6dc73e3f878290a12928ad8"
integrity sha512-xc3rkNS/Zc3CmpMKuczWEdY2sZgx09BkAxfvkxlAEBTqcMHeL8QnPqhKse+5sRTi3nrw2pJwToD2WvKn1Uhvww==
caniuse-lite@^1.0.30000975, caniuse-lite@^1.0.30000980:
version "1.0.30000981"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000981.tgz#5b6828803362363e5a1deba2eb550185cf6cec8f"
integrity sha512-JTByHj4DQgL2crHNMK6PibqAMrqqb/Vvh0JrsTJVSWG4VSUrT16EklkuRZofurlMjgA9e+zlCM4Y39F3kootMQ==
caseless@~0.12.0:
version "0.12.0"
......@@ -2855,10 +2855,10 @@ ee-first@1.1.1:
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
electron-to-chromium@^1.3.150:
version "1.3.155"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.155.tgz#ebf0cc8eeaffd6151d1efad60fd9e021fb45fd3a"
integrity sha512-/ci/XgZG8jkLYOgOe3mpJY1onxPPTDY17y7scldhnSjjZqV6VvREG/LvwhRuV7BJbnENFfuDWZkSqlTh4x9ZjQ==
electron-to-chromium@^1.3.164:
version "1.3.188"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.188.tgz#e28e1afe4bb229989e280bfd3b395c7ec03c8b7a"
integrity sha512-tEQcughYIMj8WDMc59EGEtNxdGgwal/oLLTDw+NEqJRJwGflQvH3aiyiexrWeZOETP4/ko78PVr6gwNhdozvuQ==
elliptic@^6.0.0:
version "6.4.0"
......@@ -3101,14 +3101,14 @@ eslint-plugin-mocha@^5.2.1:
ramda "^0.26.1"
eslint-plugin-react-hooks@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.6.0.tgz#348efcda8fb426399ac7b8609607c7b4025a6f5f"
integrity sha512-lHBVRIaz5ibnIgNG07JNiAuBUeKhEf8l4etNx5vfAEwqQ5tcuK3jV9yjmopPgQDagQb7HwIuQVsE3IVcGrRnag==
version "1.6.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.6.1.tgz#3c66a5515ea3e0a221ffc5d4e75c971c217b1a4c"
integrity sha512-wHhmGJyVuijnYIJXZJHDUF2WM+rJYTjulUTqF9k61d3BTk8etydz+M4dXUVH7M76ZRS85rqBTCx0Es/lLsrjnA==
eslint-plugin-react@^7.12.4:
version "7.14.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.1.tgz#0b49bed8c18b5c2819ea4eb4fdda93e236643198"
integrity sha512-fQSIHJ3t0tYgctUyPbcjDPgNUTM6jNFguFKi73ctNjq+8KgqSynMMltakn60/VTtvmNSxOtju/j8Yby8mNn3bQ==
version "7.14.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.2.tgz#94c193cc77a899ac0ecbb2766fbef88685b7ecc1"
integrity sha512-jZdnKe3ip7FQOdjxks9XPN0pjUKZYq48OggNMd16Sk+8VXx6JOvXmlElxROCgp7tiUsTsze3jd78s/9AFJP2mA==
dependencies:
array-includes "^3.0.3"
doctrine "^2.1.0"
......@@ -3910,7 +3910,7 @@ glob-watcher@^5.0.3:
just-debounce "^1.0.0"
object.defaults "^1.1.0"
glob@7.1.3, glob@^7.0.0, glob@^7.0.3, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1:
glob@7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==
......@@ -3933,7 +3933,7 @@ glob@^6.0.4:
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@^7.1.3:
glob@^7.0.0, glob@^7.0.3, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@~7.1.1:
version "7.1.4"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
......@@ -4141,7 +4141,7 @@ gulp-util@^3.0.7:
through2 "^2.0.0"
vinyl "^0.5.0"
gulp@^4.0.0:
gulp@^4.0.0, gulp@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/gulp/-/gulp-4.0.2.tgz#543651070fd0f6ab0a0650c6a3e6ff5a7cb09caa"
integrity sha512-dvEs27SCZt2ibF29xYgmnwwCYZxdxhQ/+LFWlbAW8y7jt68L/65402Lz3+CKy0Ov4rOs+NERmDq7YlZaDqUIfA==
......@@ -5989,12 +5989,7 @@ mute-stream@0.0.7:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
nan@^2.11.0:
version "2.12.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552"
integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==
nan@^2.12.1, nan@^2.13.2:
nan@^2.11.0, nan@^2.12.1, nan@^2.13.2:
version "2.13.2"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
......@@ -6231,9 +6226,9 @@ npm-bundled@^1.0.1:
integrity sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==
npm-packlist@^1.1.12, npm-packlist@^1.1.6:
version "1.4.1"
resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc"
integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==
version "1.4.4"
resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.4.tgz#866224233850ac534b63d1a6e76050092b5d2f44"
integrity sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw==
dependencies:
ignore-walk "^3.0.1"
npm-bundled "^1.0.1"
......@@ -6889,12 +6884,12 @@ postcss-url@^8.0.0:
postcss "^7.0.2"
xxhashjs "^0.2.1"
postcss-value-parser@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
postcss-value-parser@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.0.tgz#99a983d365f7b2ad8d0f9b8c3094926eab4b936d"
integrity sha512-ESPktioptiSUchCKgggAkzdmkgzKfmp0EU8jXH+5kbIUB+unr0Y4CY9SRMvibuvYUBjNh1ACLbxqYNpdTQOteQ==
postcss@^7.0.13, postcss@^7.0.16, postcss@^7.0.2:
postcss@^7.0.13, postcss@^7.0.17, postcss@^7.0.2:
version "7.0.17"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.17.tgz#4da1bdff5322d4a0acaab4d87f3e782436bad31f"
integrity sha512-546ZowA+KZ3OasvQZHsbuEpysvwTZNGJv9EfyCQdsIDltPSWHAeTQ5fQy/Npi2ZDtLI3zs7Ps/p6wThErhm9fQ==
......@@ -8705,9 +8700,9 @@ universalify@^0.1.0:
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
unorm@^1.3.3:
version "1.5.0"
resolved "https://registry.yarnpkg.com/unorm/-/unorm-1.5.0.tgz#01fa9b76f1c60f7916834605c032aa8962c3f00a"
integrity sha512-sMfSWoiRaXXeDZSXC+YRZ23H4xchQpwxjpw1tmfR+kgbBCaOgln4NI0LXejJIhnBuKINrB3WRn+ZI8IWssirVw==
version "1.6.0"
resolved "https://registry.yarnpkg.com/unorm/-/unorm-1.6.0.tgz#029b289661fba714f1a9af439eb51d9b16c205af"
integrity sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
......@@ -8929,11 +8924,12 @@ watchify@^3.7.0:
xtend "^4.0.0"
websocket@^1.0.22:
version "1.0.28"
resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.28.tgz#9e5f6fdc8a3fe01d4422647ef93abdd8d45a78d3"
integrity sha512-00y/20/80P7H4bCYkzuuvvfDvh+dgtXi5kzDf3UcZwN6boTYaKvsrtZ5lIYm1Gsg48siMErd9M4zjSYfYFHTrA==
version "1.0.29"
resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.29.tgz#3f83e49d3279657c58b02a22d90749c806101b98"
integrity sha512-WhU8jKXC8sTh6ocLSqpZRlOKMNYGwUvjA5+XcIgIk/G3JCaDfkZUr0zA19sVSxJ0TEvm0i5IBzr54RZC4vzW7g==
dependencies:
debug "^2.2.0"
gulp "^4.0.2"
nan "^2.11.0"
typedarray-to-buffer "^3.1.5"
yaeti "^0.0.6"
......
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