diff --git a/package-lock.json b/package-lock.json index 0ebddba0..ad9dce24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1383,9 +1383,9 @@ } }, "@edx/frontend-lib-special-exams": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-1.8.1.tgz", - "integrity": "sha512-6vQrwlE9+PCq8NsAUqY6EOpQtG3KiwKrvdj1lvYOdQMtww3gIHQRh6ZOukAoFlDTDEO4PC6VVx7RFFEj1nTDjg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-1.9.0.tgz", + "integrity": "sha512-dG9iW/vru/6Rh9+1kkqDu3gdiSVF49/9JvdjPb+7H1mdZPs4/FdP43HicPgaJ4r6PKfd9Ssam+3rq1NhxpWzvw==", "requires": { "@fortawesome/fontawesome-svg-core": "1.2.34", "@fortawesome/free-brands-svg-icons": "5.11.2", diff --git a/package.json b/package.json index 38a5de10..d437bd0e 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-component-footer": "10.1.5", "@edx/frontend-enterprise": "4.2.3", - "@edx/frontend-lib-special-exams": "1.8.1", + "@edx/frontend-lib-special-exams": "1.9.0", "@edx/frontend-platform": "1.11.0", "@edx/paragon": "15.2.2", "@fortawesome/fontawesome-svg-core": "1.2.34", diff --git a/src/course-home/data/__factories__/index.js b/src/course-home/data/__factories__/index.js index e8ebf460..e421d00c 100644 --- a/src/course-home/data/__factories__/index.js +++ b/src/course-home/data/__factories__/index.js @@ -2,4 +2,4 @@ import './courseHomeMetadata.factory'; import './datesTabData.factory'; import './outlineTabData.factory'; import './progressTabData.factory'; -import './upgradeCardData.factory'; +import './upgradeNotificationData.factory'; diff --git a/src/course-home/data/__factories__/outlineTabData.factory.js b/src/course-home/data/__factories__/outlineTabData.factory.js index 21a9ecd0..0a740c3d 100644 --- a/src/course-home/data/__factories__/outlineTabData.factory.js +++ b/src/course-home/data/__factories__/outlineTabData.factory.js @@ -29,6 +29,7 @@ Factory.define('outlineTabData') upgrade_url: `${host}/dashboard`, })) .attrs({ + has_scheduled_content: null, access_expiration: null, can_show_upgrade_sock: false, cert_data: { diff --git a/src/course-home/data/__factories__/upgradeCardData.factory.js b/src/course-home/data/__factories__/upgradeNotificationData.factory.js similarity index 93% rename from src/course-home/data/__factories__/upgradeCardData.factory.js rename to src/course-home/data/__factories__/upgradeNotificationData.factory.js index fcabc3d4..fcb75241 100644 --- a/src/course-home/data/__factories__/upgradeCardData.factory.js +++ b/src/course-home/data/__factories__/upgradeNotificationData.factory.js @@ -1,6 +1,6 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies -Factory.define('upgradeCardData') +Factory.define('upgradeNotificationData') .option('host', 'http://localhost:18000') .option('dateBlocks', []) .option('offer', null) diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index 7a8a20b0..283e0455 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -6,6 +6,7 @@ Object { "courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseStatus": "loaded", "gradesFeatureIsLocked": false, + "targetUserId": undefined, "toastBodyLink": null, "toastBodyText": null, "toastHeader": "", @@ -302,6 +303,7 @@ Object { "courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseStatus": "loaded", "gradesFeatureIsLocked": false, + "targetUserId": undefined, "toastBodyLink": null, "toastBodyText": null, "toastHeader": "", @@ -380,6 +382,7 @@ Object { "block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object { "effortActivities": undefined, "effortTime": undefined, + "hasScheduledContent": false, "id": "course-v1:edX+DemoX+Demo_Course_1", "sectionIds": Array [ "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", @@ -448,6 +451,7 @@ Object { }, "handoutsHtml": "", "hasEnded": undefined, + "hasScheduledContent": null, "id": "course-v1:edX+DemoX+Demo_Course_1", "offer": null, "resumeCourse": Object { @@ -479,6 +483,7 @@ Object { "courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseStatus": "loaded", "gradesFeatureIsLocked": false, + "targetUserId": undefined, "toastBodyLink": null, "toastBodyText": null, "toastHeader": "", diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 73fa35aa..f1f59060 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -116,6 +116,7 @@ export function normalizeOutlineBlocks(courseId, blocks) { id: courseId, title: block.display_name, sectionIds: block.children || [], + hasScheduledContent: block.has_scheduled_content, }; break; @@ -211,8 +212,15 @@ export async function getDatesTabData(courseId) { } } -export async function getProgressTabData(courseId) { - const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`; +export async function getProgressTabData(courseId, targetUserId) { + let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`; + + // If targetUserId is passed in, we will get the progress page data + // for the user with the provided id, rather than the requesting user. + if (targetUserId) { + url += `/${targetUserId}/`; + } + try { const { data } = await getAuthenticatedHttpClient().get(url); const camelCasedData = camelCaseObject(data); @@ -316,6 +324,7 @@ export async function getOutlineTabData(courseId) { const datesWidget = camelCaseObject(data.dates_widget); const enrollAlert = camelCaseObject(data.enroll_alert); const handoutsHtml = data.handouts_html; + const hasScheduledContent = data.has_scheduled_content; const hasEnded = data.has_ended; const offer = camelCaseObject(data.offer); const resumeCourse = camelCaseObject(data.resume_course); @@ -334,6 +343,7 @@ export async function getOutlineTabData(courseId) { datesWidget, enrollAlert, handoutsHtml, + hasScheduledContent, hasEnded, offer, resumeCourse, diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js index 62bd0d6b..c4ae61d7 100644 --- a/src/course-home/data/redux.test.js +++ b/src/course-home/data/redux.test.js @@ -115,6 +115,20 @@ describe('Data layer integration tests', () => { expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state).toMatchSnapshot(); }); + + it('Should handle the url including a targetUserId', async () => { + const progressTabData = Factory.build('progressTabData', { courseId }); + const targetUserId = 2; + const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`; + + axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata); + axiosMock.onGet(progressUrl).reply(200, progressTabData); + + await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch); + + const state = store.getState(); + expect(state.courseHome.targetUserId).toEqual(2); + }); }); describe('Test saveCourseGoal', () => { diff --git a/src/course-home/data/slice.js b/src/course-home/data/slice.js index 0cefaae3..4534887e 100644 --- a/src/course-home/data/slice.js +++ b/src/course-home/data/slice.js @@ -22,6 +22,7 @@ const slice = createSlice({ }, fetchTabSuccess: (state, { payload }) => { state.courseId = payload.courseId; + state.targetUserId = payload.targetUserId; state.courseStatus = LOADED; }, fetchTabFailure: (state, { payload }) => { diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js index 44cabead..f95a7052 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -27,12 +27,12 @@ const eventTypes = { POST_EVENT: 'post_event', }; -export function fetchTab(courseId, tab, getTabData) { +export function fetchTab(courseId, tab, getTabData, targetUserId) { return async (dispatch) => { dispatch(fetchTabRequest({ courseId })); Promise.allSettled([ getCourseHomeCourseMetadata(courseId), - getTabData(courseId), + getTabData(courseId, targetUserId), ]).then(([courseHomeCourseMetadataResult, tabDataResult]) => { const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled'; const fetchedTabData = tabDataResult.status === 'fulfilled'; @@ -62,7 +62,7 @@ export function fetchTab(courseId, tab, getTabData) { } if (fetchedCourseHomeCourseMetadata && fetchedTabData) { - dispatch(fetchTabSuccess({ courseId })); + dispatch(fetchTabSuccess({ courseId, targetUserId })); } else { dispatch(fetchTabFailure({ courseId })); } @@ -74,8 +74,8 @@ export function fetchDatesTab(courseId) { return fetchTab(courseId, 'dates', getDatesTabData); } -export function fetchProgressTab(courseId) { - return fetchTab(courseId, 'progress', getProgressTabData); +export function fetchProgressTab(courseId, targetUserId) { + return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId); } export function fetchOutlineTab(courseId) { diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx index a4a2f051..a6183bfb 100644 --- a/src/course-home/outline-tab/OutlineTab.jsx +++ b/src/course-home/outline-tab/OutlineTab.jsx @@ -16,13 +16,14 @@ import messages from './messages'; import Section from './Section'; import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert'; import UpdateGoalSelector from './widgets/UpdateGoalSelector'; -import UpgradeCard from '../../generic/upgrade-card/UpgradeCard'; +import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification'; import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert'; import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert'; import useCertificateAvailableAlert from './alerts/certificate-status-alert'; import useCourseEndAlert from './alerts/course-end-alert'; import useCourseStartAlert from './alerts/course-start-alert'; import usePrivateCourseAlert from './alerts/private-course-alert'; +import useScheduledContentAlert from './alerts/scheduled-content-alert'; import { useModel } from '../../generic/model-store'; import WelcomeMessage from './widgets/WelcomeMessage'; import ProctoringInfoPanel from './widgets/ProctoringInfoPanel'; @@ -90,6 +91,7 @@ function OutlineTab({ intl }) { const courseEndAlert = useCourseEndAlert(courseId); const certificateAvailableAlert = useCertificateAvailableAlert(courseId); const privateCourseAlert = usePrivateCourseAlert(courseId); + const scheduledContentAlert = useScheduledContentAlert(courseId); const rootCourseId = courses && Object.keys(courses)[0]; @@ -152,6 +154,7 @@ function OutlineTab({ intl }) { ...certificateAvailableAlert, ...courseEndAlert, ...courseStartAlert, + ...scheduledContentAlert, }} /> )} @@ -216,7 +219,7 @@ function OutlineTab({ intl }) { { MMP2P.state.isEnabled ? : ( - { expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument(); }); }); + + describe('Scheduled Content Alert', () => { + it('appears correctly', async () => { + const now = new Date(); + const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { hasScheduledContent: true }); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + setMetadata({ is_enrolled: true }); + setTabData({ + course_blocks: { blocks: courseBlocks.blocks }, + date_blocks: [ + { + date_type: 'course-end-date', + date: tomorrow.toISOString(), + title: 'End', + }, + ], + }); + await fetchAndRender(); + expect(screen.queryByText('More content is coming soon!')).toBeInTheDocument(); + }); + }); + describe('Scheduled Content Alert not present without courseBlocks', () => { + it('appears correctly', async () => { + const now = new Date(); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + setMetadata({ is_enrolled: true }); + setTabData({ + course_blocks: null, + date_blocks: [ + { + date_type: 'course-end-date', + date: tomorrow.toISOString(), + title: 'End', + }, + ], + }); + await fetchAndRender(); + expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument(); + expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument(); + }); + }); }); describe('Certificate (web) Complete Alert', () => { @@ -722,6 +763,33 @@ describe('Outline Tab', () => { }); }); + describe('Requesting Certificate Alert', () => { + it('appears', async () => { + const now = new Date(); + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + setMetadata({ is_enrolled: true }); + setTabData({ + cert_data: { + cert_status: CERT_STATUS_TYPE.REQUESTING, + cert_web_view_url: null, + certificate_available_date: null, + download_url: null, + }, + }, { + date_blocks: [ + { + date_type: 'course-end-date', + date: yesterday.toISOString(), + title: 'End', + }, + ], + }); + await fetchAndRender(); + expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument(); + expect(screen.queryByText('Request certificate')).toBeInTheDocument(); + }); + }); + describe('Certificate (pdf) Complete Alert', () => { it('appears', async () => { const now = new Date(); diff --git a/src/course-home/outline-tab/alerts/certificate-status-alert/CertificateStatusAlert.jsx b/src/course-home/outline-tab/alerts/certificate-status-alert/CertificateStatusAlert.jsx index 47a1be18..7e9c069b 100644 --- a/src/course-home/outline-tab/alerts/certificate-status-alert/CertificateStatusAlert.jsx +++ b/src/course-home/outline-tab/alerts/certificate-status-alert/CertificateStatusAlert.jsx @@ -7,23 +7,29 @@ import { intlShape, } from '@edx/frontend-platform/i18n'; import { Alert, Button } from '@edx/paragon'; +import { useDispatch } from 'react-redux'; + import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCheckCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { getConfig } from '@edx/frontend-platform'; import certMessages from './messages'; import certStatusMessages from '../../../progress-tab/certificate-status/messages'; +import { requestCert } from '../../../data/thunks'; export const CERT_STATUS_TYPE = { EARNED_NOT_AVAILABLE: 'earned_but_not_available', DOWNLOADABLE: 'downloadable', + REQUESTING: 'requesting', UNVERIFIED: 'unverified', }; function CertificateStatusAlert({ intl, payload }) { + const dispatch = useDispatch(); const { certificateAvailableDate, - certStatusType, + certStatus, courseEndDate, + courseId, certURL, isWebCert, userTimezone, @@ -38,7 +44,7 @@ function CertificateStatusAlert({ intl, payload }) { icon: faCheckCircle, iconClassName: 'alert-icon text-success-500', }; - if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) { + if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) { const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const certificateAvailableDateFormatted = ; const courseEndDateFormatted = ; @@ -57,7 +63,7 @@ function CertificateStatusAlert({ intl, payload }) { />

); - } else if (certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) { + } else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) { alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader); if (isWebCert) { alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton); @@ -66,6 +72,12 @@ function CertificateStatusAlert({ intl, payload }) { } alertProps.buttonVisible = true; alertProps.buttonLink = certURL; + } else if (certStatus === CERT_STATUS_TYPE.REQUESTING) { + alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader); + alertProps.buttonMessage = intl.formatMessage(certStatusMessages.requestableButton); + alertProps.buttonVisible = true; + alertProps.buttonLink = ''; + alertProps.buttonAction = () => { dispatch(requestCert(courseId)); }; } return alertProps; }; @@ -86,9 +98,10 @@ function CertificateStatusAlert({ intl, payload }) { }; let alertProps = {}; - switch (certStatusType) { + switch (certStatus) { case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE: case CERT_STATUS_TYPE.DOWNLOADABLE: + case CERT_STATUS_TYPE.REQUESTING: alertProps = renderCertAwardedStatus(); break; case CERT_STATUS_TYPE.UNVERIFIED: @@ -106,22 +119,26 @@ function CertificateStatusAlert({ intl, payload }) { iconClassName, icon, header, - buttonLink, body, + buttonAction, + buttonLink, buttonMessage, }) => ( -
+
{header} {body}
{buttonVisible && ( -
+
@@ -139,8 +156,9 @@ CertificateStatusAlert.propTypes = { intl: intlShape.isRequired, payload: PropTypes.shape({ certificateAvailableDate: PropTypes.string, - certStatusType: PropTypes.string, + certStatus: PropTypes.string, courseEndDate: PropTypes.string, + courseId: PropTypes.string, certURL: PropTypes.string, isWebCert: PropTypes.bool, userTimezone: PropTypes.string, diff --git a/src/course-home/outline-tab/alerts/certificate-status-alert/hooks.js b/src/course-home/outline-tab/alerts/certificate-status-alert/hooks.js index 2f2f130f..35ad4ad3 100644 --- a/src/course-home/outline-tab/alerts/certificate-status-alert/hooks.js +++ b/src/course-home/outline-tab/alerts/certificate-status-alert/hooks.js @@ -3,29 +3,28 @@ import React, { useMemo } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useAlert } from '../../../../generic/user-messages'; import { useModel } from '../../../../generic/model-store'; + import { CERT_STATUS_TYPE } from './CertificateStatusAlert'; const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert')); function verifyCertStatusType(status) { - // This method will only return cert statuses when we want to alert on them. - // It should be modified when we want to alert on a new status type. - if (status === CERT_STATUS_TYPE.DOWNLOADABLE) { - return CERT_STATUS_TYPE.DOWNLOADABLE; + switch (status) { + case CERT_STATUS_TYPE.DOWNLOADABLE: + case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE: + case CERT_STATUS_TYPE.REQUESTING: + case CERT_STATUS_TYPE.UNVERIFIED: + return true; + default: + return false; } - if (status === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) { - return CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE; - } - if (status === CERT_STATUS_TYPE.UNVERIFIED) { - return CERT_STATUS_TYPE.UNVERIFIED; - } - return ''; } function useCertificateStatusAlert(courseId) { const { isEnrolled, } = useModel('courseHomeMeta', courseId); + const { datesWidget: { courseDateBlocks, @@ -41,7 +40,6 @@ function useCertificateStatusAlert(courseId) { downloadUrl, } = certData || {}; const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date'); - const certStatusType = verifyCertStatusType(certStatus); const isWebCert = downloadUrl === null; let certURL = ''; @@ -51,14 +49,15 @@ function useCertificateStatusAlert(courseId) { // PDF Certificate certURL = downloadUrl; } - const hasCertStatus = certStatusType !== ''; + const hasAlertingCertStatus = verifyCertStatusType(certStatus); // Only show if there is a known cert status that we want provide status on. - const isVisible = isEnrolled && hasCertStatus; + const isVisible = isEnrolled && hasAlertingCertStatus; const payload = { certificateAvailableDate, certURL, - certStatusType, + certStatus, + courseId, courseEndDate: endBlock && endBlock.date, userTimezone, isWebCert, diff --git a/src/course-home/outline-tab/alerts/scheduled-content-alert/ScheduledCotentAlert.jsx b/src/course-home/outline-tab/alerts/scheduled-content-alert/ScheduledCotentAlert.jsx new file mode 100644 index 00000000..4f0dddbc --- /dev/null +++ b/src/course-home/outline-tab/alerts/scheduled-content-alert/ScheduledCotentAlert.jsx @@ -0,0 +1,49 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert, Button } from '@edx/paragon'; +import React from 'react'; +import PropTypes from 'prop-types'; + +function ScheduledContentAlert({ payload }) { + const { + datesTabLink, + } = payload; + + return ( + +
+
+ + + + +
+
+ {datesTabLink && ( + + )} +
+
+
+ ); +} + +ScheduledContentAlert.propTypes = { + payload: PropTypes.shape({ + datesTabLink: PropTypes.string, + }).isRequired, +}; + +export default ScheduledContentAlert; diff --git a/src/course-home/outline-tab/alerts/scheduled-content-alert/hooks.js b/src/course-home/outline-tab/alerts/scheduled-content-alert/hooks.js new file mode 100644 index 00000000..fe29a220 --- /dev/null +++ b/src/course-home/outline-tab/alerts/scheduled-content-alert/hooks.js @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; + +import { useAlert } from '../../../../generic/user-messages'; +import { useModel } from '../../../../generic/model-store'; + +const ScheduledContentAlert = React.lazy(() => import('./ScheduledCotentAlert')); + +const useScheduledContentAlert = (courseId) => { + const { + courseBlocks: { + courses, + }, + datesWidget: { + datesTabLink, + }, + } = useModel('outline', courseId); + + const hasScheduledContent = ( + !!courses + && !!Object.values(courses).find(course => course.hasScheduledContent === true) + ); + const { isEnrolled } = useModel('courseHomeMeta', courseId); + const payload = { + datesTabLink, + }; + useAlert(hasScheduledContent && isEnrolled, { + code: 'ScheduledContentAlert', + payload: useMemo(() => payload, Object.values(payload).sort()), + topic: 'outline-course-alerts', + }); + + return { ScheduledContentAlert }; +}; + +export default useScheduledContentAlert; diff --git a/src/course-home/outline-tab/alerts/scheduled-content-alert/index.js b/src/course-home/outline-tab/alerts/scheduled-content-alert/index.js new file mode 100644 index 00000000..ed12eb0b --- /dev/null +++ b/src/course-home/outline-tab/alerts/scheduled-content-alert/index.js @@ -0,0 +1 @@ +export { default } from './hooks'; diff --git a/src/course-home/progress-tab/ProgressHeader.jsx b/src/course-home/progress-tab/ProgressHeader.jsx index 2ff8bc32..38f9cfa3 100644 --- a/src/course-home/progress-tab/ProgressHeader.jsx +++ b/src/course-home/progress-tab/ProgressHeader.jsx @@ -12,16 +12,23 @@ import messages from './messages'; function ProgressHeader({ intl }) { const { courseId, + targetUserId, } = useSelector(state => state.courseHome); - const { administrator } = getAuthenticatedUser(); + const { administrator, userId } = getAuthenticatedUser(); - const { studioUrl } = useModel('progress', courseId); + const { studioUrl, username } = useModel('progress', courseId); + + const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId); + + const pageTitle = viewingOtherStudentsProgressPage + ? intl.formatMessage(messages.progressHeaderForTargetUser, { username }) + : intl.formatMessage(messages.progressHeader); return ( <>
-

{intl.formatMessage(messages.progressHeader)}

+

{pageTitle}

{administrator && studioUrl && (
@@ -118,9 +118,9 @@ function Course({ unitNavigationHandler={unitNavigationHandler} nextSequenceHandler={nextSequenceHandler} previousSequenceHandler={previousSequenceHandler} - toggleSidebar={toggleSidebar} - isSidebarVisible={isSidebarVisible} - sidebarVisible={sidebarVisible} + toggleNotificationTray={toggleNotificationTray} + isNotificationTrayVisible={isNotificationTrayVisible} + notificationTrayVisible={notificationTrayVisible} isValuePropCookieSet={isValuePropCookieSet} //* * [MM-P2P] Experiment */ mmp2p={MMP2P} diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 418a633f..5b795e5f 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -20,7 +20,7 @@ describe('Course', () => { nextSequenceHandler: () => {}, previousSequenceHandler: () => {}, unitNavigationHandler: () => {}, - toggleSidebar: () => {}, + toggleNotificationTray: () => {}, }; beforeAll(async () => { @@ -87,9 +87,9 @@ describe('Course', () => { expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument(); }); - it('displays sidebar notification button', async () => { - const toggleSidebar = jest.fn(); - const isSidebarVisible = jest.fn(); + it('displays notification trigger', async () => { + const toggleNotificationTray = jest.fn(); + const isNotificationTrayVisible = jest.fn(); // REV-2297 TODO: remove cookie related code once temporary value prop cookie code is removed. const cookieName = 'value_prop_cookie'; @@ -101,15 +101,15 @@ describe('Course', () => { const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false); const testData = { ...mockData, - toggleSidebar, - isSidebarVisible, + toggleNotificationTray, + isNotificationTrayVisible, }; render(, { store: testStore }); - const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i }); + const notificationOpenButton = screen.getByRole('button', { name: /Show notification tray/i }); expect(getSpy).toBeCalledWith(cookieName); - expect(sidebarOpenButton).toBeInTheDocument(); + expect(notificationOpenButton).toBeInTheDocument(); }); it('displays offer and expiration alert', async () => { diff --git a/src/courseware/course/NotificationIcon.jsx b/src/courseware/course/NotificationIcon.jsx index 7f982ed1..da8f0112 100644 --- a/src/courseware/course/NotificationIcon.jsx +++ b/src/courseware/course/NotificationIcon.jsx @@ -11,7 +11,7 @@ import messages from './messages'; function NotificationIcon({ intl, status, notificationColor }) { return (
- + {status === 'active' ? : null} diff --git a/src/courseware/course/Sidebar.jsx b/src/courseware/course/NotificationTray.jsx similarity index 60% rename from src/courseware/course/Sidebar.jsx rename to src/courseware/course/NotificationTray.jsx index 4721e9d0..85b83d19 100644 --- a/src/courseware/course/Sidebar.jsx +++ b/src/courseware/course/NotificationTray.jsx @@ -9,10 +9,10 @@ import { ArrowBackIos, Close } from '@edx/paragon/icons'; import messages from './messages'; import { useModel } from '../../generic/model-store'; import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize'; -import UpgradeCard from '../../generic/upgrade-card/UpgradeCard'; +import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification'; -function Sidebar({ - intl, toggleSidebar, +function NotificationTray({ + intl, toggleNotificationTray, }) { const { courseId, @@ -33,23 +33,23 @@ function Sidebar({ const shouldDisplayFullScreen = useWindowSize().width < responsiveBreakpoints.large.minWidth; return ( -
+
{shouldDisplayFullScreen ? ( -
{ toggleSidebar(); }} onKeyDown={() => { toggleSidebar(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseSidebar)}> +
{ toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}> - {intl.formatMessage(messages.responsiveCloseSidebar)} + {intl.formatMessage(messages.responsiveCloseNotificationTray)}
) : null} -
+
{intl.formatMessage(messages.notificationTitle)} {shouldDisplayFullScreen ? null - : { toggleSidebar(); }} onKeyDown={() => { toggleSidebar(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.closeSidebarButton)} />} + : { toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.closeNotificationTrigger)} />}
-
+
{verifiedMode ? ( - - ) :

{intl.formatMessage(messages.noNotificationsMessage)}

} + ) :

{intl.formatMessage(messages.noNotificationsMessage)}

}
); } -Sidebar.propTypes = { +NotificationTray.propTypes = { intl: intlShape.isRequired, - toggleSidebar: PropTypes.func, + toggleNotificationTray: PropTypes.func, }; -Sidebar.defaultProps = { - toggleSidebar: null, +NotificationTray.defaultProps = { + toggleNotificationTray: null, }; -export default injectIntl(Sidebar); +export default injectIntl(NotificationTray); diff --git a/src/courseware/course/Sidebar.scss b/src/courseware/course/NotificationTray.scss similarity index 89% rename from src/courseware/course/Sidebar.scss rename to src/courseware/course/NotificationTray.scss index 2417e329..b3471a81 100644 --- a/src/courseware/course/Sidebar.scss +++ b/src/courseware/course/NotificationTray.scss @@ -1,4 +1,4 @@ -.sidebar-container { +.notification-tray-container { border: 1px solid $light-400; border-radius: 4px; width: 31rem; @@ -23,7 +23,7 @@ height: 15rem; } -.sidebar-header { +.notification-tray-header { padding: 0.625rem 0; span { @@ -35,7 +35,7 @@ float: right; } -.sidebar-divider { +.notification-tray-divider { width: 100.5%; height: 0.5rem; background: $gray-100; @@ -43,7 +43,7 @@ border-left: 0; } -.sidebar-content { +.notification-tray-content { padding: 1rem; font-size: 0.875rem; } diff --git a/src/courseware/course/Sidebar.test.jsx b/src/courseware/course/NotificationTray.test.jsx similarity index 77% rename from src/courseware/course/Sidebar.test.jsx rename to src/courseware/course/NotificationTray.test.jsx index fdb4473b..acbcf0af 100644 --- a/src/courseware/course/Sidebar.test.jsx +++ b/src/courseware/course/NotificationTray.test.jsx @@ -10,14 +10,14 @@ import { } from '../../setupTest'; import initializeStore from '../../store'; import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils'; -import Sidebar from './Sidebar'; +import NotificationTray from './NotificationTray'; import useWindowSize from '../../generic/tabs/useWindowSize'; initializeMockApp(); jest.mock('../../generic/tabs/useWindowSize'); jest.mock('@edx/frontend-platform/analytics'); -describe('Sidebar', () => { +describe('NotificationTray', () => { let mockData; let axiosMock; let store; @@ -43,45 +43,45 @@ describe('Sidebar', () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); mockData = { - toggleSidebar: () => {}, + toggleNotificationTray: () => {}, }; }); - it('renders sidebar', async () => { + it('renders notification tray', async () => { useWindowSize.mockReturnValue({ width: 1200, height: 422 }); - await fetchAndRender(); + await fetchAndRender(); expect(screen.getByText('Notifications')).toBeInTheDocument(); expect(screen.queryByText('Back to course')).not.toBeInTheDocument(); }); it('renders upgrade card', async () => { - await fetchAndRender(); - const upgradeCard = document.querySelector('.upgrade-card'); + await fetchAndRender(); + const UpgradeNotification = document.querySelector('.upgrade-notification'); - expect(upgradeCard).toBeInTheDocument(); + expect(UpgradeNotification).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument(); expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument(); }); it('renders no notifications message if no verified mode', async () => { setMetadata({ verified_mode: null }); - await fetchAndRender(); + await fetchAndRender(); expect(screen.queryByText('You have no new notifications at this time.')).toBeInTheDocument(); }); - it('renders sidebar with full screen "Back to course" at response width', async () => { + it('renders notification tray with full screen "Back to course" at response width', async () => { useWindowSize.mockReturnValue({ width: 991, height: 422 }); - const toggleSidebar = jest.fn(); + const toggleNotificationTray = jest.fn(); const testData = { ...mockData, - toggleSidebar, + toggleNotificationTray, }; - await fetchAndRender(); + await fetchAndRender(); const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' }); await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument()); fireEvent.click(responsiveCloseButton); - expect(toggleSidebar).toHaveBeenCalledTimes(1); + expect(toggleNotificationTray).toHaveBeenCalledTimes(1); }); }); diff --git a/src/courseware/course/SidebarNotificationButton.jsx b/src/courseware/course/NotificationTrigger.jsx similarity index 51% rename from src/courseware/course/SidebarNotificationButton.jsx rename to src/courseware/course/NotificationTrigger.jsx index 4ff7ccc0..183a35a1 100644 --- a/src/courseware/course/SidebarNotificationButton.jsx +++ b/src/courseware/course/NotificationTrigger.jsx @@ -6,13 +6,13 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import NotificationIcon from './NotificationIcon'; import messages from './messages'; -function SidebarNotificationButton({ intl, toggleSidebar, isSidebarVisible }) { +function NotificationTrigger({ intl, toggleNotificationTray, isNotificationTrayVisible }) { return ( ); }; return sequenceStatus === LOADED && ( -