diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js index 3e51f91fd2..f2be1a44fb 100644 --- a/common/static/js/spec_helpers/ajax_helpers.js +++ b/common/static/js/spec_helpers/ajax_helpers.js @@ -1,6 +1,6 @@ define(['sinon', 'underscore'], function(sinon, _) { var fakeServer, fakeRequests, expectRequest, expectJsonRequest, - respondWithJson, respondWithError, respondToDelete; + respondWithJson, respondWithError, respondWithTextError, respondToDelete; /* These utility methods are used by Jasmine tests to create a mock server or * get reference to mock requests. In either case, the cleanup (restore) is done with @@ -93,6 +93,22 @@ define(['sinon', 'underscore'], function(sinon, _) { ); }; + respondWithTextError = function(requests, statusCode, textResponse, requestIndex) { + if (_.isUndefined(requestIndex)) { + requestIndex = requests.length - 1; + } + if (_.isUndefined(statusCode)) { + statusCode = 500; + } + if (_.isUndefined(textResponse)) { + textResponse = ""; + } + requests[requestIndex].respond(statusCode, + { 'Content-Type': 'text/plain' }, + textResponse + ); + }; + respondToDelete = function(requests, requestIndex) { if (_.isUndefined(requestIndex)) { requestIndex = requests.length - 1; @@ -108,6 +124,7 @@ define(['sinon', 'underscore'], function(sinon, _) { 'expectJsonRequest': expectJsonRequest, 'respondWithJson': respondWithJson, 'respondWithError': respondWithError, + 'respondWithTextError': respondWithTextError, 'respondToDelete': respondToDelete }; }); diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 2ecce19581..9d5999861d 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -512,7 +512,7 @@ class PayAndVerifyView(View): 'disable_courseware_js': True, 'display_steps': display_steps, 'contribution_amount': contribution_amount, - 'is_active': request.user.is_active, + 'is_active': json.dumps(request.user.is_active), 'messages': self._messages( message, course.display_name, diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 10532bc80b..3350a19bbf 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -384,7 +384,116 @@ 'js/student_account/enrollment', 'js/student_account/shoppingcart', ] - } + }, + 'js/verify_student/models/verification_model': { + exports: 'edx.verify_student.VerificationModel', + deps: [ 'jquery', 'underscore', 'backbone', 'jquery.cookie' ] + }, + 'js/verify_student/views/error_view': { + exports: 'edx.verify_student.ErrorView', + deps: [ 'jquery', 'underscore', 'backbone' ] + }, + 'js/verify_student/views/webcam_photo_view': { + exports: 'edx.verify_student.WebcamPhotoView', + deps: [ 'jquery', 'underscore', 'backbone', 'gettext' ] + }, + 'js/verify_student/views/progress_view': { + exports: 'edx.verify_student.ProgressView', + deps: [ 'jquery', 'underscore', 'backbone', 'gettext' ] + }, + 'js/verify_student/views/requirements_view': { + exports: 'edx.verify_student.RequirementsView', + deps: [ 'jquery', 'backbone', 'underscore', 'gettext' ] + }, + 'js/verify_student/views/step_view': { + exports: 'edx.verify_student.StepView', + deps: [ 'jquery', 'underscore', 'underscore.string', 'backbone', 'gettext' ] + }, + 'js/verify_student/views/intro_step_view': { + exports: 'edx.verify_student.IntroStepView', + deps: [ + 'jquery', + 'js/verify_student/views/step_view', + 'js/verify_student/views/requirements_view' + ] + }, + 'js/verify_student/views/make_payment_step_view': { + exports: 'edx.verify_student.MakePaymentStepView', + deps: [ + 'jquery', + 'underscore', + 'gettext', + 'jquery.cookie', + 'jquery.url', + 'js/verify_student/views/step_view', + 'js/verify_student/views/requirements_view' + ] + }, + 'js/verify_student/views/payment_confirmation_step_view': { + exports: 'edx.verify_student.PaymentConfirmationStepView', + deps: [ + 'jquery', + 'underscore', + 'gettext', + 'js/verify_student/views/step_view', + 'js/verify_student/views/requirements_view' + ] + }, + 'js/verify_student/views/face_photo_step_view': { + exports: 'edx.verify_student.FacePhotoStepView', + deps: [ + 'jquery', + 'underscore', + 'gettext', + 'js/verify_student/views/step_view', + 'js/verify_student/views/webcam_photo_view' + ] + }, + 'js/verify_student/views/id_photo_step_view': { + exports: 'edx.verify_student.IDPhotoStepView', + deps: [ + 'jquery', + 'underscore', + 'gettext', + 'js/verify_student/views/step_view', + 'js/verify_student/views/webcam_photo_view' + ] + }, + 'js/verify_student/views/review_photos_step_view': { + exports: 'edx.verify_student.ReviewPhotosStepView', + deps: [ + 'jquery', + 'underscore', + 'gettext', + 'js/verify_student/views/step_view', + 'js/verify_student/views/webcam_photo_view' + ] + }, + 'js/verify_student/views/enrollment_confirmation_step_view': { + exports: 'edx.verify_student.EnrollmentConfirmationStepView', + deps: [ + 'jquery', + 'js/verify_student/views/step_view', + ] + }, + 'js/verify_student/views/pay_and_verify_view': { + exports: 'edx.verify_student.PayAndVerifyView', + deps: [ + 'jquery', + 'underscore', + 'backbone', + 'gettext', + 'js/verify_student/models/verification_model', + 'js/verify_student/views/progress_view', + 'js/verify_student/views/intro_step_view', + 'js/verify_student/views/make_payment_step_view', + 'js/verify_student/views/payment_confirmation_step_view', + 'js/verify_student/views/face_photo_step_view', + 'js/verify_student/views/id_photo_step_view', + 'js/verify_student/views/review_photos_step_view', + 'js/verify_student/views/enrollment_confirmation_step_view' + ] + }, } }); @@ -406,7 +515,11 @@ 'lms/include/js/spec/student_account/enrollment_spec.js', 'lms/include/js/spec/student_account/emailoptin_spec.js', 'lms/include/js/spec/student_account/shoppingcart_spec.js', - 'lms/include/js/spec/student_profile/profile_spec.js' + 'lms/include/js/spec/student_profile/profile_spec.js', + 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', + 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', + 'lms/include/js/spec/verify_student/review_photos_step_view_spec.js', + 'lms/include/js/spec/verify_student/make_payment_step_view_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js/spec/photocapture_spec.js b/lms/static/js/spec/photocapture_spec.js index 14a76bade9..da0826a896 100644 --- a/lms/static/js/spec/photocapture_spec.js +++ b/lms/static/js/spec/photocapture_spec.js @@ -52,4 +52,4 @@ define(['backbone', 'jquery', 'js/verify_student/photocapture'], }); }); - }); \ No newline at end of file + }); diff --git a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js new file mode 100644 index 0000000000..543f5213f3 --- /dev/null +++ b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js @@ -0,0 +1,249 @@ +define([ + 'jquery', + 'underscore', + 'backbone', + 'js/common_helpers/ajax_helpers', + 'js/common_helpers/template_helpers', + 'js/verify_student/views/make_payment_step_view' + ], + function( $, _, Backbone, AjaxHelpers, TemplateHelpers, MakePaymentStepView ) { + 'use strict'; + + describe( 'edx.verify_student.MakePaymentStepView', function() { + + var PAYMENT_URL = "/pay"; + + var PAYMENT_PARAMS = { + orderId: "test-order", + signature: "abcd1234" + }; + + var STEP_DATA = { + minPrice: "12", + suggestedPrices: ["34.56", "78.90"], + currency: "usd", + purchaseEndpoint: PAYMENT_URL, + courseKey: "edx/test/test" + }; + + var SERVER_ERROR_MSG = "An error occurred!"; + + var createView = function( stepDataOverrides ) { + var view = new MakePaymentStepView({ + el: $( '#current-step-container' ), + templateName: 'make_payment_step', + stepData: _.extend( _.clone( STEP_DATA ), stepDataOverrides ), + errorModel: new ( Backbone.Model.extend({}) )() + }).render(); + + // Stub the payment form submission + spyOn( view, 'submitForm' ).andCallFake( function() {} ); + return view; + }; + + var expectPriceOptions = function( prices ) { + var sel; + _.each( prices, function( price ) { + sel = _.sprintf( 'input[name="contribution"][value="%s"]', price ); + expect( $( sel ).length > 0 ).toBe( true ); + }); + }; + + var expectPriceSelected = function( price ) { + var sel = $( _.sprintf( 'input[name="contribution"][value="%s"]', price ) ); + + // If the option is available, it should be selected + if ( sel.length > 0 ) { + expect( sel.prop( 'checked' ) ).toBe( true ); + } else { + // Otherwise, the text box amount should be filled in + expect( $( '#contribution-other' ).prop( 'checked' ) ).toBe( true ); + expect( $( '#contribution-other-amt' ).val() ).toEqual( price ); + } + }; + + var choosePriceOption = function( price ) { + var sel = _.sprintf( 'input[name="contribution"][value="%s"]', price ); + $( sel ).trigger( 'click' ); + }; + + var enterPrice = function( price ) { + $( '#contribution-other' ).trigger( 'click' ); + $( '#contribution-other-amt' ).val( price ); + }; + + var expectSinglePriceDisplayed = function( price ) { + var displayedPrice = $( '.contribution-option .label-value' ).text(); + expect( displayedPrice ).toEqual( price ); + }; + + var expectPaymentButtonEnabled = function( isEnabled ) { + var isDisabled = $( '#pay_button' ).hasClass('is-disabled'); + expect( !isDisabled ).toEqual( isEnabled ); + }; + + var expectPaymentDisabledBecauseInactive = function() { + var payButton = $( '#pay_button'), + activateButton = $( '#activate_button' ); + + // Payment button should be hidden + expect( payButton.length ).toEqual(0); + + // Activate button should be displayed and disabled + expect( activateButton.length ).toEqual(1); + expect( activateButton.hasClass( 'is-disabled' ) ).toBe( true ); + }; + + var goToPayment = function( requests, kwargs ) { + var params = { + contribution: kwargs.amount || "", + course_id: kwargs.courseId || "" + }; + + // Click the "go to payment" button + $( '#pay_button' ).click(); + + // Verify that the request was made to the server + AjaxHelpers.expectRequest( + requests, "POST", "/verify_student/create_order/", + $.param( params ) + ); + + // Simulate the server response + if ( kwargs.succeeds ) { + AjaxHelpers.respondWithJson( requests, PAYMENT_PARAMS ); + } else { + AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG ); + } + }; + + var expectPaymentSubmitted = function( view, params ) { + var form; + + expect(view.submitForm).toHaveBeenCalled(); + form = view.submitForm.mostRecentCall.args[0]; + + expect(form.serialize()).toEqual($.param(params)); + expect(form.attr('method')).toEqual("POST"); + expect(form.attr('action')).toEqual(PAYMENT_URL); + }; + + var expectErrorDisplayed = function( errorTitle ) { + var actualTitle = $( '#error h3.title' ).text(); + expect( actualTitle ).toEqual( errorTitle ); + }; + + beforeEach(function() { + window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + + setFixtures( '
' ); + TemplateHelpers.installTemplate( 'templates/verify_student/make_payment_step' ); + TemplateHelpers.installTemplate( 'templates/verify_student/requirements' ); + }); + + it( 'allows users to choose a suggested price', function() { + var view = createView({}), + requests = AjaxHelpers.requests(this); + + expectPriceOptions( STEP_DATA.suggestedPrices ); + expectPaymentButtonEnabled( false ); + + choosePriceOption( STEP_DATA.suggestedPrices[1] ); + expectPaymentButtonEnabled( true ); + + goToPayment( requests, { + amount: STEP_DATA.suggestedPrices[1], + courseId: STEP_DATA.courseKey, + succeeds: true + }); + expectPaymentSubmitted( view, PAYMENT_PARAMS ); + }); + + it( 'allows users to pay the minimum price if no suggested prices are given', function() { + var view = createView({ suggestedPrices: [] }), + requests = AjaxHelpers.requests( this ); + + expectSinglePriceDisplayed( STEP_DATA.minPrice ); + expectPaymentButtonEnabled( true ); + + goToPayment( requests, { + amount: STEP_DATA.minPrice, + courseId: STEP_DATA.courseKey, + succeeds: true + }); + expectPaymentSubmitted( view, PAYMENT_PARAMS ); + }); + + it( 'allows the user to enter a contribution amount', function() { + var view = createView({}), + requests = AjaxHelpers.requests( this ); + + enterPrice( "67.89" ); + expectPaymentButtonEnabled( true ); + goToPayment( requests, { + amount: "67.89", + courseId: STEP_DATA.courseKey, + succeeds: true + }); + expectPaymentSubmitted( view, PAYMENT_PARAMS ); + }); + + it( 'selects in the contribution amount if provided', function() { + // Pre-select one of the suggested prices + createView({ + contributionAmount: STEP_DATA.suggestedPrices[1] + }); + + // Expect that the price is selected + expectPriceSelected( STEP_DATA.suggestedPrices[1]); + }); + + it( 'fills in the contribution amount if provided', function() { + // Pre-select a price NOT in the suggestions + createView({ + contributionAmount: '99.99' + }); + + // Expect that the price is filled in + expectPriceSelected( '99.99' ); + }); + + it( 'ignores the contribution pre-selected if no suggested prices are given', function() { + // No suggested prices, but a contribution is set + createView({ + suggestedPrices: [], + contributionAmount: '99.99' + }); + + // Expect that the single price is displayed + expectSinglePriceDisplayed( STEP_DATA.minPrice ); + }); + + it( 'disables payment for inactive users', function() { + createView({ isActive: false }); + expectPaymentDisabledBecauseInactive(); + }); + + it( 'displays an error if the order could not be created', function() { + var requests = AjaxHelpers.requests( this ), + view = createView({}); + + choosePriceOption( STEP_DATA.suggestedPrices[0] ); + goToPayment( requests, { + amount: STEP_DATA.suggestedPrices[0], + courseId: STEP_DATA.courseKey, + succeeds: false + }); + + // Expect that an error is displayed + expect( view.errorModel.get('shown') ).toBe( true ); + expect( view.errorModel.get('errorTitle') ).toEqual( 'Could not submit order' ); + expect( view.errorModel.get('errorMsg') ).toEqual( SERVER_ERROR_MSG ); + + // Expect that the payment button is re-enabled + expectPaymentButtonEnabled( true ); + }); + + }); + } +); diff --git a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js new file mode 100644 index 0000000000..ddd15b7720 --- /dev/null +++ b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js @@ -0,0 +1,188 @@ +define(['jquery', 'js/common_helpers/template_helpers', 'js/verify_student/views/pay_and_verify_view'], + function( $, TemplateHelpers, PayAndVerifyView ) { + 'use strict'; + + describe( 'edx.verify_student.PayAndVerifyView', function() { + + var TEMPLATES = [ + 'enrollment_confirmation_step', + 'error', + 'face_photo_step', + 'id_photo_step', + 'intro_step', + 'make_payment_step', + 'payment_confirmation_step', + 'progress', + 'requirements', + 'review_photos_step', + 'webcam_photo' + ]; + + var INTRO_STEP = { + templateName: "intro_step", + name: "intro-step", + title: "Intro" + }; + + var DISPLAY_STEPS_FOR_PAYMENT = [ + { + templateName: "make_payment_step", + name: "make-payment-step", + title: "Make Payment" + }, + { + templateName: "payment_confirmation_step", + name: "payment-confirmation-step", + title: "Payment Confirmation" + } + ]; + + var DISPLAY_STEPS_FOR_VERIFICATION = [ + { + templateName: "face_photo_step", + name: "face-photo-step", + title: "Take Face Photo" + }, + { + templateName: "id_photo_step", + name: "id-photo-step", + title: "ID Photo" + }, + { + templateName: "review_photos_step", + name: "review-photos-step", + title: "Review Photos" + }, + { + templateName: "enrollment_confirmation_step", + name: "enrollment-confirmation-step", + title: "Enrollment Confirmation" + } + ]; + + + var createView = function( displaySteps, currentStep ) { + return new PayAndVerifyView({ + displaySteps: displaySteps, + currentStep: currentStep + }).render(); + }; + + var expectStepRendered = function( stepName, stepNum, numSteps ) { + var i, j, sel; + + // Expect that the step container div rendered + expect( $( '.' + stepName ).length > 0 ).toBe( true ); + + // Expect that the progress indicator shows the correct step + expect( $( '#progress-step-' + stepNum ).hasClass( 'is-current' ) ).toBe( true ); + + // Expect that all steps before this step are completed + for ( i = 1; i < stepNum; i++ ) { + sel = $( '#progress-step-' + i ); + expect( sel.hasClass('is-completed') ).toBe( true ); + expect( sel.hasClass('is-current') ).toBe( false ); + } + + // Expect that all steps after this step are neither completed nor current + for ( j = stepNum + 1; j <= numSteps; j++ ) { + sel = $( '#progress-step-' + j ); + expect( sel.hasClass('is-completed') ).toBe( false ); + expect( sel.hasClass('is-current') ).toBe( false ); + } + }; + + beforeEach(function() { + window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + + setFixtures('
'); + $.each( TEMPLATES, function( index, templateName ) { + TemplateHelpers.installTemplate('templates/verify_student/' + templateName ); + }); + }); + + it( 'renders payment and verification steps', function() { + // Create the view, starting on the first step + var view = createView( + DISPLAY_STEPS_FOR_PAYMENT.concat(DISPLAY_STEPS_FOR_VERIFICATION), + 'make-payment-step' + ); + + // Verify that the first step rendered + expectStepRendered('make-payment-step', 1, 6); + + // Iterate through the steps, ensuring that each is rendered + view.nextStep(); + expectStepRendered('payment-confirmation-step', 2, 6); + + view.nextStep(); + expectStepRendered('face-photo-step', 3, 6); + + view.nextStep(); + expectStepRendered('id-photo-step', 4, 6); + + view.nextStep(); + expectStepRendered('review-photos-step', 5, 6); + + view.nextStep(); + expectStepRendered('enrollment-confirmation-step', 6, 6); + + // Going past the last step stays on the last step + view.nextStep(); + expectStepRendered('enrollment-confirmation-step', 6, 6); + }); + + it( 'renders intro and verification steps', function() { + var view = createView( + [INTRO_STEP].concat(DISPLAY_STEPS_FOR_VERIFICATION), + 'intro-step' + ); + + // Verify that the first step rendered + expectStepRendered('intro-step', 1, 5); + + // Iterate through the steps, ensuring that each is rendered + view.nextStep(); + expectStepRendered('face-photo-step', 2, 5); + + view.nextStep(); + expectStepRendered('id-photo-step', 3, 5); + + view.nextStep(); + expectStepRendered('review-photos-step', 4, 5); + + view.nextStep(); + expectStepRendered('enrollment-confirmation-step', 5, 5); + }); + + it( 'starts from a later step', function() { + // Start from the payment confirmation step + var view = createView( + DISPLAY_STEPS_FOR_PAYMENT.concat(DISPLAY_STEPS_FOR_VERIFICATION), + 'payment-confirmation-step' + ); + + // Verify that we start on the right step + expectStepRendered('payment-confirmation-step', 2, 6); + + // Try moving to the next step + view.nextStep(); + expectStepRendered('face-photo-step', 3, 6); + + }); + + it( 'jumps to a particular step', function() { + // Start on the review photos step + var view = createView( + DISPLAY_STEPS_FOR_VERIFICATION, + 'review-photos-step' + ); + + // Jump back to the face photo step + view.goToStep('face-photo-step'); + expectStepRendered('face-photo-step', 1, 4); + + }); + }); + } +); diff --git a/lms/static/js/spec/verify_student/review_photos_step_view_spec.js b/lms/static/js/spec/verify_student/review_photos_step_view_spec.js new file mode 100644 index 0000000000..65e86ef098 --- /dev/null +++ b/lms/static/js/spec/verify_student/review_photos_step_view_spec.js @@ -0,0 +1,149 @@ +define([ + 'jquery', + 'underscore', + 'backbone', + 'js/common_helpers/ajax_helpers', + 'js/common_helpers/template_helpers', + 'js/verify_student/views/review_photos_step_view', + 'js/verify_student/models/verification_model' + ], + function( $, _, Backbone, AjaxHelpers, TemplateHelpers, ReviewPhotosStepView, VerificationModel ) { + 'use strict'; + + describe( 'edx.verify_student.ReviewPhotosStepView', function() { + + var STEP_DATA = {}, + FULL_NAME = "Test User", + FACE_IMAGE = "abcd1234", + PHOTO_ID_IMAGE = "efgh56789", + SERVER_ERROR_MSG = "An error occurred!"; + + var createView = function() { + return new ReviewPhotosStepView({ + el: $( '#current-step-container' ), + templateName: 'review_photos_step', + stepData: STEP_DATA, + model: new VerificationModel({ + faceImage: FACE_IMAGE, + identificationImage: PHOTO_ID_IMAGE + }), + errorModel: new ( Backbone.Model.extend({}) )() + }).render(); + }; + + var confirmPhotos = function( isConfirmed ) { + $('#confirm_pics_good').trigger( 'click' ); + }; + + var submitPhotos = function( requests, expectedParams, succeeds ) { + // Submit the photos + $( '#next_step_button' ).click(); + + // Expect a request to the server + AjaxHelpers.expectRequest( + requests, "POST", "/verify_student/submit-photos/", + $.param( expectedParams ) + ); + + // Simulate the server response + if ( succeeds ) { + AjaxHelpers.respondWithJson( requests ); + } else { + AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG ); + } + }; + + var setFullName = function( fullName ) { + $('#new-name').val( fullName ); + }; + + var expectSubmitEnabled = function( isEnabled ) { + var isDisabled = $('#next_step_button').hasClass('is-disabled'); + expect( !isDisabled ).toBe( isEnabled ); + }; + + beforeEach(function() { + window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + + setFixtures( '
' ); + TemplateHelpers.installTemplate( 'templates/verify_student/review_photos_step' ); + }); + + it( 'requires the user to confirm before submitting photos', function() { + createView(); + + // Initially disabled + expectSubmitEnabled( false ); + + // Confirm the photos, enabling submission + confirmPhotos( true ); + expectSubmitEnabled( true ); + + // Unconfirm the photos, disabling submission + confirmPhotos( false ); + expectSubmitEnabled( false ); + }); + + it( 'allows the user to change her full name', function() { + var requests = AjaxHelpers.requests( this ); + + createView(); + setFullName( FULL_NAME ); + confirmPhotos( true ); + submitPhotos( + requests, + { + face_image: FACE_IMAGE, + photo_id_image: PHOTO_ID_IMAGE, + full_name: FULL_NAME + }, + true + ); + }); + + it( 'submits photos for verification', function() { + var requests = AjaxHelpers.requests( this ); + + createView(); + confirmPhotos( true ); + submitPhotos( + requests, + { + face_image: FACE_IMAGE, + photo_id_image: PHOTO_ID_IMAGE + }, + true + ); + + // Expect that submission is disabled to prevent + // duplicate submission. + expectSubmitEnabled( false ); + }); + + it( 'displays an error if photo submission fails', function() { + var view = createView(), + requests = AjaxHelpers.requests( this ); + + confirmPhotos( true ); + submitPhotos( + requests, + { + face_image: FACE_IMAGE, + photo_id_image: PHOTO_ID_IMAGE + }, + false + ); + + // Expect the submit button is re-enabled to allow + // the user to retry. + expectSubmitEnabled( true ); + + // Expect that an error message is displayed + expect( view.errorModel.get('shown') ).toBe( true ); + expect( view.errorModel.get('errorTitle') ).toEqual( 'Could not submit photos' ); + expect( view.errorModel.get('errorMsg') ).toEqual( SERVER_ERROR_MSG ); + }); + + }); + } +); diff --git a/lms/static/js/spec/verify_student/webcam_photo_view_spec.js b/lms/static/js/spec/verify_student/webcam_photo_view_spec.js new file mode 100644 index 0000000000..f5eecc0cd9 --- /dev/null +++ b/lms/static/js/spec/verify_student/webcam_photo_view_spec.js @@ -0,0 +1,212 @@ +define([ + 'jquery', + 'backbone', + 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', + 'js/verify_student/views/webcam_photo_view', + 'js/verify_student/models/verification_model' + ], + function( $, Backbone, TemplateHelpers, AjaxHelpers, WebcamPhotoView, VerificationModel ) { + 'use strict'; + + describe( 'edx.verify_student.WebcamPhotoView', function() { + + var IMAGE_DATA = "abcd1234", + VIDEO_ERROR_TITLE = "video capture error", + VIDEO_ERROR_MSG = "video error msg"; + + /** + * For the purposes of these tests, we stub out the backend + * video capture implementation. + * This allows us to easily test the application logic + * without needing to handle the subtleties of video capture + * (especially cross-browser). + * However, this means that the test suite does NOT adequately + * cover the HTML5 / Flash webcam integration. We will need + * cross-browser manual testing to verify that this works correctly. + */ + var StubBackend = function( name, isSupported, snapshotSuccess ) { + + if ( _.isUndefined( isSupported ) ) { + isSupported = true; + } + + if ( _.isUndefined( snapshotSuccess ) ) { + snapshotSuccess = true; + } + + return { + name: name, + initialize: function() {}, + isSupported: function() { return isSupported; }, + snapshot: function() { return snapshotSuccess; }, + getImageData: function() { return IMAGE_DATA; }, + reset: function() {} + }; + }; + + var createView = function( backends ) { + return new WebcamPhotoView({ + el: $( '#current-step-container' ), + model: new VerificationModel({}), + modelAttribute: 'faceImage', + errorModel: new ( Backbone.Model.extend({}) )(), + submitButton: $( '#submit_button' ), + backends: backends + }).render(); + }; + + var takeSnapshot = function() { + $( '#webcam_capture_button' ).click(); + }; + + var resetWebcam = function() { + $( '#webcam_reset_button' ).click(); + }; + + var expectButtonShown = function( obj ) { + var resetButton = $( '#webcam_reset_button' ), + captureButton = $( '#webcam_capture_button' ); + + expect( captureButton.hasClass( 'is-hidden') ).toBe( !obj.snapshot ); + expect( resetButton.hasClass( 'is-hidden') ).toBe( !obj.reset ); + }; + + var expectSubmitEnabled = function( isEnabled ) { + var isDisabled = $( '#submit_button' ).hasClass( 'is-disabled' ); + expect( !isDisabled ).toEqual( isEnabled ); + }; + + beforeEach(function() { + window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + + setFixtures( + '
' + + '' + ); + TemplateHelpers.installTemplate( 'templates/verify_student/webcam_photo' ); + }); + + it( 'takes a snapshot', function() { + var view = createView( [ StubBackend( "html5" ) ] ); + + // Spy on the backend + spyOn( view.backend, 'snapshot' ).andCallThrough(); + + // Initially, only the snapshot button is shown + expectButtonShown({ + snapshot: true, + reset: false + }); + + expectSubmitEnabled( false ); + + // Take the snapshot + takeSnapshot(); + + // Expect that the backend was used to take the snapshot + expect( view.backend.snapshot ).toHaveBeenCalled(); + + // Expect that buttons were updated + expectButtonShown({ + snapshot: false, + reset: true + }); + expectSubmitEnabled( true ); + + // Expect that the image data was saved to the model + expect( view.model.get( 'faceImage' ) ).toEqual( IMAGE_DATA ); + }); + + it( 'resets the camera', function() { + var view = createView( [ StubBackend( "html5" ) ]); + + // Spy on the backend + spyOn( view.backend, 'reset' ).andCallThrough(); + + // Take the snapshot, then reset + takeSnapshot(); + resetWebcam(); + + // Expect that the backend was reset + expect( view.backend.reset ).toHaveBeenCalled(); + + // Expect that we're back to the initial button shown state + expectButtonShown({ + snapshot: true, + reset: false + }); + expectSubmitEnabled( false ); + + // Expect that the image data is wiped from the model + expect( view.model.get( 'faceImage' ) ).toEqual( "" ); + }); + + it( 'falls back to a second video capture backend', function() { + var backends = [ StubBackend( "html5", false ), StubBackend( "flash", true ) ], + view = createView( backends ); + + // Expect that the second backend is chosen + expect( view.backend.name ).toEqual( backends[1].name ); + }); + + it( 'displays an error if no video backend is supported', function() { + var backends = [ StubBackend( "html5", false ), StubBackend( "flash", false ) ], + view = createView( backends ); + + // Expect an error + expect( view.errorModel.get( 'errorTitle' ) ).toEqual( 'No Flash Detected' ); + expect( view.errorModel.get( 'errorMsg' ) ).toContain( 'Get Flash' ); + expect( view.errorModel.get( 'shown' ) ).toBe( true ); + + // Expect that submission is disabled + expectSubmitEnabled( false ); + }); + + it( 'displays an error if the snapshot fails', function() { + var backends = [ StubBackend( "html5", true, false ) ], + view = createView( backends ); + + // Take a snapshot + takeSnapshot(); + + // Do NOT expect an error displayed + expect( view.errorModel.get( 'shown' ) ).not.toBe( true ); + + // Expect that the capture button is still enabled + // so the user can retry. + expectButtonShown({ + snapshot: true, + reset: false + }); + + // Expect that submit is NOT enabled, since the user didn't + // successfully take a snapshot. + expectSubmitEnabled( false ); + }); + + it( 'displays an error triggered by the backend', function() { + var view = createView( [ StubBackend( "html5") ] ); + + // Simulate an error triggered by the backend + // This could occur at any point, including + // while the video capture is being set up. + view.backend.trigger( 'error', VIDEO_ERROR_TITLE, VIDEO_ERROR_MSG ); + + // Verify that the error is displayed + expect( view.errorModel.get( 'errorTitle' ) ).toEqual( VIDEO_ERROR_TITLE ); + expect( view.errorModel.get( 'errorMsg' ) ).toEqual( VIDEO_ERROR_MSG ); + expect( view.errorModel.get( 'shown' ) ).toBe( true ); + + // Expect that buttons are hidden + expectButtonShown({ + snapshot: false, + reset: false + }); + expectSubmitEnabled( false ); + + }); + + }); + } +); diff --git a/lms/static/js/verify_student/pay_and_verify.js b/lms/static/js/verify_student/pay_and_verify.js index 4911af7850..e2a9cdf4a1 100644 --- a/lms/static/js/verify_student/pay_and_verify.js +++ b/lms/static/js/verify_student/pay_and_verify.js @@ -10,7 +10,7 @@ */ var edx = edx || {}; -(function($) { +(function( $, _ ) { 'use strict'; var errorView, el = $('#pay-and-verify-container'); @@ -47,8 +47,11 @@ var edx = edx || {}; requirements: el.data('requirements'), courseKey: el.data('course-key'), minPrice: el.data('course-mode-min-price'), - suggestedPrices: (el.data('course-mode-suggested-prices') || "").split(","), contributionAmount: el.data('contribution-amount'), + suggestedPrices: _.filter( + (el.data('course-mode-suggested-prices') || "").split(","), + function( price ) { return Boolean( price ); } + ), currency: el.data('course-mode-currency'), purchaseEndpoint: el.data('purchase-endpoint') }, @@ -68,4 +71,4 @@ var edx = edx || {}; } } }).render(); -})(jQuery); +})( jQuery, _ ); diff --git a/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js b/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js index 20491c7b91..06feb79527 100644 --- a/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js +++ b/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js @@ -15,6 +15,14 @@ var edx = edx || {}; postRender: function() { // Track a virtual pageview, for easy funnel reconstruction. window.analytics.page( 'verification', this.templateName ); + }, + + defaultContext: function() { + return { + courseName: '', + courseStartDate: '', + coursewareUrl: '' + }; } }); diff --git a/lms/static/js/verify_student/views/intro_step_view.js b/lms/static/js/verify_student/views/intro_step_view.js index 4de4efd9ee..0eead8f279 100644 --- a/lms/static/js/verify_student/views/intro_step_view.js +++ b/lms/static/js/verify_student/views/intro_step_view.js @@ -10,6 +10,14 @@ var edx = edx || {}; edx.verify_student.IntroStepView = edx.verify_student.StepView.extend({ + defaultContext: function() { + return { + introTitle: '', + introMsg: '', + isActive: false + }; + }, + // Currently, this view doesn't need to install any custom event handlers, // since the button in the template reloads the page with a // ?skip-intro=1 GET parameter. The reason for this is that we diff --git a/lms/static/js/verify_student/views/make_payment_step_view.js b/lms/static/js/verify_student/views/make_payment_step_view.js index 28c22e6519..3b24b625e2 100644 --- a/lms/static/js/verify_student/views/make_payment_step_view.js +++ b/lms/static/js/verify_student/views/make_payment_step_view.js @@ -10,6 +10,15 @@ var edx = edx || {}; edx.verify_student.MakePaymentStepView = edx.verify_student.StepView.extend({ + defaultContext: function() { + return { + isActive: true, + suggestedPrices: [], + minPrice: 0, + currency: "usd" + }; + }, + postRender: function() { // Render requirements new edx.verify_student.RequirementsView({ @@ -26,8 +35,14 @@ var edx = edx || {}; this.selectPaymentAmount( this.stepData.contributionAmount ); } - // Enable the payment button once an amount is chosen - $( "input[name='contribution']" ).on( 'click', _.bind( this.enablePaymentButton, this ) ); + if ( this.templateContext().suggestedPrices.length > 0 ) { + // Enable the payment button once an amount is chosen + $( "input[name='contribution']" ).on( 'click', _.bind( this.enablePaymentButton, this ) ); + } else { + // If there is only one payment option, then the user isn't shown + // radio buttons, so we need to enable the radio button. + this.enablePaymentButton(); + } // Handle payment submission $( "#pay_button" ).on( 'click', _.bind( this.createOrder, this ) ); @@ -89,41 +104,49 @@ var edx = edx || {}; // this page. A virtual pageview can be used to do this. window.analytics.page( 'payment', 'payment_processor_step' ); - form.submit(); + this.submitForm( form ); }, handleCreateOrderError: function( xhr ) { + var errorMsg = gettext( 'An unexpected error occurred. Please try again.' ); + if ( xhr.status === 400 ) { - this.errorModel.set({ - errorTitle: gettext( 'Could not submit order' ), - errorMsg: xhr.responseText, - shown: true - }); - } else { - this.errorModel.set({ - errorTitle: gettext( 'Could not submit order' ), - errorMsg: gettext( 'An unexpected error occurred. Please try again' ), - shown: true - }); + errorMsg = xhr.responseText; } + this.errorModel.set({ + errorTitle: gettext( 'Could not submit order' ), + errorMsg: errorMsg, + shown: true + }); + // Re-enable the button so the user can re-try - $( "#payment-processor-form" ).removeClass("is-disabled"); + $( "#pay_button" ).removeClass("is-disabled"); }, getPaymentAmount: function() { - var contributionInput = $("input[name='contribution']:checked", this.el); + var contributionInput = $("input[name='contribution']:checked", this.el), + amount = null; if ( contributionInput.attr('id') === 'contribution-other' ) { - return $( "input[name='contribution-other-amt']", this.el ).val(); + amount = $( "input[name='contribution-other-amt']", this.el ).val(); } else { - return contributionInput.val(); + amount = contributionInput.val(); } + + // If no suggested prices are available, then the user does not + // get the option to select a price. Default to the minimum. + if ( !amount ) { + amount = this.templateContext().minPrice; + } + + return amount; }, selectPaymentAmount: function( amount ) { var amountFloat = parseFloat( amount ), - foundPrice; + foundPrice, + sel; // Check if we have a suggested price that matches the amount foundPrice = _.find( @@ -135,7 +158,8 @@ var edx = edx || {}; // If we've found an option for the price, select it. if ( foundPrice ) { - $( '#contribution-' + foundPrice, this.el ).prop( 'checked', true ); + sel = _.sprintf( 'input[name="contribution"][value="%s"]', foundPrice ); + $( sel ).prop( 'checked', true ); } else { // Otherwise, enter the value into the text box $( '#contribution-other-amt', this.el ).val( amount ); @@ -144,6 +168,13 @@ var edx = edx || {}; // In either case, enable the payment button this.enablePaymentButton(); + + return amount; + }, + + // Stubbed out in tests + submitForm: function( form ) { + form.submit(); } }); diff --git a/lms/static/js/verify_student/views/pay_and_verify_view.js b/lms/static/js/verify_student/views/pay_and_verify_view.js index 554a0bb151..343ef98139 100644 --- a/lms/static/js/verify_student/views/pay_and_verify_view.js +++ b/lms/static/js/verify_student/views/pay_and_verify_view.js @@ -16,8 +16,6 @@ var edx = edx || {}; edx.verify_student.PayAndVerifyView = Backbone.View.extend({ el: '#pay-and-verify-container', - template: '#progress-tpl', - subviews: {}, VERIFICATION_VIEW_NAMES: [ @@ -27,7 +25,7 @@ var edx = edx || {}; ], initialize: function( obj ) { - this.errorModel = obj.errorModel || {}; + this.errorModel = obj.errorModel || null; this.displaySteps = obj.displaySteps || []; this.progressView = new edx.verify_student.ProgressView({ @@ -43,7 +41,7 @@ var edx = edx || {}; ) }); - this.initializeStepViews( obj.stepInfo ); + this.initializeStepViews( obj.stepInfo || {} ); }, initializeStepViews: function( stepInfo ) { diff --git a/lms/static/js/verify_student/views/review_photos_step_view.js b/lms/static/js/verify_student/views/review_photos_step_view.js index 1e434dcf19..822fc3aec3 100644 --- a/lms/static/js/verify_student/views/review_photos_step_view.js +++ b/lms/static/js/verify_student/views/review_photos_step_view.js @@ -10,9 +10,14 @@ var edx = edx || {}; edx.verify_student.ReviewPhotosStepView = edx.verify_student.StepView.extend({ - postRender: function() { - var model = this.model; + defaultContext: function() { + return { + platformName: "", + fullName: "", + }; + }, + postRender: function() { // Load the photos from the previous steps $( '#face_image' )[0].src = this.model.get('faceImage'); $( '#photo_id_image' )[0].src = this.model.get('identificationImage'); @@ -49,6 +54,8 @@ var edx = edx || {}; }, submitPhotos: function() { + var fullName = $( '#new-name' ).val(); + // Disable the submit button to prevent duplicate submissions $( '#next_step_button' ).addClass( 'is-disabled' ); @@ -59,30 +66,28 @@ var edx = edx || {}; this.listenToOnce( this.model, 'error', _.bind( this.handleSubmissionError, this ) ); // Submit - this.model.set( 'fullName', $( '#new-name' ).val() ); + if ( fullName ) { + this.model.set( 'fullName', fullName ); + } this.model.save(); }, handleSubmissionError: function( xhr ) { + var isConfirmChecked = $( "#confirm_pics_good" ).prop('checked'), + errorMsg = gettext( 'An unexpected error occurred. Please try again later.' ); + // Re-enable the submit button to allow the user to retry - var isConfirmChecked = $( '#confirm_pics_good' ).prop( 'checked' ); $( '#next_step_button' ).toggleClass( 'is-disabled', !isConfirmChecked ); - // Display the error if ( xhr.status === 400 ) { - this.errorModel.set({ - errorTitle: gettext( 'Could not submit photos' ), - errorMsg: xhr.responseText, - shown: true - }); - } - else { - this.errorModel.set({ - errorTitle: gettext( 'Could not submit photos' ), - errorMsg: gettext( 'An unexpected error occurred. Please try again later.' ), - shown: true - }); + errorMsg = xhr.responseText; } + + this.errorModel.set({ + errorTitle: gettext( 'Could not submit photos' ), + errorMsg: errorMsg, + shown: true + }); }, expandCallback: function( event ) { diff --git a/lms/static/js/verify_student/views/step_view.js b/lms/static/js/verify_student/views/step_view.js index 869754566a..5b3afa31cb 100644 --- a/lms/static/js/verify_student/views/step_view.js +++ b/lms/static/js/verify_student/views/step_view.js @@ -26,19 +26,11 @@ }, render: function() { - var templateHtml = $( "#" + this.templateName + "-tpl" ).html(), - templateContext = { - nextStepNum: this.nextStepNum, - nextStepTitle: this.nextStepTitle - }; - - // Include step-specific information from the server - // (passed in from data- attributes to the parent view) - _.extend( templateContext, this.stepData ); + var templateHtml = $( "#" + this.templateName + "-tpl" ).html(); // Allow subclasses to add additional information // to the template context, perhaps asynchronously. - this.updateContext( templateContext ).done( + this.updateContext( this.templateContext() ).done( function( templateContext ) { // Render the template into the DOM $( this.el ).html( _.template( templateHtml, templateContext ) ); @@ -47,6 +39,8 @@ this.postRender(); } ).fail( _.bind( this.handleError, this ) ); + + return this; }, handleResponse: function( data ) { @@ -58,10 +52,8 @@ // Include step-specific information _.extend( context, this.stepData ); - this.renderedHtml = _.template( data, context ); - $( this.el ).html( this.renderedHtml ); - - this.postRender(); + // Track a virtual pageview, for easy funnel reconstruction. + window.analytics.page( 'verification', this.templateName ); }, handleError: function( errorTitle, errorMsg ) { @@ -72,6 +64,26 @@ }); }, + templateContext: function() { + var context = { + nextStepNum: this.nextStepNum, + nextStepTitle: this.nextStepTitle + }; + return _.extend( context, this.defaultContext(), this.stepData ); + }, + + /** + * Provide default values for the template context. + * Subclasses can use this to fill in values that + * the underscore templates expect to be defined. + * This is especially useful for testing, so that the + * tests can pass in only the values relevant + * to the test. + */ + defaultContext: function() { + return {}; + }, + /** * Subclasses can override this to add information to * the template context. This returns an asynchronous diff --git a/lms/static/js/verify_student/views/webcam_photo_view.js b/lms/static/js/verify_student/views/webcam_photo_view.js index d1f00f8495..6bbca162e9 100644 --- a/lms/static/js/verify_student/views/webcam_photo_view.js +++ b/lms/static/js/verify_student/views/webcam_photo_view.js @@ -13,9 +13,10 @@ template: "#webcam_photo-tpl", - videoCaptureBackend: { + backends: [ + { + name: "html5", - html5: { initialize: function( obj ) { this.URL = (window.URL || window.webkitURL); this.video = obj.video || ""; @@ -90,7 +91,9 @@ } }, - flash: { + { + name: "flash", + initialize: function( obj ) { this.wrapper = obj.wrapper || ""; this.imageData = ""; @@ -193,15 +196,18 @@ // so we don't need to keep checking. } } - }, - - videoBackendPriority: ['html5', 'flash'], + ], initialize: function( obj ) { this.submitButton = obj.submitButton || ""; this.modelAttribute = obj.modelAttribute || ""; - this.errorModel = obj.errorModel || {}; - this.backend = this.chooseVideoCaptureBackend(); + this.errorModel = obj.errorModel || null; + this.backend = _.find( + obj.backends || this.backends, + function( backend ) { + return backend.isSupported(); + } + ); if ( !this.backend ) { this.handleError( @@ -232,15 +238,20 @@ // Initialize the video capture backend // We need to do this after rendering the template // so that the backend has the opportunity to modify the DOM. - this.backend.initialize({ - wrapper: "#camera", - video: '#photo_id_video', - canvas: '#photo_id_canvas' - }); + if ( this.backend ) { + this.backend.initialize({ + wrapper: "#camera", + video: '#photo_id_video', + canvas: '#photo_id_canvas' + }); - // Install event handlers - $( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) ); - $( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) ); + // Install event handlers + $( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) ); + $( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) ); + + // Show the capture button + $( "#webcam_capture_button", this.el ).removeClass('is-hidden'); + } return this; }, @@ -252,9 +263,12 @@ // Reset the video capture this.backend.reset(); + // Reset data on the model + this.model.set( this.modelAttribute, "" ); + // Go back to the initial button state - $( "#webcam_reset_button", this.el ).hide(); - $( "#webcam_capture_button", this.el ).show(); + $( "#webcam_reset_button", this.el ).addClass('is-hidden'); + $( "#webcam_capture_button", this.el ).removeClass('is-hidden'); }, capture: function() { @@ -267,8 +281,8 @@ this.trigger( 'imageCaptured' ); // Hide the capture button, and show the reset button - $( "#webcam_capture_button", this.el ).hide(); - $( "#webcam_reset_button", this.el ).show(); + $( "#webcam_capture_button", this.el ).addClass('is-hidden'); + $( "#webcam_reset_button", this.el ).removeClass('is-hidden'); // Save the data to the model this.model.set( this.modelAttribute, this.backend.getImageData() ); @@ -278,29 +292,19 @@ } }, - chooseVideoCaptureBackend: function() { - var i, backendName, backend; - - for ( i = 0; i < this.videoBackendPriority.length; i++ ) { - backendName = this.videoBackendPriority[i]; - backend = this.videoCaptureBackend[backendName]; - if ( backend.isSupported() ) { - return backend; - } - } - }, - handleError: function( errorTitle, errorMsg ) { // Hide the buttons - $( "#webcam_capture_button", this.el ).hide(); - $( "#webcam_reset_button", this.el ).hide(); + $( "#webcam_capture_button", this.el ).addClass('is-hidden'); + $( "#webcam_reset_button", this.el ).addClass('is-hidden'); // Show the error message - this.errorModel.set({ - errorTitle: errorTitle, - errorMsg: errorMsg, - shown: true - }); + if ( this.errorModel ) { + this.errorModel.set({ + errorTitle: errorTitle, + errorMsg: errorMsg, + shown: true + }); + } } }); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index e5fbda003a..a1635660b1 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -79,6 +79,7 @@ fixture_paths: - templates/dashboard - templates/student_account - templates/student_profile + - templates/verify_student - templates/file-upload.underscore requirejs: diff --git a/lms/templates/verify_student/enrollment_confirmation_step.underscore b/lms/templates/verify_student/enrollment_confirmation_step.underscore index 1432ac9ea2..7226556271 100644 --- a/lms/templates/verify_student/enrollment_confirmation_step.underscore +++ b/lms/templates/verify_student/enrollment_confirmation_step.underscore @@ -1,4 +1,4 @@ -
+

<%- gettext( "Congratulations! You are now enrolled in the verified track." ) %>

diff --git a/lms/templates/verify_student/face_photo_step.underscore b/lms/templates/verify_student/face_photo_step.underscore index bcdcc22b4f..c278f37a64 100644 --- a/lms/templates/verify_student/face_photo_step.underscore +++ b/lms/templates/verify_student/face_photo_step.underscore @@ -1,4 +1,4 @@ -
+

<%- gettext( "Take Your Photo" ) %>

diff --git a/lms/templates/verify_student/id_photo_step.underscore b/lms/templates/verify_student/id_photo_step.underscore index 80039fe42f..e7d389b701 100644 --- a/lms/templates/verify_student/id_photo_step.underscore +++ b/lms/templates/verify_student/id_photo_step.underscore @@ -1,4 +1,4 @@ -
+

<%- gettext( "Show Us Your ID" ) %>

diff --git a/lms/templates/verify_student/intro_step.underscore b/lms/templates/verify_student/intro_step.underscore index a9ce15f01f..e97723167b 100644 --- a/lms/templates/verify_student/intro_step.underscore +++ b/lms/templates/verify_student/intro_step.underscore @@ -1,4 +1,4 @@ -
+

<%- introTitle %>

<%- introMsg %>

@@ -9,11 +9,11 @@