From ac17fd72943972590b3126b004c51c4bae8480f2 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com> Date: Wed, 21 Feb 2024 14:37:37 +0500 Subject: [PATCH] feat: implemented error handling mockup (#663) * feat: implemented error handling mockup * fix: fixed incontextsidebar issues * fix: fixed discussion home test cases * fix: fixed dicussion home test cases * refactor: added code review fixes --------- Co-authored-by: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> --- src/assets/ContentUnavailable.jsx | 14 ++ .../__factories__/navigationBar.factory.js | 2 +- src/components/NavigationBar/data/api.test.js | 4 +- src/components/NavigationBar/data/slice.js | 1 + src/components/NavigationBar/data/thunks.js | 1 + .../CourseContentUnavailable.jsx | 52 ++++++++ .../discussions-home/DiscussionsHome.jsx | 123 +++++++++++------- .../discussions-home/DiscussionsHome.test.jsx | 65 +++++++-- src/discussions/messages.js | 15 +++ src/index.scss | 4 + 10 files changed, 218 insertions(+), 63 deletions(-) create mode 100644 src/assets/ContentUnavailable.jsx create mode 100644 src/discussions/course-content-unavailable/CourseContentUnavailable.jsx diff --git a/src/assets/ContentUnavailable.jsx b/src/assets/ContentUnavailable.jsx new file mode 100644 index 000000000..4d86837a6 --- /dev/null +++ b/src/assets/ContentUnavailable.jsx @@ -0,0 +1,14 @@ +const ContentUnavailable = () => ( + + + + + + + + + + +); + +export default ContentUnavailable; diff --git a/src/components/NavigationBar/data/__factories__/navigationBar.factory.js b/src/components/NavigationBar/data/__factories__/navigationBar.factory.js index 172bcd832..811cdbf38 100644 --- a/src/components/NavigationBar/data/__factories__/navigationBar.factory.js +++ b/src/components/NavigationBar/data/__factories__/navigationBar.factory.js @@ -19,7 +19,7 @@ Factory.define('navigationBar') user_message: null, })) .option('course_id', null, 'course-v1:edX+DemoX+Demo_Course') - .attr('is_enrolled', null, false) + .sequence('is_enrolled', ['isEnrolled'], (idx, isEnrolled) => isEnrolled) .attr('is_self_paced', null, false) .attr('is_staff', null, true) .attr('number', null, 'DemoX') diff --git a/src/components/NavigationBar/data/api.test.js b/src/components/NavigationBar/data/api.test.js index 142ec4b2d..154b5cdb2 100644 --- a/src/components/NavigationBar/data/api.test.js +++ b/src/components/NavigationBar/data/api.test.js @@ -34,7 +34,7 @@ describe('Navigation bar api tests', () => { }); it('Successfully get navigation tabs', async () => { - axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1))); + axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true }))); await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); expect(store.getState().courseTabs.tabs).toHaveLength(4); @@ -58,7 +58,7 @@ describe('Navigation bar api tests', () => { it('Denied to get navigation bar when user has no access on course', async () => { axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply( 200, - (Factory.build('navigationBar', 1, { hasCourseAccess: false })), + (Factory.build('navigationBar', 1, { hasCourseAccess: false, isEnrolled: true })), ); await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); diff --git a/src/components/NavigationBar/data/slice.js b/src/components/NavigationBar/data/slice.js index 17e43641f..1d27c0c9b 100644 --- a/src/components/NavigationBar/data/slice.js +++ b/src/components/NavigationBar/data/slice.js @@ -47,6 +47,7 @@ const slice = createSlice({ courseTitle: payload.courseTitle, courseNumber: payload.courseNumber, org: payload.org, + isEnrolled: payload.isEnrolled, } ), }, diff --git a/src/components/NavigationBar/data/thunks.js b/src/components/NavigationBar/data/thunks.js index 621a52b34..d9ffad4d2 100644 --- a/src/components/NavigationBar/data/thunks.js +++ b/src/components/NavigationBar/data/thunks.js @@ -23,6 +23,7 @@ export default function fetchTab(courseId, rootSlug) { org: courseHomeCourseMetadata.org, courseNumber: courseHomeCourseMetadata.number, courseTitle: courseHomeCourseMetadata.title, + isEnrolled: courseHomeCourseMetadata.isEnrolled, })); } } catch (e) { diff --git a/src/discussions/course-content-unavailable/CourseContentUnavailable.jsx b/src/discussions/course-content-unavailable/CourseContentUnavailable.jsx new file mode 100644 index 000000000..db636dbbd --- /dev/null +++ b/src/discussions/course-content-unavailable/CourseContentUnavailable.jsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; +import propTypes from 'prop-types'; + +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; + +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; + +import ContentUnavailableIcon from '../../assets/ContentUnavailable'; +import selectCourseTabs from '../../components/NavigationBar/data/selectors'; +import { useIsOnDesktop, useIsOnXLDesktop } from '../data/hooks'; +import messages from '../messages'; + +const CourseContentUnavailable = ({ subTitleMessage }) => { + const intl = useIntl(); + const isOnDesktop = useIsOnDesktop(); + const isOnXLDesktop = useIsOnXLDesktop(); + const { courseId } = useSelector(selectCourseTabs); + + const redirectToDashboard = useCallback(() => { + window.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/about`); + }, [courseId]); + + return ( +
+
+ +

{intl.formatMessage(messages.contentUnavailableTitle)}

+

{intl.formatMessage(subTitleMessage)}

+ +
+
+ ); +}; + +CourseContentUnavailable.propTypes = { + subTitleMessage: propTypes.shape({ + id: propTypes.string, + defaultMessage: propTypes.string, + description: propTypes.string, + }).isRequired, +}; + +export default React.memo(CourseContentUnavailable); diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx index 6a44544b8..a884519c6 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -12,8 +12,10 @@ import { LearningHeader as Header } from '@edx/frontend-component-header'; import { Spinner } from '../../components'; import selectCourseTabs from '../../components/NavigationBar/data/selectors'; +import { LOADED } from '../../components/NavigationBar/data/slice'; import { ALL_ROUTES, DiscussionProvider, Routes as ROUTES } from '../../data/constants'; import DiscussionContext from '../common/context'; +import ContentUnavailable from '../course-content-unavailable/CourseContentUnavailable'; import { useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible, } from '../data/hooks'; @@ -41,7 +43,9 @@ const DiscussionsHome = () => { const postEditorVisible = useSelector(selectPostEditorVisible); const provider = useSelector(selectDiscussionProvider); const enableInContext = useSelector(selectEnableInContext); - const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs); + const { + courseNumber, courseTitle, org, courseStatus, isEnrolled, + } = useSelector(selectCourseTabs); const pageParams = useMatch(ROUTES.COMMENTS.PAGE)?.params; const page = pageParams?.page || null; const matchPattern = ALL_ROUTES.find((route) => matchPath({ path: route }, location.pathname)); @@ -80,6 +84,7 @@ const DiscussionsHome = () => { )}
{!enableInContextSidebar && } + {(isEnrolled || enableInContextSidebar) && (
{ })} > {!enableInContextSidebar && ( - + )}
+ )} + {provider === DiscussionProvider.LEGACY && ( - )}> - - {[ - ROUTES.TOPICS.CATEGORY, - ROUTES.TOPICS.CATEGORY_POST, - ROUTES.TOPICS.CATEGORY_POST_EDIT, - ROUTES.TOPICS.TOPIC, - ROUTES.TOPICS.TOPIC_POST, - ROUTES.TOPICS.TOPIC_POST_EDIT, - ].map((route) => ( - } - /> - ))} - - + )}> + + {[ + ROUTES.TOPICS.CATEGORY, + ROUTES.TOPICS.CATEGORY_POST, + ROUTES.TOPICS.CATEGORY_POST_EDIT, + ROUTES.TOPICS.TOPIC, + ROUTES.TOPICS.TOPIC_POST, + ROUTES.TOPICS.TOPIC_POST_EDIT, + ].map((route) => ( + } + /> + ))} + + )} -
- )}> - - - {displayContentArea && ( + {(courseStatus === LOADED || enableInContextSidebar) && ( +
+ { isEnrolled === false ? ( )}> - - - )} - {!displayContentArea && ( - - <> - {ROUTES.TOPICS.PATH.map(route => ( - : } - /> - ))} - } - /> - {[`${ROUTES.POSTS.PATH}/*`, ROUTES.POSTS.ALL_POSTS, ROUTES.LEARNERS.POSTS].map((route) => ( + + {ALL_ROUTES.map((route) => ( } + element={()} /> ))} - } /> - - - )} + + + ) + : ( +
+ )}> + + + {displayContentArea && ( + )}> + + + )} + {!displayContentArea && ( + + <> + {ROUTES.TOPICS.PATH.map(route => ( + : } + /> + ))} + } + /> + {[`${ROUTES.POSTS.PATH}/*`, ROUTES.POSTS.ALL_POSTS, ROUTES.LEARNERS.POSTS].map((route) => ( + } + /> + ))} + } /> + + + )} +
+ )}
+ )} {!enableInContextSidebar && ( )} diff --git a/src/discussions/discussions-home/DiscussionsHome.test.jsx b/src/discussions/discussions-home/DiscussionsHome.test.jsx index eb460a65e..d855ec42e 100644 --- a/src/discussions/discussions-home/DiscussionsHome.test.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.test.jsx @@ -65,6 +65,8 @@ describe('DiscussionsHome', () => { }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); store = initializeStore(); + axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true }))); + await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); }); async function setUpV1TopicsMockResponse() { @@ -142,7 +144,9 @@ describe('DiscussionsHome', () => { await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); await renderComponent(`/${courseId}/${searchByEndPoint}`); - expect(screen.queryByText('Add a post')).toBeInTheDocument(); + waitFor(() => { + expect(screen.queryByText('Add a post')).toBeInTheDocument(); + }); }); it.each([ @@ -166,7 +170,9 @@ describe('DiscussionsHome', () => { await executeThunk(fetchThreads(courseId), store.dispatch, store.getState); await renderComponent(`/${courseId}/${searchByEndPoint}`); - expect(screen.queryByText(result)).toBeInTheDocument(); + waitFor(() => { + expect(screen.queryByText(result)).toBeInTheDocument(); + }); }); it.each([ @@ -193,7 +199,9 @@ describe('DiscussionsHome', () => { await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState); await renderComponent(`/${courseId}/${searchByEndPoint}`); - expect(screen.queryByText('No topic selected')).toBeInTheDocument(); + waitFor(() => { + expect(screen.queryByText('No topic selected')).toBeInTheDocument(); + }); }, ); @@ -202,14 +210,19 @@ describe('DiscussionsHome', () => { await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); await renderComponent(`/${courseId}/learners`); - expect(screen.queryByText('Nothing here yet')).toBeInTheDocument(); + waitFor(() => { + expect(screen.queryByText('Nothing here yet')).toBeInTheDocument(); + }); }); it('should display post editor form when click on add a post button for posts', async () => { await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); await renderComponent(`/${courseId}/my-posts`); + + const addPost = await screen.findByText('Add a post'); + await act(async () => { - fireEvent.click(screen.queryByText('Add a post')); + fireEvent.click(addPost); }); await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument()); @@ -222,7 +235,7 @@ describe('DiscussionsHome', () => { await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); await renderComponent(`/${courseId}/topics`); - expect(screen.queryByText('Nothing here yet')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Nothing here yet')).toBeInTheDocument()); await act(async () => { fireEvent.click(screen.queryByText('Add a post')); @@ -234,28 +247,56 @@ describe('DiscussionsHome', () => { it('should display Add a post button for legacy topics view', async () => { await renderComponent(`/${courseId}/topics/topic-1`); - expect(screen.queryByText('Add a post')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Add a post')).toBeInTheDocument()); }); it('should display No post selected for legacy topics view', async () => { await setUpV1TopicsMockResponse(); await renderComponent(`/${courseId}/topics/category-1-topic-1`); - expect(screen.queryByText('No post selected')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('No post selected')).toBeInTheDocument()); }); it('should display No topic selected for legacy topics view', async () => { await setUpV1TopicsMockResponse(); await renderComponent(`/${courseId}/topics`); - expect(screen.queryByText('No topic selected')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('No topic selected')).toBeInTheDocument()); }); it('should display navigation tabs', async () => { - axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1))); - await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); renderComponent(`/${courseId}/topics`); - expect(screen.queryByText('Discussion')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Discussion')).toBeInTheDocument()); + }); + + it('should display content unavailable message when the user is not enrolled in the course.', async () => { + axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: false }))); + await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); + + renderComponent(); + + await waitFor(() => expect(screen.queryByText('Content unavailable')).toBeInTheDocument()); + }); + + it('should redirect to dashboard when the user clicks on the Enroll button.', async () => { + const replaceMock = jest.fn(); + delete window.location; + window.location = { replace: replaceMock }; + + axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: false }))); + await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); + + renderComponent(); + + const enrollButton = await screen.findByText('Enroll'); + + await act(async () => { + fireEvent.click(enrollButton); + }); + + await waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith(expect.stringContaining('about')); + }); }); }); diff --git a/src/discussions/messages.js b/src/discussions/messages.js index b9a3b7471..ac78169d9 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -193,6 +193,21 @@ const messages = defineMessages({ defaultMessage: 'Ok', description: 'Modal dismiss button text', }, + contentUnavailableTitle: { + id: 'discussions.content.unavailable.title', + defaultMessage: 'Content unavailable', + description: 'Title on content page when the user has not logged into the MFE or not enrolled in the course.', + }, + contentUnavailableSubTitle: { + id: 'discussions.content.unavailable.subTitle', + defaultMessage: 'You may not be able to see this content because you\'re not logged in, you\'re not enrolled in the course, or your audit access has expired.', + description: 'Sub title on content page when the user has not logged into the MFE or not enrolled in the course.', + }, + contentUnavailableAction: { + id: 'discussions.content.unavailable.action', + defaultMessage: 'Enroll', + description: 'Action button on content page when the user has not logged into the MFE or not enrolled in the course.', + }, }); export default messages; diff --git a/src/index.scss b/src/index.scss index cb19989a2..8e0411b40 100755 --- a/src/index.scss +++ b/src/index.scss @@ -567,3 +567,7 @@ code { line-height: 1; word-break: break-all; } + +.content-unavailable-desktop { + width: 32.188rem !important; +}