diff --git a/src/course-home/data/__factories__/outlineTabData.factory.js b/src/course-home/data/__factories__/outlineTabData.factory.js
index 4f332c33..381a7c7b 100644
--- a/src/course-home/data/__factories__/outlineTabData.factory.js
+++ b/src/course-home/data/__factories__/outlineTabData.factory.js
@@ -17,6 +17,10 @@ Factory.define('outlineTabData')
blocks: courseBlocks.blocks,
};
})
+ .attr('course_goals', [], () => ({
+ goal_options: [],
+ selected_goal: {},
+ }))
.attr('enroll_alert', {
can_enroll: true,
extra_text: 'Contact the administrator.',
diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap
index 8ebc3f75..8a5cd8db 100644
--- a/src/course-home/data/__snapshots__/redux.test.js.snap
+++ b/src/course-home/data/__snapshots__/redux.test.js.snap
@@ -5,8 +5,9 @@ Object {
"courseHome": Object {
"courseId": null,
"courseStatus": "loading",
- "resetDatesToastBody": null,
- "resetDatesToastHeader": null,
+ "toastBodyLink": null,
+ "toastBodyText": null,
+ "toastHeader": null,
},
"courseware": Object {
"courseId": null,
@@ -23,8 +24,9 @@ Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
- "resetDatesToastBody": null,
- "resetDatesToastHeader": null,
+ "toastBodyLink": null,
+ "toastBodyText": null,
+ "toastHeader": null,
},
"courseware": Object {
"courseId": null,
@@ -108,8 +110,9 @@ Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
- "resetDatesToastBody": null,
- "resetDatesToastHeader": null,
+ "toastBodyLink": null,
+ "toastBodyText": null,
+ "toastHeader": null,
},
"courseware": Object {
"courseId": null,
@@ -201,6 +204,10 @@ Object {
},
},
"courseExpiredHtml": "
Course expired
",
+ "courseGoals": Object {
+ "goalOptions": Array [],
+ "selectedGoal": Object {},
+ },
"courseTools": Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js
index 41cc6ad8..5dca115b 100644
--- a/src/course-home/data/api.js
+++ b/src/course-home/data/api.js
@@ -69,6 +69,7 @@ export async function getOutlineTabData(courseId) {
data,
} = tabData;
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
+ const courseGoals = camelCaseObject(data.course_goals);
const courseExpiredHtml = data.course_expired_html;
const courseTools = camelCaseObject(data.course_tools);
const datesWidget = camelCaseObject(data.dates_widget);
@@ -80,6 +81,7 @@ export async function getOutlineTabData(courseId) {
return {
courseBlocks,
+ courseGoals,
courseExpiredHtml,
courseTools,
datesWidget,
@@ -96,6 +98,11 @@ export async function postCourseDeadlines(courseId) {
return getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
}
+export async function postCourseGoals(courseId, goalKey) {
+ const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
+ return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
+}
+
export async function postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
diff --git a/src/course-home/data/index.js b/src/course-home/data/index.js
index 5c088879..c5f79fa6 100644
--- a/src/course-home/data/index.js
+++ b/src/course-home/data/index.js
@@ -3,6 +3,7 @@ export {
fetchOutlineTab,
fetchProgressTab,
resetDeadlines,
+ saveCourseGoal,
} from './thunks';
export { reducer } from './slice';
diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js
index ad152694..2446eebf 100644
--- a/src/course-home/data/redux.test.js
+++ b/src/course-home/data/redux.test.js
@@ -106,6 +106,18 @@ describe('Data layer integration tests', () => {
});
});
+ describe('Test saveCourseGoal', () => {
+ it('Should save course goal', async () => {
+ const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
+ axiosMock.onPost(goalUrl).reply(200, {});
+
+ await thunks.saveCourseGoal(courseId, 'unsure');
+
+ expect(axiosMock.history.post[0].url).toEqual(goalUrl);
+ expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
+ });
+ });
+
describe('Test resetDeadlines', () => {
it('Should reset course deadlines', async () => {
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
diff --git a/src/course-home/data/slice.js b/src/course-home/data/slice.js
index a78dc8b6..8b55f619 100644
--- a/src/course-home/data/slice.js
+++ b/src/course-home/data/slice.js
@@ -10,8 +10,9 @@ const slice = createSlice({
initialState: {
courseStatus: 'loading',
courseId: null,
- resetDatesToastBody: null,
- resetDatesToastHeader: null,
+ toastBodyText: null,
+ toastBodyLink: null,
+ toastHeader: null,
},
reducers: {
fetchTabRequest: (state, { payload }) => {
@@ -26,13 +27,15 @@ const slice = createSlice({
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
- setResetDatesToast: (state, { payload }) => {
+ setCallToActionToast: (state, { payload }) => {
const {
- body,
header,
+ link,
+ linkText,
} = payload;
- state.resetDatesToastBody = body;
- state.resetDatesToastHeader = header;
+ state.toastBodyLink = link;
+ state.toastBodyText = linkText;
+ state.toastHeader = header;
},
},
});
@@ -41,7 +44,7 @@ export const {
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
- setResetDatesToast,
+ setCallToActionToast,
} = slice.actions;
export const {
diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js
index bd4373b2..4c8f66f6 100644
--- a/src/course-home/data/thunks.js
+++ b/src/course-home/data/thunks.js
@@ -7,6 +7,7 @@ import {
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
+ postCourseGoals,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
@@ -19,7 +20,7 @@ import {
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
- setResetDatesToast,
+ setCallToActionToast,
} from './slice';
const eventTypes = {
@@ -81,20 +82,6 @@ export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
-export function resetDeadlines(courseId, getTabData) {
- return async (dispatch) => {
- postCourseDeadlines(courseId).then(response => {
- const { data } = response;
- const {
- body,
- header,
- } = data;
- dispatch(getTabData(courseId));
- dispatch(setResetDatesToast({ body, header }));
- });
- };
-}
-
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
@@ -103,6 +90,25 @@ export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}
+export function resetDeadlines(courseId, getTabData) {
+ return async (dispatch) => {
+ postCourseDeadlines(courseId).then(response => {
+ const { data } = response;
+ const {
+ header,
+ link,
+ link_text: linkText,
+ } = data;
+ dispatch(getTabData(courseId));
+ dispatch(setCallToActionToast({ header, link, linkText }));
+ });
+ };
+}
+
+export async function saveCourseGoal(courseId, goalKey) {
+ return postCourseGoals(courseId, goalKey);
+}
+
export function processEvent(eventData, getTabData) {
return async (dispatch) => {
const event = camelCaseObject(eventData);
@@ -110,11 +116,12 @@ export function processEvent(eventData, getTabData) {
executePostFromPostEvent(event.postData).then(response => {
const { data } = response;
const {
- body,
header,
+ link,
+ link_text: linkText,
} = data;
dispatch(getTabData(event.postData.bodyParams.courseId));
- dispatch(setResetDatesToast({ body, header }));
+ dispatch(setCallToActionToast({ header, link, linkText }));
});
}
};
diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx
index d249d1a4..f0ba8a2b 100644
--- a/src/course-home/outline-tab/OutlineTab.jsx
+++ b/src/course-home/outline-tab/OutlineTab.jsx
@@ -1,14 +1,17 @@
-import React from 'react';
+import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
+import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts';
import CourseTools from './widgets/CourseTools';
+import LearningToast from '../../toast/LearningToast';
import messages from './messages';
import Section from './Section';
+import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
import useCourseEndAlert from './alerts/course-end-alert';
@@ -39,6 +42,10 @@ function OutlineTab({ intl }) {
courses,
sections,
},
+ courseGoals: {
+ goalOptions,
+ selectedGoal,
+ },
courseExpiredHtml,
resumeCourse: {
hasVisitedCourse,
@@ -47,6 +54,9 @@ function OutlineTab({ intl }) {
offerHtml,
} = useModel('outline', courseId);
+ const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
+ const [goalToastHeader, setGoalToastHeader] = useState(null);
+
// Above the tab alerts (appearing in the order listed here)
const logistrationAlert = useLogistrationAlert();
const enrollmentAlert = useEnrollmentAlert(courseId);
@@ -71,6 +81,11 @@ function OutlineTab({ intl }) {
...logistrationAlert,
}}
/>
+ setGoalToastHeader(null)}
+ show={!!(goalToastHeader)}
+ />
{title}
{resumeCourseUrl && (
@@ -80,7 +95,16 @@ function OutlineTab({ intl }) {
)}
-
+
+ {!courseGoalToDisplay && goalOptions && (
+
{ setCourseGoalToDisplay(newGoal); }}
+ setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
+ />
+ )}
))}
-
+
+ {courseGoalToDisplay && goalOptions && (
+
{ setCourseGoalToDisplay(newGoal); }}
+ setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
+ />
+ )}
diff --git a/src/course-home/outline-tab/messages.js b/src/course-home/outline-tab/messages.js
index c914ad0f..66b9fe44 100644
--- a/src/course-home/outline-tab/messages.js
+++ b/src/course-home/outline-tab/messages.js
@@ -9,6 +9,25 @@ const messages = defineMessages({
id: 'learning.outline.dates',
defaultMessage: 'Upcoming Dates',
},
+ editGoal: {
+ id: 'learning.outline.editGoal',
+ defaultMessage: 'Edit goal',
+ description: 'Edit course goal button',
+ },
+ goal: {
+ id: 'learning.outline.goal',
+ defaultMessage: 'Goal',
+ description: 'Label for the selected course goal',
+ },
+ goalUnsure: {
+ id: 'learning.outline.goalUnsure',
+ defaultMessage: 'Not sure yet',
+ },
+ goalWelcome: {
+ id: 'learning.outline.goalWelcome',
+ defaultMessage: 'Welcome to',
+ description: 'This precedes the title of the course',
+ },
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
@@ -17,6 +36,10 @@ const messages = defineMessages({
id: 'learning.outline.resume',
defaultMessage: 'Resume Course',
},
+ setGoal: {
+ id: 'learning.outline.setGoal',
+ defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
+ },
start: {
id: 'learning.outline.start',
defaultMessage: 'Start Course',
diff --git a/src/course-home/outline-tab/widgets/CourseGoalCard.jsx b/src/course-home/outline-tab/widgets/CourseGoalCard.jsx
new file mode 100644
index 00000000..7c068423
--- /dev/null
+++ b/src/course-home/outline-tab/widgets/CourseGoalCard.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Card } from '@edx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import messages from '../messages';
+
+import { saveCourseGoal } from '../../data';
+
+function CourseGoalCard({
+ courseId,
+ goalOptions,
+ intl,
+ title,
+ setGoalToDisplay,
+ setGoalToastHeader,
+}) {
+ function selectGoalHandler(event) {
+ const selectedGoal = {
+ key: event.currentTarget.getAttribute('data-goal-key'),
+ text: event.currentTarget.getAttribute('data-goal-text'),
+ };
+
+ saveCourseGoal(courseId, selectedGoal.key).then((response) => {
+ const { data } = response;
+ const {
+ header,
+ } = data;
+
+ setGoalToDisplay(selectedGoal);
+ setGoalToastHeader(header);
+ });
+ }
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.goalWelcome)} {title}
+
+
+
+
+
+ {intl.formatMessage(messages.setGoal)}
+
+ {goalOptions.map((goal) => {
+ const [goalKey, goalText] = goal;
+ return (
+ (goalKey !== 'unsure') && (
+
+
+
+ )
+ );
+ })}
+
+
+
+ );
+}
+
+CourseGoalCard.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ goalOptions: PropTypes.arrayOf(
+ PropTypes.arrayOf(PropTypes.string),
+ ).isRequired,
+ intl: intlShape.isRequired,
+ title: PropTypes.string.isRequired,
+ setGoalToDisplay: PropTypes.func.isRequired,
+ setGoalToastHeader: PropTypes.func.isRequired,
+};
+
+export default injectIntl(CourseGoalCard);
diff --git a/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx b/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
new file mode 100644
index 00000000..4a234cfe
--- /dev/null
+++ b/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
@@ -0,0 +1,107 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { Button, Card, Input } from '@edx/paragon';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
+
+import messages from '../messages';
+import { saveCourseGoal } from '../../data';
+
+function UpdateGoalSelector({
+ courseId,
+ goalOptions,
+ intl,
+ selectedGoal,
+ setGoalToDisplay,
+ setGoalToastHeader,
+}) {
+ const [editingGoal, setEditingGoal] = useState(false);
+
+ function selectGoalHandler(event) {
+ const key = event.currentTarget.value;
+ const { options } = event.currentTarget;
+ const { text } = options[options.selectedIndex];
+ const newGoal = {
+ key,
+ text,
+ };
+
+ setEditingGoal(false);
+ setGoalToDisplay(newGoal);
+ saveCourseGoal(courseId, key).then((response) => {
+ const { data } = response;
+ const {
+ header,
+ } = data;
+
+ setGoalToastHeader(header);
+ });
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {!editingGoal && (
+
{selectedGoal.text}
+ )}
+ {editingGoal && (
+
{ setEditingGoal(false); }}
+ onChange={(event) => { selectGoalHandler(event); }}
+ options={goalOptions.map(([goalKey, goalText]) => (
+ { value: goalKey, label: goalText }
+ ))}
+ autoFocus
+ />
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+UpdateGoalSelector.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ goalOptions: PropTypes.arrayOf(
+ PropTypes.arrayOf(PropTypes.string),
+ ).isRequired,
+ intl: intlShape.isRequired,
+ selectedGoal: PropTypes.shape({
+ key: PropTypes.string,
+ text: PropTypes.string,
+ }).isRequired,
+ setGoalToDisplay: PropTypes.func.isRequired,
+ setGoalToastHeader: PropTypes.func.isRequired,
+};
+
+export default injectIntl(UpdateGoalSelector);
diff --git a/src/tab-page/TabPage.jsx b/src/tab-page/TabPage.jsx
index 54d8fc56..ca3fc120 100644
--- a/src/tab-page/TabPage.jsx
+++ b/src/tab-page/TabPage.jsx
@@ -9,7 +9,7 @@ import PageLoading from '../generic/PageLoading';
import messages from './messages';
import LoadedTabPage from './LoadedTabPage';
import LearningToast from '../toast/LearningToast';
-import { setResetDatesToast } from '../course-home/data/slice';
+import { setCallToActionToast } from '../course-home/data/slice';
function TabPage({
intl,
@@ -17,8 +17,9 @@ function TabPage({
...passthroughProps
}) {
const {
- resetDatesToastBody,
- resetDatesToastHeader,
+ toastBodyLink,
+ toastBodyText,
+ toastHeader,
} = useSelector(state => state.courseHome);
const dispatch = useDispatch();
@@ -37,10 +38,11 @@ function TabPage({
return (
<>
dispatch(setResetDatesToast({ body: null, header: null }))}
- show={!!(resetDatesToastBody && resetDatesToastHeader)}
+ bodyLink={toastBodyLink}
+ bodyText={toastBodyText}
+ header={toastHeader}
+ onClose={() => dispatch(setCallToActionToast({ header: null, link: null, link_text: null }))}
+ show={!!(toastHeader)}
/>
>
diff --git a/src/toast/LearningToast.jsx b/src/toast/LearningToast.jsx
index 66274fd1..8335145f 100644
--- a/src/toast/LearningToast.jsx
+++ b/src/toast/LearningToast.jsx
@@ -5,7 +5,8 @@ import { Toast } from '@edx/paragon';
import './LearningToast.scss';
export default function LearningToast({
- body,
+ bodyLink,
+ bodyText,
header,
onClose,
show,
@@ -14,36 +15,37 @@ export default function LearningToast({
-
- {/* eslint-disable-next-line react/no-danger */}
-
+
+ {header}
-
- {/* eslint-disable-next-line react/no-danger */}
-
-
+ {bodyLink && bodyText && (
+
+ {bodyText}
+
+ )}
);
}
LearningToast.propTypes = {
- body: PropTypes.string,
+ bodyLink: PropTypes.string,
+ bodyText: PropTypes.string,
header: PropTypes.string,
onClose: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
};
LearningToast.defaultProps = {
- body: null,
+ bodyLink: null,
+ bodyText: null,
header: null,
};
diff --git a/src/toast/LearningToast.scss b/src/toast/LearningToast.scss
index 437e9164..f2bed044 100644
--- a/src/toast/LearningToast.scss
+++ b/src/toast/LearningToast.scss
@@ -1,8 +1,18 @@
-.toast-header {
- div {
- margin-top: 10px;
- }
+.learning-toast {
+ bottom: 20px;
+ radius: 3px;
+ left: 20px;
+ max-width: 400px;
+ padding: 11px 20px;
+ @media only screen and (max-width: 768px) {
+ bottom: 10px;
+ left: 10px;
+ right: 10px;
+ }
+}
+
+.toast-header {
button {
align-self: flex-start;
color: #FFFFFF;
@@ -13,7 +23,7 @@
}
.toast-body {
- a {
- text-decoration: underline;
+ a:hover {
+ color: #2D323E;
}
}