Skip to content

Commit

Permalink
Test ToastMessages
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Jul 31, 2023
1 parent fe979b6 commit 4ade16c
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 41 deletions.
41 changes: 23 additions & 18 deletions via/static/scripts/video_player/components/ToastMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const BaseToastMessageTransition: TransitionComponent = ({
const containerRef = useRef<HTMLDivElement>(null);
const handleAnimation = (e: AnimationEvent) => {
// Ignore non-relevant animations:
// * Happening on children elements
// * Happening on child elements
// * Animations which are not relevant for toast messages getting "in" or "out"
if (
e.target !== containerRef.current ||
Expand All @@ -78,6 +78,7 @@ const BaseToastMessageTransition: TransitionComponent = ({

return (
<div
data-testid="animation-container"
onAnimationEnd={handleAnimation}
ref={containerRef}
className={classnames('relative w-full container', {
Expand All @@ -97,6 +98,7 @@ const BaseToastMessageTransition: TransitionComponent = ({
export type ToastMessagesProps = {
messages: ToastMessage[];
onMessageDismiss: (id: string) => void;
setTimeout_?: typeof setTimeout;
};

/**
Expand All @@ -106,6 +108,8 @@ export type ToastMessagesProps = {
export default function ToastMessages({
messages,
onMessageDismiss,
/* istanbul ignore next - test seam */
setTimeout_ = setTimeout,
}: ToastMessagesProps) {
const [dismissedMessages, setDismissedMessages] = useState<string[]>([]);
const scheduledMessages = useRef(new Set<string>());
Expand All @@ -123,17 +127,29 @@ export default function ToastMessages({
// Track that this message has been scheduled to be dismissed. After a
// period of time, actually dismiss it
scheduledMessages.current.add(id);
setTimeout(() => {
setTimeout_(() => {
dismissMessage(id);
scheduledMessages.current.delete(id);
}, 5000);
},
[dismissMessage]
[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]
);

// 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.
return (
<ul
aria-live="polite"
Expand All @@ -157,18 +173,7 @@ export default function ToastMessages({
>
<BaseToastMessageTransition
direction={isDismissed ? 'out' : 'in'}
onTransitionEnd={direction => {
if (direction === 'in') {
if (message.autoDismiss ?? true) {
scheduleMessageDismiss(message.id);
}
} else {
onMessageDismiss(message.id);
setDismissedMessages(ids =>
ids.filter(id => id !== message.id)
);
}
}}
onTransitionEnd={direction => onTransitionEnd(direction, message)}
>
<ToastMessageItem message={message} onDismiss={dismissMessage} />
</BaseToastMessageTransition>
Expand Down
171 changes: 171 additions & 0 deletions via/static/scripts/video_player/components/test/ToastMessages-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { mount } from 'enzyme';

import { delay } from '../../../test-util/wait';
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 a setTimeout which is fast enough to not make the test too slow,
// but allows to check that subsequent schedules of the same message are ignored
callback => setTimeout(callback, 50)
);
const scheduleFirstMessageDismiss = () =>
triggerAnimationEnd(wrapper, 0, 'in');

scheduleFirstMessageDismiss();
scheduleFirstMessageDismiss();
scheduleFirstMessageDismiss();

// Wait for the first scheduled timeout
await delay(60);

// Once dismiss animation has finished, onMessageDismiss is called
triggerAnimationEnd(wrapper, 0);
assert.equal(fakeOnMessageDismiss.callCount, 1);
});

['slide-in-from-right', 'fade-in', 'fade-out'].forEach(animationName => {
it('invokes onTransitionEnd when proper animation is dispatched', () => {
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', { animationName }));

assert.called(fakeOnMessageDismiss);
});
});

it('does not invoke onTransitionEnd for irrelevant animations', () => {
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', { animationName: 'invalid' })
);

assert.notCalled(fakeOnMessageDismiss);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { mount } from 'enzyme';

import { useToastMessages } from '../use-toast-messages';

describe('useToastMessages', () => {
function FakeToastMessagesContainer() {
const { toastMessages, appendToastMessage, dismissToastMessage } =
useToastMessages();

return (
<div>
{!toastMessages.length && (
<span data-testid="no-toast-messages">
There are no toast messages
</span>
)}
{toastMessages.map(({ message }, index) => (
<span key={`${message}_${index}`} className="toast-message">
{message}
</span>
))}
<button
data-testid="append-button"
onClick={() =>
appendToastMessage({
type: 'success',
message: 'Toast message',
})
}
>
Append
</button>
<button
data-testid="dismiss-button"
onClick={() =>
toastMessages.length && dismissToastMessage(toastMessages[0].id)
}
>
Dismiss
</button>
</div>
);
}

function createFakeToastMessages() {
return mount(<FakeToastMessagesContainer />);
}

it('has no toast messages at first', () => {
const wrapper = createFakeToastMessages();

assert.isTrue(wrapper.exists('[data-testid="no-toast-messages"]'));
assert.isFalse(wrapper.exists('.toast-message'));
});

it('can append toast messages', () => {
const wrapper = createFakeToastMessages();
const appendButton = wrapper.find('[data-testid="append-button"]');

appendButton.simulate('click');
appendButton.simulate('click');
appendButton.simulate('click');

assert.isFalse(wrapper.exists('[data-testid="no-toast-messages"]'));
assert.equal(3, wrapper.find('.toast-message').length);
});

it('can dismiss toast messages', () => {
const wrapper = createFakeToastMessages();
const appendButton = wrapper.find('[data-testid="append-button"]');
const dismissButton = wrapper.find('[data-testid="dismiss-button"]');

// Append five messages
appendButton.simulate('click');
appendButton.simulate('click');
appendButton.simulate('click');
appendButton.simulate('click');
appendButton.simulate('click');
assert.equal(5, wrapper.find('.toast-message').length);

// Dismiss three
dismissButton.simulate('click');
dismissButton.simulate('click');
dismissButton.simulate('click');
assert.equal(2, wrapper.find('.toast-message').length);
});
});
25 changes: 17 additions & 8 deletions via/static/scripts/video_player/hooks/use-toast-messages.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { useCallback, useState } from 'preact/hooks';

import type { ToastMessage } from '../components/ToastMessages';
import { generateHexString } from '../utils/generate-hex-string';

export function useToastMessages() {
export type ToastMessageData = Omit<ToastMessage, 'id'>;

export type ToastMessageAppender = (toastMessageData: ToastMessageData) => void;

export type ToastMessages = {
toastMessages: ToastMessage[];
appendToastMessage: ToastMessageAppender;
dismissToastMessage: (id: string) => void;
};

// Keep a global incremental counter to use as unique toast message ID
let toastMessageCounter = 0;

export function useToastMessages(): ToastMessages {
const [toastMessages, setToastMessages] = useState<ToastMessage[]>([]);
const appendToastMessage = useCallback(
(toastMessageData: Omit<ToastMessage, 'id'>) => {
const id = generateHexString(10);
(toastMessageData: ToastMessageData) => {
toastMessageCounter++;
const id = `${toastMessageCounter}`;
setToastMessages(messages => [...messages, { ...toastMessageData, id }]);
},
[]
Expand All @@ -22,7 +35,3 @@ export function useToastMessages() {

return { toastMessages, appendToastMessage, dismissToastMessage };
}

export type ToastMessageAppender = ReturnType<
typeof useToastMessages
>['appendToastMessage'];
Loading

0 comments on commit 4ade16c

Please sign in to comment.