Merge pull request #6679 from open-craft/problem-tooltips
Support for inline explanatory popups in problem XML
This commit is contained in:
@@ -6,8 +6,6 @@ These tags do not have state, so they just get passed the system (for access to
|
||||
and the xml element.
|
||||
"""
|
||||
|
||||
from .registry import TagRegistry
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -137,3 +135,35 @@ class TargetedFeedbackRenderer(object):
|
||||
return xhtml
|
||||
|
||||
registry.register(TargetedFeedbackRenderer)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ClarificationRenderer(object):
|
||||
"""
|
||||
A clarification appears as an inline icon which reveals more information when the user
|
||||
hovers over it.
|
||||
|
||||
e.g. <p>Enter the ROA <clarification>Return on Assets</clarification> for 2015:</p>
|
||||
"""
|
||||
tags = ['clarification']
|
||||
|
||||
def __init__(self, system, xml):
|
||||
self.system = system
|
||||
# Get any text content found inside this tag prior to the first child tag. It may be a string or None type.
|
||||
initial_text = xml.text if xml.text else ''
|
||||
self.inner_html = initial_text + ''.join(etree.tostring(element) for element in xml) # pylint: disable=no-member
|
||||
self.tail = xml.tail
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Return the contents of this tag, rendered to html, as an etree element.
|
||||
"""
|
||||
context = {'clarification': self.inner_html}
|
||||
html = self.system.render_template("clarification.html", context)
|
||||
xml = etree.XML(html) # pylint: disable=no-member
|
||||
# We must include any text that was following our original <clarification>...</clarification> XML node.:
|
||||
xml.tail = self.tail
|
||||
return xml
|
||||
|
||||
registry.register(ClarificationRenderer)
|
||||
|
||||
5
common/lib/capa/capa/templates/clarification.html
Normal file
5
common/lib/capa/capa/templates/clarification.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<span class="clarification" tabindex="0" role="note" aria-label="Clarification">
|
||||
<i data-tooltip="${clarification | h}" data-tooltip-show-on-click="true"
|
||||
class="fa fa-info-circle" aria-hidden="true"></i>
|
||||
<span class="sr">(${clarification})</span>
|
||||
</span>
|
||||
@@ -162,6 +162,12 @@ div.problem {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
span.clarification i {
|
||||
font-style: normal;
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.unanswered {
|
||||
|
||||
@@ -34,6 +34,12 @@ class @Problem
|
||||
@$('div.action input.reset').click @reset
|
||||
@$('div.action button.show').click @show
|
||||
@$('div.action input.save').click @save
|
||||
# Accessibility helper for sighted keyboard users to show <clarification> tooltips on focus:
|
||||
@$('.clarification').focus (ev) =>
|
||||
icon = $(ev.target).children "i"
|
||||
window.globalTooltipManager.openTooltip icon
|
||||
@$('.clarification').blur (ev) =>
|
||||
window.globalTooltipManager.hide()
|
||||
|
||||
@bindResetCorrectness()
|
||||
|
||||
|
||||
@@ -70,6 +70,17 @@ describe('TooltipManager', function () {
|
||||
expect($('.tooltip')).toBeHidden();
|
||||
});
|
||||
|
||||
it('can be configured to show when user clicks on the element', function () {
|
||||
this.element.attr('data-tooltip-show-on-click', true);
|
||||
this.element.trigger($.Event("click"));
|
||||
expect($('.tooltip')).toBeVisible();
|
||||
});
|
||||
|
||||
it('can be be triggered manually', function () {
|
||||
this.tooltip.openTooltip(this.element);
|
||||
expect($('.tooltip')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should moves correctly', function () {
|
||||
showTooltip(this.element);
|
||||
expect($('.tooltip')).toBeVisible();
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
'mouseover.TooltipManager': this.showTooltip,
|
||||
'mousemove.TooltipManager': this.moveTooltip,
|
||||
'mouseout.TooltipManager': this.hideTooltip,
|
||||
'click.TooltipManager': this.hideTooltip
|
||||
'click.TooltipManager': this.click
|
||||
}, this.SELECTOR);
|
||||
},
|
||||
|
||||
@@ -46,17 +46,31 @@
|
||||
},
|
||||
|
||||
showTooltip: function(event) {
|
||||
var tooltipText = $(event.currentTarget).attr('data-tooltip');
|
||||
this.tooltip
|
||||
.html(tooltipText)
|
||||
.css(this.getCoords(event.pageX, event.pageY));
|
||||
|
||||
this.prepareTooltip(event.currentTarget, event.pageX, event.pageY);
|
||||
if (this.tooltipTimer) {
|
||||
clearTimeout(this.tooltipTimer);
|
||||
}
|
||||
this.tooltipTimer = setTimeout(this.show, 500);
|
||||
},
|
||||
|
||||
prepareTooltip: function(element, pageX, pageY) {
|
||||
pageX = typeof pageX !== 'undefined' ? pageX : element.offset().left + element.width()/2;
|
||||
pageY = typeof pageY !== 'undefined' ? pageY : element.offset().top + element.height()/2;
|
||||
var tooltipText = $(element).attr('data-tooltip');
|
||||
this.tooltip
|
||||
.html(tooltipText)
|
||||
.css(this.getCoords(pageX, pageY));
|
||||
},
|
||||
|
||||
// To manually trigger a tooltip to reveal, other than through user mouse movement:
|
||||
openTooltip: function(element) {
|
||||
this.prepareTooltip(element);
|
||||
this.show();
|
||||
if (this.tooltipTimer) {
|
||||
clearTimeout(this.tooltipTimer);
|
||||
}
|
||||
},
|
||||
|
||||
moveTooltip: function(event) {
|
||||
this.tooltip.css(this.getCoords(event.pageX, event.pageY));
|
||||
},
|
||||
@@ -68,6 +82,18 @@
|
||||
this.tooltipTimer = setTimeout(this.hide, 50);
|
||||
},
|
||||
|
||||
click: function(event) {
|
||||
var showOnClick = !!$(event.currentTarget).data('tooltip-show-on-click'); // Default is false
|
||||
if (showOnClick) {
|
||||
this.show();
|
||||
if (this.tooltipTimer) {
|
||||
clearTimeout(this.tooltipTimer);
|
||||
}
|
||||
} else {
|
||||
this.hideTooltip(event);
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
this.tooltip.remove();
|
||||
// Unbind all delegated event handlers in the ".TooltipManager"
|
||||
@@ -78,6 +104,6 @@
|
||||
|
||||
window.TooltipManager = TooltipManager;
|
||||
$(document).ready(function () {
|
||||
new TooltipManager(document.body);
|
||||
window.globalTooltipManager = new TooltipManager(document.body);
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -46,3 +46,19 @@ class ProblemPage(PageObject):
|
||||
Is there a "correct" status showing?
|
||||
"""
|
||||
return self.q(css="div.problem div.capa_inputtype.textline div.correct p.status").is_present()
|
||||
|
||||
def click_clarification(self, index=0):
|
||||
"""
|
||||
Click on an inline icon that can be included in problem text using an HTML <clarification> element:
|
||||
|
||||
Problem <clarification>clarification text hidden by an icon in rendering</clarification> Text
|
||||
"""
|
||||
self.q(css='div.problem .clarification:nth-child({index}) i[data-tooltip]'.format(index=index + 1)).click()
|
||||
|
||||
@property
|
||||
def visible_tooltip_text(self):
|
||||
"""
|
||||
Get the text seen in any tooltip currently visible on the page.
|
||||
"""
|
||||
self.wait_for_element_visibility('body > .tooltip', 'A tooltip is visible.')
|
||||
return self.q(css='body > .tooltip').text[0]
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for the LMS.
|
||||
Test for matlab problems
|
||||
"""
|
||||
import time
|
||||
|
||||
from ..helpers import UniqueCourseTest
|
||||
from ...pages.studio.auto_auth import AutoAuthPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.matlab_problem import MatlabProblemPage
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...fixtures.course import XBlockFixtureDesc
|
||||
from ...fixtures.xqueue import XQueueResponseFixture
|
||||
from .test_lms_problems import ProblemsTest
|
||||
from textwrap import dedent
|
||||
|
||||
|
||||
class MatlabProblemTest(UniqueCourseTest):
|
||||
class MatlabProblemTest(ProblemsTest):
|
||||
"""
|
||||
Tests that verify matlab problem "Run Code".
|
||||
"""
|
||||
USERNAME = "STAFF_TESTER"
|
||||
EMAIL = "johndoe@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(MatlabProblemTest, self).setUp()
|
||||
|
||||
self.XQUEUE_GRADE_RESPONSE = None
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
# Install a course with sections/problems, tabs, updates, and handouts
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a matlab problem for the test.
|
||||
"""
|
||||
problem_data = dedent("""
|
||||
<problem markdown="null">
|
||||
<text>
|
||||
@@ -62,18 +48,7 @@ class MatlabProblemTest(UniqueCourseTest):
|
||||
</text>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Matlab Problem', data=problem_data)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
|
||||
course_id=self.course_id, staff=False).visit()
|
||||
return XBlockFixtureDesc('problem', 'Test Matlab Problem', data=problem_data)
|
||||
|
||||
def _goto_matlab_problem_page(self):
|
||||
"""
|
||||
@@ -92,13 +67,13 @@ class MatlabProblemTest(UniqueCourseTest):
|
||||
# Enter a submission, which will trigger a pre-defined response from the XQueue stub.
|
||||
self.submission = "a=1" + self.unique_id[0:5]
|
||||
|
||||
self.XQUEUE_GRADE_RESPONSE = {'msg': self.submission}
|
||||
self.xqueue_grade_response = {'msg': self.submission}
|
||||
|
||||
matlab_problem_page = self._goto_matlab_problem_page()
|
||||
|
||||
# Configure the XQueue stub's response for the text we will submit
|
||||
if self.XQUEUE_GRADE_RESPONSE is not None:
|
||||
XQueueResponseFixture(self.submission, self.XQUEUE_GRADE_RESPONSE).install()
|
||||
if self.xqueue_grade_response is not None:
|
||||
XQueueResponseFixture(self.submission, self.xqueue_grade_response).install()
|
||||
|
||||
matlab_problem_page.set_response(self.submission)
|
||||
matlab_problem_page.click_run_code()
|
||||
@@ -113,6 +88,6 @@ class MatlabProblemTest(UniqueCourseTest):
|
||||
|
||||
self.assertEqual(u'', matlab_problem_page.get_grader_msg(".external-grader-message")[0])
|
||||
self.assertEqual(
|
||||
self.XQUEUE_GRADE_RESPONSE.get("msg"),
|
||||
self.xqueue_grade_response.get("msg"),
|
||||
matlab_problem_page.get_grader_msg(".ungraded-matlab-result")[0]
|
||||
)
|
||||
|
||||
88
common/test/acceptance/tests/lms/test_lms_problems.py
Normal file
88
common/test/acceptance/tests/lms/test_lms_problems.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bok choy acceptance tests for problems in the LMS
|
||||
|
||||
See also old lettuce tests in lms/djangoapps/courseware/features/problems.feature
|
||||
"""
|
||||
from ..helpers import UniqueCourseTest
|
||||
from ...pages.studio.auto_auth import AutoAuthPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.problem import ProblemPage
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from textwrap import dedent
|
||||
|
||||
|
||||
class ProblemsTest(UniqueCourseTest):
|
||||
"""
|
||||
Base class for tests of problems in the LMS.
|
||||
"""
|
||||
USERNAME = "joe_student"
|
||||
EMAIL = "joe@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(ProblemsTest, self).setUp()
|
||||
|
||||
self.xqueue_grade_response = None
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
# Install a course with a hierarchy and problems
|
||||
course_fixture = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
problem = self.get_problem()
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(problem)
|
||||
)
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
|
||||
course_id=self.course_id, staff=False).visit()
|
||||
|
||||
def get_problem(self):
|
||||
""" Subclasses should override this to complete the fixture """
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class ProblemClarificationTest(ProblemsTest):
|
||||
"""
|
||||
Tests the <clarification> element that can be used in problem XML.
|
||||
"""
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a problem with a <clarification>
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem markdown="null">
|
||||
<text>
|
||||
<p>
|
||||
Given the data in Table 7 <clarification>Table 7: "Example PV Installation Costs",
|
||||
Page 171 of Roberts textbook</clarification>, compute the ROI
|
||||
<clarification>Return on Investment <strong>(per year)</strong></clarification> over 20 years.
|
||||
</p>
|
||||
<numericalresponse answer="6.5">
|
||||
<textline label="Enter the annual ROI" trailing_text="%" />
|
||||
</numericalresponse>
|
||||
</text>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TOOLTIP TEST PROBLEM', data=xml)
|
||||
|
||||
def test_clarification(self):
|
||||
"""
|
||||
Test that we can see the <clarification> tooltips.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, 'TOOLTIP TEST PROBLEM')
|
||||
problem_page.click_clarification(0)
|
||||
self.assertIn('"Example PV Installation Costs"', problem_page.visible_tooltip_text)
|
||||
problem_page.click_clarification(1)
|
||||
tooltip_text = problem_page.visible_tooltip_text
|
||||
self.assertIn('Return on Investment', tooltip_text)
|
||||
self.assertIn('per year', tooltip_text)
|
||||
self.assertNotIn('strong', tooltip_text)
|
||||
Reference in New Issue
Block a user