From c3df0b0692ea187aed325357882f5cea5b1fb2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Fri, 24 May 2024 05:24:59 -0500 Subject: [PATCH] feat: Show toast when exporting course tags (#995) Show in in-progress toast when exporting course tags --- src/course-outline/CourseOutline.jsx | 30 +++++++++++++++ src/course-outline/CourseOutline.test.jsx | 46 +++++++++++++++++++++-- src/course-outline/data/api.js | 31 +++++++++++++++ src/course-outline/messages.js | 15 ++++++++ src/header/utils.js | 2 +- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index c1796df99a..e7468e68fb 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -8,6 +8,7 @@ import { Layout, Row, TransitionReplace, + Toast, } from '@openedx/paragon'; import { Helmet } from 'react-helmet'; import { @@ -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'; @@ -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, @@ -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 = () => { @@ -458,6 +479,15 @@ const CourseOutline = ({ courseId }) => { onInternetConnectionFailed={handleInternetConnectionFailed} /> + {toastMessage && ( + setToastMessage(null)} + data-testid="taxonomy-toast" + > + {toastMessage} + + )} ); }; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 86dbaef422..062711d41a 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -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'; @@ -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, @@ -19,6 +20,7 @@ import { getCourseBlockApiUrl, getCourseItemApiUrl, getXBlockBaseApiUrl, + exportTags, } from './data/api'; import { RequestStatus } from '../data/constants'; import { @@ -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', () => ({ @@ -135,6 +135,10 @@ describe('', () => { }, }); + useLocation.mockReturnValue({ + pathname: mockPathname, + }); + store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock @@ -2248,4 +2252,38 @@ describe('', () => { // 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(); + 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(); + 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(); + }); }); diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index fd2dd907ce..fc61f3c117 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -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 @@ -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); +} diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index 6bc73bc697..511bf94668 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -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; diff --git a/src/header/utils.js b/src/header/utils.js index c1de7e0923..3f9d92b59e 100644 --- a/src/header/utils.js +++ b/src/header/utils.js @@ -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']), }] : [] ),