}
+ */
+export async function sendRequestForCourseCreator() {
+ const { data } = await getAuthenticatedHttpClient().post(getRequestCourseCreatorUrl());
+ return camelCaseObject(data);
+}
diff --git a/src/studio-home/data/api.test.js b/src/studio-home/data/api.test.js
new file mode 100644
index 0000000000..f608ed906e
--- /dev/null
+++ b/src/studio-home/data/api.test.js
@@ -0,0 +1,60 @@
+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,
+ getCourseNotificationUrl,
+ getStudioHomeData,
+ handleCourseNotification,
+ sendRequestForCourseCreator,
+} from './api';
+
+let axiosMock;
+
+describe('studio-home api calls', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should get studio home data', async () => {
+ axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
+ const result = await getStudioHomeData();
+
+ expect(axiosMock.history.get[0].url).toEqual(getStudioHomeApiUrl());
+ expect(result).toEqual(studioHomeMock);
+ });
+
+ it('should handle course notification request', async () => {
+ const dismissLink = 'to://dismiss-link';
+ const successResponse = { status: 'OK' };
+ axiosMock.onDelete(getCourseNotificationUrl(dismissLink)).reply(200, successResponse);
+ const result = await handleCourseNotification(dismissLink);
+
+ expect(axiosMock.history.delete[0].url).toEqual(getCourseNotificationUrl(dismissLink));
+ expect(result).toEqual(successResponse);
+ });
+
+ it('should send request to course creating access', async () => {
+ const successResponse = { status: 'OK' };
+ axiosMock.onPost(getRequestCourseCreatorUrl()).reply(200, successResponse);
+ const result = await sendRequestForCourseCreator();
+
+ expect(axiosMock.history.post[0].url).toEqual(getRequestCourseCreatorUrl());
+ expect(result).toEqual(successResponse);
+ });
+});
diff --git a/src/studio-home/data/selectors.js b/src/studio-home/data/selectors.js
new file mode 100644
index 0000000000..62957e58af
--- /dev/null
+++ b/src/studio-home/data/selectors.js
@@ -0,0 +1,3 @@
+export const getStudioHomeData = state => state.studioHome.studioHomeData;
+export const getLoadingStatuses = (state) => state.studioHome.loadingStatuses;
+export const getSavingStatuses = (state) => state.studioHome.savingStatuses;
diff --git a/src/studio-home/data/slice.js b/src/studio-home/data/slice.js
new file mode 100644
index 0000000000..0b35c7ede9
--- /dev/null
+++ b/src/studio-home/data/slice.js
@@ -0,0 +1,40 @@
+/* eslint-disable no-param-reassign */
+import { createSlice } from '@reduxjs/toolkit';
+
+import { RequestStatus } from '../../data/constants';
+
+const slice = createSlice({
+ name: 'studioHome',
+ initialState: {
+ loadingStatuses: {
+ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS,
+ courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS,
+ },
+ savingStatuses: {
+ courseCreatorSavingStatus: '',
+ deleteNotificationSavingStatus: '',
+ },
+ studioHomeData: {},
+ },
+ reducers: {
+ updateLoadingStatuses: (state, { payload }) => {
+ state.loadingStatuses = { ...state.loadingStatuses, ...payload };
+ },
+ updateSavingStatuses: (state, { payload }) => {
+ state.savingStatuses = { ...state.savingStatuses, ...payload };
+ },
+ fetchStudioHomeDataSuccess: (state, { payload }) => {
+ Object.assign(state.studioHomeData, payload);
+ },
+ },
+});
+
+export const {
+ updateSavingStatuses,
+ updateLoadingStatuses,
+ fetchStudioHomeDataSuccess,
+} = slice.actions;
+
+export const {
+ reducer,
+} = slice;
diff --git a/src/studio-home/data/thunks.js b/src/studio-home/data/thunks.js
new file mode 100644
index 0000000000..95815b7d7f
--- /dev/null
+++ b/src/studio-home/data/thunks.js
@@ -0,0 +1,55 @@
+import { RequestStatus } from '../../data/constants';
+import { getStudioHomeData, sendRequestForCourseCreator, handleCourseNotification } from './api';
+import {
+ fetchStudioHomeDataSuccess,
+ updateLoadingStatuses,
+ updateSavingStatuses,
+} from './slice';
+
+function fetchStudioHomeData(search) {
+ return async (dispatch) => {
+ dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS }));
+
+ try {
+ const studioHomeData = await getStudioHomeData(search || '');
+ dispatch(fetchStudioHomeDataSuccess(studioHomeData));
+ dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.FAILED }));
+ }
+ };
+}
+
+function handleDeleteNotificationQuery(url) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ deleteNotificationSavingStatus: RequestStatus.PENDING }));
+
+ try {
+ await handleCourseNotification(url);
+ dispatch(updateSavingStatuses({ deleteNotificationSavingStatus: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateSavingStatuses({ deleteNotificationSavingStatus: RequestStatus.FAILED }));
+ }
+ };
+}
+
+function requestCourseCreatorQuery() {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ courseCreatorSavingStatus: RequestStatus.PENDING }));
+
+ try {
+ await sendRequestForCourseCreator();
+ dispatch(updateSavingStatuses({ courseCreatorSavingStatus: RequestStatus.SUCCESSFUL }));
+ return true;
+ } catch (error) {
+ dispatch(updateSavingStatuses({ courseCreatorSavingStatus: RequestStatus.FAILED }));
+ return false;
+ }
+ };
+}
+
+export {
+ fetchStudioHomeData,
+ requestCourseCreatorQuery,
+ handleDeleteNotificationQuery,
+};
diff --git a/src/studio-home/home-sidebar/HomeSidebar.test.jsx b/src/studio-home/home-sidebar/HomeSidebar.test.jsx
new file mode 100644
index 0000000000..38a506dd58
--- /dev/null
+++ b/src/studio-home/home-sidebar/HomeSidebar.test.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import { COURSE_CREATOR_STATES } from '../../constants';
+import initializeStore from '../../store';
+import { studioHomeMock } from '../__mocks__';
+import HomeSidebar from '.';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+let store;
+const {
+ studioName,
+ studioShortName,
+} = studioHomeMock;
+
+const RootWrapper = () => (
+
+
+
+
+
+);
+
+describe(' ', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ });
+
+ it('renders about and other sidebar titles correctly', () => {
+ useSelector.mockReturnValue(studioHomeMock);
+
+ const { getByText } = render( );
+ expect(getByText(`New to ${studioName}?`)).toBeInTheDocument();
+ expect(getByText(`Click "Looking for help with Studio" at the bottom of the page to access our continually updated documentation and other ${studioShortName} resources.`)).toBeInTheDocument();
+ });
+
+ it('shows mail to get instruction', () => {
+ const studioHomeInitial = {
+ ...studioHomeMock,
+ courseCreatorStatus: COURSE_CREATOR_STATES.disallowedForThisSite,
+ studioRequestEmail: 'mock@example.com',
+ };
+ useSelector.mockReturnValue(studioHomeInitial);
+
+ const { getByText } = render( );
+ expect(getByText(`Can I create courses in ${studioName}?`)).toBeInTheDocument();
+ expect(getByText(`In order to create courses in ${studioName}, you must`)).toBeInTheDocument();
+ });
+
+ it('shows unrequested instructions', () => {
+ const studioHomeInitial = {
+ ...studioHomeMock,
+ courseCreatorStatus: COURSE_CREATOR_STATES.unrequested,
+ };
+ useSelector.mockReturnValue(studioHomeInitial);
+
+ const { getByText } = render( );
+ expect(getByText(`Can I create courses in ${studioName}?`)).toBeInTheDocument();
+ expect(getByText(`In order to create courses in ${studioName}, you must have course creator privileges to create your own course.`)).toBeInTheDocument();
+ });
+
+ it('shows denied instructions', () => {
+ const studioHomeInitial = {
+ ...studioHomeMock,
+ courseCreatorStatus: COURSE_CREATOR_STATES.denied,
+ };
+ useSelector.mockReturnValue(studioHomeInitial);
+
+ const { getByText } = render( );
+ expect(getByText(`Can I create courses in ${studioName}?`)).toBeInTheDocument();
+ expect(getByText(`Your request to author courses in ${studioName} has been denied.`, { exact: false })).toBeInTheDocument();
+ });
+});
diff --git a/src/studio-home/home-sidebar/index.jsx b/src/studio-home/home-sidebar/index.jsx
new file mode 100644
index 0000000000..af470b92c7
--- /dev/null
+++ b/src/studio-home/home-sidebar/index.jsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { MailtoLink } from '@edx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { COURSE_CREATOR_STATES } from '../../constants';
+import { useHelpUrls } from '../../help-urls/hooks';
+import { HelpSidebar, HelpSidebarLink } from '../../generic/help-sidebar';
+import { getStudioHomeData } from '../data/selectors';
+import messages from './messages';
+
+const HomeSidebar = () => {
+ const intl = useIntl();
+ const {
+ studioName,
+ platformName,
+ studioShortName,
+ studioRequestEmail,
+ techSupportEmail,
+ courseCreatorStatus,
+ } = useSelector(getStudioHomeData);
+ const { home: aboutHomeLink } = useHelpUrls(['home']);
+
+ // eslint-disable-next-line max-len
+ const isShowMailToGetInstruction = courseCreatorStatus === COURSE_CREATOR_STATES.disallowedForThisSite
+ && !!studioRequestEmail;
+ const isShowUnrequestedInstruction = courseCreatorStatus === COURSE_CREATOR_STATES.unrequested;
+ const isShowDeniedInstruction = courseCreatorStatus === COURSE_CREATOR_STATES.denied;
+
+ return (
+
+
+ {intl.formatMessage(messages.aboutTitle, { studioName })}
+
+
+ {intl.formatMessage(messages.aboutDescription, { studioShortName })}
+
+
+ {isShowMailToGetInstruction && (
+ <>
+
+
+ {intl.formatMessage(messages.sidebarHeader2, { studioName })}
+
+
+ {intl.formatMessage(messages.sidebarDescription2, {
+ studioName,
+ mailTo: (
+ {
+ intl.formatMessage(messages.sidebarDescription2MailTo, { platformName })
+ }
+
+ ),
+ })}
+
+ >
+ )}
+ {isShowUnrequestedInstruction && (
+ <>
+
+
+ {intl.formatMessage(messages.sidebarHeader3, { studioName })}
+
+
+ {intl.formatMessage(messages.sidebarDescription3, { studioName })}
+
+ >
+ )}
+ {isShowDeniedInstruction && (
+ <>
+
+
+ {intl.formatMessage(messages.sidebarHeader4, { studioName })}
+
+
+ {intl.formatMessage(messages.sidebarDescription4, {
+ studioName,
+ mailTo: (
+ {
+ intl.formatMessage(messages.sidebarDescription4MailTo, { platformName })
+ }
+
+ ),
+ })}
+
+ >
+ )}
+
+ );
+};
+
+export default HomeSidebar;
diff --git a/src/studio-home/home-sidebar/messages.js b/src/studio-home/home-sidebar/messages.js
new file mode 100644
index 0000000000..161ea021a1
--- /dev/null
+++ b/src/studio-home/home-sidebar/messages.js
@@ -0,0 +1,50 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ aboutTitle: {
+ id: 'course-authoring.studio-home.sidebar.about.title',
+ defaultMessage: 'New to {studioName}?',
+ },
+ aboutDescription: {
+ id: 'course-authoring.studio-home.sidebar.about.description',
+ defaultMessage: 'Click "Looking for help with Studio" at the bottom of the page to access our continually updated documentation and other {studioShortName} resources.',
+ },
+ studioHomeLinkToGettingStarted: {
+ id: 'course-authoring.studio-home.sidebar.about.getting-started',
+ defaultMessage: 'Getting started with {studioName}',
+ },
+ sidebarHeader2: {
+ id: 'course-authoring.studio-home.sidebar.about.header-2',
+ defaultMessage: 'Can I create courses in {studioName}?',
+ },
+ sidebarDescription2: {
+ id: 'course-authoring.studio-home.sidebar.about.description-2',
+ defaultMessage: 'In order to create courses in {studioName}, you must {mailTo}',
+ },
+ sidebarDescription2MailTo: {
+ id: 'course-authoring.studio-home.sidebar.about.description-2.mail-to',
+ defaultMessage: 'contact {platformName} staff to help you create a course.',
+ },
+ sidebarHeader3: {
+ id: 'course-authoring.studio-home.sidebar.about.header-3',
+ defaultMessage: 'Can I create courses in {studioName}?',
+ },
+ sidebarDescription3: {
+ id: 'course-authoring.studio-home.sidebar.about.description-3',
+ defaultMessage: 'In order to create courses in {studioName}, you must have course creator privileges to create your own course.',
+ },
+ sidebarHeader4: {
+ id: 'course-authoring.studio-home.sidebar.about.header-4',
+ defaultMessage: 'Can I create courses in {studioName}?',
+ },
+ sidebarDescription4: {
+ id: 'course-authoring.studio-home.sidebar.about.description-4',
+ defaultMessage: 'Your request to author courses in {studioName} has been denied. Please {mailTo}.',
+ },
+ sidebarDescription4MailTo: {
+ id: 'course-authoring.studio-home.sidebar.about.description-4.mail-to',
+ defaultMessage: 'contact {platformName} staff with further questions',
+ },
+});
+
+export default messages;
diff --git a/src/studio-home/hooks.jsx b/src/studio-home/hooks.jsx
new file mode 100644
index 0000000000..c351d26ae1
--- /dev/null
+++ b/src/studio-home/hooks.jsx
@@ -0,0 +1,88 @@
+import { useEffect, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { RequestStatus } from '../data/constants';
+import { COURSE_CREATOR_STATES } from '../constants';
+import { getCourseData, getSavingStatus } from '../generic/data/selectors';
+import { fetchStudioHomeData } from './data/thunks';
+import {
+ getLoadingStatuses,
+ getSavingStatuses,
+ getStudioHomeData,
+} from './data/selectors';
+import { updateSavingStatuses } from './data/slice';
+
+const useStudioHome = () => {
+ const location = useLocation();
+ const dispatch = useDispatch();
+ const studioHomeData = useSelector(getStudioHomeData);
+ const newCourseData = useSelector(getCourseData);
+ const { studioHomeLoadingStatus } = useSelector(getLoadingStatuses);
+ const savingCreateRerunStatus = useSelector(getSavingStatus);
+ const {
+ courseCreatorSavingStatus,
+ deleteNotificationSavingStatus,
+ } = useSelector(getSavingStatuses);
+ const [showNewCourseContainer, setShowNewCourseContainer] = useState(false);
+ const isLoadingPage = studioHomeLoadingStatus === RequestStatus.IN_PROGRESS;
+
+ useEffect(() => {
+ dispatch(fetchStudioHomeData(location.search ?? ''));
+ setShowNewCourseContainer(false);
+ }, [location.search]);
+
+ useEffect(() => {
+ if (courseCreatorSavingStatus === RequestStatus.SUCCESSFUL) {
+ dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' }));
+ dispatch(fetchStudioHomeData());
+ }
+ }, [courseCreatorSavingStatus]);
+
+ useEffect(() => {
+ if (deleteNotificationSavingStatus === RequestStatus.SUCCESSFUL) {
+ dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' }));
+ dispatch(fetchStudioHomeData());
+ } else if (deleteNotificationSavingStatus === RequestStatus.FAILED) {
+ dispatch(updateSavingStatuses({ deleteNotificationSavingStatus: '' }));
+ }
+ }, [deleteNotificationSavingStatus]);
+
+ const {
+ allowCourseReruns,
+ rerunCreatorStatus,
+ optimizationEnabled,
+ studioRequestEmail,
+ inProcessCourseActions,
+ courseCreatorStatus,
+ } = studioHomeData;
+
+ const isShowOrganizationDropdown = optimizationEnabled && courseCreatorStatus === COURSE_CREATOR_STATES.granted;
+ const isShowEmailStaff = courseCreatorStatus === COURSE_CREATOR_STATES.disallowedForThisSite && !!studioRequestEmail;
+ const isShowProcessing = allowCourseReruns && rerunCreatorStatus && inProcessCourseActions.length > 0;
+ const hasAbilityToCreateNewCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted;
+ const anyQueryIsPending = [deleteNotificationSavingStatus, courseCreatorSavingStatus, savingCreateRerunStatus]
+ .includes(RequestStatus.PENDING);
+ const anyQueryIsFailed = [deleteNotificationSavingStatus, courseCreatorSavingStatus, savingCreateRerunStatus]
+ .includes(RequestStatus.FAILED);
+
+ return {
+ isLoadingPage,
+ newCourseData,
+ studioHomeData,
+ isShowProcessing,
+ anyQueryIsFailed,
+ isShowEmailStaff,
+ anyQueryIsPending,
+ showNewCourseContainer,
+ courseCreatorSavingStatus,
+ isShowOrganizationDropdown,
+ hasAbilityToCreateNewCourse,
+ deleteNotificationSavingStatus,
+ dispatch,
+ setShowNewCourseContainer,
+ };
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export { useStudioHome };
diff --git a/src/studio-home/index.js b/src/studio-home/index.js
new file mode 100644
index 0000000000..9e496c5cff
--- /dev/null
+++ b/src/studio-home/index.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as StudioHome } from './StudioHome';
diff --git a/src/studio-home/messages.js b/src/studio-home/messages.js
new file mode 100644
index 0000000000..d39ba522c0
--- /dev/null
+++ b/src/studio-home/messages.js
@@ -0,0 +1,78 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ headingTitle: {
+ id: 'course-authoring.studio-home.heading.title',
+ defaultMessage: '{studioShortName} home',
+ },
+ addNewCourseBtnText: {
+ id: 'course-authoring.studio-home.add-new-course.btn.text',
+ defaultMessage: 'New course',
+ },
+ 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?',
+ },
+ defaultSection_1_Description: {
+ id: 'course-authoring.studio-home.default-section-1.description',
+ defaultMessage: 'The course creator must give you access to the course. Contact the course creator or administrator for the course you are helping to author.',
+ },
+ defaultSection_2_Title: {
+ id: 'course-authoring.studio-home.default-section-2.title',
+ defaultMessage: 'Create your first course',
+ },
+ defaultSection_2_Description: {
+ id: 'course-authoring.studio-home.default-section-2.description',
+ defaultMessage: 'Your new course is just a click away!',
+ },
+ btnAddNewCourseText: {
+ id: 'course-authoring.studio-home.btn.add-new-course.text',
+ defaultMessage: 'Create your first course',
+ },
+ btnReRunText: {
+ id: 'course-authoring.studio-home.btn.re-run.text',
+ defaultMessage: 'Re-run course',
+ },
+ viewLiveBtnText: {
+ id: 'course-authoring.studio-home.btn.view-live.text',
+ defaultMessage: 'View live',
+ },
+ organizationTitle: {
+ id: 'course-authoring.studio-home.organization.title',
+ defaultMessage: 'Organization and library settings',
+ },
+ organizationLabel: {
+ id: 'course-authoring.studio-home.organization.label',
+ defaultMessage: 'Show all courses in organization:',
+ },
+ organizationSubmitBtnText: {
+ id: 'course-authoring.studio-home.organization.btn.submit.text',
+ defaultMessage: 'Submit',
+ },
+ organizationInputPlaceholder: {
+ id: 'course-authoring.studio-home.organization.input.placeholder',
+ defaultMessage: 'For example, MITx',
+ },
+ organizationInputNoOptions: {
+ id: 'course-authoring.studio-home.organization.input.no-options',
+ defaultMessage: 'No options',
+ },
+});
+
+export default messages;
diff --git a/src/studio-home/organization-section/OrganizationSection.test.jsx b/src/studio-home/organization-section/OrganizationSection.test.jsx
new file mode 100644
index 0000000000..9589335e52
--- /dev/null
+++ b/src/studio-home/organization-section/OrganizationSection.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { act, fireEvent, render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { history, initializeMockApp } from '@edx/frontend-platform';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import { fetchOrganizationsQuery } from '../../generic/data/thunks';
+import { getOrganizationsUrl } from '../../generic/data/api';
+import initializeStore from '../../store';
+import { executeThunk } from '../../utils';
+import messages from '../messages';
+import OrganizationSection from '.';
+
+let store;
+let axiosMock;
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+const RootWrapper = () => (
+
+
+
+
+
+);
+
+describe(' ', async () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onDelete(getOrganizationsUrl).reply(200);
+ await executeThunk(fetchOrganizationsQuery(), store.dispatch);
+ useSelector.mockReturnValue(['edX', 'org']);
+ });
+
+ it('renders text content correctly', () => {
+ const { getByText } = render( );
+ expect(getByText(messages.organizationTitle.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.organizationLabel.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.organizationSubmitBtnText.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('should change path after selecting org', () => {
+ const selectedOrgStr = 'edX';
+ const {
+ getByPlaceholderText,
+ getByRole,
+ getByText,
+ getByDisplayValue,
+ } = render( );
+
+ const orgInput = getByPlaceholderText(messages.organizationInputPlaceholder.defaultMessage);
+ act(() => {
+ fireEvent.click(orgInput);
+ });
+
+ const selectedOrg = getByText(selectedOrgStr);
+ act(() => {
+ fireEvent.click(selectedOrg);
+ });
+
+ const submitButton = getByRole('button', { name: messages.organizationSubmitBtnText.defaultMessage });
+ act(() => {
+ fireEvent.click(submitButton);
+ });
+
+ expect(history.location.pathname).toBe('/home');
+ expect(history.location.search).toBe(`?org=${selectedOrgStr}`);
+ expect(getByDisplayValue(selectedOrgStr)).toBeInTheDocument();
+ });
+});
diff --git a/src/studio-home/organization-section/index.jsx b/src/studio-home/organization-section/index.jsx
new file mode 100644
index 0000000000..c6711efe37
--- /dev/null
+++ b/src/studio-home/organization-section/index.jsx
@@ -0,0 +1,77 @@
+import React, { useState, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+import { isEmpty } from 'lodash';
+import { history } from '@edx/frontend-platform';
+import { Button, Form, FormLabel } from '@edx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { TypeaheadDropdown } from '@edx/frontend-lib-content-components';
+
+import { getOrganizations } from '../../generic/data/selectors';
+import { fetchOrganizationsQuery } from '../../generic/data/thunks';
+import messages from '../messages';
+
+const OrganizationSection = ({ intl }) => {
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const fieldName = 'org';
+ const searchParams = new URLSearchParams(location.search);
+ const orgURLValue = searchParams.get(fieldName) || '';
+ const [inputValue, setInputValue] = useState('');
+ const organizations = useSelector(getOrganizations);
+
+ useEffect(() => {
+ if (isEmpty(organizations)) {
+ dispatch(fetchOrganizationsQuery());
+ }
+ }, []);
+
+ // We have to set value only after the list of organizations to be received to display the initial state correctly.
+ useEffect(() => {
+ if (organizations.length) {
+ setInputValue(orgURLValue);
+ }
+ }, [orgURLValue, organizations]);
+
+ const handleSubmit = () => {
+ history.push({
+ pathname: '/home',
+ search: `?${fieldName}=${inputValue}`,
+ });
+ };
+
+ return (
+
+
+ {intl.formatMessage(messages.organizationTitle)}
+
+
+
+ {intl.formatMessage(messages.organizationLabel)}
+
+ setInputValue(e.target.value)}
+ handleChange={(value) => setInputValue(value)}
+ noOptionsMessage={intl.formatMessage(messages.organizationInputNoOptions)}
+ helpMessage=""
+ errorMessage=""
+ floatingLabel=""
+ />
+
+
+ {intl.formatMessage(messages.organizationSubmitBtnText)}
+
+
+ );
+};
+
+OrganizationSection.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(OrganizationSection);
diff --git a/src/studio-home/processing-courses/ProcessingCourses.test.jsx b/src/studio-home/processing-courses/ProcessingCourses.test.jsx
new file mode 100644
index 0000000000..c9f2595685
--- /dev/null
+++ b/src/studio-home/processing-courses/ProcessingCourses.test.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import initializeStore from '../../store';
+import { studioHomeMock } from '../__mocks__';
+import messages from './messages';
+import ProcessingCourses from '.';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+let store;
+
+const RootWrapper = () => (
+
+
+
+
+
+);
+
+describe(' ', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ useSelector.mockReturnValue(studioHomeMock);
+ });
+ it('renders successfully processing courses', () => {
+ const studioHomeInitial = {
+ ...studioHomeMock,
+ inProcessCourseActions: [{ a: '1' }, { b: '2' }],
+ };
+ useSelector.mockReturnValue(studioHomeInitial);
+ const { getByText, queryAllByTestId } = render( );
+ expect(getByText(messages.processingTitle.defaultMessage)).toBeInTheDocument();
+ expect(queryAllByTestId('course-item')).toHaveLength(2);
+ });
+
+ it('renders successfully empty list', () => {
+ const { getByText, queryAllByTestId } = render( );
+ expect(getByText(messages.processingTitle.defaultMessage)).toBeInTheDocument();
+ expect(queryAllByTestId('course-item')).toHaveLength(0);
+ });
+});
diff --git a/src/studio-home/processing-courses/course-item/CourseItem.test.jsx b/src/studio-home/processing-courses/course-item/CourseItem.test.jsx
new file mode 100644
index 0000000000..3c55e36fc0
--- /dev/null
+++ b/src/studio-home/processing-courses/course-item/CourseItem.test.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import initializeStore from '../../../store';
+import { executeThunk } from '../../../utils';
+import { handleDeleteNotificationQuery } from '../../data/thunks';
+import { getCourseNotificationUrl } from '../../data/api';
+import messages from './messages';
+import CourseItem from '.';
+
+let store;
+let axiosMock;
+
+const RootWrapper = (props) => (
+
+
+
+
+
+);
+
+const course = {
+ displayName: 'course-name-fake',
+ courseKey: 'course-key-fake',
+ org: 'course-org-fake',
+ number: 'course-number-fake',
+ run: 'course-run-fake',
+ isInProgress: true,
+ isFailed: true,
+ dismissLink: 'course-dismiss-link-fake',
+};
+
+const props = { course };
+
+describe(' ', async () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onDelete(getCourseNotificationUrl(course.dismissLink)).reply(200);
+ await executeThunk(handleDeleteNotificationQuery(course.dismissLink), store.dispatch);
+ });
+
+ it('renders successfully', () => {
+ const { getByText, getAllByText } = render( );
+ const subtitle = `${course.org} / ${course.number} / ${course.run}`;
+ expect(getAllByText(course.displayName)).toHaveLength(2);
+ expect(getAllByText(subtitle)).toHaveLength(2);
+ expect(getByText(messages.itemInProgressActionText.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.itemIsFailedActionText.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.itemFailedFooterText.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.itemFailedFooterButton.defaultMessage)).toBeInTheDocument();
+ });
+});
diff --git a/src/studio-home/processing-courses/course-item/index.jsx b/src/studio-home/processing-courses/course-item/index.jsx
new file mode 100644
index 0000000000..32f76239ce
--- /dev/null
+++ b/src/studio-home/processing-courses/course-item/index.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ ActionRow,
+ Card,
+ Hyperlink,
+ Button,
+ Icon,
+} from '@edx/paragon';
+import {
+ Close as CloseIcon,
+ Warning as WarningIcon,
+ RotateRight as RotateRightIcon,
+} from '@edx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { handleDeleteNotificationQuery } from '../../data/thunks';
+import messages from './messages';
+
+const CourseItem = ({ course }) => {
+ const intl = useIntl();
+ const dispatch = useDispatch();
+ const {
+ displayName, org, number, run, isInProgress, isFailed, dismissLink,
+ } = course;
+ const subtitle = `${org} / ${number} / ${run}`;
+
+ return (
+
+ {isInProgress && (
+
+ {displayName}}
+ subtitle={subtitle}
+ actions={(
+
+
+
+ {intl.formatMessage(messages.itemInProgressActionText)}
+
+ )}
+ />
+
+
+ {intl.formatMessage(messages.itemInProgressFooterText, {
+ refresh: (
+
+ {intl.formatMessage(messages.itemInProgressFooterHyperlink)}
+
+ ),
+ })}
+
+
+ )}
+
+ {isFailed && (
+
+ {displayName}}
+ subtitle={subtitle}
+ actions={(
+
+
+ {intl.formatMessage(messages.itemIsFailedActionText)}
+
+ )}
+ />
+
+
+ {intl.formatMessage(messages.itemFailedFooterText)}
+ dispatch(handleDeleteNotificationQuery(dismissLink))}
+ iconBefore={CloseIcon}
+ variant="outline-danger"
+ size="sm"
+ >
+ {intl.formatMessage(messages.itemFailedFooterButton)}
+
+
+
+ )}
+
+ );
+};
+
+CourseItem.propTypes = {
+ course: PropTypes.shape({
+ displayName: PropTypes.string.isRequired,
+ courseKey: PropTypes.string.isRequired,
+ org: PropTypes.string.isRequired,
+ number: PropTypes.string.isRequired,
+ run: PropTypes.string.isRequired,
+ isFailed: PropTypes.bool.isRequired,
+ isInProgress: PropTypes.bool.isRequired,
+ dismissLink: PropTypes.string.isRequired,
+ }).isRequired,
+};
+
+export default CourseItem;
diff --git a/src/studio-home/processing-courses/course-item/messages.js b/src/studio-home/processing-courses/course-item/messages.js
new file mode 100644
index 0000000000..3543d5af25
--- /dev/null
+++ b/src/studio-home/processing-courses/course-item/messages.js
@@ -0,0 +1,30 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ itemInProgressFooterText: {
+ id: 'course-authoring.studio-home.processing.course-item.footer.in-progress',
+ defaultMessage: 'The new course will be added to your course list in 5-10 minutes. Return to this page or {refresh} to update the course list. The new course will need some manual configuration.',
+ },
+ itemInProgressFooterHyperlink: {
+ id: 'course-authoring.studio-home.processing.course-item.footer.in-progress.hyperlink',
+ defaultMessage: 'refresh it',
+ },
+ itemInProgressActionText: {
+ id: 'course-authoring.studio-home.processing.course-item.action.in-progress',
+ defaultMessage: 'Configuring as re-run',
+ },
+ itemIsFailedActionText: {
+ id: 'course-authoring.studio-home.processing.course-item.action.failed',
+ defaultMessage: 'Configuration error',
+ },
+ itemFailedFooterText: {
+ id: 'course-authoring.studio-home.processing.course-item.footer.failed',
+ defaultMessage: 'A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.',
+ },
+ itemFailedFooterButton: {
+ id: 'course-authoring.studio-home.processing.course-item.footer.failed.button',
+ defaultMessage: 'Dismiss',
+ },
+});
+
+export default messages;
diff --git a/src/studio-home/processing-courses/index.jsx b/src/studio-home/processing-courses/index.jsx
new file mode 100644
index 0000000000..b41786fd03
--- /dev/null
+++ b/src/studio-home/processing-courses/index.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { Stack } from '@edx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { getStudioHomeData } from '../data/selectors';
+import CourseItem from './course-item';
+import messages from './messages';
+
+const ProcessingCourses = () => {
+ const intl = useIntl();
+ const { inProcessCourseActions } = useSelector(getStudioHomeData);
+
+ return (
+ <>
+
+ {intl.formatMessage(messages.processingTitle)}
+
+
+
+ {inProcessCourseActions.map((course) => (
+
+ ))}
+
+ >
+ );
+};
+
+export default ProcessingCourses;
diff --git a/src/studio-home/processing-courses/messages.js b/src/studio-home/processing-courses/messages.js
new file mode 100644
index 0000000000..98d90ccf36
--- /dev/null
+++ b/src/studio-home/processing-courses/messages.js
@@ -0,0 +1,10 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ processingTitle: {
+ id: 'course-authoring.studio-home.processing.title',
+ defaultMessage: 'Courses being processed',
+ },
+});
+
+export default messages;
diff --git a/src/studio-home/scss/StudioHome.scss b/src/studio-home/scss/StudioHome.scss
new file mode 100644
index 0000000000..0f1d2c3fa8
--- /dev/null
+++ b/src/studio-home/scss/StudioHome.scss
@@ -0,0 +1,92 @@
+.studio-home {
+ margin: 3rem 1.5rem 1.5rem;
+
+ .help-sidebar {
+ margin-top: 0;
+ }
+
+ .studio-home-sub-header {
+ margin-bottom: 2rem;
+ }
+}
+
+.organization-section {
+ margin-bottom: 2.25rem;
+
+ .organization-section-title {
+ color: $black;
+ }
+
+ .organization-section-form {
+ margin: $spacer 0;
+
+ .organization-section-form-label {
+ color: $gray-700;
+ margin-bottom: 0;
+ margin-right: .75rem;
+ }
+
+ .organization-section-form-control {
+ border-color: $gray-500;
+
+ .form-control {
+ font-size: .875rem;
+ line-height: 1.5rem;
+ height: 2.75rem;
+ }
+ }
+ }
+}
+
+.studio-home-tabs {
+ border: none;
+ margin-bottom: 1.625rem;
+
+ .nav-link {
+ border-bottom: .125rem solid $light-400;
+ }
+
+ .nav-link.active {
+ background-color: transparent;
+ }
+}
+
+.courses-tab {
+ margin: 1.625rem 0;
+}
+
+.card-item {
+ margin-bottom: 1.5rem;
+
+ .pgn__card-header {
+ padding: .9375rem 1.25rem;
+
+ .pgn__card-header-content {
+ margin: 0;
+ overflow: hidden;
+ }
+
+ .pgn__card-header-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ }
+ }
+
+ .card-item-title {
+ font: normal $font-weight-normal 1.125rem/1.75rem $font-family-base;
+ color: $black;
+ margin-bottom: .1875rem;
+ }
+
+ .pgn__card-header-subtitle-md {
+ font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
+ color: $gray-700;
+ margin: 0;
+ }
+}
+
+.spinner-icon {
+ animation: rotate 2s infinite linear;
+}
diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx
new file mode 100644
index 0000000000..d8abe0e1a5
--- /dev/null
+++ b/src/studio-home/tabs-section/TabsSection.test.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { waitFor, render, fireEvent } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import initializeStore from '../../store';
+import { studioHomeMock } from '../__mocks__';
+import messages from '../messages';
+import TabsSection from '.';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+const { studioShortName } = studioHomeMock;
+
+let store;
+
+const RootWrapper = () => (
+
+
+
+
+
+);
+
+describe(' ', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ 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();
+ });
+ 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 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();
+ });
+ 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();
+ });
+ 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);
+ });
+ });
+});
diff --git a/src/studio-home/tabs-section/archived-tab/index.jsx b/src/studio-home/tabs-section/archived-tab/index.jsx
new file mode 100644
index 0000000000..3611d3a102
--- /dev/null
+++ b/src/studio-home/tabs-section/archived-tab/index.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import CardItem from '../../card-item';
+import { sortAlphabeticallyArray } from '../utils';
+
+const ArchivedTab = ({ archivedCoursesData }) => (
+
+ {sortAlphabeticallyArray(archivedCoursesData).map(({
+ courseKey, displayName, lmsLink, org, rerunLink, number, run, url,
+ }) => (
+
+ ))}
+
+);
+
+ArchivedTab.propTypes = {
+ archivedCoursesData: PropTypes.arrayOf(
+ PropTypes.shape({
+ courseKey: PropTypes.string.isRequired,
+ displayName: PropTypes.string.isRequired,
+ lmsLink: PropTypes.string.isRequired,
+ number: PropTypes.string.isRequired,
+ org: PropTypes.string.isRequired,
+ rerunLink: PropTypes.string.isRequired,
+ run: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ }),
+ ).isRequired,
+};
+
+export default ArchivedTab;
diff --git a/src/studio-home/tabs-section/courses-tab/contact-administrator/index.jsx b/src/studio-home/tabs-section/courses-tab/contact-administrator/index.jsx
new file mode 100644
index 0000000000..bbf2d80f3e
--- /dev/null
+++ b/src/studio-home/tabs-section/courses-tab/contact-administrator/index.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import PropTypes from 'prop-types';
+import { Button, Card } from '@edx/paragon';
+import { Add as AddIcon } from '@edx/paragon/icons/es5';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import { getStudioHomeData } from '../../../data/selectors';
+import messages from '../../../messages';
+
+const ContactAdministrator = ({
+ intl, hasAbilityToCreateCourse, showNewCourseContainer, onClickNewCourse,
+}) => {
+ const { studioShortName } = useSelector(getStudioHomeData);
+
+ return (
+
+
+ {intl.formatMessage(messages.defaultSection_1_Description)}
+
+ {hasAbilityToCreateCourse && (
+ <>
+
+
+ {intl.formatMessage(messages.btnAddNewCourseText)}
+
+ )}
+ >
+ {intl.formatMessage(messages.defaultSection_2_Description)}
+
+ >
+ )}
+
+ );
+};
+
+ContactAdministrator.defaultProps = {
+ hasAbilityToCreateCourse: false,
+};
+
+ContactAdministrator.propTypes = {
+ intl: intlShape.isRequired,
+ hasAbilityToCreateCourse: PropTypes.bool,
+ showNewCourseContainer: PropTypes.bool.isRequired,
+ onClickNewCourse: PropTypes.func.isRequired,
+};
+
+export default injectIntl(ContactAdministrator);
diff --git a/src/studio-home/tabs-section/courses-tab/index.jsx b/src/studio-home/tabs-section/courses-tab/index.jsx
new file mode 100644
index 0000000000..50990501d0
--- /dev/null
+++ b/src/studio-home/tabs-section/courses-tab/index.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useSelector } from 'react-redux';
+
+import { COURSE_CREATOR_STATES } from '../../../constants';
+import { getStudioHomeData } from '../../data/selectors';
+import CardItem from '../../card-item';
+import CollapsibleStateWithAction from '../../collapsible-state-with-action';
+import { sortAlphabeticallyArray } from '../utils';
+import ContactAdministrator from './contact-administrator';
+
+const CoursesTab = ({
+ coursesDataItems,
+ showNewCourseContainer,
+ onClickNewCourse,
+}) => {
+ const {
+ courseCreatorStatus,
+ optimizationEnabled,
+ } = useSelector(getStudioHomeData);
+ const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted;
+ const showCollapsible = [
+ COURSE_CREATOR_STATES.denied,
+ COURSE_CREATOR_STATES.pending,
+ COURSE_CREATOR_STATES.unrequested,
+ ].includes(courseCreatorStatus);
+
+ return (
+ <>
+ {coursesDataItems?.length ? (
+ sortAlphabeticallyArray(coursesDataItems).map(
+ ({
+ courseKey,
+ displayName,
+ lmsLink,
+ org,
+ rerunLink,
+ number,
+ run,
+ url,
+ }) => (
+
+ ),
+ )
+ ) : (!optimizationEnabled && (
+
+ )
+ )}
+ {showCollapsible && (
+
+ )}
+ >
+ );
+};
+
+CoursesTab.propTypes = {
+ coursesDataItems: PropTypes.arrayOf(
+ PropTypes.shape({
+ courseKey: PropTypes.string.isRequired,
+ displayName: PropTypes.string.isRequired,
+ lmsLink: PropTypes.string.isRequired,
+ number: PropTypes.string.isRequired,
+ org: PropTypes.string.isRequired,
+ rerunLink: PropTypes.string.isRequired,
+ run: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ }),
+ ).isRequired,
+ showNewCourseContainer: PropTypes.bool.isRequired,
+ onClickNewCourse: PropTypes.func.isRequired,
+};
+
+export default CoursesTab;
diff --git a/src/studio-home/tabs-section/index.jsx b/src/studio-home/tabs-section/index.jsx
new file mode 100644
index 0000000000..ea18f82af6
--- /dev/null
+++ b/src/studio-home/tabs-section/index.jsx
@@ -0,0 +1,128 @@
+import React, { useMemo } 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 LibrariesTab from './libraries-tab';
+import ArchivedTab from './archived-tab';
+import CoursesTab from './courses-tab';
+
+const TabsSection = ({
+ intl, tabsData, showNewCourseContainer, onClickNewCourse,
+}) => {
+ const TABS_LIST = {
+ courses: 'courses',
+ libraries: 'libraries',
+ archived: 'archived',
+ };
+ const {
+ libraryAuthoringMfeUrl,
+ redirectToLibraryAuthoringMfe,
+ } = useSelector(getStudioHomeData);
+ const {
+ activeTab, courses, librariesEnabled, libraries, archivedCourses,
+ } = tabsData;
+
+ // Controlling the visibility of tabs when using conditional rendering is necessary for
+ // the correct operation of iterating over child elements inside the Paragon Tabs component.
+ const visibleTabs = useMemo(() => {
+ const tabs = [];
+ tabs.push(
+
+
+ ,
+ );
+
+ if (archivedCourses?.length) {
+ tabs.push(
+
+
+ ,
+ );
+ }
+
+ if (librariesEnabled) {
+ tabs.push(
+
+ {!redirectToLibraryAuthoringMfe && }
+ ,
+ );
+ }
+
+ return tabs;
+ }, [archivedCourses, librariesEnabled, showNewCourseContainer]);
+
+ const handleSelectTab = (tab) => {
+ if (tab === TABS_LIST.libraries && redirectToLibraryAuthoringMfe) {
+ window.location.href = libraryAuthoringMfeUrl;
+ }
+ };
+
+ return (
+
+ {visibleTabs}
+
+ );
+};
+
+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,
+};
+
+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
new file mode 100644
index 0000000000..e2f17c6fe6
--- /dev/null
+++ b/src/studio-home/tabs-section/libraries-tab/index.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import CardItem from '../../card-item';
+import { sortAlphabeticallyArray } from '../utils';
+
+const LibrariesTab = ({ libraries }) => (
+
+ {sortAlphabeticallyArray(libraries).map(({
+ displayName, org, number, url,
+ }) => (
+
+ ))}
+
+);
+
+LibrariesTab.propTypes = {
+ libraries: PropTypes.arrayOf(
+ PropTypes.shape({
+ displayName: PropTypes.string.isRequired,
+ libraryKey: PropTypes.string.isRequired,
+ number: PropTypes.string.isRequired,
+ org: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ }),
+ ).isRequired,
+};
+
+export default LibrariesTab;
diff --git a/src/studio-home/tabs-section/utils.js b/src/studio-home/tabs-section/utils.js
new file mode 100644
index 0000000000..5d3822b8ed
--- /dev/null
+++ b/src/studio-home/tabs-section/utils.js
@@ -0,0 +1,12 @@
+/**
+ * Alphabetical sorting for arrays of courses and libraries.
+ *
+ * @param {array} arr - Array of courses or libraries.
+ * @returns {array} - An array of alphabetically sorted courses or libraries.
+ */
+const sortAlphabeticallyArray = (arr) => [...arr]
+ .sort((firstArrayData, secondArrayData) => firstArrayData
+ .displayName.localeCompare(secondArrayData.displayName));
+
+// eslint-disable-next-line import/prefer-default-export
+export { sortAlphabeticallyArray };
diff --git a/src/studio-home/tabs-section/utils.test.js b/src/studio-home/tabs-section/utils.test.js
new file mode 100644
index 0000000000..d10d56a07b
--- /dev/null
+++ b/src/studio-home/tabs-section/utils.test.js
@@ -0,0 +1,26 @@
+import { sortAlphabeticallyArray } from './utils';
+
+const testData = [
+ { displayName: 'Apple' },
+ { displayName: 'Orange' },
+ { displayName: 'Banana' },
+];
+
+describe('sortAlphabeticallyArray', () => {
+ it('sortAlphabeticallyArray sorts array alphabetically', () => {
+ const sortedData = sortAlphabeticallyArray(testData);
+
+ expect(sortedData).toEqual([
+ { displayName: 'Apple' },
+ { displayName: 'Banana' },
+ { displayName: 'Orange' },
+ ]);
+ });
+
+ it('sortAlphabeticallyArray does not mutate the original array', () => {
+ const originalData = [...testData];
+ sortAlphabeticallyArray(testData);
+
+ expect(testData).toEqual(originalData);
+ });
+});
diff --git a/src/studio-home/verify-email-layout/VerifyEmailLayout.test.jsx b/src/studio-home/verify-email-layout/VerifyEmailLayout.test.jsx
new file mode 100644
index 0000000000..ed5f507831
--- /dev/null
+++ b/src/studio-home/verify-email-layout/VerifyEmailLayout.test.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+import VerifyEmailLayout from '.';
+
+const mockPathname = '/foo-bar';
+const fakeAuthenticatedUser = {
+ email: 'email@fake.com',
+ username: 'fake-user',
+};
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useLocation: () => ({
+ pathname: mockPathname,
+ }),
+}));
+jest.mock('@edx/frontend-platform/auth');
+getAuthenticatedUser.mockImplementation(() => fakeAuthenticatedUser);
+
+const RootWrapper = () => (
+
+
+
+);
+
+describe(' ', () => {
+ it('renders successfully', () => {
+ const { getByText } = render( );
+ expect(
+ getByText(`Thanks for signing up, ${fakeAuthenticatedUser.username}!`, {
+ exact: false,
+ }),
+ ).toBeInTheDocument();
+ expect(getByText(messages.bannerTitle.defaultMessage)).toBeInTheDocument();
+ expect(getByText(
+ `Almost there! In order to complete your sign up we need you to verify your email address (${fakeAuthenticatedUser.email}). An activation message and next steps should be waiting for you there.`,
+ { exact: false },
+ )).toBeInTheDocument();
+ expect(getByText(messages.sidebarTitle.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.sidebarDescription.defaultMessage)).toBeInTheDocument();
+ });
+});
diff --git a/src/studio-home/verify-email-layout/index.jsx b/src/studio-home/verify-email-layout/index.jsx
new file mode 100644
index 0000000000..4a394e0333
--- /dev/null
+++ b/src/studio-home/verify-email-layout/index.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Layout, Card } from '@edx/paragon';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { HelpSidebar } from '../../generic/help-sidebar';
+import messages from './messages';
+
+const VerifyEmailLayout = () => {
+ const intl = useIntl();
+ const { email, username } = getAuthenticatedUser();
+
+ return (
+
+
+
+ {intl.formatMessage(messages.headingTitle, { username })}
+
+
+ {intl.formatMessage(messages.bannerDescription, { email })}
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.sidebarTitle)}
+
+
+ {intl.formatMessage(messages.sidebarDescription)}
+
+
+
+
+ );
+};
+
+export default VerifyEmailLayout;
diff --git a/src/studio-home/verify-email-layout/messages.js b/src/studio-home/verify-email-layout/messages.js
new file mode 100644
index 0000000000..a7cd32b787
--- /dev/null
+++ b/src/studio-home/verify-email-layout/messages.js
@@ -0,0 +1,26 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ headingTitle: {
+ id: 'course-authoring.studio-home.verify-email.heading',
+ defaultMessage: 'Thanks for signing up, {username}!',
+ },
+ bannerTitle: {
+ id: 'course-authoring.studio-home.verify-email.banner.title',
+ defaultMessage: 'We need to verify your email address',
+ },
+ bannerDescription: {
+ id: 'course-authoring.studio-home.verify-email.banner.description',
+ defaultMessage: 'Almost there! In order to complete your sign up we need you to verify your email address ({email}). An activation message and next steps should be waiting for you there.',
+ },
+ sidebarTitle: {
+ id: 'course-authoring.studio-home.verify-email.sidebar.title',
+ defaultMessage: 'Need help?',
+ },
+ sidebarDescription: {
+ id: 'course-authoring.studio-home.verify-email.sidebar.description',
+ defaultMessage: 'Please check your Junk or Spam folders in case our email isn\'t in your INBOX. Still can\'t find the verification email? Request help via the link below.',
+ },
+});
+
+export default messages;