diff --git a/src/components/App/index.jsx b/src/components/App/index.jsx index 29eb7cd810..861bd7d056 100644 --- a/src/components/App/index.jsx +++ b/src/components/App/index.jsx @@ -17,7 +17,7 @@ import NotFoundPage from '../NotFoundPage'; import { SystemWideWarningBanner } from '../system-wide-banner'; import store from '../../data/store'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; const AppWrapper = () => { const apiClient = getAuthenticatedHttpClient(); diff --git a/src/components/CodeManagement/CouponCodeTabs.jsx b/src/components/CodeManagement/CouponCodeTabs.jsx index 63f8b0b083..c43697f052 100644 --- a/src/components/CodeManagement/CouponCodeTabs.jsx +++ b/src/components/CodeManagement/CouponCodeTabs.jsx @@ -11,7 +11,7 @@ import { import { SubsidyRequestsContext } from '../subsidy-requests'; import ManageCodesTab from './ManageCodesTab'; import ManageRequestsTab from './ManageRequestsTab'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import { MANAGE_CODES_TAB, MANAGE_REQUESTS_TAB, diff --git a/src/components/CodeManagement/ManageCodesTab.jsx b/src/components/CodeManagement/ManageCodesTab.jsx index 06ad5db0da..171c2ee112 100644 --- a/src/components/CodeManagement/ManageCodesTab.jsx +++ b/src/components/CodeManagement/ManageCodesTab.jsx @@ -17,7 +17,7 @@ import LoadingMessage from '../LoadingMessage'; import Coupon from '../Coupon'; import { updateUrl } from '../../utils'; import { fetchCouponOrders, clearCouponOrders } from '../../data/actions/coupons'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import NewFeatureAlertBrowseAndRequest from '../NewFeatureAlertBrowseAndRequest'; import { SubsidyRequestsContext } from '../subsidy-requests'; import { SUPPORTED_SUBSIDY_TYPES } from '../../data/constants/subsidyRequests'; diff --git a/src/components/ContentHighlights/ContentHighlightRoutes.jsx b/src/components/ContentHighlights/ContentHighlightRoutes.jsx index 685cf16d35..584d723fda 100644 --- a/src/components/ContentHighlights/ContentHighlightRoutes.jsx +++ b/src/components/ContentHighlights/ContentHighlightRoutes.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Route } from 'react-router-dom'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import ContentHighlightSet from './ContentHighlightSet'; import ContentHighlightsDashboard from './ContentHighlightsDashboard'; @@ -16,7 +16,7 @@ const ContentHighlightRoutes = ({ enterpriseSlug }) => { exact /> diff --git a/src/components/ContentHighlights/ContentHighlightSetCard.jsx b/src/components/ContentHighlights/ContentHighlightSetCard.jsx index 2320cdf33b..eb095276ca 100644 --- a/src/components/ContentHighlights/ContentHighlightSetCard.jsx +++ b/src/components/ContentHighlights/ContentHighlightSetCard.jsx @@ -3,14 +3,14 @@ import { Card } from '@edx/paragon'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { useHistory } from 'react-router-dom'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import { ContentHighlightsContext } from './ContentHighlightsContext'; import { toggleStepperModalAction } from './data/actions'; const ContentHighlightSetCard = ({ imageCapSrc, title, - highlightUUID, + highlightSetUUID, isPublished, enterpriseSlug, itemCount, @@ -22,7 +22,7 @@ const ContentHighlightSetCard = ({ const handleHighlightSetClick = () => { if (isPublished) { // redirect to individual highlighted courses based on uuid - return history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}/${highlightUUID}`); + return history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}/${highlightSetUUID}`); } return dispatch(toggleStepperModalAction({ isOpen: true })); }; @@ -43,7 +43,7 @@ const ContentHighlightSetCard = ({ ContentHighlightSetCard.propTypes = { title: PropTypes.string.isRequired, - highlightUUID: PropTypes.string.isRequired, + highlightSetUUID: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, isPublished: PropTypes.bool.isRequired, itemCount: PropTypes.number.isRequired, diff --git a/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx b/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx index 2512c73aeb..dd066951b9 100644 --- a/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx +++ b/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx @@ -16,11 +16,12 @@ const ContentHighlightSetCardContainer = () => ( ))} {/* eslint-enable camelcase */} ); + export default ContentHighlightSetCardContainer; diff --git a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx index 03a9a4891f..18681461d9 100644 --- a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx +++ b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx @@ -1,12 +1,10 @@ import React, { useState } from 'react'; import { CardGrid } from '@edx/paragon'; -import { useParams } from 'react-router-dom'; import { camelCaseObject } from '@edx/frontend-platform'; import ContentHighlightCardItem from './ContentHighlightCardItem'; import { TEST_COURSE_HIGHLIGHTS_DATA } from './data/constants'; const ContentHighlightsCardItemsContainer = () => { - const { highlightUUID } = useParams(); // eslint-disable-line const [highlightCourses] = useState( camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA)[0]?.highlightedContent, ); diff --git a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx index f075e0b88c..9b136c4ecd 100644 --- a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx +++ b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx @@ -1,17 +1,47 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { Container } from '@edx/paragon'; +import { useHistory } from 'react-router-dom'; +import { Container, Toast } from '@edx/paragon'; + import ZeroStateHighlights from './ZeroState'; import CurrentContentHighlights from './CurrentContentHighlights'; import ContentHighlightHelmet from './ContentHighlightHelmet'; import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; -const ContentHighlightsDashboardBase = ({ children }) => ( - - - {children} - -); +const ContentHighlightsDashboardBase = ({ children }) => { + const history = useHistory(); + const { location } = history; + const { state: locationState } = location; + + const [hasDeletedHighlightSetToast, setHasDeletedHighlightSetToast] = useState(false); + + // TODO: the below `useEffect` needs test coverage. deferred until there is a reducer + // for the `ContentHighlights` module, where `DeleteHighlightSet` can dispatch an action + // to trigger the `Toast`, rather than relying on history's location state. + /* istanbul ignore next */ + useEffect(() => { + if (!locationState?.deletedHighlightSet) { + return; + } + setHasDeletedHighlightSetToast(true); + const newState = { ...locationState }; + delete newState.deletedHighlightSet; + history.replace({ ...location, state: newState }); + }, [history, location, locationState]); + + return ( + + + {children} + setHasDeletedHighlightSetToast(false)} + show={hasDeletedHighlightSetToast} + > + Highlight collection deleted. + + + ); +}; ContentHighlightsDashboardBase.propTypes = { children: PropTypes.node.isRequired, diff --git a/src/components/ContentHighlights/CurrentContentHighlightItemsHeader.jsx b/src/components/ContentHighlights/CurrentContentHighlightItemsHeader.jsx index e754bc4221..d342e75650 100644 --- a/src/components/ContentHighlights/CurrentContentHighlightItemsHeader.jsx +++ b/src/components/ContentHighlights/CurrentContentHighlightItemsHeader.jsx @@ -1,12 +1,13 @@ import React from 'react'; -import { Button, ActionRow } from '@edx/paragon'; +import { ActionRow } from '@edx/paragon'; import { useParams } from 'react-router-dom'; import ContentHighlightHelmet from './ContentHighlightHelmet'; +import DeleteHighlightSet from './DeleteHighlightSet'; const CurrentContentHighlightItemsHeader = () => { - const { highlightUUID } = useParams(); + const { highlightSetUUID } = useParams(); - const highlightTitle = highlightUUID; + const highlightTitle = highlightSetUUID; const titleName = `${highlightTitle} - Highlights`; @@ -18,7 +19,7 @@ const CurrentContentHighlightItemsHeader = () => { {highlightTitle} - + ); diff --git a/src/components/ContentHighlights/DeleteHighlightSet.jsx b/src/components/ContentHighlights/DeleteHighlightSet.jsx new file mode 100644 index 0000000000..860ed30f92 --- /dev/null +++ b/src/components/ContentHighlights/DeleteHighlightSet.jsx @@ -0,0 +1,111 @@ +import React, { useContext, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + Button, + AlertModal, + useToggle, + ActionRow, + StatefulButton, +} from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; +import { useParams, useHistory } from 'react-router-dom'; +import { logError } from '@edx/frontend-platform/logging'; +import { connect } from 'react-redux'; + +import EnterpriseCatalogApiService from '../../data/services/EnterpriseCatalogApiService'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; +import { enterpriseCurationActions } from '../EnterpriseApp/data/enterpriseCurationReducer'; + +function DeleteHighlightSet({ enterpriseSlug }) { + const { highlightSetUUID } = useParams(); + const [isOpen, open, close] = useToggle(false); + const [deletionState, setDeletionState] = useState('default'); + const history = useHistory(); + const { enterpriseCuration: { dispatch } } = useContext(EnterpriseAppContext); + const [isDeleted, setIsDeleted] = useState(false); + const [deletionError, setDeletionError] = useState(null); + + const handleDeleteClick = () => { + const deleteHighlightSet = async () => { + setDeletionState('pending'); + try { + await EnterpriseCatalogApiService.deleteHighlightSet(highlightSetUUID); + dispatch(enterpriseCurationActions.deleteHighlightSet(highlightSetUUID)); + setIsDeleted(true); + } catch (error) { + logError(error); + setDeletionError(error); + } finally { + setDeletionState('default'); + } + }; + deleteHighlightSet(); + }; + + useEffect(() => { + if (isDeleted) { + close(); + history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}`, { + // TODO: expose the highlight set name here so it can be + // displayed in the Toast notification. once ContentHighlights has + // a reducer in its context value, we can use that to communicate between + // components instead of history's location state. + deletedHighlightSet: true, + }); + } + }, [isDeleted, close, highlightSetUUID, enterpriseSlug, history]); + + return ( + <> + + + + + + )} + > + setDeletionError(null)} + variant="danger" + dismissible + icon={Info} + > +

+ An error occurred while deleting this highlight collection. Please try again. +

+
+

+ Deleting this highlight collection will remove it from your + learners. This action is permanent and cannot be undone. +

+
+ + ); +} + +DeleteHighlightSet.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, +}; + +const mapStateToProps = (state) => ({ + enterpriseSlug: state.portalConfiguration.enterpriseSlug, +}); + +export default connect(mapStateToProps)(DeleteHighlightSet); diff --git a/src/components/ContentHighlights/HighlightSetSection.jsx b/src/components/ContentHighlights/HighlightSetSection.jsx index 03acd11231..0c10667d28 100644 --- a/src/components/ContentHighlights/HighlightSetSection.jsx +++ b/src/components/ContentHighlights/HighlightSetSection.jsx @@ -31,7 +31,7 @@ const HighlightSetSection = ({ { <> { diff --git a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx index 81fe0f2064..e866babea3 100644 --- a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx +++ b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx @@ -16,25 +16,19 @@ const ZeroStateHighlights = ({ cardClassName }) => { const { dispatch, stepperModal: { isOpen } } = useContext(ContentHighlightsContext); return ( - + - -

You haven't created any "highlights" collections yet.

-

"Highlights" feature allows you to create and recommend course collections to your learners, + +

You haven't created any highlights yet.

+

+ Create and recommend course collections to your learners, enable them to quickly locate relevant content.

- - + + +
diff --git a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx index 35b925aa8a..be62125656 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx @@ -20,17 +20,18 @@ const mockStore = configureMockStore([thunk]); const mockData = { title: 'Test Title', - highlightUUID: 'test-uuid', + highlightSetUUID: 'test-uuid', enterpriseSlug: 'test-enterprise-slug', itemCount: 0, imageCapSrc: 'http://fake.image', + isPublished: true, }; const initialState = { portalConfiguration: { - enterpriseSlug: 'test-enterprise-id', + enterpriseSlug: 'test-enterprise', }, - highlightUUID: 'test-uuid', + highlightSetUUID: 'test-uuid', }; const ContentHighlightSetCardWrapper = (props) => { @@ -58,7 +59,11 @@ describe('', () => { expect(screen.getByText('Test Title')).toBeInTheDocument(); }); it('Displays the stepper modal on click of the draft status', () => { - renderWithRouter(); + const props = { + ...mockData, + isPublished: false, + }; + renderWithRouter(); fireEvent.click(screen.getByText('Test Title')); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); diff --git a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx index e05bc3b097..52cc40bc75 100644 --- a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx @@ -11,7 +11,7 @@ const mockStore = configureMockStore([thunk]); const initialState = { portalConfiguration: { - enterpriseSlug: 'test-enterprise-id', + enterpriseSlug: 'test-enterprise', }, }; diff --git a/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx index d353f5eeae..6421d80f38 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx @@ -1,30 +1,25 @@ import { screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; + import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { camelCaseObject } from '@edx/frontend-platform'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; + import ContentHighlightsCardItemsContainer from '../ContentHighlightsCardItemsContainer'; import { TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; const mockStore = configureMockStore([thunk]); -const highlightUUID = '1'; +const highlightSetUUID = '1'; const contentByUUID = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA).filter( - highlight => highlight.uuid === highlightUUID, + highlight => highlight.uuid === highlightSetUUID, )[0]?.highlightedContent; -/* Currently mocks TEST_COURSE_HIGHLIGHTS_DATA from data/constants.js by the uuid */ -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - highlightUUID, - }), -})); const initialState = { portalConfiguration: { - enterpriseSlug: 'test-enterprise-id', + enterpriseSlug: 'test-enterprise', }, }; @@ -42,6 +37,7 @@ describe('', () => { expect(screen.getByText(firstTitle)).toBeInTheDocument(); expect(screen.getByText(lastTitle)).toBeInTheDocument(); }); + it('Displays all content data content types', () => { renderWithRouter(); const firstContentType = contentByUUID[0].contentType; @@ -49,6 +45,7 @@ describe('', () => { expect(screen.getByText(firstContentType)).toBeInTheDocument(); expect(screen.getByText(lastContentType)).toBeInTheDocument(); }); + it('Displays only the first organization', () => { renderWithRouter(); const firstContentType = contentByUUID[0] diff --git a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx index 851a5ee681..e0d1a12a5f 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx @@ -1,6 +1,8 @@ import { screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { useReducer, useMemo } from 'react'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; @@ -18,9 +20,8 @@ const mockStore = configureMockStore([thunk]); const initialState = { portalConfiguration: { - enterpriseSlug: 'test-enterprise-id', + enterpriseSlug: 'test-enterprise', }, - highlightUUID: 'test-uuid', }; const initialEnterpriseAppContextValue = { @@ -31,6 +32,13 @@ const initialEnterpriseAppContextValue = { }, }; +const exampleHighlightSet = { + uuid: 'fake-uuid', + title: 'Test Highlight Set', + isPublished: false, + highlightedContentUuids: [], +}; + /* eslint-disable react/prop-types */ const ContentHighlightsDashboardWrapper = ({ enterpriseAppContextValue = initialEnterpriseAppContextValue, @@ -46,34 +54,32 @@ const ContentHighlightsDashboardWrapper = ({ dispatch, }), [contentHighlightsState]); return ( - - - - - - - + + + + + + + + + ); }; describe('', () => { it('Displays ZeroState on empty highlighted content list', () => { renderWithRouter(); - expect(screen.getByText('You haven\'t created any "highlights" collections yet.')).toBeTruthy(); + expect(screen.getByText('You haven\'t created any highlights yet.')).toBeTruthy(); }); - it('Displays New Highlight Modal on button click with no highlighted content list', () => { + + it('Displays New highlight Modal on button click with no highlighted content list', () => { renderWithRouter(); - const newHighlight = screen.getByText('New Highlight'); + const newHighlight = screen.getByText('New highlight'); fireEvent.click(newHighlight); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); + it('Displays current highlights when data is populated', () => { - const exampleHighlightSet = { - uuid: 'fake-uuid', - title: 'Test Highlight Set', - isPublished: false, - highlightedContentUuids: [], - }; renderWithRouter( ', () => { ); expect(screen.getByText('Highlight collections')).toBeInTheDocument(); }); - it('Displays New Highlight Modal on button click with highlighted content list', () => { + + it('Displays New highlight modal on button click with highlighted content list', () => { renderWithRouter(); - const newHighlight = screen.getByText('New Highlight'); + const newHighlight = screen.getByText('New highlight'); fireEvent.click(newHighlight); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); diff --git a/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx b/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx index 074d543cda..688205cc7e 100644 --- a/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx +++ b/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx @@ -1,42 +1,35 @@ import { screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; + import { renderWithRouter } from '@edx/frontend-enterprise-utils'; -import { TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; -import CurrentContentHighlightItemsHeader from '../CurrentContentHighlightItemsHeader'; +import { Route } from 'react-router-dom'; -const mockStore = configureMockStore([thunk]); +import CurrentContentHighlightItemsHeader from '../CurrentContentHighlightItemsHeader'; -const highlightUUID = '1'; -const contentByUUID = TEST_COURSE_HIGHLIGHTS_DATA.filter( - highlight => highlight.uuid === highlightUUID, -)[0]; -/* Currently mocks TEST_COURSE_HIGHLIGHTS_DATA from data/constants.js by the uuid */ -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - highlightUUID, - }), +jest.mock('../DeleteHighlightSet', () => ({ + __esModule: true, + default: () =>
, })); -const initialState = { - portalConfiguration: { - enterpriseSlug: 'test-enterprise-id', - }, -}; +const highlightSetUUID = 'fake-uuid'; -const ContentHighlightsCardItemsHeaderWrapper = (props) => ( - - - -); +function CurrentContentHighlightItemsHeaderWrapper(props) { + return ( + } + /> + ); +} describe('', () => { it('Displays all content data titles', () => { - renderWithRouter(); - const { uuid } = contentByUUID; - expect(screen.getByText(uuid)).toBeInTheDocument(); + const initialRouterEntry = `/test-enterprise/admin/content-highlights/${highlightSetUUID}`; + renderWithRouter( + , + { route: initialRouterEntry }, + ); + expect(screen.getByText(highlightSetUUID)).toBeInTheDocument(); + expect(screen.getByTestId('deleteHighlightSet')).toBeInTheDocument(); }); }); diff --git a/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx b/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx index ca11985f48..5608a0a5a4 100644 --- a/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx +++ b/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx @@ -18,7 +18,7 @@ const mockStore = configureMockStore([thunk]); const initialState = { portalConfiguration: { - enterpriseSlug: 'test-enterprise-id', + enterpriseSlug: 'test-enterprise', }, }; diff --git a/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx b/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx new file mode 100644 index 0000000000..6bb1395d36 --- /dev/null +++ b/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx @@ -0,0 +1,155 @@ +import { screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; + +import { logError } from '@edx/frontend-platform/logging'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { Provider } from 'react-redux'; +import { Route } from 'react-router-dom'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderWithRouter } from '@edx/frontend-enterprise-utils'; + +import DeleteHighlightSet from '../DeleteHighlightSet'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { enterpriseCurationActions } from '../../EnterpriseApp/data/enterpriseCurationReducer'; +import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; + +jest.mock('../../../data/services/EnterpriseCatalogApiService'); + +const mockStore = configureMockStore([thunk]); +const initialState = { + portalConfiguration: + { + enterpriseSlug: 'test-enterprise', + }, +}; + +const highlightSetUUID = 'fake-uuid'; + +const mockDispatchFn = jest.fn(); +const initialEnterpriseAppContextValue = { + enterpriseCuration: { + dispatch: mockDispatchFn, + }, +}; + +const initialRouterEntry = `/test-enterprise/admin/${ROUTE_NAMES.contentHighlights}/${highlightSetUUID}`; + +/* eslint-disable react/prop-types */ +const DeleteHighlightSetWrapper = ({ + enterpriseAppContextValue = initialEnterpriseAppContextValue, + ...props +}) => ( +/* eslint-enable react/prop-types */ + + + + } + /> + + + +); + +describe('', () => { + const getDeleteHighlightBtn = () => { + const deleteBtn = screen.getByText('Delete highlight'); + return deleteBtn; + }; + + const clickDeleteHighlightBtn = () => { + const deleteBtn = getDeleteHighlightBtn(); + userEvent.click(deleteBtn); + expect(screen.getByText('Delete highlight?')).toBeInTheDocument(); + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('has delete highlight button', () => { + renderWithRouter( + , + { route: initialRouterEntry }, + ); + const deleteBtn = getDeleteHighlightBtn(); + expect(deleteBtn).toBeInTheDocument(); + }); + + it('clicking delete highlight button opens confirmation modal', () => { + renderWithRouter( + , + { route: initialRouterEntry }, + ); + clickDeleteHighlightBtn(); + }); + + it('cancelling confirmation modal closes modal', () => { + renderWithRouter( + , + { route: initialRouterEntry }, + ); + clickDeleteHighlightBtn(); + userEvent.click(screen.getByText('Cancel')); + expect(screen.queryByText('Delete highlight?')).not.toBeInTheDocument(); + }); + + it('confirming deletion in confirmation modal deletes via API', async () => { + EnterpriseCatalogApiService.deleteHighlightSet.mockResolvedValueOnce(); + + const { history } = renderWithRouter( + , + { route: initialRouterEntry }, + ); + clickDeleteHighlightBtn(); + userEvent.click(screen.getByTestId('delete-confirmation-button')); + expect(screen.getByText('Deleting highlight...')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockDispatchFn).toHaveBeenCalledWith( + enterpriseCurationActions.deleteHighlightSet(highlightSetUUID), + ); + }); + + expect(screen.queryByText('Delete highlight?')).not.toBeInTheDocument(); + expect(history.location.pathname).toEqual(`/test-enterprise/admin/${ROUTE_NAMES.contentHighlights}`); + expect(history.location.state).toEqual( + expect.objectContaining({ + deletedHighlightSet: true, + }), + ); + }); + + it('confirming deletion in confirmation modal handles error via API', async () => { + EnterpriseCatalogApiService.deleteHighlightSet.mockRejectedValueOnce(new Error('oh noes!')); + + renderWithRouter( + , + { route: initialRouterEntry }, + ); + clickDeleteHighlightBtn(); + userEvent.click(screen.getByTestId('delete-confirmation-button')); + expect(screen.getByText('Deleting highlight...')).toBeInTheDocument(); + + await waitFor(() => { + expect(logError).toHaveBeenCalled(); + }); + expect(mockDispatchFn).not.toHaveBeenCalledWith( + enterpriseCurationActions.deleteHighlightSet(highlightSetUUID), + ); + + expect(screen.queryByText('Delete highlight?')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + const alertDismissBtn = screen.getByText('Dismiss'); + expect(alertDismissBtn).toBeInTheDocument(); + userEvent.click(alertDismissBtn); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx b/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx index 500f55129f..7c84d1e9f6 100644 --- a/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx @@ -3,7 +3,9 @@ import PropTypes from 'prop-types'; import { EnterpriseSubsidiesContext, useEnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; import { SubsidyRequestsContext, useSubsidyRequestsContext } from '../subsidy-requests/SubsidyRequestsContext'; -import { useEnterpriseCurationContext } from './data/hooks'; +import { + useEnterpriseCurationContext, +} from './data/hooks'; import EnterpriseAppSkeleton from './EnterpriseAppSkeleton'; /** diff --git a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx index f1190b35ec..e4f1eaa8e5 100644 --- a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx @@ -11,7 +11,7 @@ import LoadingMessage from '../LoadingMessage'; import SettingsPage from '../settings'; import { SubscriptionManagementPage } from '../subscriptions'; import { PlotlyAnalyticsPage } from '../PlotlyAnalytics'; -import { ROUTE_NAMES } from './constants'; +import { ROUTE_NAMES } from './data/constants'; import BulkEnrollmentResultsDownloadPage from '../BulkEnrollmentResultsDownloadPage'; import LearnerCreditManagement from '../learner-credit-management'; import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; diff --git a/src/components/EnterpriseApp/constants.js b/src/components/EnterpriseApp/data/constants.js similarity index 100% rename from src/components/EnterpriseApp/constants.js rename to src/components/EnterpriseApp/data/constants.js diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js new file mode 100644 index 0000000000..d5cb4f3994 --- /dev/null +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js @@ -0,0 +1,81 @@ +import { logError } from '@edx/frontend-platform/logging'; + +export const initialReducerState = { + isLoading: true, + enterpriseCuration: null, + fetchError: null, +}; + +export const SET_IS_LOADING = 'SET_IS_LOADING'; +export const SET_ENTERPRISE_CURATION = 'SET_ENTERPRISE_CURATION'; +export const SET_FETCH_ERROR = 'SET_FETCH_ERROR'; +export const DELETE_HIGHLIGHT_SET = 'DELETE_HIGHLIGHT_SET'; +export const ADD_HIGHLIGHT_SET = 'ADD_HIGHLIGHT_SET'; + +export const enterpriseCurationActions = { + setIsLoading: (payload) => ({ + type: SET_IS_LOADING, + payload, + }), + setEnterpriseCuration: (payload) => ({ + type: SET_ENTERPRISE_CURATION, + payload, + }), + setFetchError: (payload) => ({ + type: SET_FETCH_ERROR, + payload, + }), + deleteHighlightSet: (payload) => ({ + type: DELETE_HIGHLIGHT_SET, + payload, + }), + addHighlightSet: (payload) => ({ + type: ADD_HIGHLIGHT_SET, + payload, + }), +}; + +function getHighlightSetsFromState(state) { + return state.enterpriseCuration?.highlightSets || []; +} + +function enterpriseCurationReducer(state, action) { + switch (action.type) { + case SET_IS_LOADING: + return { ...state, isLoading: action.payload }; + case SET_ENTERPRISE_CURATION: + return { ...state, enterpriseCuration: action.payload }; + case SET_FETCH_ERROR: + return { ...state, fetchError: action.payload }; + case DELETE_HIGHLIGHT_SET: { + const existingHighlightSets = getHighlightSetsFromState(state); + const filteredHighlightSets = existingHighlightSets.filter( + highlightSet => highlightSet.uuid !== action.payload, + ); + return { + ...state, + enterpriseCuration: { + ...state.enterpriseCuration, + highlightSets: filteredHighlightSets, + }, + }; + } + case ADD_HIGHLIGHT_SET: { + const existingHighlightSets = getHighlightSetsFromState(state); + return { + ...state, + enterpriseCuration: { + ...state.enterpriseCuration, + highlightSets: [...existingHighlightSets, action.payload], + }, + }; + } + default: { + const msg = `enterpriseCurationReducer received an unexpected action type: ${action.type}`; + logError(msg); + return state; + } + } +} + +export default enterpriseCurationReducer; diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js new file mode 100644 index 0000000000..b3a57716d6 --- /dev/null +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js @@ -0,0 +1,96 @@ +import { logError } from '@edx/frontend-platform/logging'; + +import enterpriseCurationReducer, { enterpriseCurationActions } from './enterpriseCurationReducer'; + +const initialState = { + isLoading: false, + enterpriseCuration: null, + fetchError: null, +}; +const highlightSetUUID = 'fake-uuid'; + +describe('enterpriseCurationReducer', () => { + it('should set loading state', () => { + expect( + enterpriseCurationReducer( + initialState, + enterpriseCurationActions.setIsLoading(true), + ), + ).toMatchObject({ isLoading: true }); + }); + + it('should set enterprise curation', () => { + const enterpriseCuration = { uuid: 'fake-uuid' }; + expect( + enterpriseCurationReducer( + initialState, + enterpriseCurationActions.setEnterpriseCuration(enterpriseCuration), + ), + ).toMatchObject({ enterpriseCuration }); + }); + + it('should set fetch error', () => { + const fetchError = new Error('oh noes!'); + expect( + enterpriseCurationReducer( + initialState, + enterpriseCurationActions.setFetchError(fetchError), + ), + ).toMatchObject({ fetchError }); + }); + + it('should delete highlight set', () => { + const highlightSet = { uuid: highlightSetUUID }; + const initialStateWithHighlights = { + ...initialState, + enterpriseCuration: { + highlightSets: [highlightSet], + }, + }; + expect( + enterpriseCurationReducer( + initialStateWithHighlights, + enterpriseCurationActions.deleteHighlightSet(highlightSetUUID), + ), + ).toMatchObject({ enterpriseCuration: { highlightSets: [] } }); + }); + + it('should add highlight set', () => { + const highlightSet = { uuid: highlightSetUUID }; + const initialStateWithoutHighlights = { + ...initialState, + enterpriseCuration: { + highlightSets: [], + }, + }; + expect( + enterpriseCurationReducer( + initialStateWithoutHighlights, + enterpriseCurationActions.addHighlightSet(highlightSet), + ), + ).toMatchObject({ enterpriseCuration: { highlightSets: [highlightSet] } }); + }); + + it('should handle missing enterpriseCuration when adding/deleting a highlight set', () => { + const highlightSet = { uuid: highlightSetUUID }; + expect( + enterpriseCurationReducer( + initialState, + enterpriseCurationActions.addHighlightSet(highlightSet), + ), + ).toMatchObject({ enterpriseCuration: { highlightSets: [highlightSet] } }); + + expect( + enterpriseCurationReducer( + initialState, + enterpriseCurationActions.deleteHighlightSet(highlightSet.uuid), + ), + ).toMatchObject({ enterpriseCuration: { highlightSets: [] } }); + }); + + it('should handle unknown action type', () => { + const invalidActionType = 'invalid'; + enterpriseCurationReducer(initialState, { type: invalidActionType }); + expect(logError).toHaveBeenCalledWith(`enterpriseCurationReducer received an unexpected action type: ${invalidActionType}`); + }); +}); diff --git a/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js b/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js index 4a1cef836b..b7086d2c92 100644 --- a/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js +++ b/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js @@ -1,5 +1,10 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useReducer } from 'react'; + import useEnterpriseCuration from './useEnterpriseCuration'; +import enterpriseCurationReducer, { + initialReducerState, + enterpriseCurationActions, +} from '../enterpriseCurationReducer'; /** * TODO @@ -10,6 +15,8 @@ function useEnterpriseCurationContext({ enterpriseId, curationTitleForCreation, }) { + const [enterpriseCurationState, dispatch] = useReducer(enterpriseCurationReducer, initialReducerState); + const { isLoading, enterpriseCuration, @@ -19,11 +26,22 @@ function useEnterpriseCurationContext({ curationTitleForCreation, }); + useEffect(() => { + dispatch(enterpriseCurationActions.setIsLoading(isLoading)); + }, [isLoading]); + + useEffect(() => { + dispatch(enterpriseCurationActions.setEnterpriseCuration(enterpriseCuration)); + }, [enterpriseCuration]); + + useEffect(() => { + dispatch(enterpriseCurationActions.setFetchError(fetchError)); + }, [fetchError]); + const contextValue = useMemo(() => ({ - isLoading, - enterpriseCuration, - fetchError, - }), [isLoading, enterpriseCuration, fetchError]); + ...enterpriseCurationState, + dispatch, + }), [enterpriseCurationState]); return contextValue; } diff --git a/src/components/NewFeatureAlertBrowseAndRequest/NewFeatureAlertBrowseAndRequest.test.jsx b/src/components/NewFeatureAlertBrowseAndRequest/NewFeatureAlertBrowseAndRequest.test.jsx index 7a00244983..d007370336 100644 --- a/src/components/NewFeatureAlertBrowseAndRequest/NewFeatureAlertBrowseAndRequest.test.jsx +++ b/src/components/NewFeatureAlertBrowseAndRequest/NewFeatureAlertBrowseAndRequest.test.jsx @@ -16,7 +16,7 @@ import { REDIRECT_SETTINGS_BUTTON_TEXT, BROWSE_AND_REQUEST_ALERT_TEXT, } from './data/constants'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import { SETTINGS_TABS_VALUES } from '../settings/data/constants'; const mockStore = configureMockStore([thunk]); diff --git a/src/components/NewFeatureAlertBrowseAndRequest/index.jsx b/src/components/NewFeatureAlertBrowseAndRequest/index.jsx index feaa103ccb..3e0f203f6b 100644 --- a/src/components/NewFeatureAlertBrowseAndRequest/index.jsx +++ b/src/components/NewFeatureAlertBrowseAndRequest/index.jsx @@ -10,7 +10,7 @@ import { BROWSE_AND_REQUEST_ALERT_TEXT, REDIRECT_SETTINGS_BUTTON_TEXT, } from '../subscriptions/data/constants'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import { SETTINGS_TABS_VALUES } from '../settings/data/constants'; const cookies = new Cookies(); diff --git a/src/components/ProductTours/ProductTours.jsx b/src/components/ProductTours/ProductTours.jsx index bb11e90066..242ebf1a93 100644 --- a/src/components/ProductTours/ProductTours.jsx +++ b/src/components/ProductTours/ProductTours.jsx @@ -37,7 +37,7 @@ const ProductTours = ({ [LEARNER_CREDIT_COOKIE_NAME]: useLearnerCreditTour()[0], [HIGHLIGHTS_COOKIE_NAME]: useHighlightsTour(FEATURE_CONTENT_HIGHLIGHTS)[0], }; - const checkpoints = { + const newFeatureTourCheckpoints = { [PORTAL_APPEARANCE_TOUR_COOKIE_NAME]: portalAppearanceTour({ enterpriseSlug, history, @@ -55,9 +55,9 @@ const ProductTours = ({ history, }), }; - const checkpointsArray = filterCheckpoints(checkpoints, enabledFeatures); + const checkpointsArray = filterCheckpoints(newFeatureTourCheckpoints, enabledFeatures); const tours = [{ - tourID: 'a', + tourId: 'newFeatureTour', advanceButtonText: 'Next', dismissButtonText: 'Dismiss', endButtonText: 'End', @@ -65,6 +65,7 @@ const ProductTours = ({ onEnd: () => disableAll(), checkpoints: checkpointsArray, }]; + return ( { diff --git a/src/components/subscriptions/SubscriptionPlanRoutes.jsx b/src/components/subscriptions/SubscriptionPlanRoutes.jsx index 80da3be7c9..5613a3bb6f 100644 --- a/src/components/subscriptions/SubscriptionPlanRoutes.jsx +++ b/src/components/subscriptions/SubscriptionPlanRoutes.jsx @@ -6,7 +6,7 @@ import { connect } from 'react-redux'; import MultipleSubscriptionsPage from './MultipleSubscriptionsPage'; import ConnectedSubscriptionDetailPage from './SubscriptionDetailPage'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import { MANAGE_LEARNERS_TAB } from './data/constants'; const SubscriptionPlanRoutes = ({ enterpriseSlug }) => { diff --git a/src/components/subscriptions/SubscriptionTabs.jsx b/src/components/subscriptions/SubscriptionTabs.jsx index bb80276740..78021d8773 100644 --- a/src/components/subscriptions/SubscriptionTabs.jsx +++ b/src/components/subscriptions/SubscriptionTabs.jsx @@ -11,7 +11,7 @@ import { import { SubsidyRequestsContext } from '../subsidy-requests'; import SubscriptionSubsidyRequests from './SubscriptionSubsidyRequests'; import SubscriptionPlanRoutes from './SubscriptionPlanRoutes'; -import { ROUTE_NAMES } from '../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import { MANAGE_LEARNERS_TAB, MANAGE_REQUESTS_TAB, diff --git a/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx b/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx index fa8f8d6d34..e476bdce5a 100644 --- a/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx +++ b/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx @@ -10,7 +10,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { renderWithRouter } from '../../test/testUtils'; import { SubscriptionContext } from '../SubscriptionData'; -import { ROUTE_NAMES } from '../../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import MultipleSubscriptionsPage from '../MultipleSubscriptionsPage'; const fakeSlug = 'sluggo'; diff --git a/src/components/subscriptions/tests/SubscriptionDetailPage.test.jsx b/src/components/subscriptions/tests/SubscriptionDetailPage.test.jsx index 8c5eff8005..0d8713a1ab 100644 --- a/src/components/subscriptions/tests/SubscriptionDetailPage.test.jsx +++ b/src/components/subscriptions/tests/SubscriptionDetailPage.test.jsx @@ -6,7 +6,7 @@ import { useSubscriptionFromParams } from '../data/contextHooks'; import { SubscriptionDetailPage } from '../SubscriptionDetailPage'; import { SubscriptionManagementContext, SUBSCRIPTION_PLAN_ZERO_STATE } from './TestUtilities'; import { renderWithRouter } from '../../test/testUtils'; -import { ROUTE_NAMES } from '../../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import { MANAGE_LEARNERS_TAB } from '../data/constants'; jest.mock('../SubscriptionDetails', () => ({ diff --git a/src/components/subscriptions/tests/SubscriptionManagementPage.test.jsx b/src/components/subscriptions/tests/SubscriptionManagementPage.test.jsx index 1ddaa3b748..74331eaaa6 100644 --- a/src/components/subscriptions/tests/SubscriptionManagementPage.test.jsx +++ b/src/components/subscriptions/tests/SubscriptionManagementPage.test.jsx @@ -10,7 +10,7 @@ import { TEST_ENTERPRISE_CUSTOMER_SLUG, createMockStore, } from './TestUtilities'; import SubscriptionManagementPage from '../SubscriptionManagementPage'; -import { ROUTE_NAMES } from '../../EnterpriseApp/constants'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import { renderWithRouter } from '../../test/testUtils'; import * as hooks from '../data/hooks'; import { SubsidyRequestsContext } from '../../subsidy-requests'; diff --git a/src/data/services/EnterpriseCatalogApiService.js b/src/data/services/EnterpriseCatalogApiService.js index 83ad2067b5..a11a780725 100644 --- a/src/data/services/EnterpriseCatalogApiService.js +++ b/src/data/services/EnterpriseCatalogApiService.js @@ -12,6 +12,8 @@ class EnterpriseCatalogApiService { static enterpriseCurationUrl = `${EnterpriseCatalogApiService.baseUrl}/enterprise-curations-admin/`; + static highlightSetUrl = `${EnterpriseCatalogApiService.baseUrl}/highlight-sets-admin/`; + static fetchApplicableCatalogs({ enterpriseId, courseRunIds }) { // This API call will *only* obtain the enterprise's catalogs whose // catalog queries return/contain the specified courseRunIds. @@ -50,6 +52,10 @@ class EnterpriseCatalogApiService { payload, ); } + + static deleteHighlightSet(highlightSetUUID) { + return EnterpriseCatalogApiService.apiClient().delete(`${EnterpriseCatalogApiService.highlightSetUrl}${highlightSetUUID}/`); + } } export default EnterpriseCatalogApiService;