Aids styleability of CAPA checkbox and radio problems

by making CAPA <input> elements siblings of their <label>s, instead of children.

Also:

* Moves radio submitted status block down below the problem
  to match the checkbox problem status blocks.
* Marks submitted choicegroup answers with a class
This commit is contained in:
Jillian Vogel
2019-07-23 16:23:40 +09:30
parent 3e4e027234
commit fb981bfbbe
6 changed files with 149 additions and 123 deletions

View File

@@ -18,47 +18,39 @@ import six
<p class="question-description" id="${description_id}">${description_text}</p>
% endfor
% for choice_id, choice_label in choices:
<%
label_class = 'response-label field-label label-inline'
input_class = 'field-input input-' + input_type
input_checked = ''
if is_radio_input(choice_id) or (input_type != 'radio' and choice_id in value):
input_class += ' submitted'
if status.classname and not show_correctness == 'never':
label_class += ' choicegroup_' + status.classname
%>
<div class="field">
<%
label_class = 'response-label field-label label-inline'
%>
<label id="${id}-${choice_id}-label"
## If the student has selected this choice...
% if is_radio_input(choice_id):
% if status.classname and not show_correctness == 'never':
<% label_class += ' choicegroup_' + status.classname %>
% endif
% endif
class="${label_class}"
${describedby_html}
>
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" class="field-input input-${input_type}" value="${choice_id}"
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}"
class="${input_class}" value="${choice_id}"
## If the student selected this choice...
% if is_radio_input(choice_id):
checked="true"
% elif input_type != 'radio' and choice_id in value:
checked="true"
% endif
/> ${HTML(choice_label)}
% if is_radio_input(choice_id):
% if not show_correctness == 'never' and status.classname != 'unanswered':
<%include file="status_span.html" args="status=status, status_id=id"/>
% endif
% endif
/><label id="${id}-${choice_id}-label" for="input_${id}_${choice_id}"
class="${label_class}"
${describedby_html}
> ${HTML(choice_label)}
</label>
</div>
% endfor
<span id="answer_${id}"></span>
</fieldset>
<div class="indicator-container">
% if input_type == 'checkbox' or status.classname == 'unanswered':
% if show_correctness != 'never':
<%include file="status_span.html" args="status=status, status_id=id"/>
% else:
<%include file="status_span.html" args="status=status, status_id=id, hide_correctness=True"/>
% endif
% if show_correctness != 'never':
<%include file="status_span.html" args="status=status, status_id=id"/>
% else:
<%include file="status_span.html" args="status=status, status_id=id, hide_correctness=True"/>
% endif
</div>
% if show_correctness == "never" and (value or status not in ['unsubmitted']):

View File

@@ -118,8 +118,8 @@ class TemplateTestCase(unittest.TestCase):
If no elements are found, the assertion fails.
"""
element_list = xml_root.xpath(xpath)
self.assertGreater(len(element_list), 0, "Could not find element at '%s'" % str(xpath))
self.assertGreater(len(element_list), 0, "Could not find element at '%s'\n%s" %
(str(xpath), etree.tostring(xml_root)))
if exact:
self.assertEqual(text, element_list[0].text.strip())
else:
@@ -345,7 +345,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
def test_option_marked_correct(self):
"""
Test conditions under which a particular option
(not the entire problem) is marked correct.
and the entire problem is marked correct.
"""
conditions = [
{'input_type': 'radio', 'value': '2'},
@@ -359,14 +359,14 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
xpath = "//label[contains(@class, 'choicegroup_correct')]"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator-container']/span"
self.assert_no_xpath(xml, xpath, self.context)
# Should also mark the whole problem
xpath = "//div[@class='indicator-container']/span[@class='status correct']"
self.assert_has_xpath(xml, xpath, self.context)
def test_option_marked_incorrect(self):
"""
Test conditions under which a particular option
(not the entire problem) is marked incorrect.
and the entire problem is marked incorrect.
"""
conditions = [
{'input_type': 'radio', 'value': '2'},
@@ -380,9 +380,9 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
xpath = "//label[contains(@class, 'choicegroup_incorrect')]"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator-container']/span"
self.assert_no_xpath(xml, xpath, self.context)
# Should also mark the whole problem
xpath = "//div[@class='indicator-container']/span[@class='status incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
def test_never_show_correctness(self):
"""

View File

@@ -222,52 +222,6 @@ div.problem {
&::after {
@include margin-left($baseline*0.75);
}
&:hover {
border: 2px solid $blue;
}
&.choicegroup_correct {
@include status-icon($correct, $checkmark-icon);
border: 2px solid $correct;
// keep green for correct answers on hover.
&:hover {
border-color: $correct;
}
}
&.choicegroup_partially-correct {
@include status-icon($partially-correct, $asterisk-icon);
border: 2px solid $partially-correct;
// keep green for correct answers on hover.
&:hover {
border-color: $partially-correct;
}
}
&.choicegroup_incorrect {
@include status-icon($incorrect, $cross-icon);
border: 2px solid $incorrect;
// keep red for incorrect answers on hover.
&:hover {
border-color: $incorrect;
}
}
&.choicegroup_submitted {
border: 2px solid $submitted;
// keep blue for submitted answers on hover.
&:hover {
border-color: $submitted;
}
}
}
.indicator-container {
@@ -284,6 +238,41 @@ div.problem {
input[type="checkbox"] {
@include margin(($baseline/4) ($baseline/2) ($baseline/4) ($baseline/4));
}
input {
&:focus,
&:hover {
& + label {
border: 2px solid $blue;
}
}
&,
&:focus,
&:hover {
& + label.choicegroup_correct {
@include status-icon($correct, $checkmark-icon);
border: 2px solid $correct;
}
& + label.choicegroup_partially-correct {
@include status-icon($partially-correct, $asterisk-icon);
border: 2px solid $partially-correct;
}
& + label.choicegroup_incorrect {
@include status-icon($incorrect, $cross-icon);
border: 2px solid $incorrect;
}
& + label.choicegroup_submitted {
border: 2px solid $submitted;
}
}
}
}
// +Problem - Choice Group
@@ -292,6 +281,10 @@ div.problem {
.choicegroup {
@extend %choicegroup-base;
.field {
position: relative;
}
label {
@include padding($baseline/2);
@include padding-left($baseline*1.9);
@@ -308,6 +301,9 @@ div.problem {
position: absolute;
top: em(9);
width: $baseline*1.1;
height: $baseline*1.1;
z-index: 1;
}
legend {
@@ -1628,6 +1624,17 @@ div.problem .imageinput.capa_inputtype {
top: 3px;
width: 25px;
height: 20px;
&.unsubmitted,
&.unanswered {
.status-icon {
content: '';
}
.status-message {
display: none;
}
}
}
.correct {
@@ -1658,6 +1665,17 @@ div.problem .annotation-input {
top: 3px;
width: 25px;
height: 20px;
&.unsubmitted,
&.unanswered {
.status-icon {
content: '';
}
.status-message {
display: none;
}
}
}
.correct {

View File

@@ -230,16 +230,16 @@
// Render 'x point(s) possible (un/graded, results hidden)' if no current score provided.
if (graded) {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (graded, results hidden)',
'%(num_points)s points possible (graded, results hidden)',
// Translators: {num_points} is the number of points possible (examples: 1, 3, 10).;
'{num_points} point possible (graded, results hidden)',
'{num_points} points possible (graded, results hidden)',
totalScore
);
} else {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (ungraded, results hidden)',
'%(num_points)s points possible (ungraded, results hidden)',
// Translators: {num_points} is the number of points possible (examples: 1, 3, 10).;
'{num_points} point possible (ungraded, results hidden)',
'{num_points} points possible (ungraded, results hidden)',
totalScore
);
}
@@ -248,14 +248,14 @@
// But if staff has overridden score to a non-zero number, show it
if (graded) {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (graded)', '%(num_points)s points possible (graded)',
// Translators: {num_points} is the number of points possible (examples: 1, 3, 10).;
'{num_points} point possible (graded)', '{num_points} points possible (graded)',
totalScore
);
} else {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (ungraded)', '%(num_points)s points possible (ungraded)',
// Translators: {num_points} is the number of points possible (examples: 1, 3, 10).;
'{num_points} point possible (ungraded)', '{num_points} points possible (ungraded)',
totalScore
);
}
@@ -264,25 +264,25 @@
if (graded) {
progressTemplate = ngettext(
// This comment needs to be on one line to be properly scraped for the translators.
// Translators: %(earned)s is the number of points earned. %(possible)s is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points);
'%(earned)s/%(possible)s point (graded)', '%(earned)s/%(possible)s points (graded)',
// Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points);
'{earned}/{possible} point (graded)', '{earned}/{possible} points (graded)',
totalScore
);
} else {
progressTemplate = ngettext(
// This comment needs to be on one line to be properly scraped for the translators.
// Translators: %(earned)s is the number of points earned. %(possible)s is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points);
'%(earned)s/%(possible)s point (ungraded)', '%(earned)s/%(possible)s points (ungraded)',
// Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points);
'{earned}/{possible} point (ungraded)', '{earned}/{possible} points (ungraded)',
totalScore
);
}
}
progress = interpolate(
progress = edx.StringUtils.interpolate(
progressTemplate, {
earned: curScore,
num_points: totalScore,
possible: totalScore
}, true
}
);
return this.$('.problem-progress').text(progress);
};
@@ -379,7 +379,7 @@
Problem.prototype.render = function(content, focusCallback) {
var that = this;
if (content) {
this.el.html(content);
edx.HtmlUtils.setHtml(this.el, edx.HtmlUtils.HTML(content));
return JavascriptLoader.executeModuleScripts(this.el, function() {
that.setupInputTypes();
that.bind();
@@ -389,7 +389,7 @@
});
} else {
return $.postWithPrefix('' + this.url + '/problem_get', function(response) {
that.el.html(response.html);
edx.HtmlUtils.setHtml(that.el, edx.HtmlUtils.HTML(response.html));
return JavascriptLoader.executeModuleScripts(that.el, function() {
that.setupInputTypes();
that.bind();
@@ -560,11 +560,12 @@
}
));
}
fd.append(element.id, file);
fd.append(element.id, file); // xss-lint: disable=javascript-jquery-append
}
if (element.files.length === 0) {
fileNotSelected = true;
fd.append(element.id, ''); // In case we want to allow submissions with no file
// In case we want to allow submissions with no file
fd.append(element.id, ''); // xss-lint: disable=javascript-jquery-append
}
if (requiredFiles.length !== 0) {
requiredFilesNotSubmitted = true;
@@ -575,18 +576,21 @@
));
}
} else {
fd.append(element.id, element.value);
fd.append(element.id, element.value); // xss-lint: disable=javascript-jquery-append
}
});
if (fileNotSelected) {
errors.push(gettext('You did not select any files to submit.'));
}
errorHtml = '<ul>\n';
errorHtml = '';
for (i = 0, len = errors.length; i < len; i++) {
error = errors[i];
errorHtml += '<li>' + error + '</li>\n';
errorHtml = edx.HtmlUtils.joinHtml(
errorHtml,
edx.HtmlUtils.interpolateHtml(edx.HtmlUtils.HTML('<li>{error}</li>'), {error: error})
);
}
errorHtml += '</ul>';
errorHtml = edx.HtmlUtils.interpolateHtml(edx.HtmlUtils.HTML('<ul>{errors}</ul>'), {errors: errorHtml});
this.gentle_alert(errorHtml);
abortSubmission = fileTooLarge || fileNotSelected || unallowedFileSubmitted || requiredFilesNotSubmitted;
if (abortSubmission) {
@@ -965,6 +969,7 @@
return $(element).find('input').on('input', function() {
var $p;
$p = $(element).find('span.status');
$p.removeClass('correct incorrect submitted');
return $p.parent().removeAttr('class').addClass('unsubmitted');
});
},
@@ -1002,6 +1007,7 @@
return $(element).find('input').on('input', function() {
var $p;
$p = $(element).find('span.status');
$p.removeClass('correct incorrect submitted');
return $p.parent().removeClass('correct incorrect').addClass('unsubmitted');
});
}
@@ -1069,8 +1075,8 @@
results = [];
for (i = 0, len = answer.length; i < len; i++) {
choice = answer[i];
$inputLabel = $element.find('#input_' + inputId + '_' + choice).parent('label');
$inputStatus = $inputLabel.find('#status_' + inputId);
$inputLabel = $element.find('#input_' + inputId + '_' + choice + ' + label');
$inputStatus = $element.find('#status_' + inputId);
// If the correct answer was already Submitted before "Show Answer" was selected,
// the status HTML will already be present. Otherwise, inject the status HTML.
@@ -1078,12 +1084,13 @@
// will be marked as "unanswered". In that case, for correct answers update the
// classes accordingly.
if ($inputStatus.hasClass('unanswered')) {
$inputStatus.removeAttr('class').addClass('status correct');
edx.HtmlUtils.append($inputLabel, edx.HtmlUtils.HTML(correctStatusHtml));
$inputLabel.addClass('choicegroup_correct');
} else if (!$inputLabel.hasClass('choicegroup_correct')) {
// If the status HTML is not already present (due to clicking Submit), append
// the status HTML for correct answers.
edx.HtmlUtils.append($inputLabel, edx.HtmlUtils.HTML(correctStatusHtml));
$inputLabel.removeClass('choicegroup_incorrect');
results.push($inputLabel.addClass('choicegroup_correct'));
}
}
@@ -1182,7 +1189,7 @@
types[key](context, value);
}
});
container.html(canvas);
edx.HtmlUtils.setHtml(container, edx.HtmlUtils.HTML(canvas));
} else {
console.log('Answer is absent for image input with id=' + id); // eslint-disable-line no-console
}

View File

@@ -489,30 +489,39 @@ class ProblemPage(PageObject):
solution_selector = '.solution-span div.detailed-solution'
return self.q(css=solution_selector).is_present()
def is_choice_highlighted(self, choice, choices_list):
def is_choice_highlighted(self, choice, choices_list, show_answer=True):
"""
Check if the given answer/choice is highlighted for choice group.
show_answer: if set, then requires each choice to be marked with a status.
If not set, then the status can be elswhere in the problem.
"""
choice_status_xpath = (u'//fieldset/div[contains(@class, "field")][{{0}}]'
u'/label[contains(@class, "choicegroup_{choice}")]'
u'/span[contains(@class, "status {choice}")]'.format(choice=choice))
any_status_xpath = u'//fieldset/div[contains(@class, "field")][{0}]/label/span'
for choice in choices_list:
if not self.q(xpath=choice_status_xpath.format(choice)).is_present():
if show_answer:
choice_status_xpath = (u'//fieldset/div[contains(@class, "field")][{{0}}]'
u'/label[contains(@class, "choicegroup_{choice}")]'
u'/span[contains(@class, "status {choice}")]'.format(choice=choice))
any_status_xpath = u'//fieldset/div[contains(@class, "field")][{0}]/label/span'
else:
choice_status_xpath = (u'//fieldset/div[contains(@class, "field")][{{0}}]'
u'/label[contains(@class, "choicegroup_{choice}")]'.format(choice=choice))
any_status_xpath = u'//div[contains(@class, "indicator-container")]/span[contains(@class, "status")]'
for possible_choice in choices_list:
if not self.q(xpath=choice_status_xpath.format(possible_choice)).is_present():
return False
# Check that there is only a single status span, as there were some bugs with multiple
# spans (with various classes) being appended.
if not len(self.q(xpath=any_status_xpath.format(choice)).results) == 1:
if not len(self.q(xpath=any_status_xpath.format(possible_choice)).results) == 1:
return False
return True
def is_correct_choice_highlighted(self, correct_choices):
def is_correct_choice_highlighted(self, correct_choices, show_answer=True):
"""
Check if correct answer/choice highlighted for choice group.
"""
return self.is_choice_highlighted('correct', correct_choices)
return self.is_choice_highlighted('correct', correct_choices, show_answer)
def is_submitted_choice_highlighted(self, correct_choices):
"""

View File

@@ -786,7 +786,7 @@ class MultipleChoiceProblemTypeTest(MultipleChoiceProblemTypeBase, ProblemTypeTe
# After submit, the answer should be marked as correct.
self.problem_page.click_submit()
self.assertTrue(self.problem_page.is_correct_choice_highlighted(correct_choices=[3]))
self.assertTrue(self.problem_page.is_correct_choice_highlighted(correct_choices=[3], show_answer=False))
# Switch to an incorrect answer. This will hide the correctness indicator.
self.answer_problem('incorrect')