diff --git a/src/generic/alert-message/AlertMessage.test.jsx b/src/generic/alert-message/AlertMessage.test.tsx similarity index 100% rename from src/generic/alert-message/AlertMessage.test.jsx rename to src/generic/alert-message/AlertMessage.test.tsx diff --git a/src/generic/alert-message/index.jsx b/src/generic/alert-message/index.jsx deleted file mode 100644 index 41efb771d..000000000 --- a/src/generic/alert-message/index.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { Alert } from '@openedx/paragon'; -import PropTypes from 'prop-types'; - -const AlertMessage = ({ title, description, ...props }) => ( - - {title} - {description} - -); - -AlertMessage.propTypes = { - title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), -}; - -AlertMessage.defaultProps = { - title: undefined, - description: undefined, -}; - -export default AlertMessage; diff --git a/src/generic/alert-message/index.tsx b/src/generic/alert-message/index.tsx new file mode 100644 index 000000000..6b3c3214c --- /dev/null +++ b/src/generic/alert-message/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Alert } from '@openedx/paragon'; + +interface Props extends React.ComponentPropsWithoutRef { + title?: string | React.ReactNode; + description?: string | React.ReactNode; +} + +const AlertMessage: React.FC = ({ title, description, ...props }) => ( + + {title} + {description} + +); + +export default AlertMessage; diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.tsx similarity index 95% rename from src/studio-home/StudioHome.jsx rename to src/studio-home/StudioHome.tsx index cc62a40a3..9af6ccb2b 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.tsx @@ -8,7 +8,7 @@ import { Row, } from '@openedx/paragon'; import { Add as AddIcon, Error } from '@openedx/paragon/icons'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { StudioFooter } from '@edx/frontend-component-footer'; import { getConfig } from '@edx/frontend-platform'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -28,7 +28,8 @@ import messages from './messages'; import { useStudioHome } from './hooks'; import AlertMessage from '../generic/alert-message'; -const StudioHome = ({ intl }) => { +const StudioHome = () => { + const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); @@ -46,7 +47,6 @@ const StudioHome = ({ intl }) => { hasAbilityToCreateNewCourse, isFiltered, setShowNewCourseContainer, - dispatch, } = useStudioHome(isPaginationCoursesEnabled); const libMode = getConfig().LIBRARY_MODE; @@ -64,7 +64,7 @@ const StudioHome = ({ intl }) => { } = studioHomeData; const getHeaderButtons = useCallback(() => { - const headerButtons = []; + const headerButtons: JSX.Element[] = []; if (isFailedLoadingPage || !userIsActive) { return headerButtons; @@ -160,11 +160,9 @@ const StudioHome = ({ intl }) => { )} {isShowOrganizationDropdown && } setShowNewCourseContainer(true)} isShowProcessing={isShowProcessing && !isFiltered} - dispatch={dispatch} isPaginationCoursesEnabled={isPaginationCoursesEnabled} /> @@ -203,8 +201,4 @@ const StudioHome = ({ intl }) => { ); }; -StudioHome.propTypes = { - intl: intlShape.isRequired, -}; - -export default injectIntl(StudioHome); +export default StudioHome; diff --git a/src/studio-home/card-item/CardItem.test.jsx b/src/studio-home/card-item/CardItem.test.jsx deleted file mode 100644 index 74eb6e870..000000000 --- a/src/studio-home/card-item/CardItem.test.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { render, fireEvent } from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { initializeMockApp, getConfig } from '@edx/frontend-platform'; - -import { studioHomeMock } from '../__mocks__'; -import messages from '../messages'; -import initializeStore from '../../store'; -import { trimSlashes } from './utils'; -import CardItem from '.'; - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -let store; - -const RootWrapper = (props) => ( - - - - - -); - -describe('', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - useSelector.mockReturnValue(studioHomeMock); - }); - it('should render course details for non-library course', () => { - const props = studioHomeMock.archivedCourses[0]; - const { getByText } = render(); - expect(getByText(`${props.org} / ${props.number} / ${props.run}`)).toBeInTheDocument(); - }); - - it('should render correct links for non-library course', () => { - const props = studioHomeMock.archivedCourses[0]; - const { getByText } = render(); - const courseTitleLink = getByText(props.displayName); - expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`); - const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage); - expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink)); - const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage); - expect(viewLiveLink).toHaveAttribute('href', props.lmsLink); - }); - - it('should render correct links for non-library course pagination', () => { - const props = studioHomeMock.archivedCourses[0]; - const { getByText, getByTestId } = render(); - const courseTitleLink = getByText(props.displayName); - expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`); - const dropDownMenu = getByTestId('toggle-dropdown'); - fireEvent.click(dropDownMenu); - const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage); - expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink)); - const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage); - expect(viewLiveLink).toHaveAttribute('href', props.lmsLink); - }); - it('should render course details for library course', () => { - const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true }; - const { getByText } = render(); - const courseTitleLink = getByText(props.displayName); - expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`); - expect(getByText(`${props.org} / ${props.number}`)).toBeInTheDocument(); - }); - it('should hide rerun button if disallowed', () => { - const props = studioHomeMock.archivedCourses[0]; - useSelector.mockReturnValue({ ...studioHomeMock, allowCourseReruns: false }); - const { queryByText } = render(); - expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument(); - }); - it('should be read only course if old mongo course', () => { - const props = studioHomeMock.courses[1]; - const { queryByText } = render(); - expect(queryByText(props.displayName)).not.toHaveAttribute('href'); - expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument(); - expect(queryByText(messages.viewLiveBtnText.defaultMessage)).not.toBeInTheDocument(); - }); - - it('should render course key if displayname is empty', () => { - const props = studioHomeMock.courses[1]; - const courseKeyTest = 'course-key'; - const { getByText } = render( - , - ); - expect(getByText(courseKeyTest)).toBeInTheDocument(); - }); -}); diff --git a/src/studio-home/card-item/CardItem.test.tsx b/src/studio-home/card-item/CardItem.test.tsx new file mode 100644 index 000000000..e2cd39345 --- /dev/null +++ b/src/studio-home/card-item/CardItem.test.tsx @@ -0,0 +1,89 @@ +import * as reactRedux from 'react-redux'; +import { getConfig } from '@edx/frontend-platform'; + +import { studioHomeMock } from '../__mocks__'; +import messages from '../messages'; +import { trimSlashes } from './utils'; +import { + fireEvent, + initializeMocks, + render, + screen, +} from '../../testUtils'; +import CardItem from '.'; + +jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => studioHomeMock); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('should render course details for non-library course', () => { + const props = studioHomeMock.archivedCourses[0]; + render(); + expect(screen.getByText(`${props.org} / ${props.number} / ${props.run}`)).toBeInTheDocument(); + }); + + it('should render correct links for non-library course', () => { + const props = studioHomeMock.archivedCourses[0]; + render(); + const courseTitleLink = screen.getByText(props.displayName); + expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`); + const btnReRunCourse = screen.getByText(messages.btnReRunText.defaultMessage); + expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink)); + const viewLiveLink = screen.getByText(messages.viewLiveBtnText.defaultMessage); + expect(viewLiveLink).toHaveAttribute('href', props.lmsLink); + }); + + it('should render correct links for non-library course pagination', () => { + const props = studioHomeMock.archivedCourses[0]; + render(); + const courseTitleLink = screen.getByText(props.displayName); + expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`); + const dropDownMenu = screen.getByTestId('toggle-dropdown'); + fireEvent.click(dropDownMenu); + const btnReRunCourse = screen.getByText(messages.btnReRunText.defaultMessage); + expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink)); + const viewLiveLink = screen.getByText(messages.viewLiveBtnText.defaultMessage); + expect(viewLiveLink).toHaveAttribute('href', props.lmsLink); + }); + it('should render course details for library course', () => { + const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true }; + render(); + const courseTitleLink = screen.getByText(props.displayName); + expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`); + expect(screen.getByText(`${props.org} / ${props.number}`)).toBeInTheDocument(); + }); + it('should hide rerun button if disallowed', () => { + const props = studioHomeMock.archivedCourses[0]; + // Update our mocked redux data: + jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => ( + { ...studioHomeMock, allowCourseReruns: false } + )); + const { queryByText } = render(); + expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument(); + }); + it('should be read only course if old mongo course', () => { + const props = studioHomeMock.courses[1]; + render(); + expect(screen.queryByText(props.displayName)).not.toHaveAttribute('href'); + expect(screen.queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByText(messages.viewLiveBtnText.defaultMessage)).not.toBeInTheDocument(); + }); + + it('should render course key if displayname is empty', () => { + const props = studioHomeMock.courses[1]; + const courseKeyTest = 'course-key'; + render( + , + ); + expect(screen.getByText(courseKeyTest)).toBeInTheDocument(); + }); +}); diff --git a/src/studio-home/card-item/index.jsx b/src/studio-home/card-item/index.tsx similarity index 69% rename from src/studio-home/card-item/index.jsx rename to src/studio-home/card-item/index.tsx index 11f932737..35883bb90 100644 --- a/src/studio-home/card-item/index.jsx +++ b/src/studio-home/card-item/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; import { Card, Hyperlink, @@ -9,16 +8,40 @@ import { ActionRow, } from '@openedx/paragon'; import { MoreHoriz } from '@openedx/paragon/icons'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; +import { Link } from 'react-router-dom'; import { COURSE_CREATOR_STATES } from '../../constants'; import { getStudioHomeData } from '../data/selectors'; import messages from '../messages'; import { trimSlashes } from './utils'; -const CardItem = ({ - intl, +interface BaseProps { + displayName: string; + org: string; + number: string; + run?: string; + lmsLink?: string | null; + rerunLink?: string | null; + courseKey?: string; + isLibraries?: boolean; + isPaginated?: boolean; +} +type Props = BaseProps & ( + /** If we should open this course/library in this MFE, this is the path to the edit page, e.g. '/course/foo' */ + { path: string, url?: never } | + /** + * If we might be redirecting to the legacy Studio view, this is the URL to redirect to. + * URLs starting with '/' are assumed to be relative to the legacy Studio root. + */ + { url: string, path?: never } +); + +/** + * A card on the Studio home page that represents a Course or a Library + */ +const CardItem: React.FC = ({ displayName, lmsLink = '', rerunLink = '', @@ -28,14 +51,16 @@ const CardItem = ({ isLibraries = false, courseKey = '', isPaginated = false, + path, url, }) => { + const intl = useIntl(); const { allowCourseReruns, courseCreatorStatus, rerunCreatorStatus, } = useSelector(getStudioHomeData); - const destinationUrl = () => new URL(url, getConfig().STUDIO_BASE_URL); + const destinationUrl: string = path ?? new URL(url, getConfig().STUDIO_BASE_URL).toString(); const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`; const readOnlyItem = !(lmsLink || rerunLink || url); const showActions = !(readOnlyItem || isLibraries); @@ -49,12 +74,12 @@ const CardItem = ({ {hasDisplayName} - + ) : ( {displayName} )} @@ -70,7 +95,7 @@ const CardItem = ({ /> {isShowRerunLink && ( - + {messages.btnReRunText.defaultMessage} )} @@ -84,7 +109,7 @@ const CardItem = ({ {isShowRerunLink && ( {intl.formatMessage(messages.btnReRunText)} @@ -92,7 +117,7 @@ const CardItem = ({ )} {intl.formatMessage(messages.viewLiveBtnText)} @@ -105,18 +130,4 @@ const CardItem = ({ ); }; -CardItem.propTypes = { - intl: intlShape.isRequired, - displayName: PropTypes.string.isRequired, - lmsLink: PropTypes.string, - rerunLink: PropTypes.string, - org: PropTypes.string.isRequired, - run: PropTypes.string, - number: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - isLibraries: PropTypes.bool, - courseKey: PropTypes.string, - isPaginated: PropTypes.bool, -}; - -export default injectIntl(CardItem); +export default CardItem; diff --git a/src/studio-home/card-item/utils.js b/src/studio-home/card-item/utils.ts similarity index 71% rename from src/studio-home/card-item/utils.js rename to src/studio-home/card-item/utils.ts index 8bbc7c2e2..be873d9fe 100644 --- a/src/studio-home/card-item/utils.js +++ b/src/studio-home/card-item/utils.ts @@ -4,4 +4,4 @@ * @returns {string} The trimmed string. */ // eslint-disable-next-line import/prefer-default-export -export const trimSlashes = (str) => str.replace(/^\/|\/$/g, ''); +export const trimSlashes = (str: string): string => str.replace(/^\/|\/$/g, ''); diff --git a/src/studio-home/data/api.js b/src/studio-home/data/api.js index 630bb52b6..b70af23dd 100644 --- a/src/studio-home/data/api.js +++ b/src/studio-home/data/api.js @@ -16,6 +16,7 @@ export async function getStudioHomeData() { return camelCaseObject(data); } +/** Get list of courses from the deprecated non-paginated API */ export async function getStudioHomeCourses(search) { const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`); return camelCaseObject(data); diff --git a/src/studio-home/factories/mockApiResponses.jsx b/src/studio-home/factories/mockApiResponses.jsx index 5d75f9f59..a9993b2ca 100644 --- a/src/studio-home/factories/mockApiResponses.jsx +++ b/src/studio-home/factories/mockApiResponses.jsx @@ -48,36 +48,32 @@ export const generateGetStudioHomeDataApiResponse = () => ({ allowToCreateNewOrg: false, }); +/** Mock for the deprecated /api/contentstore/v1/home/courses endpoint. Note this endpoint is NOT paginated. */ export const generateGetStudioCoursesApiResponse = () => ({ - count: 5, - next: null, - previous: null, - numPages: 2, - results: { - courses: [ - { - courseKey: 'course-v1:HarvardX+123+2023', - displayName: 'Managing Risk in the Information Age', - lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course', - number: '123', - org: 'HarvardX', - rerunLink: '/course_rerun/course-v1:HarvardX+123+2023', - run: '2023', - url: '/course/course-v1:HarvardX+123+2023', - }, - { - courseKey: 'org.0/course_0/Run_0', - displayName: 'Run 0', - lmsLink: null, - number: 'course_0', - org: 'org.0', - rerunLink: null, - run: 'Run_0', - url: null, - }, - ], - inProcessCourseActions: [], - }, + archivedCourses: /** @type {any[]} */([]), + courses: [ + { + courseKey: 'course-v1:HarvardX+123+2023', + displayName: 'Managing Risk in the Information Age', + lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course', + number: '123', + org: 'HarvardX', + rerunLink: '/course_rerun/course-v1:HarvardX+123+2023', + run: '2023', + url: '/course/course-v1:HarvardX+123+2023', + }, + { + courseKey: 'org.0/course_0/Run_0', + displayName: 'Run 0', + lmsLink: null, + number: 'course_0', + org: 'org.0', + rerunLink: null, + run: 'Run_0', + url: null, + }, + ], + inProcessCourseActions: [], }); export const generateGetStudioCoursesApiResponseV2 = () => ({ diff --git a/src/studio-home/hooks.jsx b/src/studio-home/hooks.jsx index e596e18be..08662810f 100644 --- a/src/studio-home/hooks.jsx +++ b/src/studio-home/hooks.jsx @@ -93,7 +93,6 @@ const useStudioHome = (isPaginated = false) => { isShowOrganizationDropdown, hasAbilityToCreateNewCourse, isFiltered, - dispatch, setShowNewCourseContainer, }; }; diff --git a/src/studio-home/index.js b/src/studio-home/index.ts similarity index 100% rename from src/studio-home/index.js rename to src/studio-home/index.ts diff --git a/src/studio-home/messages.js b/src/studio-home/messages.ts similarity index 100% rename from src/studio-home/messages.js rename to src/studio-home/messages.ts diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.tsx similarity index 82% rename from src/studio-home/tabs-section/TabsSection.test.jsx rename to src/studio-home/tabs-section/TabsSection.test.tsx index 6f40c9212..23540af71 100644 --- a/src/studio-home/tabs-section/TabsSection.test.jsx +++ b/src/studio-home/tabs-section/TabsSection.test.tsx @@ -1,16 +1,6 @@ -import React from 'react'; -import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { getConfig, initializeMockApp, setConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { - waitFor, render, fireEvent, screen, act, -} from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { AppProvider } from '@edx/frontend-platform/react'; -import MockAdapter from 'axios-mock-adapter'; +import { Routes, Route } from 'react-router-dom'; +import { getConfig, setConfig } from '@edx/frontend-platform'; -import initializeStore from '../../store'; import { studioHomeMock } from '../__mocks__'; import messages from '../messages'; import tabMessages from './messages'; @@ -27,6 +17,13 @@ import { executeThunk } from '../../utils'; import { fetchLibraryData, fetchStudioHomeData } from '../data/thunks'; import { getContentLibraryV2ListApiUrl } from '../../library-authoring/data/api'; import contentLibrariesListV2 from '../../library-authoring/__mocks__/contentLibrariesListV2'; +import { + initializeMocks, + render as baseRender, + fireEvent, + screen, + waitFor, +} from '../../testUtils'; const { studioShortName } = studioHomeMock; @@ -36,56 +33,39 @@ const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`; const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`; const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`; -const mockDispatch = jest.fn(); - -const queryClient = new QueryClient(); - const tabSectionComponent = (overrideProps) => ( {}} + isShowProcessing {...overrideProps} /> ); -const RootWrapper = (overrideProps) => ( - - - - - - - - - - - - - +const render = (overrideProps = {}) => baseRender( + + + + + , + { routerProps: { initialEntries: ['/home'] } }, ); describe('', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(initialState); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + const newMocks = initializeMocks({ initialState }); + store = newMocks.reduxStore; + axiosMock = newMocks.axiosMock; setConfig({ ...getConfig(), LIBRARY_MODE: 'mixed', @@ -94,7 +74,7 @@ describe('', () => { }); it('should render all tabs correctly', async () => { - const data = generateGetStudioHomeDataApiResponse(); + const data: any = generateGetStudioHomeDataApiResponse(); data.archivedCourses = [{ courseKey: 'course-v1:MachineLearning+123+2023', displayName: 'Machine Learning', @@ -106,7 +86,7 @@ describe('', () => { url: '/course/course-v1:MachineLearning+123+2023', }]; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -127,7 +107,7 @@ describe('', () => { const data = generateGetStudioHomeDataApiResponse(); - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -148,7 +128,7 @@ describe('', () => { const data = generateGetStudioHomeDataApiResponse(); - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -163,8 +143,8 @@ describe('', () => { describe('course tab', () => { it('should render specific course details', async () => { - render(); - const { results: data } = generateGetStudioCoursesApiResponse(); + render(); + const data = generateGetStudioCoursesApiResponse(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLink).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -178,9 +158,9 @@ describe('', () => { it('should render default sections when courses are empty', async () => { const data = generateGetStudioCoursesApiResponse(); - data.results.courses = []; + data.courses = []; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLink).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -195,7 +175,7 @@ describe('', () => { }); it('should render course fetch failure alert', async () => { - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLink).reply(404); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -204,7 +184,7 @@ describe('', () => { }); it('should render pagination when there are courses', async () => { - render(); + render({ isPaginationCoursesEnabled: true }); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLinkV2).reply(200, generateGetStudioCoursesApiResponseV2()); await executeThunk(fetchStudioHomeData('', true, {}, true), store.dispatch); @@ -224,7 +204,7 @@ describe('', () => { it('should not render pagination when there are not courses', async () => { const data = generateGetStudioCoursesApiResponseV2(); data.results.courses = []; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLinkV2).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -236,7 +216,7 @@ describe('', () => { it('should set the url path to "/home" when switching away then back to courses tab', async () => { const data = generateGetStudioCoursesApiResponseV2(); data.results.courses = []; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLinkV2).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -250,9 +230,7 @@ describe('', () => { axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); await executeThunk(fetchLibraryData(), store.dispatch); const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(librariesTab); - }); + fireEvent.click(librariesTab); // confirm that the url path has changed expect(librariesTab).toHaveClass('active'); @@ -262,9 +240,7 @@ describe('', () => { // switch back to courses tab const coursesTab = screen.getByText(tabMessages.coursesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(coursesTab); - }); + fireEvent.click(coursesTab); // confirm that the url path is /home expect(coursesTab).toHaveClass('active'); @@ -281,7 +257,7 @@ describe('', () => { ENABLE_TAGGING_TAXONOMY_PAGES: 'false', }); - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -295,7 +271,7 @@ describe('', () => { ENABLE_TAGGING_TAXONOMY_PAGES: 'true', }); - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -310,8 +286,8 @@ describe('', () => { describe('archived tab', () => { it('should switch to Archived tab and render specific archived course details', async () => { - render(); - const { results: data } = generateGetStudioCoursesApiResponse(); + render(); + const data = generateGetStudioCoursesApiResponse(); data.archivedCourses = studioHomeMock.archivedCourses; axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLink).reply(200, data); @@ -331,7 +307,7 @@ describe('', () => { const data = generateGetStudioCoursesApiResponse(); data.archivedCourses = []; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(courseApiLink).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -347,34 +323,33 @@ describe('', () => { }); describe('library tab', () => { + beforeEach(() => { + axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse()); + }); it('should switch to Legacy Libraries tab and render specific v1 library details', async () => { - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); await executeThunk(fetchLibraryData(), store.dispatch); const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(librariesTab); - }); + fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); - expect(screen.getByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); + expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible(); }); it('should switch to Libraries tab and render specific v2 library details', async () => { - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(librariesTab); - }); + fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -397,20 +372,18 @@ describe('', () => { LIBRARY_MODE: 'v1 only', }); - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); await executeThunk(fetchLibraryData(), store.dispatch); const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(librariesTab); - }); + fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); - expect(screen.getByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); + expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible(); }); @@ -421,14 +394,12 @@ describe('', () => { LIBRARY_MODE: 'v2 only', }); - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(librariesTab); - }); + fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -449,7 +420,7 @@ describe('', () => { const data = generateGetStudioHomeDataApiResponse(); data.librariesEnabled = false; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -461,7 +432,7 @@ describe('', () => { const data = generateGetStudioHomeDataApiResponse(); data.redirectToLibraryAuthoringMfe = true; - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -474,20 +445,18 @@ describe('', () => { }); it('should render libraries fetch failure alert', async () => { - render(); + render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); axiosMock.onGet(libraryApiLink).reply(404); await executeThunk(fetchStudioHomeData(), store.dispatch); await executeThunk(fetchLibraryData(), store.dispatch); const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); - await act(async () => { - fireEvent.click(librariesTab); - }); + fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); - expect(screen.getByText(tabMessages.librariesTabErrorMessage.defaultMessage)).toBeVisible(); + expect(await screen.findByText(tabMessages.librariesTabErrorMessage.defaultMessage)).toBeVisible(); }); }); }); diff --git a/src/studio-home/tabs-section/courses-tab/index.test.jsx b/src/studio-home/tabs-section/courses-tab/index.test.tsx similarity index 98% rename from src/studio-home/tabs-section/courses-tab/index.test.jsx rename to src/studio-home/tabs-section/courses-tab/index.test.tsx index 3c4811edc..81c72307e 100644 --- a/src/studio-home/tabs-section/courses-tab/index.test.jsx +++ b/src/studio-home/tabs-section/courses-tab/index.test.tsx @@ -10,8 +10,6 @@ import { initialState } from '../../factories/mockApiResponses'; import CoursesTab from '.'; -const mockDispatch = jest.fn(); - const onClickNewCourse = jest.fn(); const isShowProcessing = false; const isLoading = false; @@ -23,7 +21,7 @@ const showNewCourseContainer = true; const renderComponent = (overrideProps = {}, studioHomeState = {}) => { // Generate a custom initial state based on studioHomeCoursesRequestParams - const customInitialState = { + const customInitialState: any = { // TODO: remove 'any' once our redux state has proper types ...initialState, studioHome: { ...initialState.studioHome, @@ -38,7 +36,6 @@ const renderComponent = (overrideProps = {}, studioHomeState = {}) => { void; + isShowProcessing: boolean; + isLoading: boolean; + isFailed: boolean; + numPages: number; + coursesCount: number; + isEnabledPagination?: boolean; +} + +const CoursesTab: React.FC = ({ coursesDataItems, showNewCourseContainer, onClickNewCourse, isShowProcessing, isLoading, isFailed, - dispatch, - numPages, - coursesCount, - isEnabledPagination, + numPages = 0, + coursesCount = 0, + isEnabledPagination = false, }) => { + const dispatch = useDispatch(); const intl = useIntl(); const location = useLocation(); const { @@ -136,7 +156,6 @@ const CoursesTab = ({ number, run, url, - cmsLink, }) => ( ), @@ -197,34 +215,4 @@ const CoursesTab = ({ ); }; -CoursesTab.defaultProps = { - numPages: 0, - coursesCount: 0, - isEnabledPagination: false, -}; - -CoursesTab.propTypes = { - coursesDataItems: PropTypes.arrayOf( - PropTypes.shape({ - courseKey: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - lmsLink: PropTypes.string.isRequired, - number: PropTypes.string.isRequired, - org: PropTypes.string.isRequired, - rerunLink: PropTypes.string.isRequired, - run: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - }), - ).isRequired, - showNewCourseContainer: PropTypes.bool.isRequired, - onClickNewCourse: PropTypes.func.isRequired, - isShowProcessing: PropTypes.bool.isRequired, - isLoading: PropTypes.bool.isRequired, - isFailed: PropTypes.bool.isRequired, - dispatch: PropTypes.func.isRequired, - numPages: PropTypes.number, - coursesCount: PropTypes.number, - isEnabledPagination: PropTypes.bool, -}; - export default CoursesTab; diff --git a/src/studio-home/tabs-section/index.jsx b/src/studio-home/tabs-section/index.tsx similarity index 95% rename from src/studio-home/tabs-section/index.jsx rename to src/studio-home/tabs-section/index.tsx index 75a3ae12b..44e1e9ea2 100644 --- a/src/studio-home/tabs-section/index.jsx +++ b/src/studio-home/tabs-section/index.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState, useEffect } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { Tab, Tabs } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { useNavigate, useLocation } from 'react-router-dom'; import { getLoadingStatuses, getStudioHomeData } from '../data/selectors'; @@ -17,13 +17,13 @@ import { fetchLibraryData } from '../data/thunks'; import { isMixedOrV1LibrariesMode, isMixedOrV2LibrariesMode } from './utils'; const TabsSection = ({ - intl, showNewCourseContainer, onClickNewCourse, isShowProcessing, - dispatch, isPaginationCoursesEnabled, }) => { + const dispatch = useDispatch(); + const intl = useIntl(); const navigate = useNavigate(); const { pathname } = useLocation(); const libMode = getConfig().LIBRARY_MODE; @@ -33,7 +33,7 @@ const TabsSection = ({ legacyLibraries: 'legacyLibraries', archived: 'archived', taxonomies: 'taxonomies', - }; + } as const; const initTabKeyState = (pname) => { if (pname.includes('/libraries-v1')) { @@ -80,7 +80,7 @@ const TabsSection = ({ // Controlling the visibility of tabs when using conditional rendering is necessary for // the correct operation of iterating over child elements inside the Paragon Tabs component. const visibleTabs = useMemo(() => { - const tabs = []; + const tabs: JSX.Element[] = []; tabs.push( , // TODO: proper typing for our redux state +} = {}) { initializeMockApp({ authenticatedUser: user, }); - reduxStore = initializeReduxStore(); + reduxStore = initializeReduxStore(initialState as any); queryClient = new QueryClient({ defaultOptions: { queries: {