Skip to content

Commit

Permalink
feat: Show toast when exporting course tags (#995)
Browse files Browse the repository at this point in the history
Show in  in-progress toast when exporting course tags
  • Loading branch information
ChrisChV authored May 24, 2024
1 parent 7247cc2 commit c3df0b0
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 5 deletions.
30 changes: 30 additions & 0 deletions src/course-outline/CourseOutline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Layout,
Row,
TransitionReplace,
Toast,
} from '@openedx/paragon';
import { Helmet } from 'react-helmet';
import {
Expand All @@ -20,6 +21,7 @@ import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useLocation } from 'react-router-dom';

import { LoadingSpinner } from '../generic/Loading';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
Expand Down Expand Up @@ -52,9 +54,11 @@ import {
} from '../generic/drag-helper/utils';
import { useCourseOutline } from './hooks';
import messages from './messages';
import { getTagsExportFile } from './data/api';

const CourseOutline = ({ courseId }) => {
const intl = useIntl();
const location = useLocation();

const {
courseName,
Expand Down Expand Up @@ -117,6 +121,23 @@ const CourseOutline = ({ courseId }) => {
errors,
} = useCourseOutline({ courseId });

// Use `setToastMessage` to show the toast.
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));

useEffect(() => {
if (location.hash === '#export-tags') {
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
getTagsExportFile(courseId, courseName).then(() => {
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
}).catch(() => {
setToastMessage(intl.formatMessage(messages.exportTagsErrorToastMessage));
});

// Delete `#export-tags` from location
window.location.href = '#';
}
}, [location]);

const [sections, setSections] = useState(sectionsList);

const restoreSectionList = () => {
Expand Down Expand Up @@ -458,6 +479,15 @@ const CourseOutline = ({ courseId }) => {
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
</div>
{toastMessage && (
<Toast
show
onClose={/* istanbul ignore next */ () => setToastMessage(null)}
data-testid="taxonomy-toast"
>
{toastMessage}
</Toast>
)}
</>
);
};
Expand Down
46 changes: 42 additions & 4 deletions src/course-outline/CourseOutline.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
act, render, waitFor, fireEvent, within,
act, render, waitFor, fireEvent, within, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
Expand All @@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cloneDeep } from 'lodash';
import { closestCorners } from '@dnd-kit/core';

import { useLocation } from 'react-router-dom';
import {
getCourseBestPracticesApiUrl,
getCourseLaunchApiUrl,
Expand All @@ -19,6 +20,7 @@ import {
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
exportTags,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
Expand Down Expand Up @@ -74,9 +76,7 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
useLocation: jest.fn(),
}));

jest.mock('../help-urls/hooks', () => ({
Expand Down Expand Up @@ -135,6 +135,10 @@ describe('<CourseOutline />', () => {
},
});

useLocation.mockReturnValue({
pathname: mockPathname,
});

store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
Expand Down Expand Up @@ -2248,4 +2252,38 @@ describe('<CourseOutline />', () => {
// check pasteFileNotices in store
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
});

it('should show toats on export tags', async () => {
const expectedResponse = 'this is a test';
axiosMock
.onGet(exportTags(courseId))
.reply(200, expectedResponse);
useLocation.mockReturnValue({
pathname: '/foo-bar',
hash: '#export-tags',
});
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
window.URL.revokeObjectURL = jest.fn();
render(<RootWrapper />);
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();

const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
expect(expectedRequest.length).toBe(1);

expect(await screen.findByText('Course tags exported successfully')).toBeInTheDocument();
});

it('should show toast on export tags error', async () => {
axiosMock
.onGet(exportTags(courseId))
.reply(404);
useLocation.mockReturnValue({
pathname: '/foo-bar',
hash: '#export-tags',
});

render(<RootWrapper />);
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
expect(await screen.findByText('An error has occurred creating the file')).toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions src/course-outline/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;

/**
* @typedef {Object} courseOutline
Expand Down Expand Up @@ -459,3 +460,33 @@ export async function dismissNotification(url) {
await getAuthenticatedHttpClient()
.delete(url);
}

/**
* Downloads the file of the exported tags
* @param {string} courseId The ID of the content
* @returns void
*/
export async function getTagsExportFile(courseId, courseName) {
// Gets exported tags and builds the blob to download CSV file.
// This can be done with this code:
// `window.location.href = exportTags(contentId);`
// but it is done in this way so we know when the operation ends to close the toast.
const response = await getAuthenticatedHttpClient().get(exportTags(courseId), {
responseType: 'blob',
});

/* istanbul ignore next */
if (response.status !== 200) {
throw response.statusText;
}

const blob = new Blob([response.data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url;
a.download = `${courseName}.csv`;
a.click();

window.URL.revokeObjectURL(url);
}
15 changes: 15 additions & 0 deletions src/course-outline/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.section-list.button.new-section',
defaultMessage: 'New section',
},
exportTagsCreatingToastMessage: {
id: 'course-authoring.course-outline.export-tags.toast.creating.message',
defaultMessage: 'Please wait. Creating export file for course tags...',
description: 'In progress message in toast when exporting tags of a course',
},
exportTagsSuccessToastMessage: {
id: 'course-authoring.course-outline.export-tags.toast.success.message',
defaultMessage: 'Course tags exported successfully',
description: 'Success message in toast when exporting tags of a course',
},
exportTagsErrorToastMessage: {
id: 'course-authoring.course-outline.export-tags.toast.error.message',
defaultMessage: 'An error has occurred creating the file',
description: 'Error message in toast when exporting tags of a course',
},
});

export default messages;
2 changes: 1 addition & 1 deletion src/header/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
},
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
? [{
href: `${studioBaseUrl}/api/content_tagging/v1/object_tags/${courseId}/export/`,
href: '#export-tags',
title: intl.formatMessage(messages['header.links.exportTags']),
}] : []
),
Expand Down

0 comments on commit c3df0b0

Please sign in to comment.