diff --git a/src/alerts/enrollment-alert/hooks.js b/src/alerts/enrollment-alert/hooks.js index d0b5b510..b7e83ff3 100644 --- a/src/alerts/enrollment-alert/hooks.js +++ b/src/alerts/enrollment-alert/hooks.js @@ -8,7 +8,7 @@ import { postCourseEnrollment } from './data/api'; export function useEnrollmentAlert(courseId) { - const course = useModel('courses', courseId); + const course = useModel('courseHomeMetadata', courseId); const code = course.isStaff ? 'clientStaffEnrollmentAlert' : 'clientEnrollmentAlert'; const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled; diff --git a/src/course-header/CourseTabsNavigation.jsx b/src/course-header/CourseTabsNavigation.jsx index a24c30e5..3efdb39f 100644 --- a/src/course-header/CourseTabsNavigation.jsx +++ b/src/course-header/CourseTabsNavigation.jsx @@ -36,7 +36,6 @@ CourseTabsNavigation.propTypes = { className: PropTypes.string, tabs: PropTypes.arrayOf(PropTypes.shape({ title: PropTypes.string.isRequired, - priority: PropTypes.number.isRequired, slug: PropTypes.string.isRequired, url: PropTypes.string.isRequired, })).isRequired, diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx index 2be2a3d6..1e3d9fae 100644 --- a/src/course-home/outline-tab/OutlineTab.jsx +++ b/src/course-home/outline-tab/OutlineTab.jsx @@ -29,7 +29,7 @@ export default function OutlineTab() { enrollmentEnd, enrollmentMode, isEnrolled, - } = useModel('courses', courseId); + } = useModel('courseHomeMetadata', courseId); const { courseBlocks: { diff --git a/src/data/api.js b/src/data/api.js index 1fda3d98..38ad54f6 100644 --- a/src/data/api.js +++ b/src/data/api.js @@ -40,12 +40,30 @@ function normalizeMetadata(metadata) { }; } +function normalizeCourseHomeCourseMetadata(metadata) { + const data = camelCaseObject(metadata); + return { + ...data, + tabs: data.tabs.map(tab => ({ + slug: tab.tabId, + title: tab.title, + url: tab.url, + })), + }; +} + export async function getCourseMetadata(courseId) { const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`; const { data } = await getAuthenticatedHttpClient().get(url); return normalizeMetadata(data); } +export async function getCourseHomeCourseMetadata(courseId) { + const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`; + const { data } = await getAuthenticatedHttpClient().get(url); + return normalizeCourseHomeCourseMetadata(data); +} + export async function getDatesTabData(courseId, version) { const url = `${getConfig().LMS_BASE_URL}/api/course_home/${version}/dates/${courseId}`; try { @@ -208,3 +226,8 @@ export async function getResumeBlock(courseId) { const { data } = await getAuthenticatedHttpClient().get(url.href, {}); return camelCaseObject(data); } + +export async function updateCourseDeadlines(courseId) { + const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`); + await getAuthenticatedHttpClient().post(url.href, { course_key: courseId }); +} diff --git a/src/data/thunks.js b/src/data/thunks.js index 51396474..98f23534 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -5,6 +5,8 @@ import { getSequenceMetadata, getDatesTabData, getOutlineTabData, + getCourseHomeCourseMetadata, + updateCourseDeadlines, } from './api'; import { addModelsMap, updateModel, updateModels, updateModelsMap, addModel, @@ -95,24 +97,27 @@ export function fetchTab(courseId, tab, version, getTabData) { return async (dispatch) => { dispatch(fetchTabRequest({ courseId })); Promise.allSettled([ - getCourseMetadata(courseId), + getCourseHomeCourseMetadata(courseId), getTabData(courseId, version), - ]).then(([courseMetadataResult, tabDataResult]) => { - const fetchedMetadata = courseMetadataResult.status === 'fulfilled'; + ]).then(([courseHomeCourseMetadataResult, tabDataResult]) => { + const fetchedCourseHomeMetaData = courseHomeCourseMetadataResult.status === 'fulfilled'; const fetchedTabData = tabDataResult.status === 'fulfilled'; - if (fetchedMetadata) { + if (fetchedCourseHomeMetaData) { /* * NOTE: The "courses" models created by this thunk do not include an array of sectionIds. * If that data is required for some use case, then fetchTab will need to call * getCourseBlocks as well. See fetchCourse above. */ dispatch(addModel({ - modelType: 'courses', - model: courseMetadataResult.value, + modelType: 'courseHomeMetadata', + model: { + id: courseId, + ...courseHomeCourseMetadataResult.value, + }, })); } else { - logError(courseMetadataResult.reason); + logError(courseHomeCourseMetadataResult.reason); } if (fetchedTabData) { @@ -127,7 +132,7 @@ export function fetchTab(courseId, tab, version, getTabData) { logError(tabDataResult.reason); } - if (fetchedMetadata && fetchedTabData) { + if (fetchedCourseHomeMetaData && fetchedTabData) { dispatch(fetchTabSuccess({ courseId })); } else { dispatch(fetchTabFailure({ courseId })); @@ -164,3 +169,13 @@ export function fetchSequence(sequenceId) { } }; } + +export function resetDeadlines(courseId, tab, getTabData) { + return async (dispatch) => { + updateCourseDeadlines(courseId).then(() => { + if (tab === 'dates') { + dispatch(getTabData(courseId)); + } + }); + }; +} diff --git a/src/dates-banner/DatesBanner.jsx b/src/dates-banner/DatesBanner.jsx new file mode 100644 index 00000000..6c8d4a0d --- /dev/null +++ b/src/dates-banner/DatesBanner.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +function DatesBanner(props) { + const { + intl, + name, + bannerClickHandler, + } = props; + + return ( +
+
+
+ + {intl.formatMessage(messages[`datesBanner.${name}.header`])} + + {intl.formatMessage(messages[`datesBanner.${name}.body`])} +
+ {bannerClickHandler && ( + + )} +
+
+ ); +} + +DatesBanner.propTypes = { + intl: intlShape.isRequired, + name: PropTypes.string.isRequired, + bannerClickHandler: PropTypes.func, +}; + +DatesBanner.defaultProps = { + bannerClickHandler: null, +}; + +export default injectIntl(DatesBanner); diff --git a/src/dates-banner/DatesBannerContainer.jsx b/src/dates-banner/DatesBannerContainer.jsx new file mode 100644 index 00000000..982c625f --- /dev/null +++ b/src/dates-banner/DatesBannerContainer.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useModel } from '../model-store'; + +import DatesBanner from './DatesBanner'; +import { fetchDatesTab, resetDeadlines } from '../data/thunks'; + +function DatesBannerContainer(props) { + const { + model, + } = props; + + const { + courseId, + } = useSelector(state => state.courseware); + + const { + datesBannerInfo, + } = useModel(model, courseId); + + const { + contentTypeGatingEnabled, + missedDeadlines, + missedGatedContent, + verifiedUpgradeLink, + } = datesBannerInfo; + + const { + isSelfPaced, + } = useModel('courseHomeMetadata', courseId); + + const dispatch = useDispatch(); + const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines; + const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent; + const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent; + const datesBanners = [ + { name: 'datesTabInfoBanner', shouldDisplay: model === 'dates' && !missedDeadlines && isSelfPaced }, + { + name: 'upgradeToCompleteGradedBanner', + shouldDisplay: upgradeToCompleteGraded, + clickHandler: () => window.location.replace(verifiedUpgradeLink), + }, + { + name: 'upgradeToResetBanner', + shouldDisplay: upgradeToReset, + clickHandler: () => window.location.replace(verifiedUpgradeLink), + }, + { + name: 'resetDatesBanner', + shouldDisplay: resetDates, + clickHandler: () => dispatch(resetDeadlines(courseId, model, fetchDatesTab)), + }, + ]; + + return ( + <> + {datesBanners.map((banner) => banner.shouldDisplay && ( + + ))} + + ); +} + +DatesBannerContainer.propTypes = { + model: PropTypes.string.isRequired, +}; + +export default DatesBannerContainer; diff --git a/src/dates-banner/messages.js b/src/dates-banner/messages.js new file mode 100644 index 00000000..cae3f829 --- /dev/null +++ b/src/dates-banner/messages.js @@ -0,0 +1,66 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'datesBanner.datesTabInfoBanner.header': { + id: 'datesBanner.datesTabInfoBanner.header', + defaultMessage: "We've built a suggested schedule to help you stay on track. ", + description: 'Strong text in Dates Tab Info Banner', + }, + 'datesBanner.datesTabInfoBanner.body': { + id: 'datesBanner.datesTabInfoBanner.body', + defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on + our suggested dates, you'll be able to adjust them to keep yourself on track.`, + description: 'Body in Dates Tab Info Banner', + }, + 'datesBanner.upgradeToCompleteGradedBanner.header': { + id: 'datesBanner.upgradeToCompleteGradedBanner.header', + defaultMessage: 'You are auditing this course, ', + description: 'Strong text in Upgrade To Complete Graded Banner', + }, + 'datesBanner.upgradeToCompleteGradedBanner.body': { + id: 'datesBanner.upgradeToCompleteGradedBanner.body', + defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded + assignments as part of this course, you can upgrade today.`, + description: 'Body in Upgrade To Complete Graded Banner', + }, + 'datesBanner.upgradeToCompleteGradedBanner.button': { + id: 'datesBanner.upgradeToCompleteGradedBanner.button', + defaultMessage: 'Upgrade now', + description: 'Button in Upgrade To Complete Graded Banner', + }, + 'datesBanner.upgradeToResetBanner.header': { + id: 'datesBanner.upgradeToResetBanner.header', + defaultMessage: 'You are auditing this course, ', + description: 'Strong text in Upgrade To Reset Banner', + }, + 'datesBanner.upgradeToResetBanner.body': { + id: 'datesBanner.upgradeToResetBanner.body', + defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed + some important deadlines based on our suggested schedule. To complete graded assignments as part of this course + and shift the past due assignments into the future, you can upgrade today.`, + description: 'Body in Upgrade To Reset Banner', + }, + 'datesBanner.upgradeToResetBanner.button': { + id: 'datesBanner.upgradeToResetBanner.button', + defaultMessage: 'Upgrade to shift due dates', + description: 'Button in Upgrade To Reset Banner', + }, + 'datesBanner.resetDatesBanner.header': { + id: 'datesBanner.resetDatesBanner.header', + defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ', + description: 'Strong text in Reset Dates Banner', + }, + 'datesBanner.resetDatesBanner.body': { + id: 'datesBanner.resetDatesBanner.body', + defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into + the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.`, + description: 'Body in Reset Dates Banner', + }, + 'datesBanner.resetDatesBanner.button': { + id: 'datesBanner.resetDatesBanner.button', + defaultMessage: 'Reset my deadlines', + description: 'Button in Reset Dates Banner', + }, +}); + +export default messages; diff --git a/src/dates-tab/DatesTab.jsx b/src/dates-tab/DatesTab.jsx index 088d2685..54403e9f 100644 --- a/src/dates-tab/DatesTab.jsx +++ b/src/dates-tab/DatesTab.jsx @@ -3,10 +3,12 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from './messages'; import Timeline from './Timeline'; +import DatesBannerContainer from '../dates-banner/DatesBannerContainer'; function DatesTab({ intl }) { return ( <> +

{intl.formatMessage(messages.title)}

diff --git a/src/tab-page/LoadedTabPage.jsx b/src/tab-page/LoadedTabPage.jsx index 8e538b44..89788416 100644 --- a/src/tab-page/LoadedTabPage.jsx +++ b/src/tab-page/LoadedTabPage.jsx @@ -20,7 +20,7 @@ function LoadedTabPage({ org, tabs, title, - } = useModel('courses', courseId); + } = useModel('courseHomeMetadata', courseId); return ( <>