From 37d56b4197cbb33323cf6d32d3f002e18ab778fb Mon Sep 17 00:00:00 2001 From: Carla Duarte Date: Mon, 31 Aug 2020 09:58:34 -0400 Subject: [PATCH] AA-125: Course Goals in Course Home Outline Tab (#190) --- .../__factories__/outlineTabData.factory.js | 4 + .../data/__snapshots__/redux.test.js.snap | 19 +++- src/course-home/data/api.js | 7 ++ src/course-home/data/index.js | 1 + src/course-home/data/redux.test.js | 12 ++ src/course-home/data/slice.js | 17 +-- src/course-home/data/thunks.js | 41 ++++--- src/course-home/outline-tab/OutlineTab.jsx | 39 ++++++- src/course-home/outline-tab/messages.js | 23 ++++ .../outline-tab/widgets/CourseGoalCard.jsx | 94 +++++++++++++++ .../widgets/UpdateGoalSelector.jsx | 107 ++++++++++++++++++ src/tab-page/TabPage.jsx | 16 +-- src/toast/LearningToast.jsx | 28 ++--- src/toast/LearningToast.scss | 22 +++- 14 files changed, 371 insertions(+), 59 deletions(-) create mode 100644 src/course-home/outline-tab/widgets/CourseGoalCard.jsx create mode 100644 src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx 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; } }