From f3f3edf4bddc51986fe446188df450ed8440e568 Mon Sep 17 00:00:00 2001 From: Harry Rein Date: Fri, 22 Sep 2017 13:45:27 -0400 Subject: [PATCH] Allow user to update course goal on course home page. LEARNER-2308 Once a goal has been set for a user on the course home message, allow them to update it on the course home side bar. Automatically sets a course goal for users when enrolling in a verifiable course. --- .../test/acceptance/pages/lms/course_home.py | 11 ++- .../tests/lms/test_lms_course_home.py | 6 +- lms/djangoapps/course_goals/api.py | 64 ++++++++++++--- lms/djangoapps/course_goals/models.py | 29 +++++-- lms/djangoapps/course_goals/signals.py | 19 ----- lms/djangoapps/course_goals/tests/test_api.py | 25 ++++-- lms/djangoapps/course_goals/views.py | 79 +++++++++++++------ lms/djangoapps/support/tests/test_views.py | 6 ++ .../sass/features/_course-experience.scss | 75 +++++++++++++++++- lms/static/sass/shared-v2/_variables.scss | 11 ++- .../course_experience/js/CourseGoals.js | 25 +++--- .../static/course_experience/js/CourseHome.js | 66 ++++++++++++++++ .../course-home-fragment.html | 30 ++++++- .../course-messages-fragment.html | 6 +- .../tests/views/test_course_home.py | 44 ++++++++++- .../course_experience/views/course_home.py | 16 ++++ .../views/course_home_messages.py | 72 ++++++----------- 17 files changed, 447 insertions(+), 137 deletions(-) delete mode 100644 lms/djangoapps/course_goals/signals.py diff --git a/common/test/acceptance/pages/lms/course_home.py b/common/test/acceptance/pages/lms/course_home.py index 479206892e..6271188040 100644 --- a/common/test/acceptance/pages/lms/course_home.py +++ b/common/test/acceptance/pages/lms/course_home.py @@ -34,13 +34,22 @@ class CourseHomePage(CoursePage): def select_course_goal(self): """ Click on a course goal in a message """ - self.q(css='.goal-option').first.click() + self.q(css='button.goal-option').first.click() self.wait_for_ajax() def is_course_goal_success_message_shown(self): """ Verifies course goal success message appears. """ return self.q(css='.success-message').present + def is_course_goal_update_field_shown(self): + """ Verifies course goal success message appears. """ + return self.q(css='.current-goal-container').visible + + def is_course_goal_update_icon_shown(self, valid=True): + """ Verifies course goal success or error icon appears. """ + correct_icon = 'check' if valid else 'close' + return self.q(css='.fa-{icon}'.format(icon=correct_icon)).present + def click_bookmarks_button(self): """ Click on Bookmarks button """ self.q(css='.bookmarks-list-button').first.click() diff --git a/common/test/acceptance/tests/lms/test_lms_course_home.py b/common/test/acceptance/tests/lms/test_lms_course_home.py index 1277b23f25..82545527d7 100644 --- a/common/test/acceptance/tests/lms/test_lms_course_home.py +++ b/common/test/acceptance/tests/lms/test_lms_course_home.py @@ -63,7 +63,6 @@ class CourseHomeTest(CourseHomeBaseTest): """ Tests the course home page with course outline. """ - def test_course_home(self): """ Smoke test of course goals, course outline, breadcrumbs to and from course outline, and bookmarks. @@ -81,11 +80,14 @@ class CourseHomeTest(CourseHomeBaseTest): # Check that the tab lands on the course home page. self.assertTrue(self.course_home_page.is_browser_on_page()) - # Check that a success message is shown when selecting a course goal + # Check that a success message and update course field are shown when selecting a course goal # TODO: LEARNER-2522: Ensure the correct message shows up for a particular goal choice self.assertFalse(self.course_home_page.is_course_goal_success_message_shown()) + self.assertFalse(self.course_home_page.is_course_goal_update_field_shown()) self.course_home_page.select_course_goal() + self.course_home_page.wait_for_ajax() self.assertTrue(self.course_home_page.is_course_goal_success_message_shown()) + self.assertTrue(self.course_home_page.is_course_goal_update_field_shown()) # Check that the course navigation appears correctly EXPECTED_SECTIONS = { diff --git a/lms/djangoapps/course_goals/api.py b/lms/djangoapps/course_goals/api.py index 9efae78b88..de86f918f2 100644 --- a/lms/djangoapps/course_goals/api.py +++ b/lms/djangoapps/course_goals/api.py @@ -1,14 +1,20 @@ """ Course Goals Python API """ -from opaque_keys.edx.keys import CourseKey +import models -from .models import CourseGoal +from opaque_keys.edx.keys import CourseKey +from django.conf import settings +from rest_framework.reverse import reverse + +from course_modes.models import CourseMode +from openedx.features.course_experience import ENABLE_COURSE_GOALS def add_course_goal(user, course_id, goal_key): """ - Add a new course goal for the provided user and course. + Add a new course goal for the provided user and course. If the goal + already exists, simply update and save the goal. Arguments: user: The user that is setting the goal @@ -16,26 +22,64 @@ def add_course_goal(user, course_id, goal_key): goal_key (string): The goal key for the new goal. """ - # Create and save a new course goal course_key = CourseKey.from_string(str(course_id)) - new_goal = CourseGoal(user=user, course_key=course_key, goal_key=goal_key) - new_goal.save() + current_goal = get_course_goal(user, course_key) + if current_goal: + # If a course goal already exists, simply update it. + current_goal.goal_key = goal_key + current_goal.save(update_fields=['goal_key']) + else: + # Otherwise, create and save a new course goal. + new_goal = models.CourseGoal(user=user, course_key=course_key, goal_key=goal_key) + new_goal.save() def get_course_goal(user, course_key): """ Given a user and a course_key, return their course goal. - If a course goal does not exist, returns None. + If the user is anonymous or a course goal does not exist, returns None. """ - course_goals = CourseGoal.objects.filter(user=user, course_key=course_key) + if user.is_anonymous(): + return None + + course_goals = models.CourseGoal.objects.filter(user=user, course_key=course_key) return course_goals[0] if course_goals else None -def remove_course_goal(user, course_key): +def remove_course_goal(user, course_id): """ - Given a user and a course_key, remove the course goal. + Given a user and a course_id, remove the course goal. """ + course_key = CourseKey.from_string(course_id) course_goal = get_course_goal(user, course_key) if course_goal: course_goal.delete() + + +def get_goal_api_url(request): + """ + Returns the endpoint for accessing REST API. + """ + return reverse('course_goals_api:v0:course_goal-list', request=request) + + +def has_course_goal_permission(request, course_id, user_access): + """ + Returns whether the user can access the course goal functionality. + + Only authenticated users that are enrolled in a verifiable course + can use this feature. + """ + course_key = CourseKey.from_string(course_id) + has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course_id))) + return user_access['is_enrolled'] and has_verified_mode and ENABLE_COURSE_GOALS.is_enabled(course_key) \ + and settings.FEATURES.get('ENABLE_COURSE_GOALS') + + +def get_course_goal_options(): + """ + Returns the valid options for goal keys, mapped to their translated + strings, as defined by theCourseGoal model. + """ + return {goal_key: goal_text for goal_key, goal_text in models.GOAL_KEY_CHOICES} diff --git a/lms/djangoapps/course_goals/models.py b/lms/djangoapps/course_goals/models.py index b216bbbcdc..29986f99da 100644 --- a/lms/djangoapps/course_goals/models.py +++ b/lms/djangoapps/course_goals/models.py @@ -3,23 +3,28 @@ Course Goals Models """ from django.contrib.auth.models import User from django.db import models +from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ from openedx.core.djangoapps.xmodule_django.models import CourseKeyField from model_utils import Choices +from .api import add_course_goal, remove_course_goal +from course_modes.models import CourseMode +from student.models import CourseEnrollment + # Each goal is represented by a goal key and a string description. GOAL_KEY_CHOICES = Choices( - ('certify', _('Earn a certificate.')), - ('complete', _('Complete the course.')), - ('explore', _('Explore the course.')), - ('unsure', _('Not sure yet.')), + ('certify', _('Earn a certificate')), + ('complete', _('Complete the course')), + ('explore', _('Explore the course')), + ('unsure', _('Not sure yet')), ) class CourseGoal(models.Model): """ - Represents a course goal set by the user. + Represents a course goal set by a user on the course home page. """ user = models.ForeignKey(User, blank=False) course_key = CourseKeyField(max_length=255, db_index=True) @@ -34,3 +39,17 @@ class CourseGoal(models.Model): class Meta: unique_together = ("user", "course_key") + + +@receiver(models.signals.post_save, sender=CourseEnrollment, dispatch_uid="update_course_goal_on_enroll_change") +def update_course_goal_on_enroll_change(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name + """ + Updates goals as follows on enrollment changes: + 1) Set the course goal to 'certify' when the user enrolls as a verified user. + 2) Remove the course goal when the user's enrollment is no longer active. + """ + course_id = str(instance.course_id).decode('utf8', 'ignore') + if not instance.is_active: + remove_course_goal(instance.user, course_id) + elif instance.mode == CourseMode.VERIFIED: + add_course_goal(instance.user, course_id, GOAL_KEY_CHOICES.certify) diff --git a/lms/djangoapps/course_goals/signals.py b/lms/djangoapps/course_goals/signals.py deleted file mode 100644 index 2957c2eb27..0000000000 --- a/lms/djangoapps/course_goals/signals.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Course Goals Signals -""" -from django.db.models.signals import post_save -from django.dispatch import receiver -from eventtracking import tracker - -from .models import CourseGoal - - -@receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goal_event") -def emit_course_goal_event(sender, instance, **kwargs): - name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated' - tracker.emit( - name, - { - 'goal_key': instance.goal_key, - } - ) diff --git a/lms/djangoapps/course_goals/tests/test_api.py b/lms/djangoapps/course_goals/tests/test_api.py index 1c20dd7ba3..93f859b8db 100644 --- a/lms/djangoapps/course_goals/tests/test_api.py +++ b/lms/djangoapps/course_goals/tests/test_api.py @@ -12,13 +12,14 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory TEST_PASSWORD = 'test' +EVENT_NAME_ADDED = 'edx.course.goal.added' +EVENT_NAME_UPDATED = 'edx.course.goal.updated' class TestCourseGoalsAPI(EventTrackingTestCase, SharedModuleStoreTestCase): """ Testing the Course Goals API. """ - def setUp(self): # Create a course with a verified track super(TestCourseGoalsAPI, self).setUp() @@ -35,17 +36,31 @@ class TestCourseGoalsAPI(EventTrackingTestCase, SharedModuleStoreTestCase): def test_add_valid_goal(self): """ Ensures a correctly formatted post succeeds. """ - response = self.post_course_goal(valid=True) - self.assert_events_emitted() + response = self.post_course_goal(valid=True, goal_key='certify') + self.assertEqual(self.get_event(-1)['name'], EVENT_NAME_ADDED) self.assertEqual(response.status_code, 201) - self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 1) + + current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id) + self.assertEqual(len(current_goals), 1) + self.assertEqual(current_goals[0].goal_key, 'certify') def test_add_invalid_goal(self): - """ Ensures a correctly formatted post succeeds. """ + """ Ensures an incorrectly formatted post does not succeed. """ response = self.post_course_goal(valid=False) self.assertEqual(response.status_code, 400) self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 0) + def test_update_goal(self): + """ Ensures that repeated course goal post events do not create new instances of the goal. """ + self.post_course_goal(valid=True, goal_key='explore') + self.post_course_goal(valid=True, goal_key='certify') + self.post_course_goal(valid=True, goal_key='unsure') + self.assertEqual(self.get_event(-1)['name'], EVENT_NAME_UPDATED) + + current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id) + self.assertEqual(len(current_goals), 1) + self.assertEqual(current_goals[0].goal_key, 'unsure') + def post_course_goal(self, valid=True, goal_key='certify'): """ Sends a post request to set a course goal and returns the response. diff --git a/lms/djangoapps/course_goals/views.py b/lms/djangoapps/course_goals/views.py index 9f589e9f2a..b7f2c67602 100644 --- a/lms/djangoapps/course_goals/views.py +++ b/lms/djangoapps/course_goals/views.py @@ -4,14 +4,17 @@ Course Goals Views - includes REST API from django.contrib.auth import get_user_model from django.db.models.signals import post_save from django.dispatch import receiver +from django.http import JsonResponse from edx_rest_framework_extensions.authentication import JwtAuthentication from eventtracking import tracker from opaque_keys.edx.keys import CourseKey from openedx.core.lib.api.permissions import IsStaffOrOwner -from rest_framework import permissions, serializers, viewsets +from rest_framework import permissions, serializers, viewsets, status from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response -from .models import CourseGoal +from .api import get_course_goal_options +from .models import CourseGoal, GOAL_KEY_CHOICES User = get_user_model() @@ -27,46 +30,72 @@ class CourseGoalSerializer(serializers.ModelSerializer): model = CourseGoal fields = ('user', 'course_key', 'goal_key') - def validate_course_key(self, value): - """ - Ensure that the course_key is valid. - """ - course_key = CourseKey.from_string(value) - if not course_key: - raise serializers.ValidationError( - 'Provided course_key ({course_key}) does not map to a course.'.format( - course_key=course_key - ) - ) - return course_key - class CourseGoalViewSet(viewsets.ModelViewSet): """ - API calls to create and retrieve a course goal. + API calls to create and update a course goal. + + Validates incoming data to ensure that course_key maps to an actual + course and that the goal_key is a valid option. **Use Case** * Create a new goal for a user. - - Http400 is returned if the format of the request is not correct, - the course_id or goal is invalid or cannot be found. - - * Retrieve goal for a user and a particular course. - - Http400 is returned if the format of the request is not correct, - or the course_id is invalid or cannot be found. + * Update an existing goal for a user **Example Requests** - GET /api/course_goals/v0/course_goals/ POST /api/course_goals/v0/course_goals/ Request data: {"course_key": , "goal_key": "", "user": ""} + Returns Http400 response if the course_key does not map to a known + course or if the goal_key does not map to a valid goal key. """ authentication_classes = (JwtAuthentication, SessionAuthentication,) permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,) queryset = CourseGoal.objects.all() serializer_class = CourseGoalSerializer + def create(self, post_data): + """ Create a new goal if one does not exist, otherwise update the existing goal. """ + # Ensure goal_key is valid + goal_options = get_course_goal_options() + goal_key = post_data.data['goal_key'] + if goal_key not in goal_options: + return Response( + 'Provided goal key, {goal_key}, is not a valid goal key (options= {goal_options}).'.format( + goal_key=goal_key, + goal_options=goal_options, + ), + status=status.HTTP_400_BAD_REQUEST, + ) + + # Ensure course key is valid + course_key = CourseKey.from_string(post_data.data['course_key']) + if not course_key: + return Response( + 'Provided course_key ({course_key}) does not map to a course.'.format( + course_key=course_key + ), + status=status.HTTP_400_BAD_REQUEST, + ) + + user = post_data.user + goal = CourseGoal.objects.filter(user=user.id, course_key=course_key).first() + if goal: + goal.goal_key = goal_key + goal.save(update_fields=['goal_key']) + else: + CourseGoal.objects.create( + user=user, + course_key=course_key, + goal_key=goal_key, + ) + data = { + 'goal_key': str(goal_key), + 'goal_text': str(goal_options[goal_key]), + 'is_unsure': goal_key == GOAL_KEY_CHOICES.unsure, + } + return JsonResponse(data, content_type="application/json", status=(200 if goal else 201)) + @receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goals_event") def emit_course_goal_event(sender, instance, **kwargs): diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 845a7d2e02..2e639f670e 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -10,9 +10,11 @@ from datetime import datetime, timedelta import ddt from django.core.urlresolvers import reverse +from django.db.models import signals from nose.plugins.attrib import attr from pytz import UTC +from common.test.utils import disable_signal from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from lms.djangoapps.verify_student.models import VerificationDeadline @@ -223,6 +225,7 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase 'reason': 'Financial Assistance', }, json.loads(response.content)[0]['manual_enrollment']) + @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_change_enrollment(self, search_string_type): self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) @@ -274,12 +277,14 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase self.assert_enrollment(CourseMode.AUDIT) self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) + @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_all_modes(self, new_mode): """ Verify support can changed the enrollment to all available modes except credit. """ self.assert_update_enrollment('username', new_mode) + @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_ended_course(self, new_mode): """ Verify support can changed the enrollment of archived course. """ @@ -301,6 +306,7 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase response = self.client.get(url) self._assert_generated_modes(response) + @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_update_enrollments_with_expired_mode(self, search_string_type): """ Verify that enrollment can be updated to verified mode. """ diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index 5a9fe4120e..743a85c543 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -81,7 +81,11 @@ color: $black-t2; } } - // Course Goal Styling + // Course Goal Message Styling + .success-message { + font-size: font-size(small); + } + .goal-options-container { margin-top: $baseline; text-align: center; @@ -155,8 +159,73 @@ @include margin-left(0); @include padding-left($baseline); - .section-tools li:not(:first-child) { - margin-top: ($baseline / 5); + // Course Goal Updates + .section-goals { + @include float(left); + border: 1px solid $lms-border-color; + padding: $baseline*0.75 $baseline*0.75 $baseline*0.25; + border-radius: 5px; + position: relative; + width: 100%; + cursor: pointer; + margin-bottom: $baseline/2; + + &.hidden { + display: none; + } + + .edit-goal-select { + display: none; + background-color: transparent; + } + + .edit-icon { + @include right($baseline/4); + position: absolute; + top: $baseline*0.6; + cursor: pointer; + border: transparent; + background-color: transparent; + + &:hover { + color: $lms-border-color; + } + } + + .current-goal-container { + .title{ + @include float(left); + @include margin-right($baseline/4); + } + + .title-label { + display: none; + } + + .goal { + @include float(left); + @include padding-left($baseline*0.4); + + } + .response-icon { + @include margin-left($baseline/4); + @include right(-1*$baseline); + top: $baseline*0.75; + position: absolute; + + &.fa-check { + color: $success-color; + } + + &.fa-close { + color: $error-color; + } + } + } + + .section-tools .course-tool:not(:first-child) { + margin-top: ($baseline / 5); + } } } diff --git a/lms/static/sass/shared-v2/_variables.scss b/lms/static/sass/shared-v2/_variables.scss index 37a1be7561..6723155c07 100644 --- a/lms/static/sass/shared-v2/_variables.scss +++ b/lms/static/sass/shared-v2/_variables.scss @@ -27,7 +27,7 @@ $lms-label-color: palette(grayscale, black) !default; $lms-active-color: palette(primary, base) !default; $lms-preview-menu-color: #c8c8c8 !default; $lms-inactive-color: rgb(94,94,94) !default; -$success-color: palette(success, accent) !default; +$success-color: rgb(0, 155, 0) !default; $success-color-hover: palette(success, text) !default; $button-bg-hover-color: $white !default; @@ -49,6 +49,8 @@ $light-grey-solid: rgba(200,200,200, 1) !default; $header-border-color: $gray-l1 !default; +$table-bg-accent: #f9f9f9 !default; + // ---------------------------- // #TYPOGRAPHY // ---------------------------- @@ -68,6 +70,13 @@ $site-status-color: rgb(182,37,103) !default; $shadow-l1: rgba(0,0,0,0.1) !default; +$error-color: rgb(203, 7, 18) !default; +$warning-color: rgb(255, 192, 31) !default; +$confirm-color: rgb(0, 132, 1) !default; +$active-color: $blue !default; +$highlight-color: rgb(255,255,0) !default; +$alert-color: rgb(212, 64, 64) !default; + // ---------------------------- // #ALERTS // ---------------------------- diff --git a/openedx/features/course_experience/static/course_experience/js/CourseGoals.js b/openedx/features/course_experience/static/course_experience/js/CourseGoals.js index 644d158028..fac60d331d 100644 --- a/openedx/features/course_experience/static/course_experience/js/CourseGoals.js +++ b/openedx/features/course_experience/static/course_experience/js/CourseGoals.js @@ -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(`
${successMsg}
`); + 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(`
${successMsg}
`); + } 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(`
${errorMsg}
`); }, @@ -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(); } }); } diff --git a/openedx/features/course_experience/static/course_experience/js/CourseHome.js b/openedx/features/course_experience/static/course_experience/js/CourseHome.js index 166928ee5c..7496c5ea59 100644 --- a/openedx/features/course_experience/static/course_experience/js/CourseHome.js +++ b/openedx/features/course_experience/static/course_experience/js/CourseHome.js @@ -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(); diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html index 96686a2b01..6659929563 100644 --- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html @@ -106,12 +106,37 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV % endif