diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx
index c1796df99..e7468e68f 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 86dbaef42..062711d41 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 fd2dd907c..fc61f3c11 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 6bc73bc69..511bf9466 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 c1de7e092..3f9d92b59 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']),
}] : []
),