diff --git a/.eslintrc.js b/.eslintrc.js index 1acd0eea97..396da39c0c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,4 @@ -const { createConfig } = require('@edx/frontend-build'); +const { createConfig } = require('@openedx/frontend-build'); const config = createConfig('eslint'); diff --git a/babel.config.js b/babel.config.js index 73278f4ec0..d0dc882cad 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,3 @@ -const { createConfig } = require('@edx/frontend-build'); +const { createConfig } = require('@openedx/frontend-build'); module.exports = createConfig('babel'); diff --git a/karma.conf.js b/karma.conf.js index e0baf16c0f..7597845201 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,4 +1,6 @@ // Karma configuration +process.env.NODE_ENV = 'dev'; + const webpackConfig = require('./webpack.prod.config.js'); module.exports = function(config) { diff --git a/openassessment/xblock/static/js/spec/lms/oa_response.js b/openassessment/xblock/static/js/spec/lms/oa_response.js index 144064841a..9c2a4e8a24 100644 --- a/openassessment/xblock/static/js/spec/lms/oa_response.js +++ b/openassessment/xblock/static/js/spec/lms/oa_response.js @@ -186,36 +186,51 @@ describe("OpenAssessment.ResponseView", function() { var rootElement = $('.step--response').parent().get(0); var baseView = new BaseView(runtime, rootElement, server, data); view = new ResponseView(rootElement, server, fileUploader, baseView.responseEditorLoader, baseView, data); - view.loadResponseEditor().then(editorCtrl => { - view.responseEditorController = editorCtrl - view.installHandlers() - - // Stub the confirmation step - // By default, we simulate the user confirming the submission. - // To instead simulate the user cancelling the submission, - // set `stubConfirm` to false. - setStubConfirm(true); - const fakeConfirm = function(_0, _1, confirmCallback, cancelCallback) { - if (stubConfirm) { - confirmCallback(); - } else { - cancelCallback(); + var mockEditorController = { + _response: ['', ''], + load: function(elements) { + this.elements = elements; + return Promise.resolve(); + }, + response: function(texts) { + if (typeof texts !== 'undefined') { + this._response = texts; + return this._response; } + return this._response; + }, + setOnChangeListener: function(callback) { + this._changeCallback = callback; } - spyOn(view.confirmationDialog, 'confirm').and.callFake(fakeConfirm); - spyOn(view, 'saveFilesDescriptions').and.callFake(function() { - for (var i=0; i < this.filesDescriptions.length; i++) { - this.fileNames.push(this.files[i].name); - } - return $.Deferred(function(defer) { - view.removeFilesDescriptions(); - defer.resolve(); - }); + }; + view.responseEditorController = mockEditorController; + view.installHandlers(); + + // Stub the confirmation step + // By default, we simulate the user confirming the submission. + // To instead simulate the user cancelling the submission, + // set `stubConfirm` to false. + setStubConfirm(true); + const fakeConfirm = function(_0, _1, confirmCallback, cancelCallback) { + if (stubConfirm) { + confirmCallback(); + } else { + cancelCallback(); + } + }; + spyOn(view.confirmationDialog, 'confirm').and.callFake(fakeConfirm); + spyOn(view, 'saveFilesDescriptions').and.callFake(function() { + for (var i=0; i < this.filesDescriptions.length; i++) { + this.fileNames.push(this.files[i].name); + } + return $.Deferred(function(defer) { + view.removeFilesDescriptions(); + defer.resolve(); }); - window.URL = mockURL; + }); + window.URL = mockURL; - done() - }) + done(); }); afterEach(function() { @@ -236,19 +251,17 @@ describe("OpenAssessment.ResponseView", function() { describe('is valid for submission', function() { it('response require text', function() { - // response is blank - view.response(['', '']); - view.handleResponseChanged(); + // Initial state - response is blank and should not be saved expect(view.submitEnabled()).toBe(true); expect(view.isValidForSubmit()).toBe(false); expect(view.saveStatus()).toContain('This response has not been saved'); - // response is whitespace + // response is whitespace - should trigger saving draft but still be invalid view.response([' \n \n ', ' ']); view.handleResponseChanged(); expect(view.submitEnabled()).toBe(true); expect(view.isValidForSubmit()).toBe(false); - expect(view.saveStatus()).toContain('This response has not been saved'); + expect(view.saveStatus()).toContain('Saving draft'); // response is not blank view.response(['Test response 1', ' ']); @@ -898,4 +911,4 @@ describe("OpenAssessment.ResponseView", function() { // Expect an error to be displayed expect(view.baseView.toggleActionError).toHaveBeenCalledWith('delete', 'ERROR'); }); -}); +}); \ No newline at end of file diff --git a/openassessment/xblock/static/js/spec/lms/oa_response_editor.js b/openassessment/xblock/static/js/spec/lms/oa_response_editor.js index b1d560c480..a5c4976ae9 100644 --- a/openassessment/xblock/static/js/spec/lms/oa_response_editor.js +++ b/openassessment/xblock/static/js/spec/lms/oa_response_editor.js @@ -20,69 +20,107 @@ describe("OpenAssessment.ResponseEditorLoader", function () { let response = 'generic response' - describe('Simple text editor', () => { + describe('Simple text editor', () => { + let loader; + let originalRequire; + beforeEach(() => { + originalRequire = window.require; + window.require = function(modules, callback) { + const MockEditorTextarea = function() { + return { + elements: null, + load: function(elements) { + this.elements = elements; + return Promise.resolve(); + }, + response: function(texts) { + if (typeof texts !== 'undefined') { + this._response = texts; + return this._response; + } + return this._response || []; + }, + setOnChangeListener: function(callback) { + this._changeCallback = callback; + } + }; + }; + setTimeout(() => callback(MockEditorTextarea), 0); + }; + loader = new ResponseEditorLoader(availableEditors); + }); + afterEach(() => { + window.require = originalRequire; + }); + it('Loads text editor js and css properly', function (done) { - let loader = new ResponseEditorLoader(availableEditors) - let elements = $('textarea') - + let elements = $('textarea'); + loader.load('text', elements).then(editor => { - // editor is an instance of oa_editor_textarea // it should have `elements` property set to given one - expect(editor.elements).toBe(elements) - + expect(editor.elements).toBe(elements); + // css file should also be included - expect($(`link[href='${cssFile}']`).length).toBe(1) - done() - }) - }) + expect($(`link[href='${cssFile}']`).length).toBe(1); + done(); + }); + }); it('Text area editor get instantiated properly', function(done) { - let loader = new ResponseEditorLoader(availableEditors) - let elements = $('textarea') + let elements = $('textarea'); loader.load('text', elements).then(editor => { - editor.response([response]) - expect(editor.response(), response) - done() + editor.response([response]); + expect(editor.response()).toEqual([response]); + done(); }); - }) + }); }) describe('WYSIWYG text editor', () => { - let loader = new ResponseEditorLoader(availableEditors) - let elements = $('textarea') - - let editorStub = (elements) => ({ - elements, - response: () => response - }) + let loader; + let elements; + let originalRequire; beforeEach(() => { - loader = new ResponseEditorLoader(availableEditors) - elements = $('textarea') - - spyOn(loader, 'load').and.callFake(function(selectedEditor, elements){ - return new Promise(resolve => { - setTimeout(() => resolve(editorStub(elements)), 500) - }) - }) - }) - + originalRequire = window.require; + window.require = function(modules, callback) { + const MockEditorTinyMCE = function() { + return { + elements: null, + load: function(elements) { + this.elements = elements; + return Promise.resolve(); + }, + response: function() { + return response; + } + }; + }; + setTimeout(() => callback(MockEditorTinyMCE), 0); + }; + loader = new ResponseEditorLoader(availableEditors); + elements = $('textarea'); + }); + afterEach(() => { + window.require = originalRequire; + }); + it('Loads tinymce editor js and css properly', function (done) { loader.load('tinymce', elements).then(editor => { // editor is an instance of oa_editor_tinymce // it should have `elements` property set to given one - expect(editor.elements).toBe(elements) - done() - }) - }) - - it('TinyMCE editor get instantiated properly', async function(done) { + expect(editor.elements).toBe(elements); + done(); + }); + }); + + it('TinyMCE editor get instantiated properly', function(done) { loader.load('tinymce', elements).then(editor => { // editor should have response even after the delay - expect(editor.response(), response) - done() + expect(editor.response()).toBe(response); + done(); }); - }) + }); }) -}) +}) \ No newline at end of file diff --git a/openassessment/xblock/static/js/spec/lms/oa_self.js b/openassessment/xblock/static/js/spec/lms/oa_self.js index cbb1fb36c6..a091160f65 100644 --- a/openassessment/xblock/static/js/spec/lms/oa_self.js +++ b/openassessment/xblock/static/js/spec/lms/oa_self.js @@ -49,11 +49,28 @@ describe("OpenAssessment.SelfView", function() { } } }); - view = baseView.selfView - view.renderResponseViaEditor().then(() => { - view.installHandlers(); - done() - }); + view = baseView.selfView; + var mockEditorController = { + _response: [], + load: function(elements) { + this.elements = elements; + return Promise.resolve(); + }, + response: function(texts) { + if (typeof texts !== 'undefined') { + this._response = texts; + return this._response; + } + return this._response; + }, + setOnChangeListener: function(callback) { + this._changeCallback = callback; + } + }; + + view.responseEditorController = mockEditorController; + view.installHandlers(); + done(); }); afterEach(function() { @@ -128,4 +145,4 @@ describe("OpenAssessment.SelfView", function() { expect(view.baseView.unsavedWarningEnabled()).toBe(false); }); -}); +}); \ No newline at end of file diff --git a/openassessment/xblock/static/js/spec/lms/oa_training.js b/openassessment/xblock/static/js/spec/lms/oa_training.js index 80cb70a1dc..48c2412b42 100644 --- a/openassessment/xblock/static/js/spec/lms/oa_training.js +++ b/openassessment/xblock/static/js/spec/lms/oa_training.js @@ -43,7 +43,7 @@ describe("OpenAssessment.StudentTrainingView", function() { // Create a new stub server server = new StubServer(); - server.renderLatex = jasmine.createSpy('renderLatex') + server.renderLatex = jasmine.createSpy('renderLatex'); // Create the object under test var rootElement = $('.step--student-training').parent().get(0); @@ -55,11 +55,35 @@ describe("OpenAssessment.StudentTrainingView", function() { } } }); - view = baseView.trainingView + view = baseView.trainingView; + + // Create a mock editor controller to avoid RequireJS loading issues in tests + var mockEditorController = { + _response: ['', ''], + load: function(elements) { + this.elements = elements; + return Promise.resolve(); + }, + response: function(texts) { + if (typeof texts !== 'undefined') { + this._response = texts; + return this._response; + } + return this._response; + }, + setOnChangeListener: function(callback) { + this._changeCallback = callback; + } + }; + + // Mock the responseEditorLoader to avoid async loading issues + spyOn(view.responseEditorLoader, 'load').and.returnValue(Promise.resolve(mockEditorController)); + + // Now call renderResponseViaEditor and installHandlers view.renderResponseViaEditor().then(() => { view.installHandlers(); - done() - }) + done(); + }); }); it("submits an assessment for a training example", function() { @@ -133,4 +157,4 @@ describe("OpenAssessment.StudentTrainingView", function() { // Expect that the steps were reloaded expect(view.baseView.loadAssessmentModules).toHaveBeenCalled(); }); -}); +}); \ No newline at end of file diff --git a/openassessment/xblock/static/js/src/lms/components/WaitingStepContent.jsx b/openassessment/xblock/static/js/src/lms/components/WaitingStepContent.jsx index 46712377a8..28a54d764f 100644 --- a/openassessment/xblock/static/js/src/lms/components/WaitingStepContent.jsx +++ b/openassessment/xblock/static/js/src/lms/components/WaitingStepContent.jsx @@ -12,13 +12,7 @@ const WaitingStepContent = ({ const oraDescriptionText = gettext( 'The "{name}" problem is configured to require a minimum of {min_grades} ' + 'peer grades, and asks to review {min_graded} peers.', - ).replace( - '{name}', waitingStepDetails.display_name, - ).replace( - '{min_grades}', waitingStepDetails.must_be_graded_by, - ).replace( - '{min_graded}', waitingStepDetails.must_grade, - ); + ).replace('{name}', waitingStepDetails.display_name).replace('{min_grades}', waitingStepDetails.must_be_graded_by).replace('{min_graded}', waitingStepDetails.must_grade); const stuckLearnersText = gettext( 'There are currently {stuck_learners} learners in the waiting state, ' diff --git a/openassessment/xblock/static/js/src/lms/components/WaitingStepList.jsx b/openassessment/xblock/static/js/src/lms/components/WaitingStepList.jsx index 6b489f7ae2..fcff977a70 100644 --- a/openassessment/xblock/static/js/src/lms/components/WaitingStepList.jsx +++ b/openassessment/xblock/static/js/src/lms/components/WaitingStepList.jsx @@ -1,10 +1,42 @@ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import moment from 'moment'; import PropTypes from 'prop-types'; import { Button, DataTable } from '@openedx/paragon'; const getReadableTime = (timestamp) => moment(timestamp).fromNow(true); +const RefreshAction = ({ refreshData }) => ( + +); + +RefreshAction.propTypes = { + refreshData: PropTypes.func.isRequired, +}; + +const ActionCell = ({ row, reviewLearnerAction }) => { + const { isSelected, original: { username: learnerUsername } } = row; + return isSelected ? ( + + ) : null; +}; + +ActionCell.propTypes = { + row: PropTypes.shape({ + isSelected: PropTypes.bool.isRequired, + original: PropTypes.shape({ + username: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, + reviewLearnerAction: PropTypes.func.isRequired, +}; + const WaitingStepList = ({ studentList, refreshData, @@ -16,13 +48,22 @@ const WaitingStepList = ({ created_at: getReadableTime(item.created_at), })); - const RefreshAction = () => ( - - ); - - const reviewLearnerAction = (learnerUsername) => { + const reviewLearnerAction = useCallback((learnerUsername) => { findLearner(learnerUsername); - }; + }, [findLearner]); + + const additionalColumns = useMemo(() => ( + selectableLearnersEnabled + ? [ + { + id: 'action', + Header: gettext('Action'), + // eslint-disable-next-line react/no-unstable-nested-components + Cell: (props) => , + }, + ] + : [] + ), [selectableLearnersEnabled, reviewLearnerAction]); return ( (isSelected ? ( - - ) : null), - }, - ] - : [] - } + additionalColumns={additionalColumns} columns={[ { Header: gettext('Username'), @@ -77,7 +98,7 @@ const WaitingStepList = ({ accessor: 'workflow_status', }, ]} - tableActions={[]} + tableActions={[]} > diff --git a/openassessment/xblock/static/js/src/lms/containers/WaitingStepDetailsContainer.jsx b/openassessment/xblock/static/js/src/lms/containers/WaitingStepDetailsContainer.jsx index 567c40b2c2..27f90e1fc5 100644 --- a/openassessment/xblock/static/js/src/lms/containers/WaitingStepDetailsContainer.jsx +++ b/openassessment/xblock/static/js/src/lms/containers/WaitingStepDetailsContainer.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { IntlProvider } from 'react-intl'; import PropTypes from 'prop-types'; import { @@ -21,7 +21,7 @@ const WaitingStepDetailsContainer = ({ student_data: [], }); - const updateData = async () => { + const updateData = useCallback(async () => { // Clear error and display loading component setLoading(true); setError(false); @@ -43,7 +43,7 @@ const WaitingStepDetailsContainer = ({ } finally { setLoading(false); } - }; + }, [waitingStepDataUrl]); const getUsernameSelected = (username) => { const button = document.querySelector('.button-staff-tools'); @@ -75,7 +75,7 @@ const WaitingStepDetailsContainer = ({ // Fetch waiting step data from API updateData(); - }, []); + }, [onMount, updateData]); return ( // Using en locale for now until we have translations. This is a temporary solution diff --git a/openassessment/xblock/static/js/src/lms/oa_base.js b/openassessment/xblock/static/js/src/lms/oa_base.js index 1521a54e07..e21ae0fb27 100644 --- a/openassessment/xblock/static/js/src/lms/oa_base.js +++ b/openassessment/xblock/static/js/src/lms/oa_base.js @@ -26,37 +26,37 @@ Returns: OpenAssessment.BaseView * */ export class BaseView { - IS_SHOWING_CLASS = 'is--showing'; + IS_SHOWING_CLASS = 'is--showing'; - SLIDABLE_CLASS = 'ui-slidable'; + SLIDABLE_CLASS = 'ui-slidable'; - SLIDABLE_CONTENT_CLASS = 'ui-slidable__content'; + SLIDABLE_CONTENT_CLASS = 'ui-slidable__content'; - SLIDABLE_CONTROLS_CLASS = 'ui-slidable__control'; + SLIDABLE_CONTROLS_CLASS = 'ui-slidable__control'; - SLIDABLE_CONTAINER_CLASS = 'ui-slidable__container'; + SLIDABLE_CONTAINER_CLASS = 'ui-slidable__container'; - READER_FEEDBACK_CLASS = '.sr.reader-feedback'; + READER_FEEDBACK_CLASS = '.sr.reader-feedback'; - constructor(runtime, element, server, data) { - this.runtime = runtime; - this.element = element; - this.server = server; - this.data = data; + constructor(runtime, element, server, data) { + this.runtime = runtime; + this.element = element; + this.server = server; + this.data = data; - const { ORA_MICROFRONTEND_URL, MFE_VIEW_ENABLED, HOTJAR_SITE_ID } = data.CONTEXT || {}; + const { ORA_MICROFRONTEND_URL, MFE_VIEW_ENABLED, HOTJAR_SITE_ID } = data.CONTEXT || {}; - if (!ORA_MICROFRONTEND_URL && MFE_VIEW_ENABLED) { - // eslint-disable-next-line no-console - console.error('ORA_MICROFRONTEND_URL is not defined. ORA MFE will not be loaded.'); - } - const isMobile = window.navigator.userAgent.includes('org.edx.mobile'); - if (!isMobile && HOTJAR_SITE_ID) { - /* + if (!ORA_MICROFRONTEND_URL && MFE_VIEW_ENABLED) { + // eslint-disable-next-line no-console + console.error('ORA_MICROFRONTEND_URL is not defined. ORA MFE will not be loaded.'); + } + const isMobile = window.navigator.userAgent.includes('org.edx.mobile'); + if (!isMobile && HOTJAR_SITE_ID) { + /* * Hotjar shouuld be rewrite and encapsulated and import on use. Window is being share * globally and it's not a good practice to have this override lms/cms `hotjar`. */ - /* eslint-disable */ + /* eslint-disable */ (function(h,o,t,j,a,r){ h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)}; h._hjSettings={hjid: HOTJAR_SITE_ID,hjsv:6}; @@ -66,55 +66,60 @@ export class BaseView { a.appendChild(r); })(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv='); /* eslint-enable */ - } + } - this.show_mfe_views = ORA_MICROFRONTEND_URL && MFE_VIEW_ENABLED && !isMobile; - - const oraMfeView = $('#ora-mfe-view', this.element); - const oraLegacyView = $('#ora-legacy-view', this.element); - - if (this.show_mfe_views) { - // remove legacy view and show mfe view - oraLegacyView.remove(); - oraMfeView.addClass('is--showing'); - } else { - // remove mfe view and show legacy view - oraMfeView.remove(); - oraLegacyView.addClass('is--showing'); - - // Initialize the views with legacy code - this.fileUploader = new FileUploader(); - - this.responseEditorLoader = new ResponseEditorLoader(data.AVAILABLE_EDITORS); - - this.responseView = new ResponseView( - this.element, this.server, this.fileUploader, this.responseEditorLoader, this, data, - ); - this.trainingView = new StudentTrainingView(this.element, this.server, this.responseEditorLoader, data, this); - this.selfView = new SelfView(this.element, this.server, this.responseEditorLoader, data, this); - this.peerView = new PeerView(this.element, this.server, this.responseEditorLoader, data, this); - this.staffView = new StaffView(this.element, this.server, this); - this.gradeView = new GradeView(this.element, this.server, this.responseEditorLoader, data, this); - this.messageView = new MessageView(this.element, this.server, this); - } + this.show_mfe_views = ORA_MICROFRONTEND_URL && MFE_VIEW_ENABLED && !isMobile; - this.leaderboardView = new LeaderboardView(this.element, this.server, this.responseEditorLoader, data, this); - // Staff-only area with information and tools for managing student submissions - this.staffAreaView = new StaffAreaView(this.element, this.server, this.responseEditorLoader, data, this); + const oraMfeView = $('#ora-mfe-view', this.element); + const oraLegacyView = $('#ora-legacy-view', this.element); - this.usageID = ''; - this.srStatusUpdates = []; + if (this.show_mfe_views) { + // remove legacy view and show mfe view + oraLegacyView.remove(); + oraMfeView.addClass('is--showing'); + } else { + // remove mfe view and show legacy view + oraMfeView.remove(); + oraLegacyView.addClass('is--showing'); - this.unsavedChanges = {}; - } + // Initialize the views with legacy code + this.fileUploader = new FileUploader(); + + this.responseEditorLoader = new ResponseEditorLoader(data.AVAILABLE_EDITORS); - // This is used by unit tests to reset state. - clearUnsavedChanges() { - this.unsavedChanges = {}; - window.onbeforeunload = null; + this.responseView = new ResponseView( + this.element, + this.server, + this.fileUploader, + this.responseEditorLoader, + this, + data, + ); + this.trainingView = new StudentTrainingView(this.element, this.server, this.responseEditorLoader, data, this); + this.selfView = new SelfView(this.element, this.server, this.responseEditorLoader, data, this); + this.peerView = new PeerView(this.element, this.server, this.responseEditorLoader, data, this); + this.staffView = new StaffView(this.element, this.server, this); + this.gradeView = new GradeView(this.element, this.server, this.responseEditorLoader, data, this); + this.messageView = new MessageView(this.element, this.server, this); } - /** + this.leaderboardView = new LeaderboardView(this.element, this.server, this.responseEditorLoader, data, this); + // Staff-only area with information and tools for managing student submissions + this.staffAreaView = new StaffAreaView(this.element, this.server, this.responseEditorLoader, data, this); + + this.usageID = ''; + this.srStatusUpdates = []; + + this.unsavedChanges = {}; + } + + // This is used by unit tests to reset state. + clearUnsavedChanges() { + this.unsavedChanges = {}; + window.onbeforeunload = null; + } + + /** * Checks to see if the scrollTo function is available, then scrolls to the * top of the list of steps (or the specified selector) for this display. * @@ -124,39 +129,39 @@ export class BaseView { * @param {string} selector optional CSS selector to scroll to. If not supplied, * the default value of ".openassessment__steps" is used. */ - scrollToTop(selector) { - if (!selector) { - selector = '.openassessment__steps'; - } - if ($.scrollTo instanceof Function) { - $(window).scrollTo($(selector, this.element), 800, { offset: -50 }); - $(`${selector} > header .${this.SLIDABLE_CLASS}`, this.element).focus(); - } + scrollToTop(selector) { + if (!selector) { + selector = '.openassessment__steps'; + } + if ($.scrollTo instanceof Function) { + $(window).scrollTo($(selector, this.element), 800, { offset: -50 }); + $(`${selector} > header .${this.SLIDABLE_CLASS}`, this.element).focus(); } + } - /** + /** * Clear the text in the Aria live region. */ - srClear() { - $(this.READER_FEEDBACK_CLASS).html(''); - } + srClear() { + $(this.READER_FEEDBACK_CLASS).html(''); + } - /** + /** * Add the text messages to the Aria live region. * * @param {string[]} texts */ - srReadTexts(texts) { - const $readerFeedbackSelector = $(this.READER_FEEDBACK_CLASS); - let htmlFeedback = ''; - this.srClear(); - $.each(texts, (ids, value) => { - htmlFeedback = `${htmlFeedback}

${value}

\n`; - }); - $readerFeedbackSelector.html(htmlFeedback); - } + srReadTexts(texts) { + const $readerFeedbackSelector = $(this.READER_FEEDBACK_CLASS); + let htmlFeedback = ''; + this.srClear(); + $.each(texts, (ids, value) => { + htmlFeedback = `${htmlFeedback}

${value}

\n`; + }); + $readerFeedbackSelector.html(htmlFeedback); + } - /** + /** * Checks the rendering status of the views that may require Screen Reader Status updates. * * The only views that should be added here are those that require Screen Reader updates when moving from one @@ -164,16 +169,16 @@ export class BaseView { * * @return {boolean} true if any step's view is still loading. */ - areSRStepsLoading() { - return this.responseView.isRendering + areSRStepsLoading() { + return this.responseView.isRendering || this.peerView.isRendering || this.selfView.isRendering || this.gradeView.isRendering || this.trainingView.isRendering || this.staffView.isRendering; - } + } - /** + /** * Updates text in the Aria live region if all sections are rendered and focuses on the specified ID. * * @param {String} stepID - The id of the Step being worked on. @@ -182,25 +187,25 @@ export class BaseView { * @param {Object} currentView - Current active view. * @param {String} focusID - The ID of the region to focus on. */ - announceStatusChangeToSRandFocus(stepID, usageID, gradeStatus, currentView, focusID) { - const text = this.getStatus(stepID, currentView, gradeStatus); + announceStatusChangeToSRandFocus(stepID, usageID, gradeStatus, currentView, focusID) { + const text = this.getStatus(stepID, currentView, gradeStatus); - if (typeof usageID !== 'undefined' + if (typeof usageID !== 'undefined' && $(stepID, currentView.element).hasClass('is--showing') && typeof focusID !== 'undefined') { - $(focusID, currentView.element).focus(); - this.srStatusUpdates.push(text); - } else if (currentView.announceStatus) { - this.srStatusUpdates.push(text); - } - if (!this.areSRStepsLoading() && this.srStatusUpdates.length > 0) { - this.srReadTexts(this.srStatusUpdates); - this.srStatusUpdates = []; - } - currentView.announceStatus = false; + $(focusID, currentView.element).focus(); + this.srStatusUpdates.push(text); + } else if (currentView.announceStatus) { + this.srStatusUpdates.push(text); + } + if (!this.areSRStepsLoading() && this.srStatusUpdates.length > 0) { + this.srReadTexts(this.srStatusUpdates); + this.srStatusUpdates = []; } + currentView.announceStatus = false; + } - /** + /** * Retrieves and returns the current status of a given step. * * @param {String} stepID - The id of the Step to retrieve status for. @@ -209,165 +214,165 @@ export class BaseView { * false if it is the assessment status * @return {String} - the current status. */ - getStatus(stepID, currentView, gradeStatus) { - const cssBase = `${stepID} .step__header .step__title `; - const cssStringTitle = `${cssBase}.step__label`; - let cssStringStatus = `${cssBase}.step__status`; + getStatus(stepID, currentView, gradeStatus) { + const cssBase = `${stepID} .step__header .step__title `; + const cssStringTitle = `${cssBase}.step__label`; + let cssStringStatus = `${cssBase}.step__status`; - if (gradeStatus) { - cssStringStatus = `${cssBase}.grade__value`; - } - - return `${$(cssStringTitle, currentView.element).text().trim()} ${ - $(cssStringStatus, currentView.element).text().trim()}`; + if (gradeStatus) { + cssStringStatus = `${cssBase}.grade__value`; } - /** + return `${$(cssStringTitle, currentView.element).text().trim()} ${ + $(cssStringStatus, currentView.element).text().trim()}`; + } + + /** * Install click handlers to expand/collapse a section. * * @param {element} parentElement JQuery selector for the container element. */ - setUpCollapseExpand(parentElement) { - const view = this; + setUpCollapseExpand(parentElement) { + const view = this; - $(`.${view.SLIDABLE_CONTROLS_CLASS}`, parentElement).each(function () { - $(this).on('click', (event) => { - event.preventDefault(); + $(`.${view.SLIDABLE_CONTROLS_CLASS}`, parentElement).each(function () { + $(this).on('click', (event) => { + event.preventDefault(); - const $slidableControl = $(event.target).closest(`.${view.SLIDABLE_CONTROLS_CLASS}`); + const $slidableControl = $(event.target).closest(`.${view.SLIDABLE_CONTROLS_CLASS}`); - const $container = $slidableControl.closest(`.${view.SLIDABLE_CONTAINER_CLASS}`); - const $toggleButton = $slidableControl.find(`.${view.SLIDABLE_CLASS}`); - const $panel = $slidableControl.next(`.${view.SLIDABLE_CONTENT_CLASS}`); + const $container = $slidableControl.closest(`.${view.SLIDABLE_CONTAINER_CLASS}`); + const $toggleButton = $slidableControl.find(`.${view.SLIDABLE_CLASS}`); + const $panel = $slidableControl.next(`.${view.SLIDABLE_CONTENT_CLASS}`); - if ($container.hasClass('is--showing')) { - $panel.slideUp(); - $toggleButton.attr('aria-expanded', 'false'); - $container.removeClass('is--showing'); - } else if (!$container.hasClass('has--error') + if ($container.hasClass('is--showing')) { + $panel.slideUp(); + $toggleButton.attr('aria-expanded', 'false'); + $container.removeClass('is--showing'); + } else if (!$container.hasClass('has--error') && !$container.hasClass('is--empty') && !$container.hasClass('is--unavailable')) { - $panel.slideDown(); - $toggleButton.attr('aria-expanded', 'true'); - $container.addClass('is--showing'); - } + $panel.slideDown(); + $toggleButton.attr('aria-expanded', 'true'); + $container.addClass('is--showing'); + } - $container.removeClass('is--initially--collapsed '); - }); + $container.removeClass('is--initially--collapsed '); }); - } + }); + } - /** + /** *Install click handler for the LaTeX preview button. * * @param {element} parentElement JQuery selector for the container element. */ - bindLatexPreview(parentElement) { - // keep the preview as display none at first - parentElement.find('.submission__preview__item').hide(); - parentElement.find('.submission__preview').click( - (eventObject) => { - eventObject.preventDefault(); - const previewName = $(eventObject.target).data('input'); - // extract typed-in response and replace newline with br - const previewText = parentElement.find(`textarea[data-preview="${previewName}"]`).val(); - const previewContainer = parentElement.find(`.preview_content[data-preview="${previewName}"]`); - previewContainer.html(previewText.replace(/\r\n|\r|\n/g, '
')); - - // Render in mathjax - previewContainer.parent().parent().parent().show(); - // eslint-disable-next-line new-cap - MathJax.Hub.Queue(['Typeset', MathJax.Hub, previewContainer[0]]); - }, - ); - } + bindLatexPreview(parentElement) { + // keep the preview as display none at first + parentElement.find('.submission__preview__item').hide(); + parentElement.find('.submission__preview').click( + (eventObject) => { + eventObject.preventDefault(); + const previewName = $(eventObject.target).data('input'); + // extract typed-in response and replace newline with br + const previewText = parentElement.find(`textarea[data-preview="${previewName}"]`).val(); + const previewContainer = parentElement.find(`.preview_content[data-preview="${previewName}"]`); + previewContainer.html(previewText.replace(/\r\n|\r|\n/g, '
')); + + // Render in mathjax + previewContainer.parent().parent().parent().show(); + // eslint-disable-next-line new-cap + MathJax.Hub.Queue(['Typeset', MathJax.Hub, previewContainer[0]]); + }, + ); + } - /** + /** * Get usage key of an XBlock. */ - getUsageID() { - if (!this.usageID) { - this.usageID = $(this.element).data('usage-id'); - } - return this.usageID; + getUsageID() { + if (!this.usageID) { + this.usageID = $(this.element).data('usage-id'); } + return this.usageID; + } - /** + /** * Asynchronously load each sub-view into the DOM. */ - load() { - if (this.show_mfe_views) { - const { ORA_MICROFRONTEND_URL, IS_STUDIO } = this.data.CONTEXT || {}; - // When using ORA MFE, we add url to iframe and let it load the view - // This is to avoid iframe from loading before we decide to show it - // Then add event listener to help resize iframe, and handle modal open/close - const xblockId = this.getUsageID(); - // lms used course-id from element data attribute, cms used global course object - const courseId = $(this.element).data('course-id') || window.course?.id; - - const oraMfeIframe = $('#ora-mfe-view>iframe', this.element); - const loadingEl = $('#ora-mfe-view .ora-loading', this.element); - - // Currently this seems to be only reasonable way to detect if we are in preview mode. - // Other way would be detecting url started with preview. Either way isn't ideal. - // The ideal way is to pass the argument from backend like IS_STUDIO. However, it seems - // that openassessment.in_studio_preview is not working. - const isPreview = $('.wrapper-preview-menu')?.length > 0; - let xblockPath = 'xblock'; - if (IS_STUDIO) { - xblockPath = 'xblock_studio'; - } else if (isPreview) { - xblockPath = 'xblock_preview'; - } - oraMfeIframe.attr( - 'src', - `${ORA_MICROFRONTEND_URL}/${xblockPath}/${courseId}/${xblockId}`, - ); + load() { + if (this.show_mfe_views) { + const { ORA_MICROFRONTEND_URL, IS_STUDIO } = this.data.CONTEXT || {}; + // When using ORA MFE, we add url to iframe and let it load the view + // This is to avoid iframe from loading before we decide to show it + // Then add event listener to help resize iframe, and handle modal open/close + const xblockId = this.getUsageID(); + // lms used course-id from element data attribute, cms used global course object + const courseId = $(this.element).data('course-id') || window.course?.id; + + const oraMfeIframe = $('#ora-mfe-view>iframe', this.element); + const loadingEl = $('#ora-mfe-view .ora-loading', this.element); + + // Currently this seems to be only reasonable way to detect if we are in preview mode. + // Other way would be detecting url started with preview. Either way isn't ideal. + // The ideal way is to pass the argument from backend like IS_STUDIO. However, it seems + // that openassessment.in_studio_preview is not working. + const isPreview = $('.wrapper-preview-menu')?.length > 0; + let xblockPath = 'xblock'; + if (IS_STUDIO) { + xblockPath = 'xblock_studio'; + } else if (isPreview) { + xblockPath = 'xblock_preview'; + } + oraMfeIframe.attr( + 'src', + `${ORA_MICROFRONTEND_URL}/${xblockPath}/${courseId}/${xblockId}`, + ); + /* eslint-disable-next-line prefer-arrow-callback */ + oraMfeIframe.on('load', function () { + loadingEl.remove(); /* eslint-disable-next-line prefer-arrow-callback */ - oraMfeIframe.on('load', function () { - loadingEl.remove(); - /* eslint-disable-next-line prefer-arrow-callback */ - window.addEventListener('message', function (event) { - if (window.origin !== event.origin) { - if (event.data.type === 'plugin.resize') { - const { height } = event.data.payload; - oraMfeIframe[0].style.height = `${height}px`; - // can't propagate to learning mfe with this height because of extra element in between - window.parent.postMessage({ - type: 'plugin.resize', - payload: { - height: document.body.scrollHeight, - }, - }, document.referrer); - } else if (event.data.type === 'plugin.modal-close') { - // Forward this event from learning MFE to child - oraMfeIframe[0].contentWindow.postMessage(event.data, '*'); - } else if (event.data.type === 'plugin.modal' && window.parent.length > 0) { - window.parent.postMessage(event.data, document.referrer); - } + window.addEventListener('message', function (event) { + if (window.origin !== event.origin) { + if (event.data.type === 'plugin.resize') { + const { height } = event.data.payload; + oraMfeIframe[0].style.height = `${height}px`; + // can't propagate to learning mfe with this height because of extra element in between + window.parent.postMessage({ + type: 'plugin.resize', + payload: { + height: document.body.scrollHeight, + }, + }, document.referrer); + } else if (event.data.type === 'plugin.modal-close') { + // Forward this event from learning MFE to child + oraMfeIframe[0].contentWindow.postMessage(event.data, '*'); + } else if (event.data.type === 'plugin.modal' && window.parent.length > 0) { + window.parent.postMessage(event.data, document.referrer); } - }); + } }); - } else { - this.responseView.load(); - this.loadAssessmentModules(); - } - this.staffAreaView.load(); + }); + } else { + this.responseView.load(); + this.loadAssessmentModules(); } + this.staffAreaView.load(); + } - /** + /** * Refresh the Assessment Modules. This should be called any time an action is * performed by the user. */ - loadAssessmentModules(usageID) { - this.trainingView.load(usageID); - this.peerView.load(usageID); - this.staffView.load(usageID); - this.selfView.load(usageID); - this.gradeView.load(usageID); - this.leaderboardView.load(usageID); - - /** + loadAssessmentModules(usageID) { + this.trainingView.load(usageID); + this.peerView.load(usageID); + this.staffView.load(usageID); + this.selfView.load(usageID); + this.gradeView.load(usageID); + this.leaderboardView.load(usageID); + + /** this.messageView.load() is intentionally omitted. Because of the asynchronous loading, there is no way to tell (from the perspective of the messageView) whether or not the peer view was able to grab an assessment to assess. Any @@ -380,75 +385,75 @@ export class BaseView { the peer view has been loaded. This is achieved by having the peer view call to render the message view after rendering itself but before exiting its load method. */ - } + } - /** + /** * Refresh the message only (called by PeerView to update and avoid race condition) */ - loadMessageView() { - this.messageView.load(); - } + loadMessageView() { + this.messageView.load(); + } - /** + /** * Report an error to the user. * * @param {string} type The type of error. Options are "save", submit", "peer", and "self". * @param {string} message The error message to display, or if null hide the message. * Note: loading errors are never hidden once displayed. */ - toggleActionError(type, message) { - const { element } = this; - let container = null; - if (type === 'save') { - container = '.response__submission__actions'; - } else if (type === 'submit' || type === 'peer' || type === 'self' || type === 'student-training') { - container = '.step__actions'; - } else if (type === 'feedback_assess') { - container = '.submission__feedback__actions'; - } else if (type === 'upload') { - container = '.upload__error'; - } else if (type === 'delete') { - container = '.delete__error'; - } + toggleActionError(type, message) { + const { element } = this; + let container = null; + if (type === 'save') { + container = '.response__submission__actions'; + } else if (type === 'submit' || type === 'peer' || type === 'self' || type === 'student-training') { + container = '.step__actions'; + } else if (type === 'feedback_assess') { + container = '.submission__feedback__actions'; + } else if (type === 'upload') { + container = '.upload__error'; + } else if (type === 'delete') { + container = '.delete__error'; + } - // If we don't have anywhere to put the message, just log it to the console - if (container === null) { - /* eslint-disable-next-line no-console */ - if (message !== null) { console.log(message); } - } else { - // Insert the error message - $(`${container} .message__content`, element).html(`

${message ? _.escape(message) : ''}

`); - // Toggle the error class - $(container, element).toggleClass('has--error', message !== null); - // Send focus to the error message - $(`${container} > .message`, element).focus(); - } + // If we don't have anywhere to put the message, just log it to the console + if (container === null) { + /* eslint-disable-next-line no-console */ + if (message !== null) { console.log(message); } + } else { + // Insert the error message + $(`${container} .message__content`, element).html(`

${message ? _.escape(message) : ''}

`); + // Toggle the error class + $(container, element).toggleClass('has--error', message !== null); + // Send focus to the error message + $(`${container} > .message`, element).focus(); + } - if (message !== null) { - const contentTitle = $(`${container} .message__title`).text(); - this.srReadTexts([contentTitle, message]); - } + if (message !== null) { + const contentTitle = $(`${container} .message__title`).text(); + this.srReadTexts([contentTitle, message]); } + } - /** + /** * Report an error loading a step. * * @param {string} stepName The step that could not be loaded. * @param {string} errorMessage An optional error message to use instead of the default. */ - showLoadError(stepName, errorMessage) { - if (!errorMessage) { - errorMessage = gettext('Unable to load'); - } - const $container = $(`.step--${stepName}`); - $container.toggleClass('has--error', true); - $container.removeClass('is--showing'); - $container.find('.ui-slidable').attr('aria-expanded', 'false'); - $container.find('.step__status__value i').removeClass().addClass('icon fa fa-exclamation-triangle'); - $container.find('.step__status__value .copy').html(_.escape(errorMessage)); + showLoadError(stepName, errorMessage) { + if (!errorMessage) { + errorMessage = gettext('Unable to load'); } + const $container = $(`.step--${stepName}`); + $container.toggleClass('has--error', true); + $container.removeClass('is--showing'); + $container.find('.ui-slidable').attr('aria-expanded', 'false'); + $container.find('.step__status__value i').removeClass().addClass('icon fa fa-exclamation-triangle'); + $container.find('.step__status__value .copy').html(_.escape(errorMessage)); + } - /** + /** * Enable/disable the "navigate away" warning to alert the user of unsaved changes. * * @param {boolean} enabled If specified, set whether the warning is enabled. @@ -458,50 +463,50 @@ export class BaseView { * if "enabled" is true. * @return {boolean} Whether the warning is enabled (only if "enabled" argument is not supplied). */ - /* eslint-disable-next-line consistent-return */ - unsavedWarningEnabled(enabled, key, message) { - if (typeof enabled === 'undefined') { - return (window.onbeforeunload !== null); - } - // To support multiple ORA XBlocks on the same page, store state by XBlock usage-id. - const usageID = $(this.element).data('usage-id'); - if (enabled) { - if (typeof this.unsavedChanges[usageID] === 'undefined' + /* eslint-disable-next-line consistent-return */ + unsavedWarningEnabled(enabled, key, message) { + if (typeof enabled === 'undefined') { + return (window.onbeforeunload !== null); + } + // To support multiple ORA XBlocks on the same page, store state by XBlock usage-id. + const usageID = $(this.element).data('usage-id'); + if (enabled) { + if (typeof this.unsavedChanges[usageID] === 'undefined' || !this.unsavedChanges[usageID]) { - this.unsavedChanges[usageID] = {}; - } - this.unsavedChanges[usageID][key] = message; - - /* eslint-disable-next-line consistent-return */ - window.onbeforeunload = function () { - let returnValue; - Object.keys(this.unsavedChanges).some((xblockUsageID) => { - if (this.unsavedChanges.hasOwnProperty(xblockUsageID)) { - const change = this.unsavedChanges[xblockUsageID]; - return Object.keys(change).some((changeKey) => { - if (change.hasOwnProperty(key)) { - returnValue = change[key]; - return true; - } - return false; - }); - } - return false; - }); - return returnValue; - }; - } else if (typeof this.unsavedChanges[usageID] !== 'undefined') { - delete this.unsavedChanges[usageID][key]; - if ($.isEmptyObject(this.unsavedChanges[usageID])) { - delete this.unsavedChanges[usageID]; - } - if ($.isEmptyObject(this.unsavedChanges)) { - window.onbeforeunload = null; - } + this.unsavedChanges[usageID] = {}; + } + this.unsavedChanges[usageID][key] = message; + + /* eslint-disable-next-line consistent-return */ + window.onbeforeunload = function () { + let returnValue; + Object.keys(this.unsavedChanges).some((xblockUsageID) => { + if (this.unsavedChanges.hasOwnProperty(xblockUsageID)) { + const change = this.unsavedChanges[xblockUsageID]; + return Object.keys(change).some((changeKey) => { + if (change.hasOwnProperty(key)) { + returnValue = change[key]; + return true; + } + return false; + }); + } + return false; + }); + return returnValue; + }; + } else if (typeof this.unsavedChanges[usageID] !== 'undefined') { + delete this.unsavedChanges[usageID][key]; + if ($.isEmptyObject(this.unsavedChanges[usageID])) { + delete this.unsavedChanges[usageID]; + } + if ($.isEmptyObject(this.unsavedChanges)) { + window.onbeforeunload = null; } } + } - /** + /** * Enable/disable the button with the given class name. * * @param {string} className The css class to find the button @@ -509,14 +514,14 @@ export class BaseView { * the state of the button is not changed, but the current enabled status is returned. * @return {boolean} whether or not the button is enabled */ - buttonEnabled(className, enabled) { - const $element = $(className, this.element); - if (typeof enabled === 'undefined') { - return !$element.prop('disabled'); - } - $element.prop('disabled', !enabled); - return enabled; + buttonEnabled(className, enabled) { + const $element = $(className, this.element); + if (typeof enabled === 'undefined') { + return !$element.prop('disabled'); } + $element.prop('disabled', !enabled); + return enabled; + } } /* XBlock JavaScript entry point for OpenAssessmentXBlock. */ diff --git a/openassessment/xblock/static/js/src/lms/oa_confirmation_alert.js b/openassessment/xblock/static/js/src/lms/oa_confirmation_alert.js index b07055aaa4..1e5588c00b 100644 --- a/openassessment/xblock/static/js/src/lms/oa_confirmation_alert.js +++ b/openassessment/xblock/static/js/src/lms/oa_confirmation_alert.js @@ -2,9 +2,9 @@ * Class wrapping a jquery-ui dialog widget to provide a convenient API */ export class ConfirmationAlert { - CONFIRM_STR = gettext('Confirm') + CONFIRM_STR = gettext('Confirm'); - CANCEL_STR = gettext('Cancel') + CANCEL_STR = gettext('Cancel'); constructor(element) { this.dialog = element.dialog({ diff --git a/openassessment/xblock/static/js/src/lms/oa_grade.js b/openassessment/xblock/static/js/src/lms/oa_grade.js index 0e79b64221..a92bf397ce 100644 --- a/openassessment/xblock/static/js/src/lms/oa_grade.js +++ b/openassessment/xblock/static/js/src/lms/oa_grade.js @@ -228,9 +228,7 @@ export class GradeView { // Submit the feedback to the server // When the server reports success, update the UI to indicate that we'v submitted. - this.server.submitFeedbackOnAssessment( - this.feedbackText(), this.feedbackOptions(), - ).done( + this.server.submitFeedbackOnAssessment(this.feedbackText(), this.feedbackOptions()).done( () => { view.feedbackState('submitted'); }, ).fail((errMsg) => { baseView.toggleActionError('feedback_assess', errMsg); diff --git a/openassessment/xblock/static/js/src/lms/oa_peer.js b/openassessment/xblock/static/js/src/lms/oa_peer.js index 2806ee6076..465fa5c606 100644 --- a/openassessment/xblock/static/js/src/lms/oa_peer.js +++ b/openassessment/xblock/static/js/src/lms/oa_peer.js @@ -13,104 +13,104 @@ Returns: OpenAssessment.PeerView * */ export class PeerView { - UNSAVED_WARNING_KEY = 'peer-assessment'; - - constructor(element, server, responseEditorLoader, data, baseView) { - this.element = element; - this.server = server; - this.responseEditorLoader = responseEditorLoader; - this.data = data; - this.baseView = baseView; - this.rubric = null; - this.isRendering = false; - this.announceStatus = false; - this.dateFactory = new DateTimeFactory(this.element); - - this.load = this.load.bind(this); - this.loadContinuedASsessment = this.loadContinuedAssessment.bind(this); - this.continueAssessmentEnabled = this.continueAssessmentEnabled.bind(this); - this.installHandlers = this.installHandlers.bind(this); - this.peerSubmitEnabled = this.peerSubmitEnabled.bind(this); - this.assessmentRubricChanges = this.assessmentRubricChanges.bind(this); - this.continuedPeerAssess = this.continuedPeerAssess.bind(this); - this.peerAssessRequest = this.peerAssessRequest.bind(this); - this.peerAssess = this.peerAssess.bind(this); - this.getUUID = this.getUUID.bind(this); - } - - /** + UNSAVED_WARNING_KEY = 'peer-assessment'; + + constructor(element, server, responseEditorLoader, data, baseView) { + this.element = element; + this.server = server; + this.responseEditorLoader = responseEditorLoader; + this.data = data; + this.baseView = baseView; + this.rubric = null; + this.isRendering = false; + this.announceStatus = false; + this.dateFactory = new DateTimeFactory(this.element); + + this.load = this.load.bind(this); + this.loadContinuedASsessment = this.loadContinuedAssessment.bind(this); + this.continueAssessmentEnabled = this.continueAssessmentEnabled.bind(this); + this.installHandlers = this.installHandlers.bind(this); + this.peerSubmitEnabled = this.peerSubmitEnabled.bind(this); + this.assessmentRubricChanges = this.assessmentRubricChanges.bind(this); + this.continuedPeerAssess = this.continuedPeerAssess.bind(this); + this.peerAssessRequest = this.peerAssessRequest.bind(this); + this.peerAssess = this.peerAssess.bind(this); + this.getUUID = this.getUUID.bind(this); + } + + /** Load the peer assessment view. * */ - load(usageID) { - const view = this; - const stepID = '.step--peer-assessment'; - const focusID = `[id='oa_peer_${usageID}']`; - - view.isRendering = true; - this.server.render('peer_assessment').done( - (html) => { - // Load the HTML and install event handlers - $(stepID, view.element).replaceWith(html); - view.isRendering = false; - - view.server.renderLatex($(stepID, view.element)); - - this.renderResponseViaEditor(); - - view.installHandlers(false); - - view.baseView.announceStatusChangeToSRandFocus(stepID, usageID, false, view, focusID); - view.announceStatus = false; - view.dateFactory.apply(); - }, - ).fail(() => { - view.baseView.showLoadError('peer-assessment'); - }); - // Called to update Messagview with info on whether or not it was able to grab a submission - // See detailed explanation/Methodology in oa_base.loadAssessmentModules - view.baseView.loadMessageView(); - } - - /** + load(usageID) { + const view = this; + const stepID = '.step--peer-assessment'; + const focusID = `[id='oa_peer_${usageID}']`; + + view.isRendering = true; + this.server.render('peer_assessment').done( + (html) => { + // Load the HTML and install event handlers + $(stepID, view.element).replaceWith(html); + view.isRendering = false; + + view.server.renderLatex($(stepID, view.element)); + + this.renderResponseViaEditor(); + + view.installHandlers(false); + + view.baseView.announceStatusChangeToSRandFocus(stepID, usageID, false, view, focusID); + view.announceStatus = false; + view.dateFactory.apply(); + }, + ).fail(() => { + view.baseView.showLoadError('peer-assessment'); + }); + // Called to update Messagview with info on whether or not it was able to grab a submission + // See detailed explanation/Methodology in oa_base.loadAssessmentModules + view.baseView.loadMessageView(); + } + + /** Use Response Editor to render response * */ - renderResponseViaEditor() { - const sel = $('.step--peer-assessment', this.element); - const responseElements = sel.find('.submission__answer__part__text__value'); - this.responseEditorLoader.load(this.data.TEXT_RESPONSE_EDITOR, responseElements); - } + renderResponseViaEditor() { + const sel = $('.step--peer-assessment', this.element); + const responseElements = sel.find('.submission__answer__part__text__value'); + this.responseEditorLoader.load(this.data.TEXT_RESPONSE_EDITOR, responseElements); + } - /** + /** Load the continued grading version of the view. This is a version of the peer grading step that a student can use to continue assessing peers after they've completed their peer assessment requirements. * */ - loadContinuedAssessment(usageID) { - const view = this; - const stepID = '.step--peer-assessment'; - const focusID = `[id='oa_peer_${usageID}']`; - - view.continueAssessmentEnabled(false); - view.isRendering = true; - this.server.renderContinuedPeer().done( - (html) => { - // Load the HTML and install event handlers - $('.step--peer-assessment', view.element).replaceWith(html); - view.server.renderLatex($('.step--peer-assessment', view.element)); - view.isRendering = false; - - view.installHandlers(true); - - view.baseView.announceStatusChangeToSRandFocus(stepID, usageID, false, view, focusID); - }, - ).fail(() => { - view.baseView.showLoadError('peer-assessment'); - view.continueAssessmentEnabled(true); - }); - } - - /** + loadContinuedAssessment(usageID) { + const view = this; + const stepID = '.step--peer-assessment'; + const focusID = `[id='oa_peer_${usageID}']`; + + view.continueAssessmentEnabled(false); + view.isRendering = true; + this.server.renderContinuedPeer().done( + (html) => { + // Load the HTML and install event handlers + $('.step--peer-assessment', view.element).replaceWith(html); + view.server.renderLatex($('.step--peer-assessment', view.element)); + view.isRendering = false; + + view.installHandlers(true); + + view.baseView.announceStatusChangeToSRandFocus(stepID, usageID, false, view, focusID); + }, + ).fail(() => { + view.baseView.showLoadError('peer-assessment'); + view.continueAssessmentEnabled(true); + }); + } + + /** Enable and disable the continue assessment button. Args: @@ -121,11 +121,11 @@ export class PeerView { A boolean. TRUE if the continue assessment button is enabled. * */ - continueAssessmentEnabled(enabled) { - return this.baseView.buttonEnabled('.action--continue--grading', enabled); - } + continueAssessmentEnabled(enabled) { + return this.baseView.buttonEnabled('.action--continue--grading', enabled); + } - /** + /** Install event handlers for the view. Args: @@ -133,57 +133,57 @@ export class PeerView { meaning that the user is continuing to grade even though she has met the requirements. * */ - installHandlers(isContinuedAssessment) { - const sel = $('.step--peer-assessment', this.element); - const view = this; - - // Install a click handler for collapse/expand - this.baseView.setUpCollapseExpand(sel); - - // Install click handler for the preview button - this.baseView.bindLatexPreview(sel); - - // Initialize the rubric - const rubricSelector = $('.peer-assessment--001__assessment', this.element); - if (rubricSelector.size() > 0) { - const rubricElement = rubricSelector.get(0); - this.rubric = new Rubric(rubricElement); - } else { - // If there was previously a rubric visible, clear the reference to it. - this.rubric = null; - } - - // Install a change handler for rubric options to enable/disable the submit button - if (this.rubric !== null) { - this.rubric.canSubmitCallback($.proxy(view.peerSubmitEnabled, view)); - - this.rubric.changesExistCallback($.proxy(view.assessmentRubricChanges, view)); - } - - // Install a click handler for assessment - sel.find('.peer-assessment--001__assessment__submit').click( - (eventObject) => { - // Override default form submission - eventObject.preventDefault(); - - // Status will change in update announce it to the Screen Reader after Render - view.announceStatus = true; - - // Handle the click - if (!isContinuedAssessment) { view.peerAssess(); } else { view.continuedPeerAssess(); } - }, - ); + installHandlers(isContinuedAssessment) { + const sel = $('.step--peer-assessment', this.element); + const view = this; + + // Install a click handler for collapse/expand + this.baseView.setUpCollapseExpand(sel); + + // Install click handler for the preview button + this.baseView.bindLatexPreview(sel); + + // Initialize the rubric + const rubricSelector = $('.peer-assessment--001__assessment', this.element); + if (rubricSelector.size() > 0) { + const rubricElement = rubricSelector.get(0); + this.rubric = new Rubric(rubricElement); + } else { + // If there was previously a rubric visible, clear the reference to it. + this.rubric = null; + } - // Install a click handler for continued assessment - sel.find('.action--continue--grading').click( - (eventObject) => { - eventObject.preventDefault(); - view.loadContinuedAssessment(view.baseView.getUsageID()); - }, - ); + // Install a change handler for rubric options to enable/disable the submit button + if (this.rubric !== null) { + this.rubric.canSubmitCallback($.proxy(view.peerSubmitEnabled, view)); + + this.rubric.changesExistCallback($.proxy(view.assessmentRubricChanges, view)); } - /** + // Install a click handler for assessment + sel.find('.peer-assessment--001__assessment__submit').click( + (eventObject) => { + // Override default form submission + eventObject.preventDefault(); + + // Status will change in update announce it to the Screen Reader after Render + view.announceStatus = true; + + // Handle the click + if (!isContinuedAssessment) { view.peerAssess(); } else { view.continuedPeerAssess(); } + }, + ); + + // Install a click handler for continued assessment + sel.find('.action--continue--grading').click( + (eventObject) => { + eventObject.preventDefault(); + view.loadContinuedAssessment(view.baseView.getUsageID()); + }, + ); + } + + /** Enable/disable the peer assess button button. Check that whether the peer assess button is enabled. @@ -198,59 +198,59 @@ export class PeerView { >> view.peerSubmitEnabled(); // check whether the button is enabled >> true * */ - peerSubmitEnabled(enabled) { - return this.baseView.buttonEnabled('.peer-assessment--001__assessment__submit', enabled); - } + peerSubmitEnabled(enabled) { + return this.baseView.buttonEnabled('.peer-assessment--001__assessment__submit', enabled); + } - /** + /** * Called when something is selected or typed in the assessment rubric. * Used to set the unsaved changes warning dialog. * * @param {boolean} changesExist true if unsaved changes exist */ - assessmentRubricChanges(changesExist) { - if (changesExist) { - this.baseView.unsavedWarningEnabled( - true, - this.UNSAVED_WARNING_KEY, - // eslint-disable-next-line max-len - gettext('If you leave this page without submitting your peer assessment, you will lose any work you have done.'), - ); - } + assessmentRubricChanges(changesExist) { + if (changesExist) { + this.baseView.unsavedWarningEnabled( + true, + this.UNSAVED_WARNING_KEY, + // eslint-disable-next-line max-len + gettext('If you leave this page without submitting your peer assessment, you will lose any work you have done.'), + ); } + } - /** + /** Send an assessment to the server and update the view. * */ - peerAssess() { - const view = this; - const { baseView } = this; - const usageID = baseView.getUsageID(); - this.peerAssessRequest(() => { - baseView.unsavedWarningEnabled(false, view.UNSAVED_WARNING_KEY); - baseView.loadAssessmentModules(usageID); - baseView.scrollToTop('.step--peer-assessment'); - }); - } - - /** + peerAssess() { + const view = this; + const { baseView } = this; + const usageID = baseView.getUsageID(); + this.peerAssessRequest(() => { + baseView.unsavedWarningEnabled(false, view.UNSAVED_WARNING_KEY); + baseView.loadAssessmentModules(usageID); + baseView.scrollToTop('.step--peer-assessment'); + }); + } + + /** * Send an assessment to the server and update the view, with the assumption * that we are continuing peer assessments beyond the required amount. */ - continuedPeerAssess() { - const view = this; - const { gradeView } = this.baseView; - const { baseView } = this; - const usageID = baseView.getUsageID(); - this.peerAssessRequest(() => { - baseView.unsavedWarningEnabled(false, view.UNSAVED_WARNING_KEY); - view.loadContinuedAssessment(usageID); - gradeView.load(); - baseView.scrollToTop('.step--peer-assessment'); - }); - } - - /** + continuedPeerAssess() { + const view = this; + const { gradeView } = this.baseView; + const { baseView } = this; + const usageID = baseView.getUsageID(); + this.peerAssessRequest(() => { + baseView.unsavedWarningEnabled(false, view.UNSAVED_WARNING_KEY); + view.loadContinuedAssessment(usageID); + gradeView.load(); + baseView.scrollToTop('.step--peer-assessment'); + }); + } + + /** Common peer assessment request building, used for all types of peer assessments. Args: @@ -259,34 +259,34 @@ export class PeerView { a peer assessment. * */ - peerAssessRequest(successFunction) { - const view = this; - const uuid = this.getUUID(); - - this.baseView.toggleActionError('peer', null); - this.peerSubmitEnabled(false); - - // Pull the assessment info from the DOM and send it to the server - this.server.peerAssess( - this.rubric.optionsSelected(), - this.rubric.criterionFeedback(), - this.rubric.overallFeedback(), - uuid, - ).done( - successFunction, - ).fail((errMsg) => { - view.baseView.toggleActionError('peer', errMsg); - view.peerSubmitEnabled(true); - }); - } - - /** + peerAssessRequest(successFunction) { + const view = this; + const uuid = this.getUUID(); + + this.baseView.toggleActionError('peer', null); + this.peerSubmitEnabled(false); + + // Pull the assessment info from the DOM and send it to the server + this.server.peerAssess( + this.rubric.optionsSelected(), + this.rubric.criterionFeedback(), + this.rubric.overallFeedback(), + uuid, + ).done( + successFunction, + ).fail((errMsg) => { + view.baseView.toggleActionError('peer', errMsg); + view.peerSubmitEnabled(true); + }); + } + + /** Get uuid of a peer assessment. * */ - getUUID() { - const xBlockElement = $(`div[data-usage-id='${this.baseView.getUsageID()}']`); - return xBlockElement.find('.step--peer-assessment').data('submission-uuid'); - } + getUUID() { + const xBlockElement = $(`div[data-usage-id='${this.baseView.getUsageID()}']`); + return xBlockElement.find('.step--peer-assessment').data('submission-uuid'); + } } export default PeerView; diff --git a/openassessment/xblock/static/js/src/lms/oa_response.js b/openassessment/xblock/static/js/src/lms/oa_response.js index 0241cc278a..52956fcc22 100644 --- a/openassessment/xblock/static/js/src/lms/oa_response.js +++ b/openassessment/xblock/static/js/src/lms/oa_response.js @@ -16,279 +16,279 @@ import Prompts from './oa_prompts'; OpenAssessment.ResponseView * */ export class ResponseView { - // Milliseconds between checks for whether we should autosave. - AUTO_SAVE_POLL_INTERVAL = 2000; - - // Required delay after the user changes a response or a save occurs - // before we can autosave. - AUTO_SAVE_WAIT = 2000; - - // Maximum size (500 * 2^20 bytes, approx. 500MB) of a single uploaded file. - MAX_FILE_SIZE = 500 * (1024 ** 2); - - // For user-facing upload limit text. - MAX_FILES_MB = 500; - - UNSAVED_WARNING_KEY = 'learner-response'; - - ICON_SAVED = 'fa-check-circle-o'; - - ICON_SAVING = 'fa-refresh'; - - ICON_ERROR = 'fa-exclamation-circle'; - - constructor(element, server, fileUploader, responseEditorLoader, baseView, data) { - this.element = element; - this.server = server; - this.fileUploader = fileUploader; - this.responseEditorLoader = responseEditorLoader; - this.baseView = baseView; - this.savedResponse = []; - this.textResponse = 'required'; - this.textResponseEditor = 'text'; - this.fileUploadResponse = ''; - this.files = null; - this.filesDescriptions = []; - this.fileNames = []; - this.filesType = null; - this.lastChangeTime = Date.now(); - this.errorOnLastSave = false; - this.autoSaveTimerId = null; - this.data = data; - this.filesUploaded = false; - this.announceStatus = false; - this.isRendering = false; - this.fileCountBeforeUpload = 0; - this.allowLearnerResubmissions = false; - this.dateFactory = new DateTimeFactory(this.element); - } - - /** + // Milliseconds between checks for whether we should autosave. + AUTO_SAVE_POLL_INTERVAL = 2000; + + // Required delay after the user changes a response or a save occurs + // before we can autosave. + AUTO_SAVE_WAIT = 2000; + + // Maximum size (500 * 2^20 bytes, approx. 500MB) of a single uploaded file. + MAX_FILE_SIZE = 500 * (1024 ** 2); + + // For user-facing upload limit text. + MAX_FILES_MB = 500; + + UNSAVED_WARNING_KEY = 'learner-response'; + + ICON_SAVED = 'fa-check-circle-o'; + + ICON_SAVING = 'fa-refresh'; + + ICON_ERROR = 'fa-exclamation-circle'; + + constructor(element, server, fileUploader, responseEditorLoader, baseView, data) { + this.element = element; + this.server = server; + this.fileUploader = fileUploader; + this.responseEditorLoader = responseEditorLoader; + this.baseView = baseView; + this.savedResponse = []; + this.textResponse = 'required'; + this.textResponseEditor = 'text'; + this.fileUploadResponse = ''; + this.files = null; + this.filesDescriptions = []; + this.fileNames = []; + this.filesType = null; + this.lastChangeTime = Date.now(); + this.errorOnLastSave = false; + this.autoSaveTimerId = null; + this.data = data; + this.filesUploaded = false; + this.announceStatus = false; + this.isRendering = false; + this.fileCountBeforeUpload = 0; + this.allowLearnerResubmissions = false; + this.dateFactory = new DateTimeFactory(this.element); + } + + /** Load the response (submission) view. * */ - load(usageID) { - const view = this; - const stepID = '.step--response'; - const focusID = `[id='oa_response_${usageID}']`; - - view.isRendering = true; - this.server.render('submission').done( - (html) => { - // Load the HTML and install event handlers - $(stepID, view.element).replaceWith(html); - view.server.renderLatex($(stepID, view.element)); - view.setupPromptDisplays(); - // First load response editor then apply other things - view.loadResponseEditor().then((editorController) => { - view.responseEditorController = editorController; - view.installHandlers(); - view.setAutoSaveEnabled(true); - view.isRendering = false; - view.baseView.announceStatusChangeToSRandFocus(stepID, usageID, false, view, focusID); - view.announceStatus = false; - view.dateFactory.apply(); - }); - }, - ).fail(() => { - view.baseView.showLoadError('response'); - }); - } + load(usageID) { + const view = this; + const stepID = '.step--response'; + const focusID = `[id='oa_response_${usageID}']`; + + view.isRendering = true; + this.server.render('submission').done( + (html) => { + // Load the HTML and install event handlers + $(stepID, view.element).replaceWith(html); + view.server.renderLatex($(stepID, view.element)); + view.setupPromptDisplays(); + // First load response editor then apply other things + view.loadResponseEditor().then((editorController) => { + view.responseEditorController = editorController; + view.installHandlers(); + view.setAutoSaveEnabled(true); + view.isRendering = false; + view.baseView.announceStatusChangeToSRandFocus(stepID, usageID, false, view, focusID); + view.announceStatus = false; + view.dateFactory.apply(); + }); + }, + ).fail(() => { + view.baseView.showLoadError('response'); + }); + } - /** + /** * Load currently selected editor. * * Returns: promise */ - loadResponseEditor() { - const sel = $('.step--response', this.element); - const editorElements = sel.find('.submission__answer__part__text__value'); - return this.responseEditorLoader.load(this.data.TEXT_RESPONSE_EDITOR, editorElements); - } + loadResponseEditor() { + const sel = $('.step--response', this.element); + const editorElements = sel.find('.submission__answer__part__text__value'); + return this.responseEditorLoader.load(this.data.TEXT_RESPONSE_EDITOR, editorElements); + } - /** + /** Install event handlers for the view. * */ - installHandlers() { - const sel = $('.step--response', this.element); - const view = this; - let uploadType = ''; - if (sel.find('.submission__answer__display__file').length) { - uploadType = sel.find('.submission__answer__display__file').data('upload-type'); - } - // Install a click handler for collapse/expand - this.baseView.setUpCollapseExpand(sel); - - // Install change handler for editor (to enable submission button) - this.savedResponse = this.response(); - this.responseEditorController.setOnChangeListener(this.handleResponseChanged.bind(this)); - - const handlePrepareUpload = function (eventData) { view.prepareUpload(eventData.target.files, uploadType); }; - sel.find('input[type=file]').on('change', handlePrepareUpload); - - const submit = $('.step--response__submit', this.element); - this.textResponse = $(submit).attr('text_response'); - this.fileUploadResponse = $(submit).attr('file_upload_response'); - this.allowLearnerResubmissions = $(submit).attr('allow_learner_resubmissions'); - - // Install a click handler for submission - sel.find('.step--response__submit').click( - (eventObject) => { - // Override default form submission - eventObject.preventDefault(); - view.handleSubmitClicked(); - }, - ); + installHandlers() { + const sel = $('.step--response', this.element); + const view = this; + let uploadType = ''; + if (sel.find('.submission__answer__display__file').length) { + uploadType = sel.find('.submission__answer__display__file').data('upload-type'); + } + // Install a click handler for collapse/expand + this.baseView.setUpCollapseExpand(sel); - // Install a click handler for the save button - sel.find('.submission__save').click( - (eventObject) => { - // Override default form submission - eventObject.preventDefault(); - view.save(); - }, - ); + // Install change handler for editor (to enable submission button) + this.savedResponse = this.response(); + this.responseEditorController.setOnChangeListener(this.handleResponseChanged.bind(this)); - // Install a click handler for the resubmission button - sel.find('.reset__submission').click((eventObject) => { - eventObject.preventDefault(); - view.handleResubmissionClicked(); - }); + const handlePrepareUpload = function (eventData) { view.prepareUpload(eventData.target.files, uploadType); }; + sel.find('input[type=file]').on('change', handlePrepareUpload); + + const submit = $('.step--response__submit', this.element); + this.textResponse = $(submit).attr('text_response'); + this.fileUploadResponse = $(submit).attr('file_upload_response'); + this.allowLearnerResubmissions = $(submit).attr('allow_learner_resubmissions'); - // Install click handler for the preview button - this.baseView.bindLatexPreview(sel); - - const uploadButton = sel.find('.file__upload'); - const spinner = sel.find('.fa-spinner'); - // Install a click handler for the save button - uploadButton.click( - (eventObject) => { - // Override default form submission - eventObject.preventDefault(); - $('.submission__answer__display__file', view.element).removeClass('is--hidden'); - uploadButton.prop('disabled', true); - spinner.removeClass('is--hidden'); - if (view.hasAllUploadFiles()) { - const promise = view.uploadFiles(); - promise.then(() => { - uploadButton.prop('disabled', false); - spinner.addClass('is--hidden'); - }); - } else { + // Install a click handler for submission + sel.find('.step--response__submit').click( + (eventObject) => { + // Override default form submission + eventObject.preventDefault(); + view.handleSubmitClicked(); + }, + ); + + // Install a click handler for the save button + sel.find('.submission__save').click( + (eventObject) => { + // Override default form submission + eventObject.preventDefault(); + view.save(); + }, + ); + + // Install a click handler for the resubmission button + sel.find('.reset__submission').click((eventObject) => { + eventObject.preventDefault(); + view.handleResubmissionClicked(); + }); + + // Install click handler for the preview button + this.baseView.bindLatexPreview(sel); + + const uploadButton = sel.find('.file__upload'); + const spinner = sel.find('.fa-spinner'); + // Install a click handler for the save button + uploadButton.click( + (eventObject) => { + // Override default form submission + eventObject.preventDefault(); + $('.submission__answer__display__file', view.element).removeClass('is--hidden'); + uploadButton.prop('disabled', true); + spinner.removeClass('is--hidden'); + if (view.hasAllUploadFiles()) { + const promise = view.uploadFiles(); + promise.then(() => { uploadButton.prop('disabled', false); spinner.addClass('is--hidden'); - } - }, - ); - - // Install click handlers for delete file buttons. - sel.find('.delete__uploaded__file').click((eventObject) => { + }); + } else { + uploadButton.prop('disabled', false); + spinner.addClass('is--hidden'); + } + }, + ); + + // Install click handlers for delete file buttons. + sel.find('.delete__uploaded__file').click((eventObject) => { + eventObject.preventDefault(); + view.handleDeleteFileClick(eventObject.target); + }); + + // Install a click handler to close the text response warning + sel.find('#team_text_response_warning_closebtn').click( + (eventObject) => { eventObject.preventDefault(); - view.handleDeleteFileClick(eventObject.target); - }); - - // Install a click handler to close the text response warning - sel.find('#team_text_response_warning_closebtn').click( - (eventObject) => { - eventObject.preventDefault(); - sel.find('#team_text_response_warning').remove(); - }, - ); - this.confirmationDialog = new ConfirmationAlert(sel.find('.step--response__dialog-confirm')); - } + sel.find('#team_text_response_warning').remove(); + }, + ); + this.confirmationDialog = new ConfirmationAlert(sel.find('.step--response__dialog-confirm')); + } - /** + /** Set up prompts and attempt to resolve any unresolved Studio URLs * */ - setupPromptDisplays() { - this.prompts = new Prompts(this.element); - this.prompts.resolveStaticLinks(); - } + setupPromptDisplays() { + this.prompts = new Prompts(this.element); + this.prompts.resolveStaticLinks(); + } - /** + /** Enable or disable autosave polling. Args: enabled (boolean): If true, start polling for whether we need to autosave. Otherwise, stop polling. * */ - setAutoSaveEnabled(enabled) { - if (enabled) { - if (this.autoSaveTimerId === null) { - this.autoSaveTimerId = setInterval( - $.proxy(this.autoSave, this), - this.AUTO_SAVE_POLL_INTERVAL, - ); - } - } else if (this.autoSaveTimerId !== null) { - clearInterval(this.autoSaveTimerId); + setAutoSaveEnabled(enabled) { + if (enabled) { + if (this.autoSaveTimerId === null) { + this.autoSaveTimerId = setInterval( + $.proxy(this.autoSave, this), + this.AUTO_SAVE_POLL_INTERVAL, + ); } + } else if (this.autoSaveTimerId !== null) { + clearInterval(this.autoSaveTimerId); } + } - /** + /** * Check if submission is valid before submitting * Returns: boolean */ - isValidForSubmit() { - const textFieldsIsNotBlank = !this.response().every( - (element) => $.trim(element) === '', - ); - let filesFiledIsNotBlank = false; - $('.submission__answer__file', this.element).each(function () { - if ( - ($(this).prop('tagName') === 'IMG' && $(this).attr('src') !== '') + isValidForSubmit() { + const textFieldsIsNotBlank = !this.response().every( + (element) => $.trim(element) === '', + ); + let filesFiledIsNotBlank = false; + $('.submission__answer__file', this.element).each(function () { + if ( + ($(this).prop('tagName') === 'IMG' && $(this).attr('src') !== '') || ($(this).prop('tagName') === 'A' && $(this).attr('href') !== '') - ) { - filesFiledIsNotBlank = true; - } - }); - if (this.textResponse === 'required' && !textFieldsIsNotBlank) { - this.baseView.toggleActionError( - 'submit', - gettext('Please provide a response.'), - ); - return false; + ) { + filesFiledIsNotBlank = true; } - if (this.fileUploadResponse === 'required' && !filesFiledIsNotBlank) { - this.baseView.toggleActionError( - 'submit', - gettext('Please upload a file.'), - ); - return false; - } - if ((this.textResponse === 'optional') && (this.fileUploadResponse === 'optional') + }); + if (this.textResponse === 'required' && !textFieldsIsNotBlank) { + this.baseView.toggleActionError( + 'submit', + gettext('Please provide a response.'), + ); + return false; + } + if (this.fileUploadResponse === 'required' && !filesFiledIsNotBlank) { + this.baseView.toggleActionError( + 'submit', + gettext('Please upload a file.'), + ); + return false; + } + if ((this.textResponse === 'optional') && (this.fileUploadResponse === 'optional') && !textFieldsIsNotBlank && !filesFiledIsNotBlank) { - this.baseView.toggleActionError( - 'submit', - gettext('Cannot submit empty response even everything is optional.'), - ); - return false; - } - - if (this.hasPendingUploadFiles()) { - this.collectFilesDescriptions(); - this.baseView.toggleActionError( - 'submit', - gettext( - 'There is still file upload in progress. Please wait until it is finished.', - ), - ); - return false; - } + this.baseView.toggleActionError( + 'submit', + gettext('Cannot submit empty response even everything is optional.'), + ); + return false; + } - return true; + if (this.hasPendingUploadFiles()) { + this.collectFilesDescriptions(); + this.baseView.toggleActionError( + 'submit', + gettext( + 'There is still file upload in progress. Please wait until it is finished.', + ), + ); + return false; } - /** + return true; + } + + /** * Check that "save" button could be enabled (or disabled) * */ - checkSaveAbility() { - const textFieldsIsNotBlank = !this.response().every((element) => $.trim(element) === ''); + checkSaveAbility() { + const textFieldsIsNotBlank = !this.response().every((element) => $.trim(element) === ''); - return !((this.textResponse === 'required') && !textFieldsIsNotBlank); - } + return !((this.textResponse === 'required') && !textFieldsIsNotBlank); + } - /** + /** Enable/disable the submit button. Check that whether the submit button is enabled. @@ -303,11 +303,11 @@ export class ResponseView { >> view.submitEnabled(); // check whether the button is enabled >> true * */ - submitEnabled(enabled) { - return this.baseView.buttonEnabled('.step--response__submit', enabled); - } + submitEnabled(enabled) { + return this.baseView.buttonEnabled('.step--response__submit', enabled); + } - /** + /** Enable/disable the preview button. Check whether the preview button is enabled. @@ -322,53 +322,53 @@ export class ResponseView { >> view.previewEnabled(); // check whether the button is enabled >> true */ - previewEnabled(enabled) { - return this.baseView.buttonEnabled('.submission__preview', enabled); - } + previewEnabled(enabled) { + return this.baseView.buttonEnabled('.submission__preview', enabled); + } - /** + /** Check if there is a file selected but not uploaded yet Returns: boolean: if we have pending files or not. * */ - hasPendingUploadFiles() { - return this.files !== null && !this.filesUploaded; - } + hasPendingUploadFiles() { + return this.files !== null && !this.filesUploaded; + } - /** + /** Check if there is a selected file moved or deleted before uploading Returns: boolean: if we have deleted/moved files or not. * */ - hasAllUploadFiles() { - if (!this.files) { - this.baseView.toggleActionError( - 'upload', - gettext('No files selected for upload.'), - ); - return false; - } - if (!this.collectFilesDescriptions()) { + hasAllUploadFiles() { + if (!this.files) { + this.baseView.toggleActionError( + 'upload', + gettext('No files selected for upload.'), + ); + return false; + } + if (!this.collectFilesDescriptions()) { + this.baseView.toggleActionError( + 'upload', + gettext('Please provide a description for each file you are uploading.'), + ); + return false; + } + for (let i = 0; i < this.files?.length; i++) { + const file = this.files[i]; + if (file.size === 0) { this.baseView.toggleActionError( 'upload', - gettext('Please provide a description for each file you are uploading.'), + gettext('Your file has been deleted or path has been changed: ') + file.name, ); return false; } - for (let i = 0; i < this.files?.length; i++) { - const file = this.files[i]; - if (file.size === 0) { - this.baseView.toggleActionError( - 'upload', - gettext('Your file has been deleted or path has been changed: ') + file.name, - ); - return false; - } - } - return true; } + return true; + } - /** + /** Set the save status message. Retrieve the save status message. @@ -379,26 +379,26 @@ export class ResponseView { Returns: string: The current status message. * */ - saveStatus(msg, iconClass) { - // Create save status text - const saveStatusSel = $('.save__submission__label', this.element); - if (typeof msg === 'undefined') { - return saveStatusSel.text(); - } - saveStatusSel.text(_.escape(msg)); - - // Update save status icon, if provided - const iconSel = $('.save__submission__icon', this.element); - let iconClasses = 'save__submission__icon icon fa '; - if (typeof msg === 'string') { - iconClasses += _.escape(iconClass); - } - iconSel.attr('class', iconClasses); - + saveStatus(msg, iconClass) { + // Create save status text + const saveStatusSel = $('.save__submission__label', this.element); + if (typeof msg === 'undefined') { return saveStatusSel.text(); } + saveStatusSel.text(_.escape(msg)); + + // Update save status icon, if provided + const iconSel = $('.save__submission__icon', this.element); + let iconClasses = 'save__submission__icon icon fa '; + if (typeof msg === 'string') { + iconClasses += _.escape(iconClass); + } + iconSel.attr('class', iconClasses); + + return saveStatusSel.text(); + } - /** + /** Set the response texts. Retrieve the response texts. @@ -408,226 +408,226 @@ export class ResponseView { Returns: array of strings: The current response texts. * */ - /* eslint-disable-next-line consistent-return */ - response(texts) { - return this.responseEditorController.response(texts); - } + /* eslint-disable-next-line consistent-return */ + response(texts) { + return this.responseEditorController.response(texts); + } - /** + /** Check whether the response texts have changed since the last save. Returns: boolean * */ - responseChanged() { - const { savedResponse } = this; - return this.response().some((element, index) => element !== savedResponse[index]); - } + responseChanged() { + const { savedResponse } = this; + return this.response().some((element, index) => element !== savedResponse[index]); + } - /** + /** Automatically save the user's response if certain conditions are met. Usually, this would be called by a timer (see `setAutoSaveEnabled()`). For testing purposes, it's useful to disable the timer and call this function synchronously. * */ - autoSave() { - const timeSinceLastChange = Date.now() - this.lastChangeTime; - - // We only autosave if the following conditions are met: - // (1) The response has changed. We don't need to keep saving the same response. - // (2) Sufficient time has passed since the user last made a change to the response. - // We don't want to save a response while the user is in the middle of typing. - if (this.responseChanged() && timeSinceLastChange > this.AUTO_SAVE_WAIT) { - this.save(); - } + autoSave() { + const timeSinceLastChange = Date.now() - this.lastChangeTime; + + // We only autosave if the following conditions are met: + // (1) The response has changed. We don't need to keep saving the same response. + // (2) Sufficient time has passed since the user last made a change to the response. + // We don't want to save a response while the user is in the middle of typing. + if (this.responseChanged() && timeSinceLastChange > this.AUTO_SAVE_WAIT) { + this.save(); } + } - /** + /** Enable/disable the submission and save buttons based on whether the user has entered a response. * */ - handleResponseChanged() { - // Update the save button, save status, and "unsaved changes" warning - // only if the response has changed - if (this.responseChanged()) { - const saveAbility = this.checkSaveAbility(); - this.previewEnabled(saveAbility); - - // If there was an error, preserve error status - if (!this.errorOnLastSave) { - this.saveStatus(gettext('Saving draft'), this.ICON_SAVING); - } - - this.baseView.unsavedWarningEnabled( - true, - this.UNSAVED_WARNING_KEY, - // eslint-disable-next-line max-len - gettext('If you leave this page without saving or submitting your response, you will lose any work you have done on the response.'), - ); + handleResponseChanged() { + // Update the save button, save status, and "unsaved changes" warning + // only if the response has changed + if (this.responseChanged()) { + const saveAbility = this.checkSaveAbility(); + this.previewEnabled(saveAbility); + + // If there was an error, preserve error status + if (!this.errorOnLastSave) { + this.saveStatus(gettext('Saving draft'), this.ICON_SAVING); } - // Record the current time (used for autosave) - this.lastChangeTime = Date.now(); + this.baseView.unsavedWarningEnabled( + true, + this.UNSAVED_WARNING_KEY, + // eslint-disable-next-line max-len + gettext('If you leave this page without saving or submitting your response, you will lose any work you have done on the response.'), + ); } - /** + // Record the current time (used for autosave) + this.lastChangeTime = Date.now(); + } + + /** Save a response without submitting it. * */ - save() { - // Update the save status and error notifications - // ... unless there was an error, this helps avoid unnecessary UI refreshes. - if (!this.errorOnLastSave) { - this.saveStatus(gettext('Saving draft...'), this.ICON_SAVING); - } + save() { + // Update the save status and error notifications + // ... unless there was an error, this helps avoid unnecessary UI refreshes. + if (!this.errorOnLastSave) { + this.saveStatus(gettext('Saving draft...'), this.ICON_SAVING); + } - // Disable the "unsaved changes" warning - this.baseView.unsavedWarningEnabled(false, this.UNSAVED_WARNING_KEY); + // Disable the "unsaved changes" warning + this.baseView.unsavedWarningEnabled(false, this.UNSAVED_WARNING_KEY); - const view = this; - const savedResponse = this.response(); + const view = this; + const savedResponse = this.response(); - this.server.save(savedResponse).done(() => { - // Remember which response we saved, once the server confirms that it's been saved... - view.savedResponse = savedResponse; + this.server.save(savedResponse).done(() => { + // Remember which response we saved, once the server confirms that it's been saved... + view.savedResponse = savedResponse; - const currentResponse = view.response(); - const currentResponseEqualsSaved = currentResponse.every((element, index) => element === savedResponse[index]); - if (currentResponseEqualsSaved) { - const msg = gettext('Draft saved!'); - view.saveStatus(msg, this.ICON_SAVED); - view.baseView.srReadTexts([msg]); + const currentResponse = view.response(); + const currentResponseEqualsSaved = currentResponse.every((element, index) => element === savedResponse[index]); + if (currentResponseEqualsSaved) { + const msg = gettext('Draft saved!'); + view.saveStatus(msg, this.ICON_SAVED); + view.baseView.srReadTexts([msg]); - // Disable error - this.baseView.toggleActionError('save', null); - view.errorOnLastSave = false; - } - }).fail((errMsg) => { - // Debounce error banner, this won't capture new errors, but will keep - // us from defocusing text area, allowing user to continue to edit their - // response. - if (!view.errorOnLastSave) { - view.saveStatus(gettext('Error'), this.ICON_ERROR); - view.baseView.toggleActionError('save', errMsg); - } + // Disable error + this.baseView.toggleActionError('save', null); + view.errorOnLastSave = false; + } + }).fail((errMsg) => { + // Debounce error banner, this won't capture new errors, but will keep + // us from defocusing text area, allowing user to continue to edit their + // response. + if (!view.errorOnLastSave) { + view.saveStatus(gettext('Error'), this.ICON_ERROR); + view.baseView.toggleActionError('save', errMsg); + } - // Remember that an error occurred - // so we can disable autosave - // (avoids repeatedly refreshing the error message) - view.errorOnLastSave = true; - }); - } + // Remember that an error occurred + // so we can disable autosave + // (avoids repeatedly refreshing the error message) + view.errorOnLastSave = true; + }); + } - /** + /** Handler for the submit button * */ - handleSubmitClicked() { - if (!this.isValidForSubmit()) { return; } - - // Immediately disable the submit button to prevent multiple submission - this.submitEnabled(false); - - const view = this; - const title = gettext('Confirm Submit Response'); - let msg = ''; - if (this.allowLearnerResubmissions === 'True') { - msg = gettext( - 'You\'re about to submit your response for this assignment. ' + handleSubmitClicked() { + if (!this.isValidForSubmit()) { return; } + + // Immediately disable the submit button to prevent multiple submission + this.submitEnabled(false); + + const view = this; + const title = gettext('Confirm Submit Response'); + let msg = ''; + if (this.allowLearnerResubmissions === 'True') { + msg = gettext( + 'You\'re about to submit your response for this assignment. ' + 'After you submit this response, you may have a limited ' + 'time to resubmit before your submission is graded.', - ); - } else { - msg = gettext( - 'You\'re about to submit your response for this assignment. ' + ); + } else { + msg = gettext( + 'You\'re about to submit your response for this assignment. ' + 'After you submit this response, you can\'t change it or ' + 'submit a new response.', - ); - } - - this.confirmationDialog.confirm( - title, - msg, - () => view.submit(), - () => view.submitEnabled(true), ); } - /** + this.confirmationDialog.confirm( + title, + msg, + () => view.submit(), + () => view.submitEnabled(true), + ); + } + + /** Send a response submission to the server and update the view. * */ - submit() { - const submission = this.response(); - this.baseView.toggleActionError('response', null); - - // Send the submission to the server - this.server.submit(submission) - .done(() => { this.moveToNextStep(); }) - .fail((errCode, errMsg) => { - // If the error is "multiple submissions", then we should move to the next step. - // Otherwise, the user will be stuck on the current step with no way to continue. - if (errCode === 'ENOMULTI') { this.moveToNextStep(); } else { - // If there is an error message, display it - if (errMsg) { this.baseView.toggleActionError('submit', errMsg); } - - // Re-enable the submit button so the user can retry - this.submitEnabled(true); - } - }); - } + submit() { + const submission = this.response(); + this.baseView.toggleActionError('response', null); + + // Send the submission to the server + this.server.submit(submission) + .done(() => { this.moveToNextStep(); }) + .fail((errCode, errMsg) => { + // If the error is "multiple submissions", then we should move to the next step. + // Otherwise, the user will be stuck on the current step with no way to continue. + if (errCode === 'ENOMULTI') { this.moveToNextStep(); } else { + // If there is an error message, display it + if (errMsg) { this.baseView.toggleActionError('submit', errMsg); } + + // Re-enable the submit button so the user can retry + this.submitEnabled(true); + } + }); + } - /** + /** Handler for the resubmission button * */ - handleResubmissionClicked() { - const view = this; - const title = gettext('Confirm Reset'); - const msg = gettext( - 'You\'re about to reset your response for this assignment. ' + handleResubmissionClicked() { + const view = this; + const title = gettext('Confirm Reset'); + const msg = gettext( + 'You\'re about to reset your response for this assignment. ' + 'You will need to submit a new response in order to complete ' + 'this step. Are you sure you want to continue?', - ); - this.confirmationDialog.confirm( - title, - msg, - () => view.resetSubmission(), - () => {}, - ); - } - - /** + ); + this.confirmationDialog.confirm( + title, + msg, + () => view.resetSubmission(), + () => {}, + ); + } + + /** Reset a response submission to the server and update the view. * */ - resetSubmission() { - this.server - .resetSubmission() - .done(() => { - window.location.reload(true); - }) - .fail((errMsg) => { - if (errMsg) { - this.baseView.toggleActionError('submit', errMsg); - } - }); - } + resetSubmission() { + this.server + .resetSubmission() + .done(() => { + window.location.reload(true); + }) + .fail((errMsg) => { + if (errMsg) { + this.baseView.toggleActionError('submit', errMsg); + } + }); + } - /** + /** Transition the user to the next step in the workflow. * */ - moveToNextStep() { - const { baseView } = this; - const usageID = baseView.getUsageID(); - const view = this; + moveToNextStep() { + const { baseView } = this; + const usageID = baseView.getUsageID(); + const view = this; - this.load(usageID); - baseView.loadAssessmentModules(usageID); + this.load(usageID); + baseView.loadAssessmentModules(usageID); - view.announceStatus = true; + view.announceStatus = true; - // Disable the "unsaved changes" warning if the user - // tries to navigate to another page. - baseView.unsavedWarningEnabled(false, this.UNSAVED_WARNING_KEY); - } + // Disable the "unsaved changes" warning if the user + // tries to navigate to another page. + baseView.unsavedWarningEnabled(false, this.UNSAVED_WARNING_KEY); + } - /** + /** When selecting a file for upload, do some quick client-side validation to ensure that it is an image, a PDF or other allowed types, and is not larger than the maximum file size. @@ -640,150 +640,150 @@ export class ResponseView { file or custom. */ - prepareUpload(files, uploadType, descriptions) { - this.files = null; - this.filesType = uploadType; - this.filesUploaded = false; - - let errorCheckerTriggered = false; - - for (let i = 0; i < files.length; i++) { - if (files[i].size > this.MAX_FILE_SIZE) { - this.baseView.toggleActionError( - 'upload', - gettext( - 'Individual file size must be {max_files_mb}MB or less.', - ).replace( - '{max_files_mb}', - this.MAX_FILES_MB, - ), - ); - errorCheckerTriggered = true; - break; - } + prepareUpload(files, uploadType, descriptions) { + this.files = null; + this.filesType = uploadType; + this.filesUploaded = false; - if (!this.isUploadSupported(files[i], uploadType)) { - this.baseView.toggleActionError( - 'upload', - gettext( - 'File upload failed: unsupported file type. ' - + 'Only the supported file types can be uploaded. ' - + 'If you have questions, please reach out to the course team.', - ), - ); - errorCheckerTriggered = true; - break; - } + let errorCheckerTriggered = false; + + for (let i = 0; i < files.length; i++) { + if (files[i].size > this.MAX_FILE_SIZE) { + this.baseView.toggleActionError( + 'upload', + gettext( + 'Individual file size must be {max_files_mb}MB or less.', + ).replace( + '{max_files_mb}', + this.MAX_FILES_MB, + ), + ); + errorCheckerTriggered = true; + break; } - if (this.getSavedFileCount(false) + files.length > this.data.MAXIMUM_FILE_UPLOAD_COUNT) { - const msg = gettext('The maximum number files that can be saved is ') + this.data.MAXIMUM_FILE_UPLOAD_COUNT; + if (!this.isUploadSupported(files[i], uploadType)) { this.baseView.toggleActionError( 'upload', - gettext(msg), + gettext( + 'File upload failed: unsupported file type. ' + + 'Only the supported file types can be uploaded. ' + + 'If you have questions, please reach out to the course team.', + ), ); errorCheckerTriggered = true; + break; } + } - if (!errorCheckerTriggered) { - this.baseView.toggleActionError('upload', null); - if (files.length > 0) { - this.files = files; - } - this.updateFilesDescriptionsFields(files, descriptions, uploadType); + if (this.getSavedFileCount(false) + files.length > this.data.MAXIMUM_FILE_UPLOAD_COUNT) { + const msg = gettext('The maximum number files that can be saved is ') + this.data.MAXIMUM_FILE_UPLOAD_COUNT; + this.baseView.toggleActionError( + 'upload', + gettext(msg), + ); + errorCheckerTriggered = true; + } + + if (!errorCheckerTriggered) { + this.baseView.toggleActionError('upload', null); + if (files.length > 0) { + this.files = files; } + this.updateFilesDescriptionsFields(files, descriptions, uploadType); } + } - isUploadSupported = (file, uploadType) => { - const ext = file.name.split('.').pop().toLowerCase(); - const fileType = file.type; + isUploadSupported = (file, uploadType) => { + const ext = file.name.split('.').pop().toLowerCase(); + const fileType = file.type; - // Check upload type/extension matches allowed types - if (uploadType === 'image' + // Check upload type/extension matches allowed types + if (uploadType === 'image' && this.data.ALLOWED_IMAGE_MIME_TYPES.indexOf(fileType) === -1 - ) { - return false; - } if ( - uploadType === 'pdf-and-image' + ) { + return false; + } if ( + uploadType === 'pdf-and-image' && this.data.ALLOWED_FILE_MIME_TYPES.indexOf(fileType) === -1 - ) { - return false; - } if ( - uploadType === 'custom' + ) { + return false; + } if ( + uploadType === 'custom' && this.data.FILE_TYPE_WHITE_LIST.indexOf(ext) === -1 - ) { - return false; - } if (this.data.FILE_EXT_BLACK_LIST.indexOf(ext) !== -1) { - return false; - } + ) { + return false; + } if (this.data.FILE_EXT_BLACK_LIST.indexOf(ext) !== -1) { + return false; + } - return true; - }; + return true; + }; - /** + /** Render textarea fields to input description for each uploaded file. */ - /* jshint -W083 */ - updateFilesDescriptionsFields(files, descriptions, uploadType) { - const filesDescriptions = $(this.element).find('.files__descriptions').first(); - let mainDiv = null; - let divLabel = null; - let divTextarea = null; - let divImage = null; - let img = null; - let textarea = null; - let descriptionsExists = true; - - this.filesDescriptions = descriptions || []; - this.fileNames = []; - - $(filesDescriptions).show().html(''); - - for (let i = 0; i < files.length; i++) { - mainDiv = $('
'); - - divLabel = $('
'); - divLabel.addClass('submission__file__description__label'); - divLabel.text(`${gettext('Describe ') + files[i].name} ${gettext('(required):')}`); - divLabel.appendTo(mainDiv); - - divTextarea = $('
'); - divTextarea.addClass('submission__file__description'); - textarea = $('