diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js index 6da57c1719..3efaea019a 100644 --- a/src/taxonomy/api/hooks/api.js +++ b/src/taxonomy/api/hooks/api.js @@ -1,20 +1,53 @@ // @ts-check -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { downloadDataAsFile } from '../../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyListApiUrl = new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; +const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; +const getExportTaxonomyApiUrl = (pk, format) => new URL( + `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}`, + getApiBaseUrl(), +).href; /** * @returns {import("../types.mjs").UseQueryResult} */ -const useTaxonomyListData = () => ( +export const useTaxonomyListData = () => ( useQuery({ queryKey: ['taxonomyList'], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl) + queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl()) .then(camelCaseObject), }) ); -export default useTaxonomyListData; +export const useExportTaxonomy = () => { + /** + * Calls the export request and downloads the file. + * + * Extra logic is needed to download the exported file, + * because it is not possible to download the file using the Content-Disposition header + * Ref: https://medium.com/@drevets/you-cant-prompt-a-file-download-with-the-content-disposition-header-using-axios-xhr-sorry-56577aa706d6 + * + * @param {import("../types.mjs").ExportRequestParams} params + * @returns {Promise} + */ + const exportTaxonomy = async (params) => { + const { pk, format, name } = params; + const response = await getAuthenticatedHttpClient().get(getExportTaxonomyApiUrl(pk, format)); + const contentType = response.headers['content-type']; + let fileExtension = ''; + let data; + if (contentType === 'application/json') { + fileExtension = 'json'; + data = JSON.stringify(response.data, null, 2); + } else { + fileExtension = 'csv'; + data = response.data; + } + downloadDataAsFile(data, contentType, `${name}.${fileExtension}`); + }; + + return useMutation(exportTaxonomy); +}; diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js index aee14b8a7f..a5d54c6077 100644 --- a/src/taxonomy/api/hooks/api.test.js +++ b/src/taxonomy/api/hooks/api.test.js @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import useTaxonomyListData from './api'; +import { useTaxonomyListData } from './api'; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), diff --git a/src/taxonomy/api/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js index 6908827caf..970ae49392 100644 --- a/src/taxonomy/api/hooks/selectors.js +++ b/src/taxonomy/api/hooks/selectors.js @@ -1,5 +1,8 @@ // @ts-check -import useTaxonomyListData from './api'; +import { + useTaxonomyListData, + useExportTaxonomy, +} from './api'; /** * @returns {import("../types.mjs").TaxonomyListData | undefined} @@ -18,3 +21,7 @@ export const useTaxonomyListDataResponse = () => { export const useIsTaxonomyListDataLoaded = () => ( useTaxonomyListData().status === 'success' ); + +export const useExportTaxonomyMutation = () => ( + useExportTaxonomy() +); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/api/hooks/selectors.test.js index b513b9b735..a8e3716032 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/api/hooks/selectors.test.js @@ -1,9 +1,9 @@ import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './selectors'; -import useTaxonomyListData from './api'; +import { useTaxonomyListData } from './api'; jest.mock('./api', () => ({ __esModule: true, - default: jest.fn(), + useTaxonomyListData: jest.fn(), })); describe('useTaxonomyListDataResponse', () => { diff --git a/src/taxonomy/api/types.mjs b/src/taxonomy/api/types.mjs index 980939b255..36e47dbe10 100644 --- a/src/taxonomy/api/types.mjs +++ b/src/taxonomy/api/types.mjs @@ -27,6 +27,13 @@ * @property {TaxonomyListData} data */ +/** + * @typedef {Object} ExportRequestParams + * @property {number} pk + * @property {string} format + * @property {string} name + */ + /** * @typedef {Object} UseQueryResult * @property {Object} data diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/modals/ExportModal.jsx index 360151dc1b..3627648c9b 100644 --- a/src/taxonomy/modals/ExportModal.jsx +++ b/src/taxonomy/modals/ExportModal.jsx @@ -8,17 +8,30 @@ import { import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from '../messages'; +import { useExportTaxonomyMutation } from '../api/hooks/selectors'; const ExportModal = ({ taxonomyId, + taxonomyName, isOpen, onClose, intl, }) => { - const [modalSize, setModalSize] = useState('csv'); + const [outputFormat, setOutputFormat] = useState('csv'); + const exportMutation = useExportTaxonomyMutation(); + + const onClickExport = () => { + onClose(); + exportMutation.mutate({ + pk: taxonomyId, + format: outputFormat, + name: taxonomyName, + }); + }; return ( setModalSize(e.target.value)} + value={outputFormat} + onChange={(e) => setOutputFormat(e.target.value)} > {intl.formatMessage(messages.taxonomyModalsCancelLabel)} - @@ -71,6 +84,7 @@ const ExportModal = ({ ExportModal.propTypes = { taxonomyId: PropTypes.number.isRequired, + taxonomyName: PropTypes.string.isRequired, isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, intl: intlShape.isRequired, diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx index df340fe1cf..48e6688d91 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx @@ -66,9 +66,24 @@ const TaxonomyCard = ({ className, original, intl }) => { return undefined; }; - const getHeaderActions = () => ( - - ); + const getHeaderActions = () => { + if (systemDefined) { + // We don't show the export menu, because the system-taxonomies + // can't be exported. The API returns and error. + // The entire menu has been hidden because currently only + // the export menu exists. + // + // TODO When adding more menus, change this logic to hide only the export menu. + return undefined; + } + return ( + + ); + }; const renderModals = () => ( // eslint-disable-next-line react/jsx-no-useless-fragment @@ -77,6 +92,8 @@ const TaxonomyCard = ({ className, original, intl }) => { setIsExportModalOpen(false)} + taxonomyId={id} + taxonomyName={name} /> )} diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 91e76c984a..1844075f6a 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -9,6 +9,10 @@ import initializeStore from '../../store'; import TaxonomyCard from './TaxonomyCard'; +jest.mock('../api/hooks/selectors', () => ({ + useExportTaxonomyMutation: jest.fn(), +})); + let store; const data = { diff --git a/src/utils.js b/src/utils.js index 67a37e6db7..d1dc1bfc5f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -256,3 +256,12 @@ export const isValidDate = (date) => { return Boolean(formattedValue.length <= 10); }; + +export const downloadDataAsFile = (data, contentType, fileName) => { + const url = window.URL.createObjectURL(new Blob([data], { type: contentType })); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); +};