Merge pull request #16096 from edx/HarryRein/LEARNER-2308-update-course-goal
Harry rein/learner 2308 update course goal
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user