Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/components/GradesView/ImportResultToast/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useIntl } from '@edx/frontend-platform/i18n';

import { actions, selectors } from 'data/redux/hooks';
import { views } from 'data/constants/app';
import messages from './messages';

/**
* <ImportResultToast />
* Reports the outcome of a grade upload, whether it succeeded or failed, and links to the
* Bulk Management History tab.
*/
export const useImportResultToastData = () => {
const { formatMessage } = useIntl();

const showSuccess = selectors.app.useShowImportSuccessToast();
const showError = selectors.app.useShowImportErrorToast();
const details = selectors.grades.useBulkImportErrorMessages();
const setAppView = actions.app.useSetView();
const setShowSuccess = actions.app.useSetShowImportSuccessToast();
const setShowError = actions.app.useSetShowImportErrorToast();

// A failure needs its message to say anything useful, so it is only reportable once both
// are present. A flagged failure suppresses the success claim either way: with no message
// there is nothing to show, but reporting success alongside a known failure is the one
// outcome never worth risking.
const isError = !!showError && !!details;
const isSuccess = !showError && !!showSuccess;

// Each outcome requires its own flag, so neither can borrow the other's wording. Starting
// a second upload clears both while this toast is still fading, and an empty string is
// the right thing to render for the instant it takes to leave the screen.
let description = '';
if (isError) {
description = formatMessage(messages.errorDescription, { details });
} else if (isSuccess) {
description = formatMessage(messages.successDescription);
}

const hide = () => {
setShowSuccess(false);
setShowError(false);
};

return {
action: {
label: formatMessage(messages.showHistoryViewBtn),
onClick: () => {
setAppView(views.bulkManagementHistory);
hide();
},
},
onClose: hide,
show: isError || isSuccess,
// Paragon defaults its Toast to auto-dismissing, and spreads extra props after that,
// so this reaches react-bootstrap. A failure waits to be dismissed; a success asks
// nothing of the reader and can time out as usual.
autohide: !isError,
description,
};
};

export default useImportResultToastData;
181 changes: 181 additions & 0 deletions src/components/GradesView/ImportResultToast/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useIntl } from '@edx/frontend-platform/i18n';

import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';

import useImportResultToastData from './hooks';
import messages from './messages';

jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
useSetShowImportSuccessToast: jest.fn(),
useSetShowImportErrorToast: jest.fn(),
},
},
selectors: {
app: {
useShowImportSuccessToast: jest.fn(),
useShowImportErrorToast: jest.fn(),
},
grades: { useBulkImportErrorMessages: jest.fn() },
},
}));

jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn((context) => context),
}));

jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: jest.fn(() => ({
formatMessage: (message, values) => (
values ? message.defaultMessage.replace('{details}', values.details) : message.defaultMessage
),
})),
}));

const setView = jest.fn().mockName('hooks.setView');
const setShowSuccess = jest.fn().mockName('hooks.setShowImportSuccessToast');
const setShowError = jest.fn().mockName('hooks.setShowImportErrorToast');

const DETAILS = 'No grades were changed.';

/** Put redux in one of the states the hook can see. */
const mockState = ({ success = false, error = false, details = '' } = {}) => {
actions.app.useSetView.mockReturnValue(setView);
actions.app.useSetShowImportSuccessToast.mockReturnValue(setShowSuccess);
actions.app.useSetShowImportErrorToast.mockReturnValue(setShowError);
selectors.app.useShowImportSuccessToast.mockReturnValue(success);
selectors.app.useShowImportErrorToast.mockReturnValue(error);
selectors.grades.useBulkImportErrorMessages.mockReturnValue(details);
};

describe('ImportResultToast hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
mockState();
});

it('initializes intl and redux hooks', () => {
useImportResultToastData();
expect(useIntl).toHaveBeenCalledWith();
expect(selectors.app.useShowImportSuccessToast).toHaveBeenCalled();
expect(selectors.app.useShowImportErrorToast).toHaveBeenCalled();
expect(selectors.grades.useBulkImportErrorMessages).toHaveBeenCalled();
expect(actions.app.useSetView).toHaveBeenCalled();
expect(actions.app.useSetShowImportSuccessToast).toHaveBeenCalled();
expect(actions.app.useSetShowImportErrorToast).toHaveBeenCalled();
});

describe('a successful import', () => {
it('shows the success message and lets it time out', () => {
mockState({ success: true });
const out = useImportResultToastData();
expect(out.show).toBe(true);
expect(out.description).toEqual(messages.successDescription.defaultMessage);
expect(out.autohide).toBe(true);
});
});

describe('a failed import', () => {
it('shows the message the server returned and waits to be dismissed', () => {
mockState({ error: true, details: DETAILS });
const out = useImportResultToastData();
expect(out.show).toBe(true);
expect(out.description).toEqual(`Import failed. ${DETAILS}`);
// A failure must not disappear on its own; the reader has to act on it.
expect(out.autohide).toBe(false);
});

it('takes precedence if a success is somehow flagged too', () => {
mockState({ success: true, error: true, details: DETAILS });
expect(useImportResultToastData().description).toEqual(`Import failed. ${DETAILS}`);
});
});

describe('nothing to report', () => {
it('stays hidden with no message', () => {
const out = useImportResultToastData();
expect(out.show).toBe(false);
expect(out.description).toEqual('');
});

it('stays hidden when a failure is flagged with no message to show', () => {
mockState({ error: true, details: '' });
expect(useImportResultToastData().show).toBe(false);
});

it('never borrows the other outcome\'s wording', () => {
// Starting another upload clears both flags while the toast is still fading. Neither
// outcome may inherit the other's message on the way off screen.
mockState({ error: true, details: DETAILS });
expect(useImportResultToastData().description).toEqual(`Import failed. ${DETAILS}`);

mockState();
const fading = useImportResultToastData();
expect(fading.show).toBe(false);
expect(fading.description).toEqual('');
expect(fading.description).not.toEqual(messages.successDescription.defaultMessage);
});
});

describe('every combination of the state it can see', () => {
// Exhaustive, because the one outcome never worth risking is claiming success while a
// failure is flagged -- which is the bug this component exists to prevent.
const SUCCESS = messages.successDescription.defaultMessage;
const FAILURE = `Import failed. ${DETAILS}`;
const cases = [
// showSuccess, showError, details, show, description
[false, false, '', false, ''],
[false, false, DETAILS, false, ''],
[true, false, '', true, SUCCESS],
[true, false, DETAILS, true, SUCCESS],
[false, true, '', false, ''],
[false, true, DETAILS, true, FAILURE],
[true, true, '', false, ''],
[true, true, DETAILS, true, FAILURE],
];

test.each(cases)(
'success=%p error=%p details=%p -> show=%p',
(success, error, details, show, description) => {
mockState({ success, error, details });
const out = useImportResultToastData();
expect(out.show).toBe(show);
expect(out.description).toEqual(description);
},
);

it('never claims success while a failure is flagged', () => {
cases
.filter(([, error]) => error)
.forEach(([success, error, details]) => {
mockState({ success, error, details });
expect(useImportResultToastData().description).not.toEqual(SUCCESS);
});
});
});

describe('dismissing', () => {
it('onClose clears both flags', () => {
useImportResultToastData().onClose();
expect(setShowSuccess).toHaveBeenCalledWith(false);
expect(setShowError).toHaveBeenCalledWith(false);
});

it('the action opens the history view and clears both flags', () => {
useImportResultToastData().action.onClick();
expect(setView).toHaveBeenCalledWith(views.bulkManagementHistory);
expect(setShowSuccess).toHaveBeenCalledWith(false);
expect(setShowError).toHaveBeenCalledWith(false);
});

it('labels the action button', () => {
expect(useImportResultToastData().action.label)
.toEqual(messages.showHistoryViewBtn.defaultMessage);
});
});
});
35 changes: 35 additions & 0 deletions src/components/GradesView/ImportResultToast/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';

import { Toast } from '@openedx/paragon';

import useImportResultToastData from './hooks';

/**
* <ImportResultToast />
* Toast component triggered by a grade upload, reporting either that it succeeded or that
* it failed -- rejected by the server, or applied no grades at all.
* Provides a link to view the Bulk Management History tab.
*/
export const ImportResultToast = () => {
const {
action,
onClose,
show,
autohide,
description,
} = useImportResultToastData();
return (
<Toast
action={action}
onClose={onClose}
show={show}
autohide={autohide}
>
{description}
</Toast>
);
};

ImportResultToast.propTypes = {};

export default ImportResultToast;
73 changes: 73 additions & 0 deletions src/components/GradesView/ImportResultToast/index.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';

import { render, initializeMocks, screen } from 'testUtilsExtra';

import ImportResultToast from '.';
import useImportResultToastData from './hooks';

jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
useSetShowImportSuccessToast: jest.fn(),
useSetShowImportErrorToast: jest.fn(),
},
},
selectors: {
app: {
useShowImportSuccessToast: jest.fn(),
useShowImportErrorToast: jest.fn(),
},
grades: { useBulkImportErrorMessages: jest.fn() },
},
}));

jest.mock('./hooks', () => jest.fn());

initializeMocks();

const SUCCESS = 'Import Successful! Grades will be updated momentarily.';
const FAILURE = 'Import failed. No grades were changed.';

const mockData = (overrides = {}) => useImportResultToastData.mockReturnValue({
action: { label: 'View Activity Log', onClick: jest.fn() },
onClose: jest.fn(),
show: true,
autohide: true,
description: SUCCESS,
...overrides,
});

describe('ImportResultToast', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the toast container but no message with show false', () => {
mockData({ show: false, description: '' });
render(<ImportResultToast />);
const toastRoot = document.getElementById('toast-root');
expect(toastRoot).toBeInTheDocument();
expect(toastRoot).toHaveClass('toast-container');
expect(screen.queryByText(SUCCESS)).toBeNull();
expect(useImportResultToastData).toHaveBeenCalled();
});

it('shows the success message', () => {
mockData();
render(<ImportResultToast />);
expect(screen.getByText(SUCCESS)).toBeInTheDocument();
});

it('shows the failure message', () => {
mockData({ description: FAILURE, autohide: false });
render(<ImportResultToast />);
expect(screen.getByText(FAILURE)).toBeInTheDocument();
});

it('renders the action button', () => {
mockData();
render(<ImportResultToast />);
expect(screen.getByText('View Activity Log')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

// The success ids keep their original `ImportSuccessToast` names: they are already in the
// translation pipeline, and renaming an id orphans every translation of it.
const messages = defineMessages({
description: {
successDescription: {
id: 'gradebook.GradesView.ImportSuccessToast.description',
defaultMessage: 'Import Successful! Grades will be updated momentarily.',
description: 'A message congratulating a successful Import of grades',
},
errorDescription: {
id: 'gradebook.GradesView.ImportErrorToast.description',
defaultMessage: 'Import failed. {details}',
description: 'Message shown when a grade import could not be applied',
},
showHistoryViewBtn: {
id: 'gradebook.GradesView.ImportSuccessToast.showHistoryViewBtn',
defaultMessage: 'View Activity Log',
Expand Down
Loading
Loading