Unverified Commit 130b1fbb authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1937 from hypothesis/toast-messages

Replace angular `toastr` with preact-compatible toast messages
parents bbaab28e c211cbbb
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" class="Icon Icon--check"><g fill-rule="evenodd"><rect fill="none" stroke="none" x="0" y="0" width="16" height="16"></rect><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 3L6 13 3 8"></path></g></svg>
......@@ -17,9 +17,9 @@ import Button from './button';
function AnnotationActionBar({
annotation,
annotationMapper,
flash,
onReply,
settings,
toastMessenger,
}) {
const userProfile = useStore(store => store.profile());
const annotationGroup = useStore(store => store.getGroup(annotation.group));
......@@ -49,7 +49,7 @@ function AnnotationActionBar({
const onDelete = () => {
if (window.confirm('Are you sure you want to delete this annotation?')) {
annotationMapper.deleteAnnotation(annotation).catch(err => {
flash.error(err.message, 'Deleting annotation failed');
toastMessenger.error(err.message, 'Deleting annotation failed');
});
}
};
......@@ -64,16 +64,13 @@ function AnnotationActionBar({
const onFlag = () => {
if (!userProfile.userid) {
flash.error(
'You must be logged in to report an annotation to moderators.',
'Log in to flag annotations'
);
toastMessenger.error('You must be logged in to report an annotation');
return;
}
annotationMapper
.flagAnnotation(annotation) // Flag annotation on service
.then(updateFlag) // Update app state with flag
.catch(err => flash.error(err.message, 'Flagging annotation failed'));
.catch(() => toastMessenger.error('Flagging annotation failed'));
};
const onReplyClick = event => {
......@@ -123,10 +120,14 @@ AnnotationActionBar.propTypes = {
// Injected services
annotationMapper: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
settings: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
AnnotationActionBar.injectedProps = ['annotationMapper', 'flash', 'settings'];
AnnotationActionBar.injectedProps = [
'annotationMapper',
'settings',
'toastMessenger',
];
export default withServices(AnnotationActionBar);
......@@ -17,7 +17,7 @@ import SvgIcon from './svg-icon';
function AnnotationShareControl({
annotation,
analytics,
flash,
toastMessenger,
group,
shareUri,
}) {
......@@ -56,9 +56,9 @@ function AnnotationShareControl({
const copyShareLink = () => {
try {
copyText(shareUri);
flash.info('Copied share link to clipboard');
toastMessenger.success('Copied share link to clipboard');
} catch (err) {
flash.error('Unable to copy link');
toastMessenger.error('Unable to copy link');
}
};
......@@ -143,9 +143,9 @@ AnnotationShareControl.propTypes = {
/* services */
analytics: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
AnnotationShareControl.injectedProps = ['analytics', 'flash'];
AnnotationShareControl.injectedProps = ['analytics', 'toastMessenger'];
export default withServices(AnnotationShareControl);
......@@ -24,11 +24,11 @@ import TagList from './tag-list';
function Annotation({
annotation,
annotationsService,
flash,
onReplyCountClick,
replyCount,
showDocumentInfo,
threadIsCollapsed,
toastMessenger,
}) {
const createDraft = useStore(store => store.createDraft);
const setCollapsed = useStore(store => store.setCollapsed);
......@@ -70,7 +70,7 @@ function Annotation({
try {
await annotationsService.save(annotation);
} catch (err) {
flash.error(err.message, 'Saving annotation failed');
toastMessenger.error('Saving annotation failed');
} finally {
setIsSaving(false);
}
......@@ -161,9 +161,9 @@ Annotation.propTypes = {
/* Injected services */
annotationsService: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
Annotation.injectedProps = ['annotationsService', 'flash'];
Annotation.injectedProps = ['annotationsService', 'toastMessenger'];
export default withServices(Annotation);
......@@ -17,10 +17,10 @@ import MenuItem from './menu-item';
function GroupListItem({
analytics,
isExpanded,
flash,
group,
groups: groupsService,
onExpand,
toastMessenger,
}) {
const canLeaveGroup = group.type === 'private';
const activityUrl = group.links.html;
......@@ -63,9 +63,9 @@ function GroupListItem({
const copyLink = () => {
try {
copyText(activityUrl);
flash.info(`Copied link for "${group.name}"`);
toastMessenger.success(`Copied link for "${group.name}"`);
} catch (err) {
flash.error('Unable to copy link');
toastMessenger.error('Unable to copy link');
}
};
......@@ -145,10 +145,10 @@ GroupListItem.propTypes = {
// Injected services.
analytics: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
groups: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
GroupListItem.injectedProps = ['analytics', 'flash', 'groups'];
GroupListItem.injectedProps = ['analytics', 'groups', 'toastMessenger'];
export default withServices(GroupListItem);
......@@ -10,7 +10,7 @@ import { withServices } from '../util/service-context';
* Banner allows moderators to hide/unhide the flagged
* annotation from other users.
*/
function ModerationBanner({ annotation, api, flash }) {
function ModerationBanner({ annotation, api, toastMessenger }) {
// actions
const store = useStore(store => ({
hide: store.hideAnnotation,
......@@ -32,7 +32,7 @@ function ModerationBanner({ annotation, api, flash }) {
store.hide(annotation.id);
})
.catch(() => {
flash.error('Failed to hide annotation');
toastMessenger.error('Failed to hide annotation');
});
};
......@@ -46,7 +46,7 @@ function ModerationBanner({ annotation, api, flash }) {
store.unhide(annotation.id);
})
.catch(() => {
flash.error('Failed to unhide annotation');
toastMessenger.error('Failed to unhide annotation');
});
};
......@@ -96,9 +96,9 @@ ModerationBanner.propTypes = {
// Injected services.
api: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
ModerationBanner.injectedProps = ['api', 'flash'];
ModerationBanner.injectedProps = ['api', 'toastMessenger'];
export default withServices(ModerationBanner);
......@@ -18,7 +18,7 @@ import SvgIcon from './svg-icon';
* are on the current page (as defined by the main frame's URI) and contained
* within the app's currently-focused group.
*/
function ShareAnnotationsPanel({ analytics, flash }) {
function ShareAnnotationsPanel({ analytics, toastMessenger }) {
const mainFrame = useStore(store => store.mainFrame());
const focusedGroup = useStore(store => store.focusedGroup());
......@@ -45,9 +45,9 @@ function ShareAnnotationsPanel({ analytics, flash }) {
const copyShareLink = () => {
try {
copyText(shareURI);
flash.info('Copied share link to clipboard');
toastMessenger.success('Copied share link to clipboard');
} catch (err) {
flash.error('Unable to copy link');
toastMessenger.error('Unable to copy link');
}
};
......@@ -118,9 +118,9 @@ function ShareAnnotationsPanel({ analytics, flash }) {
ShareAnnotationsPanel.propTypes = {
// Injected services
analytics: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
ShareAnnotationsPanel.injectedProps = ['analytics', 'flash'];
ShareAnnotationsPanel.injectedProps = ['analytics', 'toastMessenger'];
export default withServices(ShareAnnotationsPanel);
......@@ -19,6 +19,7 @@ const icons = {
edit: require('../../images/icons/edit.svg'),
email: require('../../images/icons/email.svg'),
'expand-menu': require('../../images/icons/expand-menu.svg'),
error: require('../../images/icons/cancel.svg'),
external: require('../../images/icons/external.svg'),
facebook: require('../../images/icons/facebook.svg'),
flag: require('../../images/icons/flag.svg'),
......@@ -45,6 +46,7 @@ const icons = {
reply: require('../../images/icons/reply.svg'),
search: require('../../images/icons/search.svg'),
share: require('../../images/icons/share.svg'),
success: require('../../images/icons/check.svg'),
sort: require('../../images/icons/sort.svg'),
trash: require('../../images/icons/trash.svg'),
twitter: require('../../images/icons/twitter.svg'),
......
......@@ -19,7 +19,7 @@ describe('AnnotationActionBar', () => {
// Fake services
let fakeAnnotationMapper;
let fakeFlash;
let fakeToastMessenger;
let fakePermits;
let fakeSettings;
// Fake dependencies
......@@ -31,7 +31,7 @@ describe('AnnotationActionBar', () => {
<AnnotationActionBar
annotation={fakeAnnotation}
annotationMapper={fakeAnnotationMapper}
flash={fakeFlash}
toastMessenger={fakeToastMessenger}
onReply={fakeOnReply}
settings={fakeSettings}
{...props}
......@@ -67,8 +67,7 @@ describe('AnnotationActionBar', () => {
flagAnnotation: sinon.stub().resolves(),
};
fakeFlash = {
info: sinon.stub(),
fakeToastMessenger = {
error: sinon.stub(),
};
......@@ -180,7 +179,7 @@ describe('AnnotationActionBar', () => {
button.props().onClick();
});
await waitFor(() => fakeFlash.error.called);
await waitFor(() => fakeToastMessenger.error.called);
});
});
......@@ -264,7 +263,7 @@ describe('AnnotationActionBar', () => {
button.props().onClick();
});
assert.calledOnce(fakeFlash.error);
assert.calledOnce(fakeToastMessenger.error);
assert.notCalled(fakeAnnotationMapper.flagAnnotation);
});
......@@ -304,7 +303,7 @@ describe('AnnotationActionBar', () => {
button.props().onClick();
});
await waitFor(() => fakeFlash.error.called);
await waitFor(() => fakeToastMessenger.error.called);
assert.notCalled(fakeStore.updateFlagStatus);
});
......
......@@ -12,7 +12,7 @@ describe('AnnotationShareControl', () => {
let fakeAnnotation;
let fakeAnalytics;
let fakeCopyToClipboard;
let fakeFlash;
let fakeToastMessenger;
let fakeGroup;
let fakeIsPrivate;
let fakeShareUri;
......@@ -28,7 +28,7 @@ describe('AnnotationShareControl', () => {
<AnnotationShareControl
annotation={fakeAnnotation}
analytics={fakeAnalytics}
flash={fakeFlash}
toastMessenger={fakeToastMessenger}
group={fakeGroup}
shareUri={fakeShareUri}
{...props}
......@@ -67,8 +67,8 @@ describe('AnnotationShareControl', () => {
fakeCopyToClipboard = {
copyText: sinon.stub(),
};
fakeFlash = {
info: sinon.stub(),
fakeToastMessenger = {
success: sinon.stub(),
error: sinon.stub(),
};
fakeGroup = {
......@@ -146,7 +146,10 @@ describe('AnnotationShareControl', () => {
.props()
.onClick();
assert.calledWith(fakeFlash.info, 'Copied share link to clipboard');
assert.calledWith(
fakeToastMessenger.success,
'Copied share link to clipboard'
);
});
it('flashes an error if link copying unsuccessful', () => {
......@@ -158,7 +161,7 @@ describe('AnnotationShareControl', () => {
.props()
.onClick();
assert.calledWith(fakeFlash.error, 'Unable to copy link');
assert.calledWith(fakeToastMessenger.error, 'Unable to copy link');
});
});
......
......@@ -22,7 +22,7 @@ describe('Annotation', () => {
// Injected dependency mocks
let fakeAnnotationsService;
let fakeFlash;
let fakeToastMessenger;
let fakeStore;
const setEditingMode = (isEditing = true) => {
......@@ -39,7 +39,7 @@ describe('Annotation', () => {
<Annotation
annotation={fixtures.defaultAnnotation()}
annotationsService={fakeAnnotationsService}
flash={fakeFlash}
toastMessenger={fakeToastMessenger}
onReplyCountClick={fakeOnReplyCountClick}
replyCount={0}
showDocumentInfo={false}
......@@ -57,7 +57,7 @@ describe('Annotation', () => {
save: sinon.stub().resolves(),
};
fakeFlash = {
fakeToastMessenger = {
error: sinon.stub(),
};
......@@ -255,7 +255,7 @@ describe('Annotation', () => {
);
});
it('should flash an error message on failure', async () => {
it('should show an error message on failure', async () => {
setEditingMode(true);
fakeAnnotationsService.save.rejects();
......@@ -265,7 +265,7 @@ describe('Annotation', () => {
.props()
.onSave();
await waitFor(() => fakeFlash.error.called);
await waitFor(() => fakeToastMessenger.error.called);
});
describe('saving using shortcut-key combo', () => {
......
......@@ -9,7 +9,7 @@ import { $imports } from '../group-list-item';
describe('GroupListItem', () => {
let fakeAnalytics;
let fakeCopyText;
let fakeFlash;
let fakeToastMessenger;
let fakeGroupsService;
let fakeStore;
let fakeGroupListItemCommon;
......@@ -39,8 +39,8 @@ describe('GroupListItem', () => {
events,
};
fakeFlash = {
info: sinon.stub(),
fakeToastMessenger = {
success: sinon.stub(),
error: sinon.stub(),
};
......@@ -85,7 +85,7 @@ describe('GroupListItem', () => {
const createGroupListItem = (fakeGroup, props = {}) => {
return mount(
<GroupListItem
flash={fakeFlash}
toastMessenger={fakeToastMessenger}
group={fakeGroup}
groups={fakeGroupsService}
analytics={fakeAnalytics}
......@@ -380,7 +380,7 @@ describe('GroupListItem', () => {
});
clickMenuItem(getSubmenu(wrapper), 'Copy invite link');
assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid');
assert.calledWith(fakeFlash.info, 'Copied link for "Test"');
assert.calledWith(fakeToastMessenger.success, 'Copied link for "Test"');
});
it('reports an error if "Copy link" action fails', () => {
......@@ -390,6 +390,6 @@ describe('GroupListItem', () => {
});
clickMenuItem(getSubmenu(wrapper), 'Copy invite link');
assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid');
assert.calledWith(fakeFlash.error, 'Unable to copy link');
assert.calledWith(fakeToastMessenger.error, 'Unable to copy link');
});
});
......@@ -12,16 +12,20 @@ const moderatedAnnotation = fixtures.moderatedAnnotation;
describe('ModerationBanner', () => {
let fakeApi;
let fakeFlash;
let fakeToastMessenger;
function createComponent(props) {
return mount(
<ModerationBanner api={fakeApi} flash={fakeFlash} {...props} />
<ModerationBanner
api={fakeApi}
toastMessenger={fakeToastMessenger}
{...props}
/>
);
}
beforeEach(() => {
fakeFlash = {
fakeToastMessenger = {
error: sinon.stub(),
};
......@@ -151,7 +155,7 @@ describe('ModerationBanner', () => {
wrapper.find('button').simulate('click');
setTimeout(function() {
assert.calledWith(fakeFlash.error, 'Failed to hide annotation');
assert.calledWith(fakeToastMessenger.error, 'Failed to hide annotation');
done();
}, 0);
});
......@@ -179,7 +183,10 @@ describe('ModerationBanner', () => {
);
wrapper.find('button').simulate('click');
setTimeout(function() {
assert.calledWith(fakeFlash.error, 'Failed to unhide annotation');
assert.calledWith(
fakeToastMessenger.error,
'Failed to unhide annotation'
);
done();
}, 0);
});
......
......@@ -10,7 +10,7 @@ import mockImportedComponents from '../../../test-util/mock-imported-components'
describe('ShareAnnotationsPanel', () => {
let fakeStore;
let fakeAnalytics;
let fakeFlash;
let fakeToastMessenger;
let fakeCopyToClipboard;
const fakePrivateGroup = {
......@@ -23,7 +23,7 @@ describe('ShareAnnotationsPanel', () => {
mount(
<ShareAnnotationsPanel
analytics={fakeAnalytics}
flash={fakeFlash}
toastMessenger={fakeToastMessenger}
{...props}
/>
);
......@@ -38,8 +38,8 @@ describe('ShareAnnotationsPanel', () => {
fakeCopyToClipboard = {
copyText: sinon.stub(),
};
fakeFlash = {
info: sinon.stub(),
fakeToastMessenger = {
success: sinon.stub(),
error: sinon.stub(),
};
......@@ -169,7 +169,10 @@ describe('ShareAnnotationsPanel', () => {
.props()
.onClick();
assert.calledWith(fakeFlash.info, 'Copied share link to clipboard');
assert.calledWith(
fakeToastMessenger.success,
'Copied share link to clipboard'
);
});
it('flashes an error if link copying unsuccessful', () => {
fakeCopyToClipboard.copyText.throws();
......@@ -180,7 +183,7 @@ describe('ShareAnnotationsPanel', () => {
.props()
.onClick();
assert.calledWith(fakeFlash.error, 'Unable to copy link');
assert.calledWith(fakeToastMessenger.error, 'Unable to copy link');
});
});
});
......
import { mount } from 'enzyme';
import { createElement } from 'preact';
import { act } from 'preact/test-utils';
import mockImportedComponents from '../../../test-util/mock-imported-components';
import ToastMessages, { $imports } from '../toast-messages';
import { checkAccessibility } from '../../../test-util/accessibility';
describe('ToastMessages', () => {
let fakeStore;
let fakeToastMessenger;
let fakeErrorMessage = () => {
return {
type: 'error',
message: 'boo',
id: 'someid2',
isDismissed: false,
};
};
let fakeSuccessMessage = () => {
return {
type: 'success',
message: 'yay',
id: 'someid',
isDismissed: false,
};
};
function createComponent(props) {
return mount(
<ToastMessages toastMessenger={fakeToastMessenger} {...props} />
);
}
beforeEach(() => {
fakeStore = {
getToastMessages: sinon.stub(),
};
fakeToastMessenger = {
dismiss: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
});
});
afterEach(() => {
$imports.$restore();
});
it('should render a `ToastMessage` for each message returned by the store', () => {
fakeStore.getToastMessages.returns([
fakeSuccessMessage(),
fakeErrorMessage(),
]);
const wrapper = createComponent();
assert.lengthOf(wrapper.find('ToastMessage'), 2);
});
describe('`ToastMessage` sub-component', () => {
it('should add `is-dismissed` stateful class name if message has been dismissed', () => {
const message = fakeSuccessMessage();
message.isDismissed = true;
fakeStore.getToastMessages.returns([message]);
const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessage li');
assert.isTrue(messageContainer.hasClass('is-dismissed'));
});
it('should dismiss the message when clicked', () => {
fakeStore.getToastMessages.returns([fakeSuccessMessage()]);
const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessage li');
act(() => {
messageContainer.simulate('click');
});
assert.calledOnce(fakeToastMessenger.dismiss);
});
[
{ message: fakeSuccessMessage(), className: 'toast-message--success' },
{ message: fakeErrorMessage(), className: 'toast-message--error' },
].forEach(testCase => {
it('should assign a CSS class based on message type', () => {
fakeStore.getToastMessages.returns([testCase.message]);
const wrapper = createComponent();
const messageWrapper = wrapper.find('.toast-message');
assert.isTrue(messageWrapper.hasClass(testCase.className));
});
[
{ message: fakeSuccessMessage(), prefix: 'Success' },
{ message: fakeErrorMessage(), prefix: 'Error' },
].forEach(testCase => {
it('should prefix the message with the message type', () => {
fakeStore.getToastMessages.returns([testCase.message]);
const wrapper = createComponent();
const messageContent = wrapper
.find('.toast-message__message')
.first();
assert.equal(
messageContent.text(),
`${testCase.prefix}: ${testCase.message.message}`
);
});
});
});
[
{ messages: [fakeSuccessMessage()], icons: ['success'] },
{ messages: [fakeErrorMessage()], icons: ['error'] },
{
messages: [fakeSuccessMessage(), fakeErrorMessage()],
icons: ['success', 'error'],
},
].forEach(testCase => {
it('should render an appropriate icon for the message type', () => {
fakeStore.getToastMessages.returns(testCase.messages);
const wrapper = createComponent();
const iconProps = wrapper
.find('SvgIcon')
.map(iconWrapper => iconWrapper.props().name);
assert.deepEqual(iconProps, testCase.icons);
});
});
});
describe('a11y', () => {
beforeEach(() => {
fakeStore.getToastMessages.returns([
fakeSuccessMessage(),
fakeErrorMessage(),
]);
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createComponent(),
},
])
);
});
});
......@@ -9,18 +9,22 @@ import { checkAccessibility } from '../../../test-util/accessibility';
describe('VersionInfo', function() {
let fakeVersionData;
// Services
let fakeFlash;
let fakeToastMessenger;
// Mocked dependencies
let fakeCopyToClipboard;
function createComponent(props) {
// Services
fakeFlash = {
info: sinon.stub(),
fakeToastMessenger = {
success: sinon.stub(),
error: sinon.stub(),
};
return mount(
<VersionInfo flash={fakeFlash} versionData={fakeVersionData} {...props} />
<VersionInfo
toastMessenger={fakeToastMessenger}
versionData={fakeVersionData}
{...props}
/>
);
}
......@@ -71,7 +75,10 @@ describe('VersionInfo', function() {
wrapper.find('button').simulate('click');
assert.calledWith(fakeFlash.info, 'Copied version info to clipboard');
assert.calledWith(
fakeToastMessenger.success,
'Copied version info to clipboard'
);
});
it('flashes an error if info copying unsuccessful', () => {
......@@ -80,7 +87,10 @@ describe('VersionInfo', function() {
wrapper.find('button').simulate('click');
assert.calledWith(fakeFlash.error, 'Unable to copy version info');
assert.calledWith(
fakeToastMessenger.error,
'Unable to copy version info'
);
});
});
......
import classnames from 'classnames';
import { createElement } from 'preact';
import propTypes from 'prop-types';
import useStore from '../store/use-store';
import { withServices } from '../util/service-context';
import SvgIcon from './svg-icon';
/**
* An individual toast message—a brief and transient success or error message.
* The message may be dismissed by clicking on it.
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
function ToastMessage({ message, onDismiss }) {
// Capitalize the message type for prepending
const prefix = message.type.charAt(0).toUpperCase() + message.type.slice(1);
/**
* a11y linting is disabled here: There is a click-to-remove handler on a
* non-interactive element. This allows sighted users to get the toast message
* out of their way if it interferes with interacting with the underlying
* components. This shouldn't pose the same irritation to users with screen-
* readers as the rendered toast messages shouldn't impede interacting with
* the underlying document.
*/
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
<li
className={classnames('toast-message-container', {
'is-dismissed': message.isDismissed,
})}
onClick={() => onDismiss(message.id)}
>
<div
className={classnames(
'toast-message',
`toast-message--${message.type}`
)}
>
<div className="toast-message__type">
<SvgIcon name={message.type} className="toast-message__icon" />
</div>
<div className="toast-message__message">
<strong>{prefix}: </strong>
{message.message}
</div>
</div>
</li>
);
}
ToastMessage.propTypes = {
// The message object to render
message: propTypes.object.isRequired,
onDismiss: propTypes.func,
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
function ToastMessages({ toastMessenger }) {
const messages = useStore(store => store.getToastMessages());
return (
<div>
<ul
aria-live="polite"
aria-relevant="additions"
className="toast-messages"
>
{messages.map(message => (
<ToastMessage
message={message}
key={message.id}
onDismiss={toastMessenger.dismiss}
/>
))}
</ul>
</div>
);
}
ToastMessages.propTypes = {
// Injected services
toastMessenger: propTypes.object.isRequired,
};
ToastMessages.injectedProps = ['toastMessenger'];
export default withServices(ToastMessages);
......@@ -9,13 +9,13 @@ import Button from './button';
/**
* Display current client version info
*/
function VersionInfo({ flash, versionData }) {
function VersionInfo({ toastMessenger, versionData }) {
const copyVersionData = () => {
try {
copyText(versionData.asFormattedString());
flash.info('Copied version info to clipboard');
toastMessenger.success('Copied version info to clipboard');
} catch (err) {
flash.error('Unable to copy version info');
toastMessenger.error('Unable to copy version info');
}
};
......@@ -55,9 +55,9 @@ VersionInfo.propTypes = {
versionData: propTypes.object.isRequired,
/** injected properties */
flash: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
};
VersionInfo.injectedProps = ['flash'];
VersionInfo.injectedProps = ['toastMessenger'];
export default withServices(VersionInfo);
......@@ -131,6 +131,7 @@ import SidebarContentError from './components/sidebar-content-error';
import SvgIcon from './components/svg-icon';
import TagEditor from './components/tag-editor';
import TagList from './components/tag-list';
import ToastMessages from './components/toast-messages';
import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular.
......@@ -169,6 +170,7 @@ import sessionService from './services/session';
import streamFilterService from './services/stream-filter';
import streamerService from './services/streamer';
import tagsService from './services/tags';
import toastMessenger from './services/toast-messenger';
import unicodeService from './services/unicode';
import viewFilterService from './services/view-filter';
......@@ -213,6 +215,7 @@ function startAngularApp(config) {
.register('streamer', streamerService)
.register('streamFilter', streamFilterService)
.register('tags', tagsService)
.register('toastMessenger', toastMessenger)
.register('unicode', unicodeService)
.register('viewFilter', viewFilterService)
.register('store', store);
......@@ -273,6 +276,7 @@ function startAngularApp(config) {
.component('tagEditor', wrapComponent(TagEditor))
.component('tagList', wrapComponent(TagList))
.component('threadList', threadList)
.component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar))
// Register services, the store and utilities with Angular, so that
......@@ -300,6 +304,7 @@ function startAngularApp(config) {
.service('session', () => container.get('session'))
.service('streamer', () => container.get('streamer'))
.service('streamFilter', () => container.get('streamFilter'))
.service('toastMessenger', () => container.get('toastMessenger'))
// Redux store
.service('store', () => container.get('store'))
......
import toastMessenger from '../toast-messenger';
describe('toastMessenger', function() {
let clock;
let fakeStore;
let service;
beforeEach(() => {
fakeStore = {
addToastMessage: sinon.stub(),
getToastMessage: sinon.stub(),
hasToastMessage: sinon.stub(),
removeToastMessage: sinon.stub(),
updateToastMessage: sinon.stub(),
};
clock = sinon.useFakeTimers();
service = toastMessenger(fakeStore);
});
afterEach(() => {
clock.restore();
});
describe('#success', () => {
it('does not add a new success message if a matching one already exists in the store', () => {
fakeStore.hasToastMessage.returns(true);
service.success('This is my message');
assert.calledWith(
fakeStore.hasToastMessage,
'success',
'This is my message'
);
assert.notCalled(fakeStore.addToastMessage);
});
it('adds a new success toast message to the store', () => {
fakeStore.hasToastMessage.returns(false);
service.success('hooray');
assert.calledWith(
fakeStore.addToastMessage,
sinon.match({ type: 'success', message: 'hooray' })
);
});
it('dismisses the message after timeout fires', () => {
fakeStore.hasToastMessage.returns(false);
fakeStore.getToastMessage.returns(undefined);
service.success('hooray');
// Move to the first scheduled timeout, which should invoke the
// `dismiss` method
clock.next();
assert.calledOnce(fakeStore.getToastMessage);
assert.notCalled(fakeStore.updateToastMessage);
});
});
describe('#error', () => {
it('does not add a new error message if one with the same message text already exists', () => {
fakeStore.hasToastMessage.returns(true);
service.error('This is my message');
assert.calledWith(
fakeStore.hasToastMessage,
'error',
'This is my message'
);
assert.notCalled(fakeStore.addToastMessage);
});
it('adds a new error toast message to the store', () => {
fakeStore.hasToastMessage.returns(false);
service.error('boo');
assert.calledWith(
fakeStore.addToastMessage,
sinon.match({ type: 'error', message: 'boo' })
);
});
it('dismisses the message after timeout fires', () => {
fakeStore.hasToastMessage.returns(false);
fakeStore.getToastMessage.returns(undefined);
service.error('boo');
// Move to the first scheduled timeout, which should invoke the
// `dismiss` method
clock.next();
assert.calledOnce(fakeStore.getToastMessage);
assert.notCalled(fakeStore.updateToastMessage);
});
});
describe('#dismiss', () => {
it('does not dismiss the message if it does not exist', () => {
fakeStore.getToastMessage.returns(undefined);
service.dismiss('someid');
assert.notCalled(fakeStore.updateToastMessage);
});
it('does not dismiss a message if it is already dismissed', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
isDismissed: true,
});
service.dismiss('someid');
assert.notCalled(fakeStore.updateToastMessage);
});
it('updates the message object to set `isDimissed` to `true`', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
isDismissed: false,
});
service.dismiss('someid');
assert.calledWith(
fakeStore.updateToastMessage,
sinon.match({ isDismissed: true })
);
});
it('removes the message from the store after timeout fires', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
isDismissed: false,
});
service.dismiss('someid');
// Advance the clock to fire the timeout that will remove the message
clock.next();
assert.calledOnce(fakeStore.removeToastMessage);
assert.calledWith(fakeStore.removeToastMessage, 'someid');
});
});
});
/**
* A service for managing toast messages. The service will auto-dismiss and
* remove toast messages created with `#success()` or `#error()`. Added
* messages may be manually dismissed with the `#dismiss()` method.
*/
import { generateHexString } from '../util/random';
// How long toast messages should be displayed before they are dismissed, in ms
const MESSAGE_DISPLAY_TIME = 5000;
// Delay before removing the message entirely (allows animations to complete)
const MESSAGE_DISMISS_DELAY = 500;
// @ngInject
export default function toastMessenger(store) {
/**
* Update a toast message's dismiss status and set a timeout to remove
* it after a bit. This allows effects/animations to happen before the
* message is removed entirely.
*
* @param {string} messageId - The value of the `id` property for the
* message that is to be dismissed.
*/
const dismiss = messageId => {
const message = store.getToastMessage(messageId);
if (message && !message.isDismissed) {
store.updateToastMessage({ ...message, isDismissed: true });
setTimeout(() => {
store.removeToastMessage(messageId);
}, MESSAGE_DISMISS_DELAY);
}
};
/**
* Add a new toast message to the store and set a timeout to dismiss it
* after some time. This method will not add a message that is already
* extant in the store's collection of toast messages (i.e. has the same
* `type` and `message` text of an existing message).
*
* @param {('error'|'success')} type
* @param {string} message - The message to be rendered
*/
const addMessage = (type, message) => {
// Do not add duplicate messages (messages with the same type and text)
if (store.hasToastMessage(type, message)) {
return;
}
const id = generateHexString(10);
store.addToastMessage({ type, message, id, isDismissed: false });
// Attempt to dismiss message after a set time period. NB: The message may
// have been removed by other mechanisms at this point; do not assume its
// presence.
setTimeout(() => {
dismiss(id);
}, MESSAGE_DISPLAY_TIME);
};
/**
* Add an error toast message with `messageText`
*/
const error = messageText => {
addMessage('error', messageText);
};
/**
* Add a success toast message with `messageText`
*/
const success = messageText => {
addMessage('success', messageText);
};
return {
dismiss,
error,
success,
};
}
......@@ -44,6 +44,7 @@ import route from './modules/route';
import selection from './modules/selection';
import session from './modules/session';
import sidebarPanels from './modules/sidebar-panels';
import toastMessages from './modules/toast-messages';
import viewer from './modules/viewer';
/**
......@@ -100,6 +101,7 @@ export default function store($rootScope, settings) {
selection,
session,
sidebarPanels,
toastMessages,
viewer,
];
return createStore(modules, [settings], middleware);
......
import createStore from '../../create-store';
import toastMessages from '../toast-messages';
describe('store/modules/toast-messages', function() {
let store;
let fakeToastMessage;
beforeEach(() => {
store = createStore([toastMessages]);
fakeToastMessage = {
id: 'myToast',
type: 'anyType',
message: 'This is a message',
};
});
describe('actions', () => {
describe('addToastMessage', () => {
it('adds the provided object to the array of messages in state', () => {
store.addToastMessage(fakeToastMessage);
const messages = store.getToastMessages();
assert.lengthOf(messages, 1);
// These two objects should not have the same reference
assert.notEqual(messages[0], fakeToastMessage);
assert.equal(messages[0].id, 'myToast');
assert.equal(messages[0].type, 'anyType');
assert.equal(messages[0].message, 'This is a message');
});
it('adds duplicate messages to the array of messages in state', () => {
// This store module doesn't care about duplicates
store.addToastMessage(fakeToastMessage);
store.addToastMessage(fakeToastMessage);
const messages = store.getToastMessages();
assert.lengthOf(messages, 2);
assert.notEqual(messages[0], messages[1]);
});
});
describe('removeToastMessage', () => {
it('removes messages that match the provided id', () => {
store.addToastMessage(fakeToastMessage);
fakeToastMessage.id = 'myToast2';
store.addToastMessage(fakeToastMessage);
store.removeToastMessage('myToast2');
const messages = store.getToastMessages();
assert.lengthOf(messages, 1);
assert.equal(messages[0].id, 'myToast');
});
it('should not remove any objects if none match the provided id', () => {
store.addToastMessage(fakeToastMessage);
fakeToastMessage.id = 'myToast2';
store.addToastMessage(fakeToastMessage);
store.removeToastMessage('myToast3');
assert.lengthOf(store.getToastMessages(), 2);
});
});
describe('updateToastMessage', () => {
it('should update the message object', () => {
const updatedMessage = {
id: 'myToast',
type: 'whatever',
message: 'updated',
};
store.addToastMessage(fakeToastMessage);
store.updateToastMessage(updatedMessage);
assert.deepEqual(store.getToastMessage('myToast'), updatedMessage);
});
it('should be OK if there is no matching message object', () => {
store.addToastMessage(fakeToastMessage);
store.updateToastMessage({ id: 'random' });
assert.lengthOf(store.getToastMessages(), 1);
});
});
});
describe('selectors', () => {
describe('getToastMessage', () => {
it('should return message with matching `id`', () => {
const anotherMessage = { ...fakeToastMessage, id: 'thisOne' };
store.addToastMessage(fakeToastMessage);
store.addToastMessage(anotherMessage);
const retrievedMessage = store.getToastMessage('thisOne');
assert.deepEqual(retrievedMessage, anotherMessage);
});
it('should return `undefined` if no message matches', () => {
store.addToastMessage(fakeToastMessage);
assert.isUndefined(store.getToastMessage('someRandomId'));
});
});
describe('getToastMessages', () => {
it('should return its collection of messages', () => {
assert.isArray(store.getToastMessages());
});
});
describe('hasToastMessage', () => {
it('should return `true` if one message matches `type` and `state`', () => {
store.addToastMessage(fakeToastMessage);
fakeToastMessage.type = 'anotherType';
store.addToastMessage(fakeToastMessage);
assert.isTrue(store.hasToastMessage('anyType', 'This is a message'));
});
it('should return `true` if more than one message matches `type` and `state`', () => {
store.addToastMessage(fakeToastMessage);
store.addToastMessage(fakeToastMessage);
assert.isTrue(store.hasToastMessage('anyType', 'This is a message'));
});
it('should return `false` if no messages match both `type` and `state`', () => {
store.addToastMessage(fakeToastMessage);
assert.isFalse(
store.hasToastMessage('anotherType', 'This is a message')
);
assert.isFalse(
store.hasToastMessage('anyType', 'This is another message')
);
});
});
});
});
import * as util from '../util';
/**
* A store module for managing a collection of toast messages. This module
* maintains state only; it's up to other layers to handle the management
* and interactions with these messages.
*/
function init() {
return {
messages: [],
};
}
const update = {
ADD_MESSAGE: function(state, action) {
return {
messages: state.messages.concat({ ...action.message }),
};
},
REMOVE_MESSAGE: function(state, action) {
const updatedMessages = state.messages.filter(
message => message.id !== action.id
);
return { messages: updatedMessages };
},
UPDATE_MESSAGE: function(state, action) {
const updatedMessages = state.messages.map(message => {
if (message.id && message.id === action.message.id) {
return { ...action.message };
}
return message;
});
return { messages: updatedMessages };
},
};
const actions = util.actionTypes(update);
/** Actions */
function addMessage(message) {
return { type: actions.ADD_MESSAGE, message };
}
/**
* Remove the `message` with the corresponding `id` property value.
*
* @param {string} id
*/
function removeMessage(id) {
return { type: actions.REMOVE_MESSAGE, id };
}
/**
* Update the `message` object (lookup is by `id`).
*
* @param {Object} message
*/
function updateMessage(message) {
return { type: actions.UPDATE_MESSAGE, message };
}
/** Selectors */
/**
* Retrieve a message by `id`
*
* @param {string} id
* @return {Object|undefined}
*/
function getMessage(state, id) {
return state.toastMessages.messages.find(message => message.id === id);
}
/**
* Retrieve all current messages
*
* @return {Object[]}
*/
function getMessages(state) {
return state.toastMessages.messages;
}
/**
* Return boolean indicating whether a message with the same type and message
* text exists in the state's collection of messages. This matches messages
* by content, not by ID (true uniqueness).
*
* @param {string} type
* @param {string} text
* @return {boolean}
*/
function hasMessage(state, type, text) {
return state.toastMessages.messages.some(message => {
return message.type === type && message.message === text;
});
}
export default {
init,
namespace: 'toastMessages',
update,
actions: {
addToastMessage: addMessage,
removeToastMessage: removeMessage,
updateToastMessage: updateMessage,
},
selectors: {
getToastMessage: getMessage,
getToastMessages: getMessages,
hasToastMessage: hasMessage,
},
};
......@@ -8,6 +8,7 @@
</top-bar>
<div class="content">
<toast-messages></toast-messages>
<help-panel auth="vm.auth"></help-panel>
<share-annotations-panel></share-annotations-panel>
......
@use "../../mixins/panel";
@use "../../variables" as var;
.toast-messages {
position: absolute;
z-index: 1;
width: 100%;
left: 0;
padding: 0 8px;
}
.toast-message-container {
position: relative;
width: 100%;
animation: slidein 0.3s forwards ease-in-out;
&:hover {
cursor: pointer;
}
&.is-dismissed {
animation: fadeout 0.3s forwards;
}
}
.toast-message {
@include panel.panel;
display: flex;
position: relative;
width: 100%;
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15);
border-radius: 2px;
margin-bottom: 0.75em;
&--success {
border: 1px solid var.$color-success;
}
&--error {
border: 1px solid var.$color-error;
}
&__type {
padding: 1em;
color: white;
}
&--success &__type {
background-color: var.$color-success;
}
&--error &__type {
background-color: var.$color-error;
}
&__icon {
// Specific adjustments for success and error icons
margin-top: 2px;
}
&__message {
padding: 1em;
}
}
@keyframes slidein {
0% {
opacity: 0;
left: 100%;
}
80% {
left: -10px;
}
100% {
left: 0;
opacity: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
......@@ -63,6 +63,7 @@
@use './components/tag-editor';
@use './components/tag-list';
@use './components/thread-list';
@use './components/toast-messages';
@use './components/tooltip';
@use './components/top-bar';
@use './components/tutorial';
......
......@@ -38,6 +38,9 @@ $black: #000 !default;
// Colors
$color-success: #00a36d;
$color-warning: #fbc168;
$color-error: #d93c3f;
$brand: #bd1c2b;
$highlight: #58cef4;
......
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