diff --git a/src/studio-home/__mocks__/studioHomeMock.js b/src/studio-home/__mocks__/studioHomeMock.js index 77824e564..0de8903c8 100644 --- a/src/studio-home/__mocks__/studioHomeMock.js +++ b/src/studio-home/__mocks__/studioHomeMock.js @@ -12,6 +12,7 @@ module.exports = { rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023', run: '2023', url: '/course/course-v1:MachineLearning+123+2023', + cmsLink: '//localhost:18010/courses/course-v1:MachineLearning+123+2023', }, { courseKey: 'course-v1:Design+123+e.g.2025', @@ -22,6 +23,7 @@ module.exports = { rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025', run: 'e.g.2025', url: '/course/course-v1:Design+123+e.g.2025', + cmsLink: '//localhost:18010/courses/course-v1:Design+123+e.g.2025', }, ], canCreateOrganizations: true, diff --git a/src/studio-home/card-item/CardItem.test.jsx b/src/studio-home/card-item/CardItem.test.jsx index 71c34f30f..feb7f39e9 100644 --- a/src/studio-home/card-item/CardItem.test.jsx +++ b/src/studio-home/card-item/CardItem.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { render } from '@testing-library/react'; +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'; @@ -45,13 +45,17 @@ describe('', () => { }); it('should render correct links for non-library course', () => { const props = studioHomeMock.archivedCourses[0]; - const { getByText } = render(); + 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', props.rerunLink); const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage); expect(viewLiveLink).toHaveAttribute('href', props.lmsLink); + const editInStudioLink = getByText(messages.editStudioBtnText.defaultMessage); + expect(editInStudioLink).toHaveAttribute('href', props.cmsLink); }); it('should render course details for library course', () => { const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true }; diff --git a/src/studio-home/card-item/index.jsx b/src/studio-home/card-item/index.jsx index c87b02f0f..ae7791a1a 100644 --- a/src/studio-home/card-item/index.jsx +++ b/src/studio-home/card-item/index.jsx @@ -1,7 +1,13 @@ import React from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; -import { ActionRow, Card, Hyperlink } from '@edx/paragon'; +import { + Card, + Hyperlink, + Dropdown, + IconButton, +} from '@edx/paragon'; +import { MoreHoriz } from '@edx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -10,7 +16,16 @@ import { getStudioHomeData } from '../data/selectors'; import messages from '../messages'; const CardItem = ({ - intl, displayName, lmsLink, rerunLink, org, number, run, isLibraries, url, + intl, + displayName, + lmsLink, + rerunLink, + org, + number, + run, + isLibraries, + url, + cmsLink, }) => { const { allowCourseReruns, @@ -41,16 +56,29 @@ const CardItem = ({ )} subtitle={subtitle} actions={showActions && ( - - {isShowRerunLink && ( - - {intl.formatMessage(messages.btnReRunText)} - - )} - - {intl.formatMessage(messages.viewLiveBtnText)} - - + + + + {isShowRerunLink && ( + + {messages.btnReRunText.defaultMessage} + + )} + + {intl.formatMessage(messages.viewLiveBtnText)} + + + + {intl.formatMessage(messages.editStudioBtnText)} + + + + )} /> @@ -62,12 +90,14 @@ CardItem.defaultProps = { rerunLink: '', lmsLink: '', run: '', + cmsLink: '', }; CardItem.propTypes = { intl: intlShape.isRequired, displayName: PropTypes.string.isRequired, lmsLink: PropTypes.string, + cmsLink: PropTypes.string, rerunLink: PropTypes.string, org: PropTypes.string.isRequired, run: PropTypes.string, diff --git a/src/studio-home/data/api.js b/src/studio-home/data/api.js index 546a3d5b0..bfd4680d4 100644 --- a/src/studio-home/data/api.js +++ b/src/studio-home/data/api.js @@ -16,8 +16,8 @@ export async function getStudioHomeData() { return camelCaseObject(data); } -export async function getStudioHomeCourses(search) { - const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`); +export async function getStudioHomeCourses(search, customParams) { + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParams }); return camelCaseObject(data); } diff --git a/src/studio-home/data/api.test.js b/src/studio-home/data/api.test.js index 4c0cfff23..3e8edfcee 100644 --- a/src/studio-home/data/api.test.js +++ b/src/studio-home/data/api.test.js @@ -44,7 +44,7 @@ describe('studio-home api calls', () => { }); fit('should get studio courses data', async () => { - const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`; + const apiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`; axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse()); const result = await getStudioHomeCourses(''); const expected = generateGetStudioCoursesApiResponse(); diff --git a/src/studio-home/data/selectors.js b/src/studio-home/data/selectors.js index 62957e58a..a97cb803d 100644 --- a/src/studio-home/data/selectors.js +++ b/src/studio-home/data/selectors.js @@ -1,3 +1,4 @@ export const getStudioHomeData = state => state.studioHome.studioHomeData; export const getLoadingStatuses = (state) => state.studioHome.loadingStatuses; export const getSavingStatuses = (state) => state.studioHome.savingStatuses; +export const getStudioHomeCoursesParams = (state) => state.studioHome.studioHomeCoursesCustomParams; diff --git a/src/studio-home/data/slice.js b/src/studio-home/data/slice.js index 02c60dbea..937a311a3 100644 --- a/src/studio-home/data/slice.js +++ b/src/studio-home/data/slice.js @@ -17,6 +17,9 @@ const slice = createSlice({ deleteNotificationSavingStatus: '', }, studioHomeData: {}, + studioHomeCoursesCustomParams: { + currentPage: 1, + }, }, reducers: { updateLoadingStatuses: (state, { payload }) => { @@ -29,15 +32,21 @@ const slice = createSlice({ Object.assign(state.studioHomeData, payload); }, fetchCourseDataSuccess: (state, { payload }) => { - const { courses, archivedCourses, inProcessCourseActions } = payload; + const { courses, archivedCourses = [], inProcessCourseActions } = payload.results; + const { numPages, count } = payload; state.studioHomeData.courses = courses; state.studioHomeData.archivedCourses = archivedCourses; state.studioHomeData.inProcessCourseActions = inProcessCourseActions; + state.studioHomeData.numPages = numPages; + state.studioHomeData.coursesCount = count; }, fetchLibraryDataSuccess: (state, { payload }) => { const { libraries } = payload; state.studioHomeData.libraries = libraries; }, + updateStudioHomeCoursesCustomParams: (state, { payload }) => { + state.studioHomeCoursesCustomParams = { ...state.studioHomeCoursesCustomParams, ...payload }; + }, }, }); @@ -47,6 +56,7 @@ export const { fetchStudioHomeDataSuccess, fetchCourseDataSuccess, fetchLibraryDataSuccess, + updateStudioHomeCoursesCustomParams, } = slice.actions; export const { diff --git a/src/studio-home/data/thunks.js b/src/studio-home/data/thunks.js index aca8f7ef1..81c5e2a9a 100644 --- a/src/studio-home/data/thunks.js +++ b/src/studio-home/data/thunks.js @@ -14,7 +14,7 @@ import { fetchLibraryDataSuccess, } from './slice'; -function fetchStudioHomeData(search, hasHomeData) { +function fetchStudioHomeData(search, hasHomeData, customParams = {}) { return async (dispatch) => { dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS })); dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.IN_PROGRESS })); @@ -30,7 +30,7 @@ function fetchStudioHomeData(search, hasHomeData) { } } try { - const coursesData = await getStudioHomeCourses(search || ''); + const coursesData = await getStudioHomeCourses(search || '', customParams); dispatch(fetchCourseDataSuccess(coursesData)); dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.SUCCESSFUL })); } catch (error) { diff --git a/src/studio-home/factories/mockApiResponses.jsx b/src/studio-home/factories/mockApiResponses.jsx index 9ef672ffd..8a754ed9d 100644 --- a/src/studio-home/factories/mockApiResponses.jsx +++ b/src/studio-home/factories/mockApiResponses.jsx @@ -15,6 +15,9 @@ export const initialState = { deleteNotificationSavingStatus: '', }, studioHomeData: {}, + studioHomeCoursesCustomParams: { + currentPage: 1, + }, }, }; @@ -46,51 +49,58 @@ export const generateGetStudioHomeDataApiResponse = () => ({ }); export const generateGetStudioCoursesApiResponse = () => ({ - archivedCourses: [ - { - courseKey: 'course-v1:MachineLearning+123+2023', - displayName: 'Machine Learning', - lmsLink: '//localhost:18000/courses/course-v1:MachineLearning+123+2023/jump_to/block-v1:MachineLearning+123+2023+type@course+block@course', - number: '123', - org: 'LSE', - rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023', - run: '2023', - url: '/course/course-v1:MachineLearning+123+2023', - }, - { - courseKey: 'course-v1:Design+123+e.g.2025', - displayName: 'Design', - lmsLink: '//localhost:18000/courses/course-v1:Design+123+e.g.2025/jump_to/block-v1:Design+123+e.g.2025+type@course+block@course', - number: '123', - org: 'University of Cape Town', - rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025', - run: 'e.g.2025', - url: '/course/course-v1:Design+123+e.g.2025', - }, - ], - 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: [], + count: 5, + next: null, + previous: null, + numPages: 2, + results: { + archivedCourses: [ + { + courseKey: 'course-v1:MachineLearning+123+2023', + displayName: 'Machine Learning', + lmsLink: '//localhost:18000/courses/course-v1:MachineLearning+123+2023/jump_to/block-v1:MachineLearning+123+2023+type@course+block@course', + number: '123', + org: 'LSE', + rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023', + run: '2023', + url: '/course/course-v1:MachineLearning+123+2023', + }, + { + courseKey: 'course-v1:Design+123+e.g.2025', + displayName: 'Design', + lmsLink: '//localhost:18000/courses/course-v1:Design+123+e.g.2025/jump_to/block-v1:Design+123+e.g.2025+type@course+block@course', + number: '123', + org: 'University of Cape Town', + rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025', + run: 'e.g.2025', + url: '/course/course-v1:Design+123+e.g.2025', + }, + ], + 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 generateGetStuioHomeLibrariesApiResponse = () => ({ diff --git a/src/studio-home/hooks.jsx b/src/studio-home/hooks.jsx index cfc45f972..b3dec7a53 100644 --- a/src/studio-home/hooks.jsx +++ b/src/studio-home/hooks.jsx @@ -10,6 +10,7 @@ import { getLoadingStatuses, getSavingStatuses, getStudioHomeData, + getStudioHomeCoursesParams, } from './data/selectors'; import { updateSavingStatuses } from './data/slice'; @@ -17,6 +18,7 @@ const useStudioHome = () => { const location = useLocation(); const dispatch = useDispatch(); const studioHomeData = useSelector(getStudioHomeData); + const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams); const newCourseData = useSelector(getCourseData); const { studioHomeLoadingStatus } = useSelector(getLoadingStatuses); const savingCreateRerunStatus = useSelector(getSavingStatus); @@ -33,6 +35,11 @@ const useStudioHome = () => { setShowNewCourseContainer(false); }, [location.search]); + useEffect(() => { + const { currentPage } = studioHomeCoursesParams; + dispatch(fetchStudioHomeData(location.search ?? '', false, { page: currentPage })); + }, [studioHomeCoursesParams.currentPage]); + useEffect(() => { if (courseCreatorSavingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' })); diff --git a/src/studio-home/messages.js b/src/studio-home/messages.js index 6a0e5ec7e..cc068ad38 100644 --- a/src/studio-home/messages.js +++ b/src/studio-home/messages.js @@ -49,6 +49,10 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.btn.view-live.text', defaultMessage: 'View live', }, + editStudioBtnText: { + id: 'course-authoring.studio-home.btn.edit.studio.text', + defaultMessage: 'Edit in Studio', + }, organizationTitle: { id: 'course-authoring.studio-home.organization.title', defaultMessage: 'Organization and library settings', diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx index cbb29c40b..c00ed4a97 100644 --- a/src/studio-home/tabs-section/TabsSection.test.jsx +++ b/src/studio-home/tabs-section/TabsSection.test.jsx @@ -27,13 +27,15 @@ const { studioShortName } = studioHomeMock; let axiosMock; let store; -const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`; +const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`; const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`; +const mockDispatch = jest.fn(); + const RootWrapper = () => ( - + ); @@ -92,7 +94,7 @@ describe('', () => { it('should render default sections when courses are empty', async () => { const data = generateGetStudioCoursesApiResponse(); - data.courses = []; + data.results.courses = []; render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); @@ -116,6 +118,36 @@ describe('', () => { expect(screen.getByText(tabMessages.courseTabErrorMessage.defaultMessage)).toBeVisible(); }); + + it('should render pagination when there are courses', async () => { + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + const data = generateGetStudioCoursesApiResponse(); + const coursesLength = data.results.courses.length; + const totalItems = data.count; + const paginationInfoText = `Showing ${coursesLength} of ${totalItems}`; + + expect(screen.getByText(studioHomeMock.courses[0].displayName)).toBeVisible(); + + const pagination = screen.getByRole('navigation'); + const paginationInfo = screen.getByTestId('pagination-info'); + expect(paginationInfo.textContent).toContain(paginationInfoText); + expect(pagination).toBeVisible(); + }); + + it('should not render pagination when there are not courses', async () => { + const data = generateGetStudioCoursesApiResponse(); + data.results.courses = []; + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + const pagination = screen.queryByRole('navigation'); + expect(pagination).not.toBeInTheDocument(); + }); }); describe('archived tab', () => { @@ -137,7 +169,7 @@ describe('', () => { it('should hide Archived tab when archived courses are empty', async () => { const data = generateGetStudioCoursesApiResponse(); - data.archivedCourses = []; + data.results.archivedCourses = []; render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); diff --git a/src/studio-home/tabs-section/courses-tab/index.jsx b/src/studio-home/tabs-section/courses-tab/index.jsx index 94a4ac564..c7e98f659 100644 --- a/src/studio-home/tabs-section/courses-tab/index.jsx +++ b/src/studio-home/tabs-section/courses-tab/index.jsx @@ -2,11 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Icon, Row } from '@edx/paragon'; +import { Icon, Row, Pagination } from '@edx/paragon'; import { Error } from '@edx/paragon/icons'; import { COURSE_CREATOR_STATES } from '../../../constants'; -import { getStudioHomeData } from '../../data/selectors'; +import { getStudioHomeData, getStudioHomeCoursesParams } from '../../data/selectors'; +import { updateStudioHomeCoursesCustomParams } from '../../data/slice'; import CardItem from '../../card-item'; import CollapsibleStateWithAction from '../../collapsible-state-with-action'; import { sortAlphabeticallyArray } from '../utils'; @@ -23,12 +24,16 @@ const CoursesTab = ({ isShowProcessing, isLoading, isFailed, + dispatch, + numPages, + coursesCount, }) => { const intl = useIntl(); const { courseCreatorStatus, optimizationEnabled, } = useSelector(getStudioHomeData); + const { currentPage } = useSelector(getStudioHomeCoursesParams); const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted; const showCollapsible = [ COURSE_CREATOR_STATES.denied, @@ -36,6 +41,9 @@ const CoursesTab = ({ COURSE_CREATOR_STATES.unrequested, ].includes(courseCreatorStatus); + const handlePageSelected = (page) => dispatch(updateStudioHomeCoursesCustomParams({ currentPage: page })); + const hasCourses = coursesDataItems?.length; + if (isLoading) { return ( @@ -58,30 +66,54 @@ const CoursesTab = ({ ) : ( <> {isShowProcessing && } - {coursesDataItems?.length ? ( - sortAlphabeticallyArray(coursesDataItems).map( - ({ - courseKey, - displayName, - lmsLink, - org, - rerunLink, - number, - run, - url, - }) => ( - +

+ {intl.formatMessage(messages.coursesPaginationInfo, { + length: coursesDataItems.length, + total: coursesCount, + })} +

+ + )} + {hasCourses ? ( + <> + {sortAlphabeticallyArray(coursesDataItems).map( + ({ + courseKey, + displayName, + lmsLink, + org, + rerunLink, + number, + run, + url, + cmsLink, + }) => ( + + ), + )} + + {numPages > 1 && ( + - ), - ) + )} + ) : (!optimizationEnabled && ( , ); diff --git a/src/studio-home/tabs-section/messages.js b/src/studio-home/tabs-section/messages.js index 75e945cef..05e8d0fce 100644 --- a/src/studio-home/tabs-section/messages.js +++ b/src/studio-home/tabs-section/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.courses.tab.error.message', defaultMessage: 'Failed to fetch courses. Please try again later.', }, + coursesPaginationInfo: { + id: 'course-authoring.studio-home.courses.pagination.info', + defaultMessage: 'Showing {length} of {total}', + }, librariesTabErrorMessage: { id: 'course-authoring.studio-home.libraries.tab.error.message', defaultMessage: 'Failed to fetch libraries. Please try again later.',