Merge pull request #16096 from edx/HarryRein/LEARNER-2308-update-course-goal

Harry rein/learner 2308 update course goal
This commit is contained in:
Harry Rein
2017-10-06 14:11:23 -04:00
committed by GitHub
17 changed files with 447 additions and 137 deletions

View File

@@ -15,15 +15,20 @@ export class CourseGoals { // eslint-disable-line import/prefer-default-export
user: options.username,
},
dataType: 'json',
success: () => {
// LEARNER-2522 will address the success message
const successMsg = gettext('Thank you for setting your course goal!');
// xss-lint: disable=javascript-jquery-html
$('.message-content').html(`<div class="success-message">${successMsg}</div>`);
success: (data) => { // LEARNER-2522 will address the success message
$('.section-goals').slideDown();
$('.section-goals .goal .text').text(data.goal_text);
$('.section-goals select').val(data.goal_key);
const successMsg = gettext(`Thank you for setting your course goal to ${data.goal_text.toLowerCase()}!`);
if (!data.is_unsure) {
// xss-lint: disable=javascript-jquery-html
$('.message-content').html(`<div class="success-message">${successMsg}</div>`);
} else {
$('.message-content').parent().hide();
}
},
error: () => {
// LEARNER-2522 will address the error message
const errorMsg = gettext('There was an error in setting your goal, please reload the page and try again.'); // eslint-disable-line max-len
error: () => { // LEARNER-2522 will address the error message
const errorMsg = gettext('There was an error in setting your goal, please reload the page and try again.');
// xss-lint: disable=javascript-jquery-html
$('.message-content').html(`<div class="error-message"> ${errorMsg} </div>`);
},
@@ -31,9 +36,9 @@ export class CourseGoals { // eslint-disable-line import/prefer-default-export
});
// Allow goal selection with an enter press for accessibility purposes
$('.goal-option').keyup((e) => {
$('.goal-option').keypress((e) => {
if (e.which === 13) {
$(e.target).trigger('click');
$(e.target).click();
}
});
}

View File

@@ -30,6 +30,72 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
);
});
// Course goal editing elements
const $goalSection = $('.section-goals');
const $editGoalIcon = $('.section-goals .edit-icon');
const $currentGoalText = $('.section-goals .goal');
const $goalSelect = $('.section-goals .edit-goal-select');
const $responseIndicator = $('.section-goals .response-icon');
const $responseMessageSr = $('.section-goals .sr-update-response-msg');
const $goalUpdateTitle = $('.section-goals .title:not("label")');
const $goalUpdateLabel = $('.section-goals label.title');
// Switch to editing mode when the goal section is clicked
$goalSection.on('click', (event) => {
if (!$(event.target).hasClass('edit-goal-select')) {
$goalSelect.toggle();
$currentGoalText.toggle();
$goalUpdateTitle.toggle();
$goalUpdateLabel.toggle();
$responseIndicator.removeClass().addClass('response-icon');
$goalSelect.focus();
}
});
// Trigger click event on enter press for accessibility purposes
$(document.body).on('keyup', '.section-goals .edit-icon', (event) => {
if (event.which === 13) {
$(event.target).trigger('click');
}
});
// Send an ajax request to update the course goal
$goalSelect.on('change', (event) => {
const newGoalKey = $(event.target).val();
$goalSelect.toggle();
$currentGoalText.toggle();
$goalUpdateTitle.toggle();
$goalUpdateLabel.toggle();
$responseIndicator.removeClass().addClass('response-icon fa fa-spinner fa-spin');
$.ajax({
method: 'POST',
url: options.goalApiUrl,
headers: { 'X-CSRFToken': $.cookie('csrftoken') },
data: {
goal_key: newGoalKey,
course_key: options.courseId,
user: options.username,
},
dataType: 'json',
success: (data) => {
$currentGoalText.find('.text').text(data.goal_text);
$responseMessageSr.text(gettext('You have successfully updated your goal.'));
$responseIndicator.removeClass().addClass('response-icon fa fa-check');
},
error: () => {
$responseIndicator.removeClass().addClass('response-icon fa fa-close');
$responseMessageSr.text(gettext('There was an error updating your goal.'));
},
complete: () => {
// Only show response icon indicator for 3 seconds.
setTimeout(() => {
$responseIndicator.removeClass().addClass('response-icon');
}, 3000);
$editGoalIcon.focus();
},
});
});
// Dismissibility for in course messages
$(document.body).on('click', '.course-message .dismiss', (event) => {
$(event.target).closest('.course-message').hide();

View File

@@ -106,12 +106,37 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
% endif
</main>
<aside class="course-sidebar layout-col layout-col-a">
% if has_goal_permission:
<div class="section section-goals ${'' if current_goal else 'hidden'}">
<div class="current-goal-container">
<label class="title title-label hd-6" for="goal">
<h3 class="hd-6">${_("Goal: ")}</h3>
</label>
<h3 class="title hd-6">${_("Goal: ")}</h3>
<div class="goal">
<span class="text">${goal_options[current_goal.goal_key] if current_goal else ""}</span>
</div>
<select class="edit-goal-select" id="goal">
% for goal, goal_text in goal_options.items():
<option value="${goal}" ${"selected" if current_goal and current_goal.goal_key == goal else ""}>${goal_text}</option>
% endfor
</select>
<span class="sr sr-update-response-msg" aria-live="polite"></span>
<span class="response-icon" aria-hidden="true"></span>
<span class="sr">${_("Edit your course goal:")}</span>
<button class="edit-icon">
<span class="sr">${_("Edit your course goal:")}</span>
<span class="fa fa-pencil" aria-hidden="true"></span>
</button>
</div>
</div>
% endif
% if course_tools:
<div class="section section-tools">
<h3 class="hd-6">${_("Course Tools")}</h3>
<ul class="list-unstyled">
% for course_tool in course_tools:
<li>
<li class="course-tool">
<a class="course-tool-link" data-analytics-id="${course_tool.analytics_id()}" href="${course_tool.url(course_key)}">
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
${course_tool.title()}
@@ -146,6 +171,9 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
courseRunKey: "${course_key | n, js_escaped_string}",
resumeCourseLink: ".action-resume-course",
courseToolLink: ".course-tool-link",
goalApiUrl: "${goal_api_url | n, js_escaped_string}",
username: "${username | n, js_escaped_string}",
courseId: "${course.id | n, js_escaped_string}",
});
</%static:webpack>

View File

@@ -19,13 +19,13 @@ is_rtl = get_language_bidi()
% for message in course_home_messages:
<div class="course-message grid-manual">
% if not is_rtl:
<img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/>
<img class="message-author" alt="" src="${static.url(image_src)}"/>
% endif
<div class="message-content">
<div class="message-content" aria-live="polite">
${HTML(message.message_html)}
</div>
% if is_rtl:
<img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/>
<img class="message-author" alt="" src="${static.url(image_src)}"/>
% endif
</div>
% endfor

View File

@@ -45,6 +45,8 @@ TEST_COURSE_HOME_MESSAGE_ANONYMOUS = '/login'
TEST_COURSE_HOME_MESSAGE_UNENROLLED = 'Enroll now'
TEST_COURSE_HOME_MESSAGE_PRE_START = 'Course starts in'
TEST_COURSE_GOAL_OPTIONS = 'goal-options-container'
TEST_COURSE_GOAL_UPDATE_FIELD = 'section-goals'
TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN = 'section-goals hidden'
COURSE_GOAL_DISMISS_OPTION = 'unsure'
QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES
@@ -173,7 +175,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(45, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with self.assertNumQueries(49, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
@@ -427,7 +429,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
# Verify that enrolled and verified users are not shown the set course goal message.
remove_course_goal(user, verifiable_course.id)
remove_course_goal(user, str(verifiable_course.id))
CourseEnrollment.enroll(user, verifiable_course.id, CourseMode.VERIFIED)
response = self.client.get(course_home_url(verifiable_course))
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
@@ -438,6 +440,44 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
response = self.client.get(course_home_url(audit_only_course))
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
@override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
@override_waffle_flag(ENABLE_COURSE_GOALS, active=True)
def test_course_goal_updates(self):
"""
Ensure that the following five use cases work as expected.
1) Unenrolled users are not shown the update goal selection field.
2) Enrolled users are not shown the update goal selection field if they have not yet set a course goal.
3) Enrolled users are shown the update goal selection field if they have set a course goal.
4) Enrolled users in the verified track are shown the update goal selection field.
"""
# Create a course with a verified track.
verifiable_course = CourseFactory.create()
add_course_mode(verifiable_course, upgrade_deadline_expired=False)
# Verify that unenrolled users are not shown the update goal selection field.
user = self.create_user_for_course(verifiable_course, CourseUserType.UNENROLLED)
response = self.client.get(course_home_url(verifiable_course))
self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
# Verify that enrolled users that have not set a course goal are shown a hidden update goal selection field.
enrollment = CourseEnrollment.enroll(user, verifiable_course.id)
response = self.client.get(course_home_url(verifiable_course))
self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
# Verify that enrolled users that have set a course goal are shown a visible update goal selection field.
add_course_goal(user, verifiable_course.id, COURSE_GOAL_DISMISS_OPTION)
response = self.client.get(course_home_url(verifiable_course))
self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
# Verify that enrolled and verified users are shown the update goal selection
CourseEnrollment.update_enrollment(enrollment, is_active=True, mode=CourseMode.VERIFIED)
response = self.client.get(course_home_url(verifiable_course))
self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
class CourseHomeFragmentViewTests(ModuleStoreTestCase):
CREATE_USER = False

View File

@@ -17,6 +17,7 @@ from courseware.courses import (
get_course_info_section,
get_course_with_access,
)
from lms.djangoapps.course_goals.api import get_course_goal, has_course_goal_permission, get_course_goal_options, get_goal_api_url
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.courseware.views.views import CourseTabView
from opaque_keys.edx.keys import CourseKey
@@ -155,6 +156,16 @@ class CourseHomeFragmentView(EdxFragmentView):
# Get the course tools enabled for this user and course
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key)
# Check if the user can access the course goal functionality
has_goal_permission = has_course_goal_permission(request, course_id, user_access)
# Grab the current course goal and the acceptable course goal keys mapped to translated values
current_goal = get_course_goal(request.user, course_key)
goal_options = get_course_goal_options()
# Get the course goals api endpoint
goal_api_url = get_goal_api_url(request)
# Grab the course home messages fragment to render any relevant django messages
course_home_message_fragment = CourseHomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, user_access=user_access, **kwargs
@@ -182,6 +193,11 @@ class CourseHomeFragmentView(EdxFragmentView):
'resume_course_url': resume_course_url,
'course_tools': course_tools,
'dates_fragment': dates_fragment,
'username': request.user.username,
'goal_api_url': goal_api_url,
'has_goal_permission': has_goal_permission,
'goal_options': goal_options,
'current_goal': current_goal,
'update_message_fragment': update_message_fragment,
'course_sock_fragment': course_sock_fragment,
'disable_courseware_js': True,

View File

@@ -5,7 +5,6 @@ import math
from datetime import datetime
from babel.dates import format_date, format_timedelta
from django.conf import settings
from django.contrib import auth
from django.template.loader import render_to_string
from django.utils.http import urlquote_plus
@@ -14,20 +13,16 @@ from django.utils.translation import get_language, to_locale
from django.utils.translation import ugettext as _
from django.utils.translation import get_language, to_locale
from opaque_keys.edx.keys import CourseKey
from rest_framework.reverse import reverse
from web_fragments.fragment import Fragment
from course_modes.models import CourseMode
from courseware.courses import get_course_date_blocks, get_course_with_access
from lms.djangoapps.course_goals.api import get_course_goal
from lms.djangoapps.course_goals.api import get_course_goal, get_course_goal_options, get_goal_api_url, has_course_goal_permission
from lms.djangoapps.course_goals.models import GOAL_KEY_CHOICES
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_experience import CourseHomeMessages
from student.models import CourseEnrollment
from .. import ENABLE_COURSE_GOALS
class CourseHomeMessageFragmentView(EdxFragmentView):
"""
@@ -72,14 +67,19 @@ class CourseHomeMessageFragmentView(EdxFragmentView):
course_date_block.register_alerts(request, course)
# Register a course goal message, if appropriate
if _should_show_course_goal_message(request, course, user_access):
# Only show the set course goal message for enrolled, unverified
# users that have not yet set a goal in a course that allows for
# verified statuses.
user_goal = get_course_goal(auth.get_user(request), course_key)
is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key)
if has_course_goal_permission(request, course_id, user_access) and not is_already_verified and not user_goal:
_register_course_goal_message(request, course)
# Grab the relevant messages
course_home_messages = list(CourseHomeMessages.user_messages(request))
# Pass in the url used to set a course goal
goal_api_url = reverse('course_goals_api:v0:course_goal-list', request=request)
goal_api_url = get_goal_api_url(request)
# Grab the logo
image_src = 'course_experience/images/home_message_author.png'
@@ -132,39 +132,11 @@ def _register_course_home_messages(request, course, user_access, course_start_da
)
def _should_show_course_goal_message(request, course, user_access):
"""
Returns true if the current learner should be shown a course goal message.
"""
course_key = course.id
# Don't show a message if course goals has not been enabled
if not ENABLE_COURSE_GOALS.is_enabled(course_key) or not settings.FEATURES.get('ENABLE_COURSE_GOALS'):
return False
# Don't show a message if the user is not enrolled
if not user_access['is_enrolled']:
return False
# Don't show a message if the learner has already specified a goal
if get_course_goal(auth.get_user(request), course_key):
return False
# Don't show a message if the course does not have a verified mode
if not CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course_key))):
return False
# Don't show a message if the learner has already verified
if CourseEnrollment.is_enrolled_as_verified(request.user, course_key):
return False
return True
def _register_course_goal_message(request, course):
"""
Register a message to let a learner specify a course goal.
"""
course_goal_options = get_course_goal_options()
goal_choices_html = Text(_(
'To start, set a course goal by selecting the option below that best describes '
'your learning plan. {goal_options_container}'
@@ -182,44 +154,44 @@ def _register_course_goal_message(request, course):
).format(
goal_key=GOAL_KEY_CHOICES.unsure,
aria_label_choice=Text(_("Set goal to: {choice}")).format(
choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure]
choice=course_goal_options[GOAL_KEY_CHOICES.unsure],
),
),
choice=Text(_('{choice}')).format(
choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure],
choice=course_goal_options[GOAL_KEY_CHOICES.unsure],
),
closing_tag=HTML('</div>'),
)
# Add the option to set a goal to earn a certificate,
# complete the course or explore the course
goal_options = [
GOAL_KEY_CHOICES.certify,
GOAL_KEY_CHOICES.complete,
GOAL_KEY_CHOICES.explore
]
for goal_key in goal_options:
goal_text = GOAL_KEY_CHOICES[goal_key]
course_goal_keys = course_goal_options.keys()
course_goal_keys.remove(GOAL_KEY_CHOICES.unsure)
for goal_key in course_goal_keys:
goal_text = course_goal_options[goal_key]
goal_choices_html += HTML(
'{initial_tag}{goal_text}{closing_tag}'
).format(
initial_tag=HTML(
'<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option {col_sel} btn" '
'<button tabindex="0" aria-label="{aria_label_choice}" class="goal-option {col_sel} btn" '
'data-choice="{goal_key}">'
).format(
goal_key=goal_key,
aria_label_choice=Text(_("Set goal to: {goal_text}")).format(
goal_text=Text(_(goal_text))
),
col_sel='col-' + str(int(math.floor(12 / len(goal_options))))
col_sel='col-' + str(int(math.floor(12 / len(course_goal_keys))))
),
goal_text=goal_text,
closing_tag=HTML('</div>')
closing_tag=HTML('</button>')
)
CourseHomeMessages.register_info_message(
request,
goal_choices_html,
HTML('{goal_choices_html}{closing_tag}').format(
goal_choices_html=goal_choices_html,
closing_tag=HTML('</div>')
),
title=Text(_('Welcome to {course_display_name}')).format(
course_display_name=course.display_name
)