AA-125: Course Goals in Course Home Outline Tab (#190)

This commit is contained in:
Carla Duarte
2020-08-31 09:58:34 -04:00
committed by GitHub
parent 01f69e2273
commit 37d56b4197
14 changed files with 371 additions and 59 deletions

View File

@@ -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.',

View File

@@ -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": "<div>Course expired</div>",
"courseGoals": Object {
"goalOptions": Array [],
"selectedGoal": Object {},
},
"courseTools": Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",

View File

@@ -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 });

View File

@@ -3,6 +3,7 @@ export {
fetchOutlineTab,
fetchProgressTab,
resetDeadlines,
saveCourseGoal,
} from './thunks';
export { reducer } from './slice';

View File

@@ -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`;

View File

@@ -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 {

View File

@@ -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 }));
});
}
};

View File

@@ -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,
}}
/>
<LearningToast
header={goalToastHeader}
onClose={() => setGoalToastHeader(null)}
show={!!(goalToastHeader)}
/>
<div className="d-flex justify-content-between mb-3">
<div role="heading" aria-level="1" className="h4">{title}</div>
{resumeCourseUrl && (
@@ -80,7 +95,16 @@ function OutlineTab({ intl }) {
)}
</div>
<div className="row">
<div className="col col-8">
<div className="col col-12 col-md-8">
{!courseGoalToDisplay && goalOptions && (
<CourseGoalCard
courseId={courseId}
goalOptions={goalOptions}
title={title}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<WelcomeMessage courseId={courseId} />
<AlertList
topic="outline-course-alerts"
@@ -102,7 +126,16 @@ function OutlineTab({ intl }) {
/>
))}
</div>
<div className="col col-4">
<div className="col col-12 col-md-4">
{courseGoalToDisplay && goalOptions && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<CourseTools
courseId={courseId}
/>

View File

@@ -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',

View File

@@ -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 (
<Card className="mb-3">
<Card.Body>
<div className="row w-100 m-0 justify-content-between align-items-center">
<div className="col col-8 p-0">
<Card.Title className="h6 m-0">{intl.formatMessage(messages.goalWelcome)} {title}</Card.Title>
</div>
<div className="col col-auto p-0">
<Button
variant="link"
className="p-0"
size="sm"
block
data-goal-key="unsure"
data-goal-text={`${intl.formatMessage(messages.goalUnsure)}`}
onClick={(event) => { selectGoalHandler(event); }}
>
{intl.formatMessage(messages.goalUnsure)}
</Button>
</div>
</div>
<Card.Text className="my-2 mx-1">{intl.formatMessage(messages.setGoal)}</Card.Text>
<div className="row w-100 m-0">
{goalOptions.map((goal) => {
const [goalKey, goalText] = goal;
return (
(goalKey !== 'unsure') && (
<div key={`goal-${goalKey}`} className="col-auto flex-grow-1 mx-1 my-2 p-0">
<Button
variant="outline-primary"
block
data-goal-key={goalKey}
data-goal-text={goalText}
onClick={(event) => { selectGoalHandler(event); }}
>
{goalText}
</Button>
</div>
)
);
})}
</div>
</Card.Body>
</Card>
);
}
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);

View File

@@ -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 (
<>
<section className="mb-3">
<div className="row w-100 m-0">
<div className="col-12 p-0">
<label className="h6" htmlFor="edit-goal-selector">
{intl.formatMessage(messages.goal)}
</label>
</div>
<div className="col-12 p-0">
<Card>
<Card.Body className="px-3 py-2">
<div className="row w-100 m-0 justify-content-between align-items-center">
<div className="col-10 p-0">
{!editingGoal && (
<p className="m-0">{selectedGoal.text}</p>
)}
{editingGoal && (
<Input
id="edit-goal-selector"
type="select"
defaultValue={selectedGoal.key}
onBlur={() => { setEditingGoal(false); }}
onChange={(event) => { selectGoalHandler(event); }}
options={goalOptions.map(([goalKey, goalText]) => (
{ value: goalKey, label: goalText }
))}
autoFocus
/>
)}
</div>
<Button
aria-label={intl.formatMessage(messages.editGoal)}
className="p-1"
size="sm"
variant="light"
onClick={() => { setEditingGoal(true); }}
>
<FontAwesomeIcon icon={faPencilAlt} />
</Button>
</div>
</Card.Body>
</Card>
</div>
</div>
</section>
</>
);
}
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);

View File

@@ -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 (
<>
<LearningToast
body={resetDatesToastBody}
header={resetDatesToastHeader}
onClose={() => 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)}
/>
<LoadedTabPage {...passthroughProps} />
</>

View File

@@ -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({
<Toast
onClose={onClose}
show={show}
delay={3000}
delay={5000}
autohide
className="bg-gray-700 learning-toast"
style={{
boxShadow: 'none',
position: 'fixed',
bottom: '1rem',
left: '1rem',
zIndex: 2,
}}
>
<Toast.Header className="bg-gray-700 border-bottom-0 text-light">
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: header }} />
<Toast.Header className="bg-gray-700 border-bottom-0 p-0 text-light justify-content-between align-items-center">
<p className="small m-0 mr-4">{header}</p>
</Toast.Header>
<Toast.Body className="bg-gray-700 text-light">
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: body }} />
</Toast.Body>
{bodyLink && bodyText && (
<Toast.Body className="bg-gray-700 text-light p-0 pt-3">
<a className="btn btn-sm btn-outline-light" href={bodyLink}>{bodyText}</a>
</Toast.Body>
)}
</Toast>
);
}
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,
};

View File

@@ -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;
}
}