feat: break up load api to tab specific (#740)

This commit is contained in:
Kristin Aoki
2023-12-19 09:10:05 -05:00
committed by GitHub
parent bf46008878
commit 4c7faad987
15 changed files with 864 additions and 372 deletions

View File

@@ -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(
<MailtoLink to={studioRequestEmail}>{intl.formatMessage(messages.emailStaffBtnText)}</MailtoLink>,
@@ -93,6 +102,53 @@ const StudioHome = ({ intl }) => {
return (<Loading />);
}
const getMainBody = () => {
if (isFailedLoadingPage) {
return (
<AlertMessage
variant="danger"
description={(
<Row className="m-0 align-items-center">
<Icon src={Error} className="text-danger-500 mr-1" />
<span>{intl.formatMessage(messages.homePageLoadFailedMessage)}</span>
</Row>
)}
/>
);
}
if (!userIsActive) {
return <VerifyEmailLayout />;
}
return (
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<section>
{showNewCourseContainer && (
<CreateNewCourseForm handleOnClickCancel={() => setShowNewCourseContainer(false)} />
)}
{isShowOrganizationDropdown && <OrganizationSection />}
<TabsSection
tabsData={studioHomeData}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing}
dispatch={dispatch}
/>
</section>
</Layout.Element>
<Layout.Element>
<HomeSidebar />
</Layout.Element>
</Layout>
);
};
return (
<>
<Header isHiddenMainMenu />
@@ -101,40 +157,12 @@ const StudioHome = ({ intl }) => {
<article className="studio-home-sub-header">
<section>
<SubHeader
title={intl.formatMessage(messages.headingTitle, { studioShortName })}
title={intl.formatMessage(messages.headingTitle, { studioShortName: studioShortName || 'Studio' })}
headerActions={headerButtons}
/>
</section>
</article>
{!userIsActive ? (
<VerifyEmailLayout />
) : (
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<section>
{showNewCourseContainer && (
<CreateNewCourseForm handleOnClickCancel={() => setShowNewCourseContainer(false)} />
)}
{isShowOrganizationDropdown && <OrganizationSection />}
<TabsSection
tabsData={studioHomeData}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing}
/>
</section>
</Layout.Element>
<Layout.Element>
<HomeSidebar />
</Layout.Element>
</Layout>
)}
{getMainBody()}
</section>
</Container>
<div className="alert-toast">

View File

@@ -50,174 +50,204 @@ const RootWrapper = () => (
);
describe('<StudioHome />', 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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
expect(getByText(messages.homePageLoadFailedMessage.defaultMessage)).toBeInTheDocument();
});
const { getByRole } = render(<RootWrapper />);
expect(getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage })).toBeInTheDocument();
it('should render Studio home title', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Studio home')).toBeInTheDocument();
});
});
it('should show verify email layout if user inactive', () => {
useSelector.mockReturnValue({
...studioHomeMock,
userIsActive: false,
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);
});
const { getByText } = render(<RootWrapper />);
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,
it('should render page and page title correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(`${studioShortName} home`)).toBeInTheDocument();
});
await act(async () => {
it('should render email staff header button', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.disallowedForThisSite,
});
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
expect(getByRole('link', { name: messages.emailStaffBtnText.defaultMessage }))
.toHaveAttribute('href', `mailto:${studioRequestEmail}`);
});
});
describe('render new library button', () => {
it('href should include home_library', async () => {
it('should render create new course button', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
});
const studioBaseUrl = 'http://localhost:18010';
const { getByTestId } = render(<RootWrapper />);
const createNewLibraryButton = getByTestId('new-library-button');
expect(createNewLibraryButton.getAttribute('href')).toBe(`${studioBaseUrl}/home_library`);
const { getByRole } = render(<RootWrapper />);
expect(getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage })).toBeInTheDocument();
});
it('href should include create', async () => {
it('should show verify email layout if user inactive', () => {
useSelector.mockReturnValue({
...studioHomeMock,
userIsActive: false,
});
const { getByText } = render(<RootWrapper />);
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,
});
await act(async () => {
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
});
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(<RootWrapper />);
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(<RootWrapper />);
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,
splitStudioHome: true,
redirectToLibraryAuthoringMfe: true,
});
const libraryAuthoringMfeUrl = 'http://localhost:3001';
const { getByTestId } = render(<RootWrapper />);
const createNewLibraryButton = getByTestId('new-library-button');
expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`);
});
});
const { getByRole, getByText } = render(<RootWrapper />);
const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage });
it('should render create new course container', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
});
const { getByRole, getByText } = render(<RootWrapper />);
const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage });
await act(() => fireEvent.click(createNewCourseButton));
expect(getByText(createNewCourseMessages.createNewCourse.defaultMessage)).toBeInTheDocument();
});
it('should hide create new course container', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
});
const { getByRole, queryByText, getByText } = render(<RootWrapper />);
const createNewCourseButton = getByRole('button', { name: messages.addNewCourseBtnText.defaultMessage });
fireEvent.click(createNewCourseButton);
waitFor(() => {
await act(() => fireEvent.click(createNewCourseButton));
expect(getByText(createNewCourseMessages.createNewCourse.defaultMessage)).toBeInTheDocument();
});
const cancelButton = getByRole('button', { name: createOrRerunCourseMessages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
waitFor(() => {
expect(queryByText(createNewCourseMessages.createNewCourse.defaultMessage)).not.toBeInTheDocument();
});
});
describe('contact administrator card', () => {
it('should show contact administrator card with no add course buttons', () => {
it('should hide create new course container', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courses: null,
courseCreatorStatus: COURSE_CREATOR_STATES.pending,
});
const { getByText, queryByText } = render(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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();
});
});
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
expect(getByText('Looking for help with Studio?')).toBeInTheDocument();
expect(getByText('LMS')).toHaveAttribute('href', process.env.LMS_BASE_URL);
});
});
it('should show footer', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Looking for help with Studio?')).toBeInTheDocument();
expect(getByText('LMS')).toHaveAttribute('href', process.env.LMS_BASE_URL);
});
});

View File

@@ -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<Object>}
*/
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);
}

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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,
};

View File

@@ -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',
}],
});

View File

@@ -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,
};

View File

@@ -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?',

View File

@@ -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 = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TabsSection intl={{ formatMessage: jest.fn() }} tabsData={studioHomeMock} />
<TabsSection intl={{ formatMessage: jest.fn() }} dispatch={jest.fn()} />
</IntlProvider>
</AppProvider>
);
@@ -37,68 +48,173 @@ describe('<TabsSection />', () => {
roles: [],
},
});
store = initializeStore();
useSelector.mockReturnValue(studioHomeMock);
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('should render all tabs correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.coursesTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.librariesTabTitle.defaultMessage)).toBeInTheDocument();
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(<RootWrapper />);
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 render specific course details', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(studioHomeMock.courses[0].displayName)).toBeVisible();
expect(getByText(
`${studioHomeMock.courses[0].org} / ${studioHomeMock.courses[0].number} / ${studioHomeMock.courses[0].run}`,
)).toBeVisible();
describe('course tab', () => {
it('should render specific course details', async () => {
render(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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 switch to Libraries tab and render specific library details', () => {
const { getByText } = render(<RootWrapper />);
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();
describe('archived tab', () => {
it('should switch to Archived tab and render specific archived course details', async () => {
render(<RootWrapper />);
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(<RootWrapper />);
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 switch to Archived tab and render specific archived course details', () => {
const { getByText } = render(<RootWrapper />);
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(<RootWrapper />);
expect(getByText(messages.coursesTabTitle.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.librariesTabTitle.defaultMessage)).toBeNull();
expect(getByText(messages.archivedTabTitle.defaultMessage)).toBeInTheDocument();
});
it('should hide Archived tab when archived courses are empty', () => {
studioHomeMock.librariesEnabled = true;
studioHomeMock.archivedCourses = [];
const { queryByText, getByText } = render(<RootWrapper />);
expect(getByText(messages.coursesTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.librariesTabTitle.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.archivedTabTitle.defaultMessage)).toBeNull();
});
it('should render default sections when courses are empty', () => {
studioHomeMock.courses = [];
const { getByText, getByRole } = render(<RootWrapper />);
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();
});
it('should redirect to library authoring mfe', () => {
studioHomeMock.redirectToLibraryAuthoringMfe = true;
const { getByText } = render(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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();
});
});
});

View File

@@ -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 }) => (
<div className="courses-tab">
{sortAlphabeticallyArray(archivedCoursesData).map(({
courseKey, displayName, lmsLink, org, rerunLink, number, run, url,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
const ArchivedTab = ({
archivedCoursesData,
isLoading,
isFailed,
// injected
intl,
}) => {
if (isLoading) {
return (
<Row className="m-0 mt-4 justify-content-center">
<LoadingSpinner />
</Row>
);
}
return (
isFailed ? (
<AlertMessage
variant="danger"
description={(
<Row className="m-0 align-items-center">
<Icon src={Error} className="text-danger-500 mr-1" />
<span>{intl.formatMessage(messages.archiveTabErrorMessage)}</span>
</Row>
)}
/>
))}
</div>
);
) : (
<div className="courses-tab">
{sortAlphabeticallyArray(archivedCoursesData).map(({
courseKey, displayName, lmsLink, org, rerunLink, number, run, url,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
/>
))}
</div>
)
);
};
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);

View File

@@ -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 (
<Row className="m-0 mt-4 justify-content-center">
<LoadingSpinner />
</Row>
);
}
return (
<>
{isShowProcessing && <ProcessingCourses />}
{coursesDataItems?.length ? (
sortAlphabeticallyArray(coursesDataItems).map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
/>
),
isFailed ? (
<AlertMessage
variant="danger"
description={(
<Row className="m-0 align-items-center">
<Icon src={Error} className="text-danger-500 mr-1" />
<span>{intl.formatMessage(messages.courseTabErrorMessage)}</span>
</Row>
)}
/>
) : (
<>
{isShowProcessing && <ProcessingCourses />}
{coursesDataItems?.length ? (
sortAlphabeticallyArray(coursesDataItems).map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
/>
),
)
) : (!optimizationEnabled && (
<ContactAdministrator
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
/>
)
) : (!optimizationEnabled && (
<ContactAdministrator
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
/>
)
)}
{showCollapsible && (
<CollapsibleStateWithAction
state={courseCreatorStatus}
className="mt-3"
/>
)}
</>
)}
{showCollapsible && (
<CollapsibleStateWithAction
state={courseCreatorStatus}
className="mt-3"
/>
)}
</>
)
);
};
@@ -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;

View File

@@ -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}
/>
</Tab>,
);
@@ -52,7 +63,11 @@ const TabsSection = ({
eventKey={TABS_LIST.archived}
title={intl.formatMessage(messages.archivedTabTitle)}
>
<ArchivedTab archivedCoursesData={archivedCourses} />
<ArchivedTab
archivedCoursesData={archivedCourses}
isLoading={isLoadingCourses}
isFailed={isFailedCoursesPage}
/>
</Tab>,
);
}
@@ -64,25 +79,34 @@ const TabsSection = ({
eventKey={TABS_LIST.libraries}
title={intl.formatMessage(messages.librariesTabTitle)}
>
{!redirectToLibraryAuthoringMfe && <LibrariesTab libraries={libraries} />}
{!redirectToLibraryAuthoringMfe && (
<LibrariesTab
libraries={libraries}
isLoading={isLoadingLibraries}
isFailed={isFailedLibrariesPage}
/>
)}
</Tab>,
);
}
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 (
<Tabs
className="studio-home-tabs"
variant="tabs"
defaultActiveKey={activeTab}
activeKey={tabKey}
onSelect={handleSelectTab}
>
{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);

View File

@@ -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 }) => (
<div className="courses-tab">
{sortAlphabeticallyArray(libraries).map(({
displayName, org, number, url,
}) => (
<CardItem
key={number}
isLibraries
displayName={displayName}
org={org}
number={number}
url={url}
const LibrariesTab = ({
libraries,
isLoading,
isFailed,
// injected
intl,
}) => {
if (isLoading) {
return (
<Row className="m-0 mt-4 justify-content-center">
<LoadingSpinner />
</Row>
);
}
return (
isFailed ? (
<AlertMessage
variant="danger"
description={(
<Row className="m-0 align-items-center">
<Icon src={Error} className="text-danger-500 mr-1" />
<span>{intl.formatMessage(messages.librariesTabErrorMessage)}</span>
</Row>
)}
/>
))}
</div>
);
) : (
<div className="courses-tab">
{sortAlphabeticallyArray(libraries).map(({
displayName, org, number, url,
}) => (
<CardItem
key={`${org}+${number}`}
isLibraries
displayName={displayName}
org={org}
number={number}
url={url}
/>
))}
</div>
)
);
};
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);

View File

@@ -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;