Commit 2fa444e0 authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Use new ToastMessages component with internal dismissing handling

parent 240616a8
import { useCallback, useEffect, useState } from 'preact/hooks';
import BaseToastMessages from '../../shared/components/BaseToastMessages';
import type { ToastMessage } from '../../shared/components/BaseToastMessages';
import BaseToastMessages from '../../shared/components/ToastMessages';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import type { Emitter } from '../util/emitter';
export type ToastMessagesProps = {
......
......@@ -17,6 +17,9 @@ describe('ToastMessages', () => {
const createComponent = () => mount(<ToastMessages emitter={emitter} />);
const toastMessagesList = wrapper =>
wrapper.find('[messages]').prop('messages');
beforeEach(() => {
emitter = new Emitter(new EventEmitter());
});
......@@ -25,14 +28,14 @@ describe('ToastMessages', () => {
const wrapper = createComponent();
// Initially messages is empty
assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 0);
assert.lengthOf(toastMessagesList(wrapper), 0);
emitter.publish('toastMessageAdded', fakeMessage('someId1'));
emitter.publish('toastMessageAdded', fakeMessage('someId2'));
emitter.publish('toastMessageAdded', fakeMessage('someId3'));
wrapper.update();
assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 3);
assert.lengthOf(toastMessagesList(wrapper), 3);
});
it('removes toast existing messages on toastMessageDismissed', () => {
......@@ -49,6 +52,6 @@ describe('ToastMessages', () => {
emitter.publish('toastMessageDismissed', 'someId4');
wrapper.update();
assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 2);
assert.lengthOf(toastMessagesList(wrapper), 2);
});
});
......@@ -2,7 +2,7 @@ import classnames from 'classnames';
import * as Hammer from 'hammerjs';
import { render } from 'preact';
import type { ToastMessage } from '../shared/components/BaseToastMessages';
import type { ToastMessage } from '../shared/components/ToastMessages';
import { addConfigFragment } from '../shared/config-fragment';
import { sendErrorsTo } from '../shared/frame-error-capture';
import { ListenerCollection } from '../shared/listener-collection';
......
import { Callout, Link } from '@hypothesis/frontend-shared';
import type { TransitionComponent } from '@hypothesis/frontend-shared';
import { Callout } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import type {
ComponentChildren,
ComponentProps,
FunctionComponent,
} from 'preact';
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
export type ToastMessage = {
type: 'error' | 'success' | 'notice';
id: string;
message: string;
moreInfoURL: string;
isDismissed: boolean;
visuallyHidden: boolean;
type: 'error' | 'success' | 'notice';
message: ComponentChildren;
/**
* Visually hidden messages are announced to screen readers but not visible.
* Defaults to false.
*/
visuallyHidden?: boolean;
/**
* Determines if the toast message should be auto-dismissed.
* Defaults to true.
*/
autoDismiss?: boolean;
};
export type ToastMessageTransitionClasses = {
/** Classes to apply to a toast message when appended. Defaults to 'animate-fade-in' */
transitionIn?: string;
/** Classes to apply to a toast message being dismissed. Defaults to 'animate-fade-out' */
transitionOut?: string;
};
type ToastMessageItemProps = {
......@@ -28,13 +51,6 @@ function ToastMessageItem({ message, onDismiss }: ToastMessageItemProps) {
? `${message.type.charAt(0).toUpperCase() + message.type.slice(1)}: `
: '';
/**
* 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 (
<Callout
classes={classnames({
......@@ -46,56 +62,124 @@ function ToastMessageItem({ message, onDismiss }: ToastMessageItemProps) {
>
<strong>{prefix}</strong>
{message.message}
{message.moreInfoURL && (
<div className="text-right">
<Link
href={message.moreInfoURL}
onClick={
event =>
event.stopPropagation() /* consume the event so that it does not dismiss the message */
}
target="_new"
underline="none"
>
More info
</Link>
</div>
)}
</Callout>
);
}
export type BaseToastMessageProps = {
type BaseToastMessageTransitionType = FunctionComponent<
ComponentProps<TransitionComponent> & {
transitionClasses?: ToastMessageTransitionClasses;
}
>;
const BaseToastMessageTransition: BaseToastMessageTransitionType = ({
direction,
onTransitionEnd,
children,
transitionClasses = {},
}) => {
const isDismissed = direction === 'out';
const containerRef = useRef<HTMLDivElement>(null);
const handleAnimation = (e: AnimationEvent) => {
// Ignore animations happening on child elements
if (e.target !== containerRef.current) {
return;
}
onTransitionEnd?.(direction ?? 'in');
};
const classes = useMemo(() => {
const {
transitionIn = 'animate-fade-in',
transitionOut = 'animate-fade-out',
} = transitionClasses;
return {
[transitionIn]: !isDismissed,
[transitionOut]: isDismissed,
};
}, [isDismissed, transitionClasses]);
return (
<div
data-testid="animation-container"
onAnimationEnd={handleAnimation}
ref={containerRef}
className={classnames('relative w-full container', classes)}
>
{children}
</div>
);
};
export type ToastMessagesProps = {
messages: ToastMessage[];
onMessageDismiss: (messageId: string) => void;
onMessageDismiss: (id: string) => void;
transitionClasses?: ToastMessageTransitionClasses;
setTimeout_?: typeof setTimeout;
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
export default function BaseToastMessages({
export default function ToastMessages({
messages,
onMessageDismiss,
}: BaseToastMessageProps) {
// The `ul` containing any toast messages is absolute-positioned and the full
// width of the viewport. Each toast message `li` has its position and width
// constrained by `container` configuration in tailwind.
transitionClasses,
/* istanbul ignore next - test seam */
setTimeout_ = setTimeout,
}: ToastMessagesProps) {
const [dismissedMessages, setDismissedMessages] = useState<string[]>([]);
const scheduledMessages = useRef(new Set<string>());
const dismissMessage = useCallback(
(id: string) => setDismissedMessages(ids => [...ids, id]),
[],
);
const scheduleMessageDismiss = useCallback(
(id: string) => {
if (scheduledMessages.current.has(id)) {
return;
}
// Track that this message has been scheduled to be dismissed. After a
// period of time, actually dismiss it
scheduledMessages.current.add(id);
setTimeout_(() => {
dismissMessage(id);
scheduledMessages.current.delete(id);
}, 5000);
},
[dismissMessage, setTimeout_],
);
const onTransitionEnd = useCallback(
(direction: 'in' | 'out', message: ToastMessage) => {
const autoDismiss = message.autoDismiss ?? true;
if (direction === 'in' && autoDismiss) {
scheduleMessageDismiss(message.id);
}
if (direction === 'out') {
onMessageDismiss(message.id);
setDismissedMessages(ids => ids.filter(id => id !== message.id));
}
},
[scheduleMessageDismiss, onMessageDismiss],
);
return (
<div>
<ul
aria-live="polite"
aria-relevant="additions"
className={classnames(
// Set an aggressive z-index as we want to ensure toast messages are
// rendered above other content
'z-10',
'absolute left-0 w-full',
)}
>
{messages.map(message => (
<ul
aria-live="polite"
aria-relevant="additions"
className="w-full space-y-2"
>
{messages.map(message => {
const isDismissed = dismissedMessages.includes(message.id);
return (
<li
className={classnames('relative w-full container', {
className={classnames({
// Add a bottom margin to visible messages only. Typically, we'd
// use a `space-y-2` class on the parent to space children.
// Doing that here could cause an undesired top margin on
......@@ -103,20 +187,19 @@ export default function BaseToastMessages({
// visually-hidden messages before it.
// See https://tailwindcss.com/docs/space#limitations
'mb-2': !message.visuallyHidden,
// Slide in from right in narrow viewports; fade in larger
// viewports to toast message isn't flying too far
'motion-safe:animate-slide-in-from-right lg:animate-fade-in':
!message.isDismissed,
// Only ever fade in if motion-reduction is preferred
'motion-reduce:animate-fade-in': !message.isDismissed,
'animate-fade-out': message.isDismissed,
})}
key={message.id}
>
<ToastMessageItem message={message} onDismiss={onMessageDismiss} />
<BaseToastMessageTransition
direction={isDismissed ? 'out' : 'in'}
onTransitionEnd={direction => onTransitionEnd(direction, message)}
transitionClasses={transitionClasses}
>
<ToastMessageItem message={message} onDismiss={dismissMessage} />
</BaseToastMessageTransition>
</li>
))}
</ul>
</div>
);
})}
</ul>
);
}
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { checkAccessibility } from '../../../test-util/accessibility';
import BaseToastMessages from '../BaseToastMessages';
describe('BaseToastMessages', () => {
let fakeOnMessageDismiss;
let fakeErrorMessage = () => {
return {
type: 'error',
message: 'boo',
id: 'someid2',
isDismissed: false,
};
};
let fakeSuccessMessage = () => {
return {
type: 'success',
message: 'yay',
id: 'someid',
isDismissed: false,
};
};
let fakeNoticeMessage = () => {
return {
type: 'notice',
message: 'you should know...',
id: 'someid3',
isDismissed: false,
moreInfoURL: 'http://www.example.com',
};
};
function createComponent(messages = []) {
return mount(
<BaseToastMessages
messages={messages}
onMessageDismiss={fakeOnMessageDismiss}
/>,
);
}
beforeEach(() => {
fakeOnMessageDismiss = sinon.stub();
});
it('should render a `ToastMessageItem` for each provided message', () => {
const wrapper = createComponent([
fakeSuccessMessage(),
fakeErrorMessage(),
fakeNoticeMessage(),
]);
assert.lengthOf(wrapper.find('ToastMessageItem'), 3);
});
describe('`ToastMessageItem` sub-component', () => {
it('should dismiss the message when clicked', () => {
const wrapper = createComponent([fakeSuccessMessage()]);
const messageContainer = wrapper.find('ToastMessageItem').getDOMNode();
act(() => {
messageContainer.dispatchEvent(new Event('click'));
});
assert.calledOnce(fakeOnMessageDismiss);
});
it('should set a screen-reader-only class on `visuallyHidden` messages', () => {
const message = fakeSuccessMessage();
message.visuallyHidden = true;
const wrapper = createComponent([message]);
const messageContainer = wrapper.find('ToastMessageItem').getDOMNode();
assert.include(messageContainer.className, 'sr-only');
});
it('should not dismiss the message if a "More info" link is clicked', () => {
const wrapper = createComponent([fakeNoticeMessage()]);
const link = wrapper.find('Link');
act(() => {
link.getDOMNode().dispatchEvent(new Event('click', { bubbles: true }));
});
assert.notCalled(fakeOnMessageDismiss);
});
[
{ message: fakeSuccessMessage(), prefix: 'Success: ' },
{ message: fakeErrorMessage(), prefix: 'Error: ' },
{ message: fakeNoticeMessage(), prefix: '' },
].forEach(testCase => {
it('should prefix the message with the message type', () => {
const wrapper = createComponent([testCase.message]);
assert.include(
wrapper.text(),
`${testCase.prefix}${testCase.message.message}`,
);
});
});
[
{ messages: [fakeSuccessMessage()], icons: ['CheckIcon'] },
{ messages: [fakeErrorMessage()], icons: ['CancelIcon'] },
{ messages: [fakeNoticeMessage()], icons: ['CautionIcon'] },
{
messages: [fakeSuccessMessage(), fakeErrorMessage()],
icons: ['CheckIcon', 'CancelIcon'],
},
].forEach(testCase => {
it('should render an appropriate icon for the message type', () => {
const wrapper = createComponent(testCase.messages);
testCase.icons.forEach(iconName => {
assert.isTrue(wrapper.find(iconName).exists());
});
});
});
});
it('should render a "more info" link if URL is present in message object', () => {
const wrapper = createComponent([fakeNoticeMessage()]);
const link = wrapper.find('Link');
assert.equal(link.props().href, 'http://www.example.com');
assert.equal(link.text(), 'More info');
});
describe('a11y', () => {
it(
'should pass a11y checks',
checkAccessibility([
{
content: () =>
createComponent([
fakeSuccessMessage(),
fakeErrorMessage(),
fakeNoticeMessage(),
]),
},
]),
);
});
});
import { mount } from 'enzyme';
import ToastMessages from '../ToastMessages';
describe('ToastMessages', () => {
const toastMessages = [
{
id: '1',
type: 'success',
message: 'Hello world',
},
{
id: '2',
type: 'success',
message: 'Foobar',
},
{
id: '3',
type: 'error',
message: 'Something failed',
},
];
let fakeOnMessageDismiss;
beforeEach(() => {
fakeOnMessageDismiss = sinon.stub();
});
function createToastMessages(toastMessages, setTimeout) {
const container = document.createElement('div');
document.body.appendChild(container);
return mount(
<ToastMessages
messages={toastMessages}
onMessageDismiss={fakeOnMessageDismiss}
setTimeout_={setTimeout}
/>,
{ attachTo: container },
);
}
function triggerAnimationEnd(wrapper, index, direction = 'out') {
wrapper
.find('BaseToastMessageTransition')
.at(index)
.props()
.onTransitionEnd(direction);
wrapper.update();
}
it('renders a list of toast messages', () => {
const wrapper = createToastMessages(toastMessages);
assert.equal(wrapper.find('ToastMessageItem').length, toastMessages.length);
});
toastMessages.forEach((message, index) => {
it('dismisses messages when clicked', () => {
const wrapper = createToastMessages(toastMessages);
wrapper.find('Callout').at(index).props().onClick();
// onMessageDismiss is not immediately called. Transition has to finish
assert.notCalled(fakeOnMessageDismiss);
// Once dismiss animation has finished, onMessageDismiss is called
triggerAnimationEnd(wrapper, index);
assert.calledWith(fakeOnMessageDismiss, message.id);
});
});
it('dismisses messages automatically unless instructed otherwise', () => {
const messages = [
...toastMessages,
{
id: 'foo',
type: 'success',
message: 'Not to be dismissed',
autoDismiss: false,
},
];
const wrapper = createToastMessages(
messages,
// Fake internal setTimeout, to immediately call its callback
callback => callback(),
);
// Trigger "in" animation for all messages, which will schedule dismiss for
// appropriate messages
messages.forEach((_, index) => {
triggerAnimationEnd(wrapper, index, 'in');
});
// Trigger "out" animation on components which "direction" prop is currently
// "out". That means they were scheduled for dismiss
wrapper
.find('BaseToastMessageTransition')
.forEach((transitionComponent, index) => {
if (transitionComponent.prop('direction') === 'out') {
triggerAnimationEnd(wrapper, index);
}
});
// Only one toast message will remain, as it was marked as `autoDismiss: false`
assert.equal(fakeOnMessageDismiss.callCount, 3);
});
it('schedules dismiss only once per message', async () => {
const wrapper = createToastMessages(
toastMessages,
// Fake an immediate setTimeout which does not slow down the test, but
// keeps the async behavior
callback => setTimeout(callback, 0),
);
const scheduleFirstMessageDismiss = () =>
triggerAnimationEnd(wrapper, 0, 'in');
scheduleFirstMessageDismiss();
scheduleFirstMessageDismiss();
scheduleFirstMessageDismiss();
// Once dismiss animation has finished, onMessageDismiss is called
triggerAnimationEnd(wrapper, 0);
assert.equal(fakeOnMessageDismiss.callCount, 1);
});
it('invokes onTransitionEnd when animation happens on container', () => {
const wrapper = createToastMessages(toastMessages, callback => callback());
const animationContainer = wrapper
.find('[data-testid="animation-container"]')
.first();
// Trigger "in" animation for all messages, which will schedule dismiss
toastMessages.forEach((_, index) => {
triggerAnimationEnd(wrapper, index, 'in');
});
animationContainer
.getDOMNode()
.dispatchEvent(new AnimationEvent('animationend'));
assert.called(fakeOnMessageDismiss);
});
it('does not invoke onTransitionEnd for animation events bubbling from children', () => {
const wrapper = createToastMessages(toastMessages, callback => callback());
const invalidAnimationContainer = wrapper.find('Callout').first();
// Trigger "in" animation for all messages, which will schedule dismiss
toastMessages.forEach((_, index) => {
triggerAnimationEnd(wrapper, index, 'in');
});
invalidAnimationContainer
.getDOMNode()
.dispatchEvent(new AnimationEvent('animationend', { bubbles: true }));
assert.notCalled(fakeOnMessageDismiss);
});
});
import BaseToastMessages from '../../shared/components/BaseToastMessages';
import classnames from 'classnames';
import BaseToastMessages from '../../shared/components/ToastMessages';
import { withServices } from '../service-context';
import type { ToastMessengerService } from '../services/toast-messenger';
import { useSidebarStore } from '../store';
......@@ -16,10 +18,22 @@ function ToastMessages({ toastMessenger }: ToastMessageProps) {
const messages = store.getToastMessages();
return (
<BaseToastMessages
messages={messages}
onMessageDismiss={(id: string) => toastMessenger.dismiss(id)}
/>
<div
className={classnames(
// Ensure toast messages are rendered above other content
'z-10',
'absolute left-0 w-full',
)}
>
<BaseToastMessages
messages={messages}
onMessageDismiss={(id: string) => toastMessenger.dismiss(id)}
transitionClasses={{
transitionIn:
'motion-safe:animate-slide-in-from-right lg:animate-fade-in motion-reduce:animate-fade-in',
}}
/>
</div>
);
}
......
......@@ -49,14 +49,14 @@ describe('ToastMessages', () => {
const wrapper = createComponent();
assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 3);
assert.lengthOf(wrapper.find('[messages]').prop('messages'), 3);
});
it('should dismiss the message when clicked', () => {
fakeStore.getToastMessages.returns([fakeMessage()]);
const wrapper = createComponent();
const messageContainer = wrapper.find('BaseToastMessages');
const messageContainer = wrapper.find('[onMessageDismiss]');
messageContainer.prop('onMessageDismiss')();
......
......@@ -2,7 +2,7 @@ import debounce from 'lodash.debounce';
import type { DebouncedFunction } from 'lodash.debounce';
import shallowEqual from 'shallowequal';
import type { ToastMessage } from '../../shared/components/BaseToastMessages';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import { ListenerCollection } from '../../shared/listener-collection';
import {
PortFinder,
......
import { ToastMessengerService } from '../toast-messenger';
describe('ToastMessengerService', () => {
let clock;
let fakeStore;
let fakeWindow;
let service;
......@@ -12,21 +11,15 @@ describe('ToastMessengerService', () => {
getToastMessage: sinon.stub(),
hasToastMessage: sinon.stub(),
removeToastMessage: sinon.stub(),
updateToastMessage: sinon.stub(),
};
fakeWindow = new EventTarget();
fakeWindow.document = {
hasFocus: sinon.stub().returns(true),
};
clock = sinon.useFakeTimers();
service = new ToastMessengerService(fakeStore, fakeWindow);
});
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);
......@@ -56,35 +49,6 @@ describe('ToastMessengerService', () => {
);
});
it('passes along `moreInfoURL` when present', () => {
fakeStore.hasToastMessage.returns(false);
service.success('hooray', { moreInfoURL: 'http://www.example.com' });
assert.calledWith(
fakeStore.addToastMessage,
sinon.match({
type: 'success',
message: 'hooray',
moreInfoURL: 'http://www.example.com',
}),
);
});
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);
});
it('emits "toastMessageAdded" event', () => {
fakeStore.hasToastMessage.returns(false);
......@@ -138,31 +102,14 @@ describe('ToastMessengerService', () => {
);
});
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);
});
it('does not dismiss the message if `autoDismiss` is false', () => {
fakeStore.hasToastMessage.returns(false);
fakeStore.getToastMessage.returns(undefined);
service.error('boo', { autoDismiss: false });
// Move to the first scheduled timeout.
clock.next();
assert.notCalled(fakeStore.getToastMessage);
assert.notCalled(fakeStore.updateToastMessage);
assert.notCalled(fakeStore.removeToastMessage);
});
});
......@@ -172,22 +119,10 @@ describe('ToastMessengerService', () => {
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);
assert.notCalled(fakeStore.removeToastMessage);
});
it('updates the message object to set `isDimissed` to `true`', () => {
it('removes the message from the store', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
......@@ -196,24 +131,6 @@ describe('ToastMessengerService', () => {
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');
});
......
import { TinyEmitter } from 'tiny-emitter';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import { generateHexString } from '../../shared/random';
import type { SidebarStore } from '../store';
// 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;
/**
* Additional control over the display of a particular message.
*/
export type MessageOptions = {
/** Whether the toast message automatically disappears. */
autoDismiss?: boolean;
/** Optional URL for users to visit for "more info" */
moreInfoURL?: string;
/**
* When `true`, message will be visually hidden but still available to screen
......@@ -41,9 +35,8 @@ type MessageData = {
};
/**
* 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.
* A service for managing toast messages. Added messages may be manually
* dismissed with the `#dismiss()` method.
*/
// @inject
export class ToastMessengerService extends TinyEmitter {
......@@ -81,12 +74,9 @@ export class ToastMessengerService extends TinyEmitter {
*/
dismiss(messageId: string) {
const message = this._store.getToastMessage(messageId);
if (message && !message.isDismissed) {
this._store.updateToastMessage({ ...message, isDismissed: true });
if (message) {
this._store.removeToastMessage(messageId);
this.emit('toastMessageDismissed', messageId);
setTimeout(() => {
this._store.removeToastMessage(messageId);
}, MESSAGE_DISMISS_DELAY);
}
}
......@@ -101,7 +91,6 @@ export class ToastMessengerService extends TinyEmitter {
messageText: string,
{
autoDismiss = true,
moreInfoURL = '',
visuallyHidden = false,
delayed = false,
}: MessageOptions = {},
......@@ -114,34 +103,21 @@ export class ToastMessengerService extends TinyEmitter {
if (delayed && !this._window.document.hasFocus()) {
// Ignore the "delayed" option to avoid an infinite loop of re-enqueuing
// the same messages over and over
const options = { autoDismiss, moreInfoURL, visuallyHidden };
const options = { autoDismiss, visuallyHidden };
this._delayedMessageQueue.push({ type, messageText, options });
return;
}
const id = generateHexString(10);
const message = {
const message: ToastMessage = {
type,
id,
message: messageText,
moreInfoURL,
visuallyHidden,
};
this._store.addToastMessage({
isDismissed: false,
...message,
});
this._store.addToastMessage(message);
this.emit('toastMessageAdded', message);
if (autoDismiss) {
// 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(() => {
this.dismiss(id);
}, MESSAGE_DISPLAY_TIME);
}
}
/**
......
......@@ -66,27 +66,6 @@ describe('store/modules/toast-messages', () => {
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', () => {
......
import type { ToastMessage } from '../../../shared/components/BaseToastMessages';
import type { ToastMessage } from '../../../shared/components/ToastMessages';
import { createStoreModule, makeAction } from '../create-store';
/**
......@@ -28,16 +28,6 @@ const reducers = {
);
return { messages: updatedMessages };
},
UPDATE_MESSAGE(state: State, action: { message: ToastMessage }) {
const updatedMessages = state.messages.map(message => {
if (message.id && message.id === action.message.id) {
return { ...action.message };
}
return message;
});
return { messages: updatedMessages };
},
};
/** Actions */
......@@ -53,13 +43,6 @@ function removeMessage(id: string) {
return makeAction(reducers, 'REMOVE_MESSAGE', { id });
}
/**
* Update the `message` object (lookup is by `id`).
*/
function updateMessage(message: ToastMessage) {
return makeAction(reducers, 'UPDATE_MESSAGE', { message });
}
/** Selectors */
/**
......@@ -93,7 +76,6 @@ export const toastMessagesModule = createStoreModule(initialState, {
actionCreators: {
addToastMessage: addMessage,
removeToastMessage: removeMessage,
updateToastMessage: updateMessage,
},
selectors: {
getToastMessage: getMessage,
......
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