diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx index 8b670c4ab7..ec39c13f72 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.jsx @@ -2,10 +2,12 @@ import React from 'react'; import { Button, Container, + Icon, Layout, MailtoLink, + Row, } from '@edx/paragon'; -import { Add as AddIcon } from '@edx/paragon/icons/es5'; +import { Add as AddIcon, Error } from '@edx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { StudioFooter } from '@edx/frontend-component-footer'; import { getConfig } from '@edx/frontend-platform'; @@ -21,10 +23,12 @@ import VerifyEmailLayout from './verify-email-layout'; import CreateNewCourseForm from './create-new-course-form'; import messages from './messages'; import { useStudioHome } from './hooks'; +import AlertMessage from '../generic/alert-message'; const StudioHome = ({ intl }) => { const { isLoadingPage, + isFailedLoadingPage, studioHomeData, isShowProcessing, anyQueryIsFailed, @@ -34,6 +38,7 @@ const StudioHome = ({ intl }) => { isShowOrganizationDropdown, hasAbilityToCreateNewCourse, setShowNewCourseContainer, + dispatch, } = useStudioHome(); const { @@ -47,6 +52,10 @@ const StudioHome = ({ intl }) => { function getHeaderButtons() { const headerButtons = []; + if (isFailedLoadingPage || !userIsActive) { + return headerButtons; + } + if (isShowEmailStaff) { headerButtons.push( {intl.formatMessage(messages.emailStaffBtnText)}, @@ -93,6 +102,53 @@ const StudioHome = ({ intl }) => { return (); } + const getMainBody = () => { + if (isFailedLoadingPage) { + return ( + + + {intl.formatMessage(messages.homePageLoadFailedMessage)} + + )} + /> + ); + } + if (!userIsActive) { + return ; + } + return ( + + +
+ {showNewCourseContainer && ( + setShowNewCourseContainer(false)} /> + )} + {isShowOrganizationDropdown && } + setShowNewCourseContainer(true)} + isShowProcessing={isShowProcessing} + dispatch={dispatch} + /> +
+
+ + + +
+ ); + }; + return ( <>
@@ -101,40 +157,12 @@ const StudioHome = ({ intl }) => {
- {!userIsActive ? ( - - ) : ( - - -
- {showNewCourseContainer && ( - setShowNewCourseContainer(false)} /> - )} - {isShowOrganizationDropdown && } - setShowNewCourseContainer(true)} - isShowProcessing={isShowProcessing} - /> -
-
- - - -
- )} + {getMainBody()}
diff --git a/src/studio-home/StudioHome.test.jsx b/src/studio-home/StudioHome.test.jsx index a926d7c9ef..7286acda0f 100644 --- a/src/studio-home/StudioHome.test.jsx +++ b/src/studio-home/StudioHome.test.jsx @@ -50,174 +50,204 @@ const RootWrapper = () => ( ); describe('', async () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); - await executeThunk(fetchStudioHomeData(), store.dispatch); - useSelector.mockReturnValue(studioHomeMock); - }); - - it('should render page and page title correctly', () => { - const { getByText } = render(); - expect(getByText(`${studioShortName} home`)).toBeInTheDocument(); - }); - - it('should render email staff header button', async () => { - useSelector.mockReturnValue({ - ...studioHomeMock, - courseCreatorStatus: COURSE_CREATOR_STATES.disallowedForThisSite, + describe('api fetch fails', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getStudioHomeApiUrl()).reply(404); + await executeThunk(fetchStudioHomeData(), store.dispatch); + useSelector.mockReturnValue({ studioHomeLoadingStatus: RequestStatus.FAILED }); }); - const { getByRole } = render(); - expect(getByRole('link', { name: messages.emailStaffBtnText.defaultMessage })) - .toHaveAttribute('href', `mailto:${studioRequestEmail}`); - }); - - it('should render create new course button', async () => { - useSelector.mockReturnValue({ - ...studioHomeMock, - courseCreatorStatus: COURSE_CREATOR_STATES.granted, + it('should render fetch error', () => { + const { getByText } = render(); + expect(getByText(messages.homePageLoadFailedMessage.defaultMessage)).toBeInTheDocument(); }); - const { getByRole } = render(); - expect(getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage })).toBeInTheDocument(); - }); - - it('should show verify email layout if user inactive', () => { - useSelector.mockReturnValue({ - ...studioHomeMock, - userIsActive: false, + it('should render Studio home title', () => { + const { getByText } = render(); + expect(getByText('Studio home')).toBeInTheDocument(); }); - - const { getByText } = render(); - expect(getByText('Thanks for signing up, abc123!', { exact: false })).toBeInTheDocument(); }); - it('shows the spinner before the query is complete', async () => { - useSelector.mockReturnValue({ - studioHomeLoadingStatus: RequestStatus.IN_PROGRESS, - userIsActive: true, + describe('api fetch succeeds', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + await executeThunk(fetchStudioHomeData(), store.dispatch); + useSelector.mockReturnValue(studioHomeMock); }); - await act(async () => { - const { getByRole } = render(); - const spinner = getByRole('status'); - expect(spinner.textContent).toEqual('Loading...'); + it('should render page and page title correctly', () => { + const { getByText } = render(); + expect(getByText(`${studioShortName} home`)).toBeInTheDocument(); }); - }); - describe('render new library button', () => { - it('href should include home_library', async () => { + it('should render email staff header button', async () => { useSelector.mockReturnValue({ ...studioHomeMock, - courseCreatorStatus: COURSE_CREATOR_STATES.granted, + courseCreatorStatus: COURSE_CREATOR_STATES.disallowedForThisSite, }); - const studioBaseUrl = 'http://localhost:18010'; - const { getByTestId } = render(); - const createNewLibraryButton = getByTestId('new-library-button'); - expect(createNewLibraryButton.getAttribute('href')).toBe(`${studioBaseUrl}/home_library`); + const { getByRole } = render(); + expect(getByRole('link', { name: messages.emailStaffBtnText.defaultMessage })) + .toHaveAttribute('href', `mailto:${studioRequestEmail}`); }); - it('href should include create', async () => { + + it('should render create new course button', async () => { useSelector.mockReturnValue({ ...studioHomeMock, courseCreatorStatus: COURSE_CREATOR_STATES.granted, - splitStudioHome: true, - redirectToLibraryAuthoringMfe: true, }); - const libraryAuthoringMfeUrl = 'http://localhost:3001'; - - const { getByTestId } = render(); - const createNewLibraryButton = getByTestId('new-library-button'); - expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`); - }); - }); - it('should render create new course container', async () => { - useSelector.mockReturnValue({ - ...studioHomeMock, - courseCreatorStatus: COURSE_CREATOR_STATES.granted, + const { getByRole } = render(); + expect(getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage })).toBeInTheDocument(); }); - const { getByRole, getByText } = render(); - const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage }); - - await act(() => fireEvent.click(createNewCourseButton)); - expect(getByText(createNewCourseMessages.createNewCourse.defaultMessage)).toBeInTheDocument(); - }); + it('should show verify email layout if user inactive', () => { + useSelector.mockReturnValue({ + ...studioHomeMock, + userIsActive: false, + }); - it('should hide create new course container', async () => { - useSelector.mockReturnValue({ - ...studioHomeMock, - courseCreatorStatus: COURSE_CREATOR_STATES.granted, + const { getByText } = render(); + expect(getByText('Thanks for signing up, abc123!', { exact: false })).toBeInTheDocument(); }); - const { getByRole, queryByText, getByText } = render(); - const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage }); + it('shows the spinner before the query is complete', async () => { + useSelector.mockReturnValue({ + studioHomeLoadingStatus: RequestStatus.IN_PROGRESS, + userIsActive: true, + }); - fireEvent.click(createNewCourseButton); - waitFor(() => { - expect(getByText(createNewCourseMessages.createNewCourse.defaultMessage)).toBeInTheDocument(); + await act(async () => { + const { getByRole } = render(); + const spinner = getByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); }); - const cancelButton = getByRole('button', { name: createOrRerunCourseMessages.cancelButton.defaultMessage }); - fireEvent.click(cancelButton); - waitFor(() => { - expect(queryByText(createNewCourseMessages.createNewCourse.defaultMessage)).not.toBeInTheDocument(); + describe('render new library button', () => { + it('href should include home_library', async () => { + useSelector.mockReturnValue({ + ...studioHomeMock, + courseCreatorStatus: COURSE_CREATOR_STATES.granted, + }); + const studioBaseUrl = 'http://localhost:18010'; + + const { getByTestId } = render(); + const createNewLibraryButton = getByTestId('new-library-button'); + expect(createNewLibraryButton.getAttribute('href')).toBe(`${studioBaseUrl}/home_library`); + }); + it('href should include create', async () => { + useSelector.mockReturnValue({ + ...studioHomeMock, + courseCreatorStatus: COURSE_CREATOR_STATES.granted, + splitStudioHome: true, + redirectToLibraryAuthoringMfe: true, + }); + const libraryAuthoringMfeUrl = 'http://localhost:3001'; + + const { getByTestId } = render(); + const createNewLibraryButton = getByTestId('new-library-button'); + expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`); + }); }); - }); - describe('contact administrator card', () => { - it('should show contact administrator card with no add course buttons', () => { + it('should render create new course container', async () => { useSelector.mockReturnValue({ ...studioHomeMock, - courses: null, - courseCreatorStatus: COURSE_CREATOR_STATES.pending, + courseCreatorStatus: COURSE_CREATOR_STATES.granted, }); - const { getByText, queryByText } = render(); - const defaultTitleMessage = messages.defaultSection_1_Title.defaultMessage; - const titleWithStudioName = defaultTitleMessage.replace('{studioShortName}', 'Studio'); - const administratorCardTitle = getByText(titleWithStudioName); - expect(administratorCardTitle).toBeVisible(); + const { getByRole, getByText } = render(); + const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage }); - const addCourseButton = queryByText(messages.btnAddNewCourseText.defaultMessage); - expect(addCourseButton).toBeNull(); + await act(() => fireEvent.click(createNewCourseButton)); + expect(getByText(createNewCourseMessages.createNewCourse.defaultMessage)).toBeInTheDocument(); }); - it('should show contact administrator card with add course buttons', () => { + it('should hide create new course container', async () => { useSelector.mockReturnValue({ ...studioHomeMock, - courses: null, courseCreatorStatus: COURSE_CREATOR_STATES.granted, }); - const { getByText, getByTestId } = render(); - const defaultTitleMessage = messages.defaultSection_1_Title.defaultMessage; - const titleWithStudioName = defaultTitleMessage.replace('{studioShortName}', 'Studio'); - const administratorCardTitle = getByText(titleWithStudioName); - expect(administratorCardTitle).toBeVisible(); + const { getByRole, queryByText, getByText } = render(); + const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage }); - const addCourseButton = getByTestId('contact-admin-create-course'); - expect(addCourseButton).toBeVisible(); + fireEvent.click(createNewCourseButton); + waitFor(() => { + expect(getByText(createNewCourseMessages.createNewCourse.defaultMessage)).toBeInTheDocument(); + }); - fireEvent.click(addCourseButton); - expect(getByTestId('create-course-form')).toBeVisible(); + const cancelButton = getByRole('button', { name: createOrRerunCourseMessages.cancelButton.defaultMessage }); + fireEvent.click(cancelButton); + waitFor(() => { + expect(queryByText(createNewCourseMessages.createNewCourse.defaultMessage)).not.toBeInTheDocument(); + }); }); - }); - it('should show footer', () => { - const { getByText } = render(); - expect(getByText('Looking for help with Studio?')).toBeInTheDocument(); - expect(getByText('LMS')).toHaveAttribute('href', process.env.LMS_BASE_URL); + describe('contact administrator card', () => { + it('should show contact administrator card with no add course buttons', () => { + useSelector.mockReturnValue({ + ...studioHomeMock, + courses: null, + courseCreatorStatus: COURSE_CREATOR_STATES.pending, + }); + const { getByText, queryByText } = render(); + const defaultTitleMessage = messages.defaultSection_1_Title.defaultMessage; + const titleWithStudioName = defaultTitleMessage.replace('{studioShortName}', 'Studio'); + const administratorCardTitle = getByText(titleWithStudioName); + + expect(administratorCardTitle).toBeVisible(); + + const addCourseButton = queryByText(messages.btnAddNewCourseText.defaultMessage); + expect(addCourseButton).toBeNull(); + }); + + it('should show contact administrator card with add course buttons', () => { + useSelector.mockReturnValue({ + ...studioHomeMock, + courses: null, + courseCreatorStatus: COURSE_CREATOR_STATES.granted, + }); + const { getByText, getByTestId } = render(); + const defaultTitleMessage = messages.defaultSection_1_Title.defaultMessage; + const titleWithStudioName = defaultTitleMessage.replace('{studioShortName}', 'Studio'); + const administratorCardTitle = getByText(titleWithStudioName); + + expect(administratorCardTitle).toBeVisible(); + + const addCourseButton = getByTestId('contact-admin-create-course'); + expect(addCourseButton).toBeVisible(); + + fireEvent.click(addCourseButton); + expect(getByTestId('create-course-form')).toBeVisible(); + }); + }); + + it('should show footer', () => { + const { getByText } = render(); + expect(getByText('Looking for help with Studio?')).toBeInTheDocument(); + expect(getByText('LMS')).toHaveAttribute('href', process.env.LMS_BASE_URL); + }); }); }); diff --git a/src/studio-home/data/api.js b/src/studio-home/data/api.js index f6035b9234..546a3d5b01 100644 --- a/src/studio-home/data/api.js +++ b/src/studio-home/data/api.js @@ -1,8 +1,8 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getStudioHomeApiUrl = (search) => new URL(`api/contentstore/v1/home${search}`, getApiBaseUrl()).href; +export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getStudioHomeApiUrl = () => new URL('api/contentstore/v1/home', getApiBaseUrl()).href; export const getRequestCourseCreatorUrl = () => new URL('request_course_creator', getApiBaseUrl()).href; export const getCourseNotificationUrl = (url) => new URL(url, getApiBaseUrl()).href; @@ -11,8 +11,18 @@ export const getCourseNotificationUrl = (url) => new URL(url, getApiBaseUrl()).h * @param {string} search * @returns {Promise} */ -export async function getStudioHomeData(search) { - const { data } = await getAuthenticatedHttpClient().get(getStudioHomeApiUrl(search)); +export async function getStudioHomeData() { + const { data } = await getAuthenticatedHttpClient().get(getStudioHomeApiUrl()); + return camelCaseObject(data); +} + +export async function getStudioHomeCourses(search) { + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`); + return camelCaseObject(data); +} + +export async function getStudioHomeLibraries() { + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/libraries`); return camelCaseObject(data); } diff --git a/src/studio-home/data/api.test.js b/src/studio-home/data/api.test.js index f608ed906e..4c0cfff239 100644 --- a/src/studio-home/data/api.test.js +++ b/src/studio-home/data/api.test.js @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { studioHomeMock } from '../__mocks__'; import { getStudioHomeApiUrl, getRequestCourseCreatorUrl, @@ -10,7 +9,11 @@ import { getStudioHomeData, handleCourseNotification, sendRequestForCourseCreator, + getApiBaseUrl, + getStudioHomeCourses, + getStudioHomeLibraries, } from './api'; +import { generateGetStudioCoursesApiResponse, generateGetStudioHomeDataApiResponse, generateGetStuioHomeLibrariesApiResponse } from '../factories/mockApiResponses'; let axiosMock; @@ -32,11 +35,32 @@ describe('studio-home api calls', () => { }); it('should get studio home data', async () => { - axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); const result = await getStudioHomeData(); + const expected = generateGetStudioHomeDataApiResponse(); expect(axiosMock.history.get[0].url).toEqual(getStudioHomeApiUrl()); - expect(result).toEqual(studioHomeMock); + expect(result).toEqual(expected); + }); + + fit('should get studio courses data', async () => { + const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`; + axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse()); + const result = await getStudioHomeCourses(''); + const expected = generateGetStudioCoursesApiResponse(); + + expect(axiosMock.history.get[0].url).toEqual(apiLink); + expect(result).toEqual(expected); + }); + + it('should get studio libraries data', async () => { + const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`; + axiosMock.onGet(apiLink).reply(200, generateGetStuioHomeLibrariesApiResponse()); + const result = await getStudioHomeLibraries(); + const expected = generateGetStuioHomeLibrariesApiResponse(); + + expect(axiosMock.history.get[0].url).toEqual(apiLink); + expect(result).toEqual(expected); }); it('should handle course notification request', async () => { diff --git a/src/studio-home/data/slice.js b/src/studio-home/data/slice.js index 0b35c7ede9..02c60dbea8 100644 --- a/src/studio-home/data/slice.js +++ b/src/studio-home/data/slice.js @@ -9,6 +9,8 @@ const slice = createSlice({ loadingStatuses: { studioHomeLoadingStatus: RequestStatus.IN_PROGRESS, courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS, + courseLoadingStatus: RequestStatus.IN_PROGRESS, + libraryLoadingStatus: RequestStatus.IN_PROGRESS, }, savingStatuses: { courseCreatorSavingStatus: '', @@ -26,6 +28,16 @@ const slice = createSlice({ fetchStudioHomeDataSuccess: (state, { payload }) => { Object.assign(state.studioHomeData, payload); }, + fetchCourseDataSuccess: (state, { payload }) => { + const { courses, archivedCourses, inProcessCourseActions } = payload; + state.studioHomeData.courses = courses; + state.studioHomeData.archivedCourses = archivedCourses; + state.studioHomeData.inProcessCourseActions = inProcessCourseActions; + }, + fetchLibraryDataSuccess: (state, { payload }) => { + const { libraries } = payload; + state.studioHomeData.libraries = libraries; + }, }, }); @@ -33,6 +45,8 @@ export const { updateSavingStatuses, updateLoadingStatuses, fetchStudioHomeDataSuccess, + fetchCourseDataSuccess, + fetchLibraryDataSuccess, } = slice.actions; export const { diff --git a/src/studio-home/data/thunks.js b/src/studio-home/data/thunks.js index 95815b7d7f..aca8f7ef15 100644 --- a/src/studio-home/data/thunks.js +++ b/src/studio-home/data/thunks.js @@ -1,21 +1,54 @@ import { RequestStatus } from '../../data/constants'; -import { getStudioHomeData, sendRequestForCourseCreator, handleCourseNotification } from './api'; +import { + getStudioHomeData, + sendRequestForCourseCreator, + handleCourseNotification, + getStudioHomeCourses, + getStudioHomeLibraries, +} from './api'; import { fetchStudioHomeDataSuccess, + fetchCourseDataSuccess, updateLoadingStatuses, updateSavingStatuses, + fetchLibraryDataSuccess, } from './slice'; -function fetchStudioHomeData(search) { +function fetchStudioHomeData(search, hasHomeData) { return async (dispatch) => { dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS })); + dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.IN_PROGRESS })); + + if (!hasHomeData) { + try { + const studioHomeData = await getStudioHomeData(); + dispatch(fetchStudioHomeDataSuccess(studioHomeData)); + dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.FAILED })); + return; + } + } + try { + const coursesData = await getStudioHomeCourses(search || ''); + dispatch(fetchCourseDataSuccess(coursesData)); + dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.FAILED })); + } + }; +} + +function fetchLibraryData() { + return async (dispatch) => { + dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.IN_PROGRESS })); try { - const studioHomeData = await getStudioHomeData(search || ''); - dispatch(fetchStudioHomeDataSuccess(studioHomeData)); - dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.SUCCESSFUL })); + const libraryData = await getStudioHomeLibraries(); + dispatch(fetchLibraryDataSuccess(libraryData)); + dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.FAILED })); + dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.FAILED })); } }; } @@ -50,6 +83,7 @@ function requestCourseCreatorQuery() { export { fetchStudioHomeData, + fetchLibraryData, requestCourseCreatorQuery, handleDeleteNotificationQuery, }; diff --git a/src/studio-home/factories/mockApiResponses.jsx b/src/studio-home/factories/mockApiResponses.jsx new file mode 100644 index 0000000000..9ef672ffd6 --- /dev/null +++ b/src/studio-home/factories/mockApiResponses.jsx @@ -0,0 +1,114 @@ +import { RequestStatus } from '../../data/constants'; + +export const courseId = 'course'; + +export const initialState = { + studioHome: { + loadingStatuses: { + studioHomeLoadingStatus: RequestStatus.IN_PROGRESS, + courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS, + courseLoadingStatus: RequestStatus.IN_PROGRESS, + libraryLoadingStatus: RequestStatus.IN_PROGRESS, + }, + savingStatuses: { + courseCreatorSavingStatus: '', + deleteNotificationSavingStatus: '', + }, + studioHomeData: {}, + }, +}; + +export const generateGetStudioHomeDataApiResponse = () => ({ + activeTab: 'courses', + allowCourseReruns: true, + allowedOrganizations: ['edx', 'org'], + archivedCourses: [], + canCreateOrganizations: true, + courseCreatorStatus: 'granted', + courses: [], + inProcessCourseActions: [], + libraries: [], + librariesEnabled: true, + libraryAuthoringMfeUrl: 'http://localhost:3001', + optimizationEnabled: false, + redirectToLibraryAuthoringMfe: false, + requestCourseCreatorUrl: '/request_course_creator', + rerunCreatorStatus: true, + showNewLibraryButton: true, + splitStudioHome: false, + studioName: 'Studio', + studioShortName: 'Studio', + studioRequestEmail: 'request@email.com', + techSupportEmail: 'technical@example.com', + platformName: 'Your Platform Name Here', + userIsActive: true, + allowToCreateNewOrg: false, +}); + +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: [], +}); + +export const generateGetStuioHomeLibrariesApiResponse = () => ({ + libraries: [ + { + displayName: 'MBA', + libraryKey: 'library-v1:MBA+123', + url: '/library/library-v1:MDA+123', + org: 'Cambridge', + number: '123', + canEdit: true, + }, + ], +}); + +export const generateNewVideoApiResponse = () => ({ + files: [{ + edx_video_id: 'mOckID4', + upload_url: 'http://testing.org', + }], +}); diff --git a/src/studio-home/hooks.jsx b/src/studio-home/hooks.jsx index c351d26ae1..cfc45f9720 100644 --- a/src/studio-home/hooks.jsx +++ b/src/studio-home/hooks.jsx @@ -26,6 +26,7 @@ const useStudioHome = () => { } = useSelector(getSavingStatuses); const [showNewCourseContainer, setShowNewCourseContainer] = useState(false); const isLoadingPage = studioHomeLoadingStatus === RequestStatus.IN_PROGRESS; + const isFailedLoadingPage = studioHomeLoadingStatus === RequestStatus.FAILED; useEffect(() => { dispatch(fetchStudioHomeData(location.search ?? '')); @@ -59,7 +60,7 @@ const useStudioHome = () => { const isShowOrganizationDropdown = optimizationEnabled && courseCreatorStatus === COURSE_CREATOR_STATES.granted; const isShowEmailStaff = courseCreatorStatus === COURSE_CREATOR_STATES.disallowedForThisSite && !!studioRequestEmail; - const isShowProcessing = allowCourseReruns && rerunCreatorStatus && inProcessCourseActions.length > 0; + const isShowProcessing = allowCourseReruns && rerunCreatorStatus && inProcessCourseActions?.length > 0; const hasAbilityToCreateNewCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted; const anyQueryIsPending = [deleteNotificationSavingStatus, courseCreatorSavingStatus, savingCreateRerunStatus] .includes(RequestStatus.PENDING); @@ -68,6 +69,7 @@ const useStudioHome = () => { return { isLoadingPage, + isFailedLoadingPage, newCourseData, studioHomeData, isShowProcessing, @@ -78,7 +80,6 @@ const useStudioHome = () => { courseCreatorSavingStatus, isShowOrganizationDropdown, hasAbilityToCreateNewCourse, - deleteNotificationSavingStatus, dispatch, setShowNewCourseContainer, }; diff --git a/src/studio-home/messages.js b/src/studio-home/messages.js index a6a87f72f2..6a0e5ec7e0 100644 --- a/src/studio-home/messages.js +++ b/src/studio-home/messages.js @@ -13,22 +13,14 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.add-new-library.btn.text', defaultMessage: 'New library', }, + homePageLoadFailedMessage: { + id: 'course-authoring.studio-home.page-load.failed.message', + defaultMessage: 'Failed to load Studio home. Please try again later.', + }, emailStaffBtnText: { id: 'course-authoring.studio-home.email-staff.btn.text', defaultMessage: 'Email staff to create course', }, - coursesTabTitle: { - id: 'course-authoring.studio-home.courses.tab.title', - defaultMessage: 'Courses', - }, - librariesTabTitle: { - id: 'course-authoring.studio-home.libraries.tab.title', - defaultMessage: 'Libraries', - }, - archivedTabTitle: { - id: 'course-authoring.studio-home.archived.tab.title', - defaultMessage: 'Archived courses', - }, defaultSection_1_Title: { id: 'course-authoring.studio-home.default-section-1.title', defaultMessage: 'Are you staff on an existing {studioShortName} course?', diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx index d8abe0e1a5..cbb29c40b4 100644 --- a/src/studio-home/tabs-section/TabsSection.test.jsx +++ b/src/studio-home/tabs-section/TabsSection.test.jsx @@ -1,28 +1,39 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { initializeMockApp } from '@edx/frontend-platform'; -import { waitFor, render, fireEvent } from '@testing-library/react'; +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 initializeStore from '../../store'; import { studioHomeMock } from '../__mocks__'; import messages from '../messages'; +import tabMessages from './messages'; import TabsSection from '.'; - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); +import { + initialState, + generateGetStudioHomeDataApiResponse, + generateGetStudioCoursesApiResponse, + generateGetStuioHomeLibrariesApiResponse, +} from '../factories/mockApiResponses'; +import { getApiBaseUrl, getStudioHomeApiUrl } from '../data/api'; +import { executeThunk } from '../../utils'; +import { fetchLibraryData, fetchStudioHomeData } from '../data/thunks'; const { studioShortName } = studioHomeMock; +let axiosMock; let store; +const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`; +const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`; const RootWrapper = () => ( - + ); @@ -37,68 +48,173 @@ describe('', () => { roles: [], }, }); - store = initializeStore(); - useSelector.mockReturnValue(studioHomeMock); - }); - it('should render all tabs correctly', () => { - const { getByText } = render(); - expect(getByText(messages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.archivedTabTitle.defaultMessage)).toBeInTheDocument(); - }); - it('should render specific course details', () => { - const { getByText } = render(); - expect(getByText(studioHomeMock.courses[0].displayName)).toBeVisible(); - expect(getByText( - `${studioHomeMock.courses[0].org} / ${studioHomeMock.courses[0].number} / ${studioHomeMock.courses[0].run}`, - )).toBeVisible(); - }); - it('should switch to Libraries tab and render specific library details', () => { - const { getByText } = render(); - const librariesTab = getByText(messages.librariesTabTitle.defaultMessage); - fireEvent.click(librariesTab); - expect(getByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); - expect(getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible(); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); - it('should switch to Archived tab and render specific archived course details', () => { - const { getByText } = render(); - const archivedTab = getByText(messages.archivedTabTitle.defaultMessage); - fireEvent.click(archivedTab); - expect(getByText(studioHomeMock.archivedCourses[0].displayName)).toBeVisible(); - expect(getByText( - `${studioHomeMock.archivedCourses[0].org} / ${studioHomeMock.archivedCourses[0].number} / ${studioHomeMock.archivedCourses[0].run}`, - )).toBeVisible(); - }); - it('should hide Libraries tab when libraries are disabled', () => { - studioHomeMock.librariesEnabled = false; - const { queryByText, getByText } = render(); - expect(getByText(messages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.librariesTabTitle.defaultMessage)).toBeNull(); - expect(getByText(messages.archivedTabTitle.defaultMessage)).toBeInTheDocument(); + + it('should render all tabs correctly', async () => { + const data = generateGetStudioHomeDataApiResponse(); + data.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', + }]; + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(tabMessages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByText(tabMessages.archivedTabTitle.defaultMessage)).toBeInTheDocument(); }); - it('should hide Archived tab when archived courses are empty', () => { - studioHomeMock.librariesEnabled = true; - studioHomeMock.archivedCourses = []; - const { queryByText, getByText } = render(); - expect(getByText(messages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.archivedTabTitle.defaultMessage)).toBeNull(); + + describe('course tab', () => { + it('should render specific course details', async () => { + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(studioHomeMock.courses[0].displayName)).toBeVisible(); + + expect(screen.getByText( + `${studioHomeMock.courses[0].org} / ${studioHomeMock.courses[0].number} / ${studioHomeMock.courses[0].run}`, + )).toBeVisible(); + }); + + it('should render default sections when courses are empty', async () => { + const data = generateGetStudioCoursesApiResponse(); + data.courses = []; + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(`Are you staff on an existing ${studioShortName} course?`)).toBeInTheDocument(); + + expect(screen.getByText(messages.defaultSection_1_Description.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: messages.defaultSection_2_Title.defaultMessage })).toBeInTheDocument(); + + expect(screen.getByText(messages.defaultSection_2_Description.defaultMessage)).toBeInTheDocument(); + }); + + it('should render course fetch failure alert', async () => { + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(404); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(tabMessages.courseTabErrorMessage.defaultMessage)).toBeVisible(); + }); }); - it('should render default sections when courses are empty', () => { - studioHomeMock.courses = []; - const { getByText, getByRole } = render(); - expect(getByText(`Are you staff on an existing ${studioShortName} course?`)).toBeInTheDocument(); - expect(getByText(messages.defaultSection_1_Description.defaultMessage)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.defaultSection_2_Title.defaultMessage })).toBeInTheDocument(); - expect(getByText(messages.defaultSection_2_Description.defaultMessage)).toBeInTheDocument(); + + describe('archived tab', () => { + it('should switch to Archived tab and render specific archived course details', async () => { + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + const archivedTab = screen.getByText(tabMessages.archivedTabTitle.defaultMessage); + fireEvent.click(archivedTab); + + expect(screen.getByText(studioHomeMock.archivedCourses[0].displayName)).toBeVisible(); + + expect(screen.getByText( + `${studioHomeMock.archivedCourses[0].org} / ${studioHomeMock.archivedCourses[0].number} / ${studioHomeMock.archivedCourses[0].run}`, + )).toBeVisible(); + }); + + it('should hide Archived tab when archived courses are empty', async () => { + const data = generateGetStudioCoursesApiResponse(); + data.archivedCourses = []; + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(tabMessages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); + + expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); + + expect(screen.queryByText(tabMessages.archivedTabTitle.defaultMessage)).toBeNull(); + }); }); - it('should redirect to library authoring mfe', () => { - studioHomeMock.redirectToLibraryAuthoringMfe = true; - const { getByText } = render(); - const librariesTab = getByText(messages.librariesTabTitle.defaultMessage); - fireEvent.click(librariesTab); - waitFor(() => { - expect(window.location.href).toBe(studioHomeMock.libraryAuthoringMfeUrl); + + describe('library tab', () => { + it('should switch to Libraries tab and render specific library details', async () => { + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(libraryApiLink).reply(200, generateGetStuioHomeLibrariesApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + await executeThunk(fetchLibraryData(), store.dispatch); + + const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(librariesTab); + }); + + expect(librariesTab).toHaveClass('active'); + + expect(screen.getByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); + + expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible(); + }); + + it('should hide Libraries tab when libraries are disabled', async () => { + const data = generateGetStudioHomeDataApiResponse(); + data.librariesEnabled = false; + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(tabMessages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(tabMessages.librariesTabTitle.defaultMessage)).toBeNull(); + }); + + it('should redirect to library authoring mfe', async () => { + const data = generateGetStudioHomeDataApiResponse(); + data.redirectToLibraryAuthoringMfe = true; + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); + fireEvent.click(librariesTab); + + waitFor(() => { + expect(window.location.href).toBe(data.libraryAuthoringMfeUrl); + }); + }); + + it('should render libraries fetch failure alert', async () => { + 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.librariesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(librariesTab); + }); + + expect(librariesTab).toHaveClass('active'); + + expect(screen.getByText(tabMessages.librariesTabErrorMessage.defaultMessage)).toBeVisible(); }); }); }); diff --git a/src/studio-home/tabs-section/archived-tab/index.jsx b/src/studio-home/tabs-section/archived-tab/index.jsx index 3611d3a102..7a754bc44b 100644 --- a/src/studio-home/tabs-section/archived-tab/index.jsx +++ b/src/studio-home/tabs-section/archived-tab/index.jsx @@ -1,27 +1,60 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Icon, Row } from '@edx/paragon'; +import { Error } from '@edx/paragon/icons'; +import { LoadingSpinner } from '../../../generic/Loading'; import CardItem from '../../card-item'; import { sortAlphabeticallyArray } from '../utils'; +import AlertMessage from '../../../generic/alert-message'; +import messages from '../messages'; -const ArchivedTab = ({ archivedCoursesData }) => ( -
- {sortAlphabeticallyArray(archivedCoursesData).map(({ - courseKey, displayName, lmsLink, org, rerunLink, number, run, url, - }) => ( - { + if (isLoading) { + return ( + + + + ); + } + return ( + isFailed ? ( + + + {intl.formatMessage(messages.archiveTabErrorMessage)} + + )} /> - ))} -
-); + ) : ( +
+ {sortAlphabeticallyArray(archivedCoursesData).map(({ + courseKey, displayName, lmsLink, org, rerunLink, number, run, url, + }) => ( + + ))} +
+ ) + ); +}; ArchivedTab.propTypes = { archivedCoursesData: PropTypes.arrayOf( @@ -36,6 +69,10 @@ ArchivedTab.propTypes = { url: PropTypes.string.isRequired, }), ).isRequired, + isLoading: PropTypes.bool.isRequired, + isFailed: PropTypes.bool.isRequired, + // injected + intl: intlShape.isRequired, }; -export default ArchivedTab; +export default injectIntl(ArchivedTab); diff --git a/src/studio-home/tabs-section/courses-tab/index.jsx b/src/studio-home/tabs-section/courses-tab/index.jsx index 05b2ed1e99..94a4ac5643 100644 --- a/src/studio-home/tabs-section/courses-tab/index.jsx +++ b/src/studio-home/tabs-section/courses-tab/index.jsx @@ -1,6 +1,9 @@ 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 { Error } from '@edx/paragon/icons'; import { COURSE_CREATOR_STATES } from '../../../constants'; import { getStudioHomeData } from '../../data/selectors'; @@ -9,13 +12,19 @@ import CollapsibleStateWithAction from '../../collapsible-state-with-action'; import { sortAlphabeticallyArray } from '../utils'; import ContactAdministrator from './contact-administrator'; import ProcessingCourses from '../../processing-courses'; +import { LoadingSpinner } from '../../../generic/Loading'; +import AlertMessage from '../../../generic/alert-message'; +import messages from '../messages'; const CoursesTab = ({ coursesDataItems, showNewCourseContainer, onClickNewCourse, isShowProcessing, + isLoading, + isFailed, }) => { + const intl = useIntl(); const { courseCreatorStatus, optimizationEnabled, @@ -27,48 +36,68 @@ const CoursesTab = ({ COURSE_CREATOR_STATES.unrequested, ].includes(courseCreatorStatus); + if (isLoading) { + return ( + + + + ); + } + return ( - <> - {isShowProcessing && } - {coursesDataItems?.length ? ( - sortAlphabeticallyArray(coursesDataItems).map( - ({ - courseKey, - displayName, - lmsLink, - org, - rerunLink, - number, - run, - url, - }) => ( - - ), + isFailed ? ( + + + {intl.formatMessage(messages.courseTabErrorMessage)} + + )} + /> + ) : ( + <> + {isShowProcessing && } + {coursesDataItems?.length ? ( + sortAlphabeticallyArray(coursesDataItems).map( + ({ + courseKey, + displayName, + lmsLink, + org, + rerunLink, + number, + run, + url, + }) => ( + + ), + ) + ) : (!optimizationEnabled && ( + ) - ) : (!optimizationEnabled && ( - - ) - )} - {showCollapsible && ( - - )} - + )} + {showCollapsible && ( + + )} + + ) ); }; @@ -88,6 +117,8 @@ CoursesTab.propTypes = { showNewCourseContainer: PropTypes.bool.isRequired, onClickNewCourse: PropTypes.func.isRequired, isShowProcessing: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + isFailed: PropTypes.bool.isRequired, }; export default CoursesTab; diff --git a/src/studio-home/tabs-section/index.jsx b/src/studio-home/tabs-section/index.jsx index 15279afa53..28dc5b3154 100644 --- a/src/studio-home/tabs-section/index.jsx +++ b/src/studio-home/tabs-section/index.jsx @@ -1,30 +1,39 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { Tab, Tabs } from '@edx/paragon'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { getStudioHomeData } from '../data/selectors'; -import messages from '../messages'; +import { getLoadingStatuses, getStudioHomeData } from '../data/selectors'; +import messages from './messages'; import LibrariesTab from './libraries-tab'; import ArchivedTab from './archived-tab'; import CoursesTab from './courses-tab'; +import { RequestStatus } from '../../data/constants'; +import { fetchLibraryData } from '../data/thunks'; const TabsSection = ({ - intl, tabsData, showNewCourseContainer, onClickNewCourse, isShowProcessing, + intl, showNewCourseContainer, onClickNewCourse, isShowProcessing, dispatch, }) => { const TABS_LIST = { courses: 'courses', libraries: 'libraries', archived: 'archived', }; + const [tabKey, setTabKey] = useState(TABS_LIST.courses); const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, + courses, librariesEnabled, libraries, archivedCourses, } = useSelector(getStudioHomeData); const { - activeTab, courses, librariesEnabled, libraries, archivedCourses, - } = tabsData; + courseLoadingStatus, + libraryLoadingStatus, + } = useSelector(getLoadingStatuses); + const isLoadingCourses = courseLoadingStatus === RequestStatus.IN_PROGRESS; + const isFailedCoursesPage = courseLoadingStatus === RequestStatus.FAILED; + const isLoadingLibraries = libraryLoadingStatus === RequestStatus.IN_PROGRESS; + const isFailedLibrariesPage = libraryLoadingStatus === RequestStatus.FAILED; // 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. @@ -41,6 +50,8 @@ const TabsSection = ({ showNewCourseContainer={showNewCourseContainer} onClickNewCourse={onClickNewCourse} isShowProcessing={isShowProcessing} + isLoading={isLoadingCourses} + isFailed={isFailedCoursesPage} /> , ); @@ -52,7 +63,11 @@ const TabsSection = ({ eventKey={TABS_LIST.archived} title={intl.formatMessage(messages.archivedTabTitle)} > - + , ); } @@ -64,25 +79,34 @@ const TabsSection = ({ eventKey={TABS_LIST.libraries} title={intl.formatMessage(messages.librariesTabTitle)} > - {!redirectToLibraryAuthoringMfe && } + {!redirectToLibraryAuthoringMfe && ( + + )} , ); } return tabs; - }, [archivedCourses, librariesEnabled, showNewCourseContainer]); + }, [archivedCourses, librariesEnabled, showNewCourseContainer, isLoadingCourses, isLoadingLibraries]); const handleSelectTab = (tab) => { if (tab === TABS_LIST.libraries && redirectToLibraryAuthoringMfe) { window.location.assign(libraryAuthoringMfeUrl); + } else if (tab === TABS_LIST.libraries && !redirectToLibraryAuthoringMfe) { + dispatch(fetchLibraryData()); } + setTabKey(tab); }; return ( {visibleTabs} @@ -90,41 +114,12 @@ const TabsSection = ({ ); }; -const courseDataStructure = { - 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, -}; - TabsSection.propTypes = { intl: intlShape.isRequired, - tabsData: PropTypes.shape({ - activeTab: PropTypes.string.isRequired, - archivedCourses: PropTypes.arrayOf( - PropTypes.shape(courseDataStructure), - ).isRequired, - courses: PropTypes.arrayOf( - PropTypes.shape(courseDataStructure), - ).isRequired, - libraries: PropTypes.arrayOf( - PropTypes.shape({ - displayName: PropTypes.string.isRequired, - libraryKey: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - org: PropTypes.string.isRequired, - number: PropTypes.string.isRequired, - }), - ).isRequired, - librariesEnabled: PropTypes.bool.isRequired, - }).isRequired, showNewCourseContainer: PropTypes.bool.isRequired, onClickNewCourse: PropTypes.func.isRequired, isShowProcessing: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, }; export default injectIntl(TabsSection); diff --git a/src/studio-home/tabs-section/libraries-tab/index.jsx b/src/studio-home/tabs-section/libraries-tab/index.jsx index e2f17c6fe6..b4d260a37f 100644 --- a/src/studio-home/tabs-section/libraries-tab/index.jsx +++ b/src/studio-home/tabs-section/libraries-tab/index.jsx @@ -1,26 +1,58 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Icon, Row } from '@edx/paragon'; +import { Error } from '@edx/paragon/icons'; +import { LoadingSpinner } from '../../../generic/Loading'; import CardItem from '../../card-item'; import { sortAlphabeticallyArray } from '../utils'; +import AlertMessage from '../../../generic/alert-message'; +import messages from '../messages'; -const LibrariesTab = ({ libraries }) => ( -
- {sortAlphabeticallyArray(libraries).map(({ - displayName, org, number, url, - }) => ( - { + if (isLoading) { + return ( + + + + ); + } + return ( + isFailed ? ( + + + {intl.formatMessage(messages.librariesTabErrorMessage)} + + )} /> - ))} -
-); - + ) : ( +
+ {sortAlphabeticallyArray(libraries).map(({ + displayName, org, number, url, + }) => ( + + ))} +
+ ) + ); +}; LibrariesTab.propTypes = { libraries: PropTypes.arrayOf( PropTypes.shape({ @@ -31,6 +63,10 @@ LibrariesTab.propTypes = { url: PropTypes.string.isRequired, }), ).isRequired, + isLoading: PropTypes.bool.isRequired, + isFailed: PropTypes.bool.isRequired, + // injected + intl: intlShape.isRequired, }; -export default LibrariesTab; +export default injectIntl(LibrariesTab); diff --git a/src/studio-home/tabs-section/messages.js b/src/studio-home/tabs-section/messages.js new file mode 100644 index 0000000000..75e945cef8 --- /dev/null +++ b/src/studio-home/tabs-section/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + coursesTabTitle: { + id: 'course-authoring.studio-home.courses.tab.title', + defaultMessage: 'Courses', + }, + courseTabErrorMessage: { + id: 'course-authoring.studio-home.courses.tab.error.message', + defaultMessage: 'Failed to fetch courses. Please try again later.', + }, + librariesTabErrorMessage: { + id: 'course-authoring.studio-home.libraries.tab.error.message', + defaultMessage: 'Failed to fetch libraries. Please try again later.', + }, + librariesTabTitle: { + id: 'course-authoring.studio-home.libraries.tab.title', + defaultMessage: 'Libraries', + }, + archivedTabTitle: { + id: 'course-authoring.studio-home.archived.tab.title', + defaultMessage: 'Archived courses', + }, + archiveTabErrorMessage: { + id: 'course-authoring.studio-home.archived.tab.error.message', + defaultMessage: 'Failed to fetch archived courses. Please try again later.', + }, +}); + +export default messages;