diff --git a/src/components/catalogSearchResults/CatalogSearchResults.jsx b/src/components/catalogSearchResults/CatalogSearchResults.jsx index 5ba01f53..4fe2cb0e 100644 --- a/src/components/catalogSearchResults/CatalogSearchResults.jsx +++ b/src/components/catalogSearchResults/CatalogSearchResults.jsx @@ -427,13 +427,15 @@ export const BaseCatalogSearchResults = ({ /> )} {preview && isCourseType && searchResults?.nbHits !== 0 && ( - - - +
+ + + +
)}

diff --git a/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.jsx b/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.jsx index f3340d6a..55f55a54 100644 --- a/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.jsx +++ b/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.jsx @@ -1,14 +1,21 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { Toast, Button, useToggle } from '@openedx/paragon'; -import { Download } from '@openedx/paragon/icons'; +import { + Icon, Toast, useToggle, StatefulButton, Spinner, +} from '@openedx/paragon'; +import { Check, Close, Download } from '@openedx/paragon/icons'; +import { saveAs } from 'file-saver'; +import { useIntl } from '@edx/frontend-platform/i18n'; import EnterpriseCatalogApiService from '../../../../data/services/EnterpriseCatalogAPIService'; const DownloadCsvButton = ({ facets, query }) => { const [isOpen, open, close] = useToggle(false); const [filters, setFilters] = useState(); + const [buttonState, setButtonState] = useState('default'); + + const intl = useIntl(); const formatFilterText = (filterObject) => { let filterString = ''; @@ -23,13 +30,25 @@ const DownloadCsvButton = ({ facets, query }) => { const handleClick = () => { formatFilterText(facets); open(); - const downloadUrl = EnterpriseCatalogApiService.generateCsvDownloadLink( + setButtonState('pending'); + EnterpriseCatalogApiService.generateCsvDownloadLink( facets, query, - ); - global.location.href = downloadUrl; + ).then(response => { + const blob = new Blob([response.data], { + type: response.headers['content-type'], + }); + const timestamp = new Date().toISOString(); + saveAs(blob, `Enterprise-Catalog-Export-${timestamp}.xlsx`); + setButtonState('complete'); + }).catch(() => setButtonState('error')); }; - const toastText = `Downloaded with filters: ${filters}. Check website for the most up-to-date information on courses.`; + + const toastText = intl.formatMessage({ + id: 'catalogs.catalogSearchResults.downloadCsv.toastText', + defaultMessage: 'Downloaded with filters: {filters}. Check website for the most up-to-date information on courses.', + description: 'Toast text to be displayed when the user clicks the download button on the catalog page.', + }, { filters }); return ( <> {isOpen && ( @@ -37,13 +56,40 @@ const DownloadCsvButton = ({ facets, query }) => { {toastText} )} - + /> ); }; diff --git a/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.test.jsx b/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.test.jsx index ec72de8f..ea939785 100644 --- a/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.test.jsx +++ b/src/components/catalogSearchResults/associatedComponents/downloadCsvButton/DownloadCsvButton.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { screen, act } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; +import { saveAs } from 'file-saver'; import userEvent from '@testing-library/user-event'; import DownloadCsvButton from './DownloadCsvButton'; @@ -8,12 +9,19 @@ import { renderWithRouter } from '../../../tests/testUtils'; import EnterpriseCatalogApiService from '../../../../data/services/EnterpriseCatalogAPIService'; // file-saver mocks -jest.mock('file-saver', () => ({ saveAs: jest.fn() })); +jest.mock('file-saver', () => ({ + ...jest.requireActual('file-saver'), + saveAs: jest.fn(), +})); // eslint-disable-next-line func-names global.Blob = function (content, options) { return { content, options }; }; +const mockDate = new Date('2024-01-06T12:00:00Z'); +const mockTimestamp = mockDate.toISOString(); +global.Date = jest.fn(() => mockDate); + const mockCatalogApiService = jest.spyOn( EnterpriseCatalogApiService, 'generateCsvDownloadLink', @@ -37,6 +45,9 @@ delete global.location; global.location = { href: assignMock }; describe('Download button', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('button renders and is clickable', async () => { // Render the component renderWithRouter(); @@ -48,10 +59,17 @@ describe('Download button', () => { const input = screen.getByText('Download results'); userEvent.click(input); }); - expect(mockCatalogApiService).toBeCalledWith(facets, 'foo'); + expect(mockCatalogApiService).toHaveBeenCalledWith(facets, 'foo'); }); test('download button url encodes queries', async () => { process.env.CATALOG_SERVICE_BASE_URL = 'foobar.com'; + const mockResponse = { + data: 'mock-excel-data', + headers: { + 'content-type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + }; + mockCatalogApiService.mockResolvedValue(mockResponse); // Render the component renderWithRouter(); // Expect to be in the default state @@ -62,8 +80,17 @@ describe('Download button', () => { const input = screen.getByText('Download results'); userEvent.click(input); }); - const expectedWindowLocation = `${process.env.CATALOG_SERVICE_BASE_URL}/api/v1/enterprise-catalogs/catalog_workbook?availability=Available` - + '+Now&availability=Upcoming&query=math%20%26%20science'; - expect(window.location.href).toEqual(expectedWindowLocation); + expect(mockCatalogApiService).toHaveBeenCalledTimes(1); + expect(mockCatalogApiService).toHaveBeenCalledWith(smallFacets, 'math & science'); + + expect(saveAs).toHaveBeenCalledWith( + expect.objectContaining({ + content: ['mock-excel-data'], + options: { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + }), + `Enterprise-Catalog-Export-${mockTimestamp}.xlsx`, + ); }); }); diff --git a/src/components/catalogs/styles/_enterprise_catalogs.scss b/src/components/catalogs/styles/_enterprise_catalogs.scss index 400921f2..2773c0d0 100644 --- a/src/components/catalogs/styles/_enterprise_catalogs.scss +++ b/src/components/catalogs/styles/_enterprise_catalogs.scss @@ -33,9 +33,12 @@ } .landing-page-download { - float: right; - @media only screen and (max-width: map-get($grid-breakpoints, 'md')) { - float: none !important; + position: absolute; + top: -60px; + right: 0; + @media only screen and (max-width: map-get($grid-breakpoints, 'xl')) { + position: relative; + top: 0; } } @@ -43,6 +46,7 @@ justify-content: space-between; display: flex; clear: right; + align-items: center; } .partner-logo-thumbnail { diff --git a/src/data/services/EnterpriseCatalogAPIService.js b/src/data/services/EnterpriseCatalogAPIService.js index e053060b..21b91835 100644 --- a/src/data/services/EnterpriseCatalogAPIService.js +++ b/src/data/services/EnterpriseCatalogAPIService.js @@ -1,3 +1,4 @@ +import axios from 'axios'; import { createQueryParams } from '../../utils/catalogUtils'; class EnterpriseCatalogApiService { @@ -9,7 +10,8 @@ class EnterpriseCatalogApiService { const enterpriseListUrl = `${ EnterpriseCatalogApiService.enterpriseCatalogServiceApiUrl }/catalog_workbook?${queryParams}${facetQuery}`; - return enterpriseListUrl; + + return axios.get(enterpriseListUrl, { responseType: 'blob' }); } } diff --git a/src/data/services/EnterpriseCatalogAPIService.test.js b/src/data/services/EnterpriseCatalogAPIService.test.js index 31f8e974..e3d55a51 100644 --- a/src/data/services/EnterpriseCatalogAPIService.test.js +++ b/src/data/services/EnterpriseCatalogAPIService.test.js @@ -1,17 +1,38 @@ import '@testing-library/jest-dom/extend-expect'; - +import axios from 'axios'; import EnterpriseCatalogApiService from './EnterpriseCatalogAPIService'; -describe('generateCsvDownloadLink', () => { - test('correctly formats csv download link', () => { - const options = { enterprise_catalog_query_titles: 'test' }; - const query = 'somequery'; - const generatedDownloadLink = EnterpriseCatalogApiService.generateCsvDownloadLink( - options, - query, - ); - expect(generatedDownloadLink).toEqual( - `${process.env.CATALOG_SERVICE_BASE_URL}/api/v1/enterprise-catalogs/catalog_workbook?enterprise_catalog_query_titles=test&query=${query}`, - ); +jest.mock('axios'); + +describe('EnterpriseCatalogApiService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateCsvDownloadLink', () => { + it('makes correct axios GET request with query parameters', async () => { + const options = { enterprise_catalog_query_titles: 'test' }; + const query = 'somequery'; + const expectedUrl = `${process.env.CATALOG_SERVICE_BASE_URL}/api/v1/enterprise-catalogs/catalog_workbook?enterprise_catalog_query_titles=test&query=somequery`; + + const mockResponse = { data: 'test-data' }; + axios.get.mockResolvedValue(mockResponse); + + const result = await EnterpriseCatalogApiService.generateCsvDownloadLink(options, query); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { responseType: 'blob' }); + expect(result).toEqual(mockResponse); + }); + + it('handles axios error', async () => { + const options = { enterprise_catalog_query_titles: 'test' }; + const error = new Error('Network error'); + axios.get.mockRejectedValue(error); + + await expect( + EnterpriseCatalogApiService.generateCsvDownloadLink(options), + ).rejects.toThrow('Network error'); + }); }); });