Commit fb350ed1 by Victor Shnayder

Merge pull request #1569 from MITx/feature/abarrett/annotatable_xmodule

Feature/abarrett/annotatable xmodule DO NOT MERGE
parents 7af36eb6 dfc9176f
...@@ -951,3 +951,111 @@ class EditAGeneInput(InputTypeBase): ...@@ -951,3 +951,111 @@ class EditAGeneInput(InputTypeBase):
registry.register(EditAGeneInput) registry.register(EditAGeneInput)
#---------------------------------------------------------------------
class AnnotationInput(InputTypeBase):
"""
Input type for annotations: students can enter some notes or other text
(currently ungraded), and then choose from a set of tags/optoins, which are graded.
Example:
<annotationinput>
<title>Annotation Exercise</title>
<text>
They are the ones who, at the public assembly, had put savage derangement [ate] into my thinking
[phrenes] |89 on that day when I myself deprived Achilles of his honorific portion [geras]
</text>
<comment>Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing?</comment>
<comment_prompt>Type a commentary below:</comment_prompt>
<tag_prompt>Select one tag:</tag_prompt>
<options>
<option choice="correct">ate - both a cause and an effect</option>
<option choice="incorrect">ate - a cause</option>
<option choice="partially-correct">ate - an effect</option>
</options>
</annotationinput>
# TODO: allow ordering to be randomized
"""
template = "annotationinput.html"
tags = ['annotationinput']
def setup(self):
xml = self.xml
self.debug = False # set to True to display extra debug info with input
self.return_to_annotation = True # return only works in conjunction with annotatable xmodule
self.title = xml.findtext('./title', 'Annotation Exercise')
self.text = xml.findtext('./text')
self.comment = xml.findtext('./comment')
self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:')
self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:')
self.options = self._find_options()
# Need to provide a value that JSON can parse if there is no
# student-supplied value yet.
if self.value == '':
self.value = 'null'
self._validate_options()
def _find_options(self):
''' Returns an array of dicts where each dict represents an option. '''
elements = self.xml.findall('./options/option')
return [{
'id': index,
'description': option.text,
'choice': option.get('choice')
} for (index, option) in enumerate(elements) ]
def _validate_options(self):
''' Raises a ValueError if the choice attribute is missing or invalid. '''
valid_choices = ('correct', 'partially-correct', 'incorrect')
for option in self.options:
choice = option['choice']
if choice is None:
raise ValueError('Missing required choice attribute.')
elif choice not in valid_choices:
raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices)))
def _unpack(self, json_value):
''' Unpacks the json input state into a dict. '''
d = json.loads(json_value)
if type(d) != dict:
d = {}
comment_value = d.get('comment', '')
if not isinstance(comment_value, basestring):
comment_value = ''
options_value = d.get('options', [])
if not isinstance(options_value, list):
options_value = []
return {
'options_value': options_value,
'has_options_value': len(options_value) > 0, # for convenience
'comment_value': comment_value,
}
def _extra_context(self):
extra_context = {
'title': self.title,
'text': self.text,
'comment': self.comment,
'comment_prompt': self.comment_prompt,
'tag_prompt': self.tag_prompt,
'options': self.options,
'return_to_annotation': self.return_to_annotation,
'debug': self.debug
}
extra_context.update(self._unpack(self.value))
return extra_context
registry.register(AnnotationInput)
...@@ -911,7 +911,8 @@ def sympy_check2(): ...@@ -911,7 +911,8 @@ def sympy_check2():
allowed_inputfields = ['textline', 'textbox', 'crystallography', allowed_inputfields = ['textline', 'textbox', 'crystallography',
'chemicalequationinput', 'vsepr_input', 'chemicalequationinput', 'vsepr_input',
'drag_and_drop_input', 'editamoleculeinput', 'drag_and_drop_input', 'editamoleculeinput',
'designprotein2dinput', 'editageneinput'] 'designprotein2dinput', 'editageneinput',
'annotationinput']
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
...@@ -1943,6 +1944,117 @@ class ImageResponse(LoncapaResponse): ...@@ -1943,6 +1944,117 @@ class ImageResponse(LoncapaResponse):
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class AnnotationResponse(LoncapaResponse):
'''
Checking of annotation responses.
The response contains both a comment (student commentary) and an option (student tag).
Only the tag is currently graded. Answers may be incorrect, partially correct, or correct.
'''
response_tag = 'annotationresponse'
allowed_inputfields = ['annotationinput']
max_inputfields = 1
default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 }
def setup_response(self):
xml = self.xml
self.scoring_map = self._get_scoring_map()
self.answer_map = self._get_answer_map()
self.maxpoints = self._get_max_points()
def get_score(self, student_answers):
''' Returns a CorrectMap for the student answer, which may include
partially correct answers.'''
student_answer = student_answers[self.answer_id]
student_option = self._get_submitted_option_id(student_answer)
scoring = self.scoring_map[self.answer_id]
is_valid = student_option is not None and student_option in scoring.keys()
(correctness, points) = ('incorrect', None)
if is_valid:
correctness = scoring[student_option]['correctness']
points = scoring[student_option]['points']
return CorrectMap(self.answer_id, correctness=correctness, npoints=points)
def get_answers(self):
return self.answer_map
def _get_scoring_map(self):
''' Returns a dict of option->scoring for each input. '''
scoring = self.default_scoring
choices = dict([(choice,choice) for choice in scoring])
scoring_map = {}
for inputfield in self.inputfields:
option_scoring = dict([(option['id'], {
'correctness': choices.get(option['choice']),
'points': scoring.get(option['choice'])
}) for option in self._find_options(inputfield) ])
scoring_map[inputfield.get('id')] = option_scoring
return scoring_map
def _get_answer_map(self):
''' Returns a dict of answers for each input.'''
answer_map = {}
for inputfield in self.inputfields:
correct_option = self._find_option_with_choice(inputfield, 'correct')
if correct_option is not None:
answer_map[inputfield.get('id')] = correct_option.get('description')
return answer_map
def _get_max_points(self):
''' Returns a dict of the max points for each input: input id -> maxpoints. '''
scoring = self.default_scoring
correct_points = scoring.get('correct')
return dict([(inputfield.get('id'), correct_points) for inputfield in self.inputfields])
def _find_options(self, inputfield):
''' Returns an array of dicts where each dict represents an option. '''
elements = inputfield.findall('./options/option')
return [{
'id': index,
'description': option.text,
'choice': option.get('choice')
} for (index, option) in enumerate(elements) ]
def _find_option_with_choice(self, inputfield, choice):
''' Returns the option with the given choice value, otherwise None. '''
for option in self._find_options(inputfield):
if option['choice'] == choice:
return option
def _unpack(self, json_value):
''' Unpacks a student response value submitted as JSON.'''
d = json.loads(json_value)
if type(d) != dict:
d = {}
comment_value = d.get('comment', '')
if not isinstance(d, basestring):
comment_value = ''
options_value = d.get('options', [])
if not isinstance(options_value, list):
options_value = []
return {
'options_value': options_value,
'comment_value': comment_value
}
def _get_submitted_option_id(self, student_answer):
''' Return the single option that was selected, otherwise None.'''
submitted = self._unpack(student_answer)
option_ids = submitted['options_value']
if len(option_ids) == 1:
return option_ids[0]
return None
#-----------------------------------------------------------------------------
# TEMPORARY: List of all response subclasses # TEMPORARY: List of all response subclasses
# FIXME: To be replaced by auto-registration # FIXME: To be replaced by auto-registration
...@@ -1959,4 +2071,5 @@ __all__ = [CodeResponse, ...@@ -1959,4 +2071,5 @@ __all__ = [CodeResponse,
ChoiceResponse, ChoiceResponse,
MultipleChoiceResponse, MultipleChoiceResponse,
TrueFalseResponse, TrueFalseResponse,
JavascriptResponse] JavascriptResponse,
AnnotationResponse]
<form class="annotation-input">
<div class="script_placeholder" data-src="/static/js/capa/annotationinput.js"/>
<div class="annotation-header">
${title}
% if return_to_annotation:
<a class="annotation-return" href="javascript:void(0)">Return to Annotation</a><br/>
% endif
</div>
<div class="annotation-body">
<div class="block block-highlight">${text}</div>
<div class="block block-comment">${comment}</div>
<div class="block">${comment_prompt}</div>
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
<div class="block">${tag_prompt}</div>
<ul class="tags">
% for option in options:
<li>
% if has_options_value:
% if all([c == 'correct' for c in option['choice'], status]):
<span class="tag-status correct" id="status_${id}"></span>
% elif all([c == 'partially-correct' for c in option['choice'], status]):
<span class="tag-status partially-correct" id="status_${id}"></span>
% elif all([c == 'incorrect' for c in option['choice'], status]):
<span class="tag-status incorrect" id="status_${id}"></span>
% endif
% endif
<span class="tag
% if option['id'] in options_value:
selected
% endif
" data-id="${option['id']}">
${option['description']}
</span>
</li>
% endfor
</ul>
% if debug:
<div class="debug-value">
Rendered with value:<br/>
<pre>${value|h}</pre>
Current input value:<br/>
<input type="text" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
</div>
% else:
<input type="hidden" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
% endif
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% elif status == 'incorrect' and not has_options_value:
<span class="incorrect" id="status_${id}"></span>
% endif
<p id="answer_${id}" class="answer answer-annotation"></p>
</div>
</form>
% if msg:
<span class="message">${msg|n}</span>
% endif
...@@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory): ...@@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs): def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs) return ResponseXMLFactory.textline_input_xml(**kwargs)
class AnnotationResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating <annotationresponse> XML trees """
def create_response_element(self, **kwargs):
""" Create a <annotationresponse> element """
return etree.Element("annotationresponse")
def create_input_element(self, **kwargs):
""" Create a <annotationinput> element."""
input_element = etree.Element("annotationinput")
text_children = [
{'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') },
{'tag': 'text', 'text': kwargs.get('text', 'texty text') },
{'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') },
{'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') },
{'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') }
]
for child in text_children:
etree.SubElement(input_element, child['tag']).text = child['text']
default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')]
options = kwargs.get('options', default_options)
options_element = etree.SubElement(input_element, 'options')
for (description, correctness) in options:
option_element = etree.SubElement(options_element, 'option', {'choice': correctness})
option_element.text = description
return input_element
...@@ -570,3 +570,65 @@ class DragAndDropTest(unittest.TestCase): ...@@ -570,3 +570,65 @@ class DragAndDropTest(unittest.TestCase):
context.pop('drag_and_drop_json') context.pop('drag_and_drop_json')
expected.pop('drag_and_drop_json') expected.pop('drag_and_drop_json')
self.assertEqual(context, expected) self.assertEqual(context, expected)
class AnnotationInputTest(unittest.TestCase):
'''
Make sure option inputs work
'''
def test_rendering(self):
xml_str = '''
<annotationinput>
<title>foo</title>
<text>bar</text>
<comment>my comment</comment>
<comment_prompt>type a commentary</comment_prompt>
<tag_prompt>select a tag</tag_prompt>
<options>
<option choice="correct">x</option>
<option choice="incorrect">y</option>
<option choice="partially-correct">z</option>
</options>
</annotationinput>
'''
element = etree.fromstring(xml_str)
value = {"comment": "blah blah", "options": [1]}
json_value = json.dumps(value)
state = {
'value': json_value,
'id': 'annotation_input',
'status': 'answered'
}
tag = 'annotationinput'
the_input = lookup_tag(tag)(test_system, element, state)
context = the_input._get_render_context()
expected = {
'id': 'annotation_input',
'value': value,
'status': 'answered',
'msg': '',
'title': 'foo',
'text': 'bar',
'comment': 'my comment',
'comment_prompt': 'type a commentary',
'tag_prompt': 'select a tag',
'options': [
{'id': 0, 'description': 'x', 'choice': 'correct'},
{'id': 1, 'description': 'y', 'choice': 'incorrect'},
{'id': 2, 'description': 'z', 'choice': 'partially-correct'}
],
'value': json_value,
'options_value': value['options'],
'has_options_value': len(value['options']) > 0,
'comment_value': value['comment'],
'debug': False,
'return_to_annotation': True
}
self.maxDiff = None
self.assertDictEqual(context, expected)
\ No newline at end of file
...@@ -906,3 +906,40 @@ class SchematicResponseTest(ResponseTest): ...@@ -906,3 +906,40 @@ class SchematicResponseTest(ResponseTest):
# (That is, our script verifies that the context # (That is, our script verifies that the context
# is what we expect) # is what we expect)
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
class AnnotationResponseTest(ResponseTest):
from response_xml_factory import AnnotationResponseXMLFactory
xml_factory_class = AnnotationResponseXMLFactory
def test_grade(self):
(correct, partially, incorrect) = ('correct', 'partially-correct', 'incorrect')
answer_id = '1_2_1'
options = (('x', correct),('y', partially),('z', incorrect))
make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids })}
tests = [
{'correctness': correct, 'points': 2,'answers': make_answer([0]) },
{'correctness': partially, 'points': 1, 'answers': make_answer([1]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer([2]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer([0,1,2]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer([]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer('') },
{'correctness': incorrect, 'points': 0, 'answers': make_answer(None) },
{'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null' } },
]
for (index, test) in enumerate(tests):
expected_correctness = test['correctness']
expected_points = test['points']
answers = test['answers']
problem = self.build_problem(options=options)
correct_map = problem.grade_answers(answers)
actual_correctness = correct_map.get_correctness(answer_id)
actual_points = correct_map.get_npoints(answer_id)
self.assertEqual(expected_correctness, actual_correctness,
msg="%s should be marked %s" % (answer_id, expected_correctness))
self.assertEqual(expected_points, actual_points,
msg="%s should have %d points" % (answer_id, expected_points))
...@@ -46,6 +46,7 @@ setup( ...@@ -46,6 +46,7 @@ setup(
"custom_tag_template = xmodule.raw_module:RawDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor", "about = xmodule.html_module:AboutDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
"annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
"foldit = xmodule.foldit_module:FolditDescriptor", "foldit = xmodule.foldit_module:FolditDescriptor",
] ]
} }
......
import logging
from lxml import etree
from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
log = logging.getLogger(__name__)
class AnnotatableModule(XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee'),
resource_string(__name__, 'js/src/annotatable/display.coffee')],
'js': []
}
js_module_name = "Annotatable"
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
icon_class = 'annotatable'
def _get_annotation_class_attr(self, index, el):
""" Returns a dict with the CSS class attribute to set on the annotation
and an XML key to delete from the element.
"""
attr = {}
cls = ['annotatable-span', 'highlight']
highlight_key = 'highlight'
color = el.get(highlight_key)
if color is not None:
if color in self.highlight_colors:
cls.append('highlight-'+color)
attr['_delete'] = highlight_key
attr['value'] = ' '.join(cls)
return { 'class' : attr }
def _get_annotation_data_attr(self, index, el):
""" Returns a dict in which the keys are the HTML data attributes
to set on the annotation element. Each data attribute has a
corresponding 'value' and (optional) '_delete' key to specify
an XML attribute to delete.
"""
data_attrs = {}
attrs_map = {
'body': 'data-comment-body',
'title': 'data-comment-title',
'problem': 'data-problem-id'
}
for xml_key in attrs_map.keys():
if xml_key in el.attrib:
value = el.get(xml_key, '')
html_key = attrs_map[xml_key]
data_attrs[html_key] = { 'value': value, '_delete': xml_key }
return data_attrs
def _render_annotation(self, index, el):
""" Renders an annotation element for HTML output. """
attr = {}
attr.update(self._get_annotation_class_attr(index, el))
attr.update(self._get_annotation_data_attr(index, el))
el.tag = 'span'
for key in attr.keys():
el.set(key, attr[key]['value'])
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
delete_key = attr[key]['_delete']
del el.attrib[delete_key]
def _render_content(self):
""" Renders annotatable content with annotation spans and returns HTML. """
xmltree = etree.fromstring(self.content)
xmltree.tag = 'div'
if 'display_name' in xmltree.attrib:
del xmltree.attrib['display_name']
index = 0
for el in xmltree.findall('.//annotation'):
self._render_annotation(index, el)
index += 1
return etree.tostring(xmltree, encoding='unicode')
def _extract_instructions(self, xmltree):
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
instructions = xmltree.find('instructions')
if instructions is not None:
instructions.tag = 'div'
xmltree.remove(instructions)
return etree.tostring(instructions, encoding='unicode')
return None
def get_html(self):
""" Renders parameters to template. """
context = {
'display_name': self.display_name,
'element_id': self.element_id,
'instructions_html': self.instructions,
'content_html': self._render_content()
}
return self.system.render_template('annotatable.html', context)
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
xmltree = etree.fromstring(self.definition['data'])
self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode')
self.element_id = self.location.html_id()
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
class AnnotatableDescriptor(RawDescriptor):
module_class = AnnotatableModule
stores_state = True
template_dir_name = "annotatable"
mako_template = "widgets/raw-edit.html"
$border-color: #C8C8C8;
$body-font-size: em(14);
.annotatable-header {
margin-bottom: .5em;
.annotatable-title {
font-size: em(22);
text-transform: uppercase;
padding: 2px 4px;
}
}
.annotatable-section {
position: relative;
padding: .5em 1em;
border: 1px solid $border-color;
border-radius: .5em;
margin-bottom: .5em;
&.shaded { background-color: #EDEDED; }
.annotatable-section-title {
font-weight: bold;
a { font-weight: normal; }
}
.annotatable-section-body {
border-top: 1px solid $border-color;
margin-top: .5em;
padding-top: .5em;
@include clearfix;
}
ul.instructions-template {
list-style: disc;
margin-left: 4em;
b { font-weight: bold; }
i { font-style: italic; }
code {
display: inline;
white-space: pre;
font-family: Courier New, monospace;
}
}
}
.annotatable-toggle {
position: absolute;
right: 0;
margin: 2px 1em 2px 0;
&.expanded:after { content: " \2191" }
&.collapsed:after { content: " \2193" }
}
.annotatable-span {
display: inline;
cursor: pointer;
@each $highlight in (
(yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)),
(red rgba(178,19,16,0.3) rgba(178,19,16,0.9)),
(orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)),
(green rgba(25,255,132,0.3) rgba(25,255,132,0.9)),
(blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)),
(purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) {
$marker: nth($highlight,1);
$color: nth($highlight,2);
$selected_color: nth($highlight,3);
@if $marker == yellow {
&.highlight {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.highlight-#{$marker} {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.hide {
cursor: none;
background-color: inherit;
.annotatable-icon {
display: none;
}
}
.annotatable-comment {
display: none;
}
}
.ui-tooltip.qtip.ui-tooltip {
font-size: $body-font-size;
border: 1px solid #333;
border-radius: 1em;
background-color: rgba(0,0,0,.85);
color: #fff;
-webkit-font-smoothing: antialiased;
.ui-tooltip-titlebar {
font-size: em(16);
color: inherit;
background-color: transparent;
padding: 5px 10px;
border: none;
.ui-tooltip-title {
padding: 5px 0px;
border-bottom: 2px solid #333;
font-weight: bold;
}
.ui-tooltip-icon {
right: 10px;
background: #333;
}
.ui-state-hover {
color: inherit;
border: 1px solid #ccc;
}
}
.ui-tooltip-content {
color: inherit;
font-size: em(14);
text-align: left;
font-weight: 400;
padding: 0 10px 10px 10px;
background-color: transparent;
}
p {
color: inherit;
line-height: normal;
}
}
.ui-tooltip.qtip.ui-tooltip-annotatable {
max-width: 375px;
.ui-tooltip-content {
padding: 0 10px;
.annotatable-comment {
display: block;
margin: 0px 0px 10px 0;
max-height: 225px;
overflow: auto;
}
.annotatable-reply {
display: block;
border-top: 2px solid #333;
padding: 5px 0;
margin: 0;
text-align: center;
}
}
&:after {
content: '';
display: inline-block;
position: absolute;
bottom: -20px;
left: 50%;
height: 0;
width: 0;
margin-left: -5px;
border: 10px solid transparent;
border-top-color: rgba(0, 0, 0, .85);
}
}
...@@ -231,6 +231,15 @@ section.problem { ...@@ -231,6 +231,15 @@ section.problem {
width: 25px; width: 25px;
} }
&.partially-correct {
@include inline-block();
background: url('../images/partially-correct-icon.png') center center no-repeat;
height: 20px;
position: relative;
top: 6px;
width: 25px;
}
&.incorrect, &.ui-icon-close { &.incorrect, &.ui-icon-close {
@include inline-block(); @include inline-block();
background: url('../images/incorrect-icon.png') center center no-repeat; background: url('../images/incorrect-icon.png') center center no-repeat;
...@@ -802,4 +811,91 @@ section.problem { ...@@ -802,4 +811,91 @@ section.problem {
display: none; display: none;
} }
} }
.annotation-input {
$yellow: rgba(255,255,10,0.3);
border: 1px solid #ccc;
border-radius: 1em;
margin: 0 0 1em 0;
.annotation-header {
font-weight: bold;
border-bottom: 1px solid #ccc;
padding: .5em 1em;
}
.annotation-body { padding: .5em 1em; }
a.annotation-return {
float: right;
font: inherit;
font-weight: normal;
}
a.annotation-return:after { content: " \2191" }
.block, ul.tags {
margin: .5em 0;
padding: 0;
}
.block-highlight {
padding: .5em;
color: #333;
font-style: normal;
background-color: $yellow;
border: 1px solid darken($yellow, 10%);
}
.block-comment { font-style: italic; }
ul.tags {
display: block;
list-style-type: none;
margin-left: 1em;
li {
display: block;
margin: 1em 0 0 0;
position: relative;
.tag {
display: inline-block;
cursor: pointer;
border: 1px solid rgb(102,102,102);
margin-left: 40px;
&.selected {
background-color: $yellow;
}
}
.tag-status {
position: absolute;
left: 0;
}
.tag-status, .tag { padding: .25em .5em; }
}
}
textarea.comment {
$num-lines-to-show: 5;
$line-height: 1.4em;
$padding: .2em;
width: 100%;
padding: $padding (2 * $padding);
line-height: $line-height;
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
}
.answer-annotation { display: block; margin: 0; }
/* for debugging the input value field. enable the debug flag on the inputtype */
.debug-value {
color: #fff;
padding: 1em;
margin: 1em 0;
background-color: #999;
border: 1px solid #000;
input[type="text"] { width: 100%; }
pre { background-color: #CCC; color: #000; }
&:before {
display: block;
content: "debug input value";
text-transform: uppercase;
font-weight: bold;
font-size: 1.5em;
}
}
}
} }
<section class='xmodule_display xmodule_AnnotatableModule' data-type='Annotatable'>
<div class="annotatable-wrapper">
<div class="annotatable-header">
<div class="annotatable-title">First Annotation Exercise</div>
</div>
<div class="annotatable-section">
<div class="annotatable-section-title">
Instructions
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
</div>
<div class="annotatable-section-body annotatable-instructions">
<div><p>The main goal of this exercise is to start practicing the art of slow reading.</p>
</div>
</div>
<div class="annotatable-section">
<div class="annotatable-section-title">
Guided Discussion
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
</div>
</div>
<div class="annotatable-content">
|87 No, those who are really responsible are Zeus and Fate [Moira] and the Fury [Erinys] who roams in the mist. <br/>
|88 <span data-problem-id="0" data-comment-body="Agamemnon says..." class="annotatable-span highlight" data-comment-title="Your Title Here">They are the ones who</span><br/>
|100 He [= Zeus], making a formal declaration [eukhesthai], spoke up at a meeting of all the gods and said: <br/>
|101 <span data-problem-id="1" data-comment-body="When Zeus speaks..." class="annotatable-span highlight">“hear me, all gods and all goddesses,</span><br/>
|113 but he swore a great oath.
<span data-problem-id="2" data-comment-body="How is the ‘veering off-course’ ..." class="annotatable-span highlight">And right then and there</span><br/>
</div>
</div>
</section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
describe 'Annotatable', ->
beforeEach ->
loadFixtures 'annotatable.html'
describe 'constructor', ->
el = $('.xmodule_display.xmodule_AnnotatableModule')
beforeEach ->
@annotatable = new Annotatable(el)
it 'works', ->
expect(1).toBe(1)
\ No newline at end of file
class @Annotatable
_debug: false
# selectors for the annotatable xmodule
toggleAnnotationsSelector: '.annotatable-toggle-annotations'
toggleInstructionsSelector: '.annotatable-toggle-instructions'
instructionsSelector: '.annotatable-instructions'
sectionSelector: '.annotatable-section'
spanSelector: '.annotatable-span'
replySelector: '.annotatable-reply'
# these selectors are for responding to events from the annotation capa problem type
problemXModuleSelector: '.xmodule_CapaModule'
problemSelector: 'section.problem'
problemInputSelector: 'section.problem .annotation-input'
problemReturnSelector: 'section.problem .annotation-return'
constructor: (el) ->
console.log 'loaded Annotatable' if @_debug
@el = el
@$el = $(el)
@init()
$: (selector) ->
$(selector, @el)
init: () ->
@initEvents()
@initTips()
initEvents: () ->
# Initialize toggle handlers for the instructions and annotations sections
[@annotationsHidden, @instructionsHidden] = [false, false]
@$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations
@$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions
# Initialize handler for 'reply to annotation' events that scroll to
# the associated problem. The reply buttons are part of the tooltip
# content. It's important that the tooltips be configured to render
# as descendants of the annotation module and *not* the document.body.
@$el.delegate @replySelector, 'click', @onClickReply
# Initialize handler for 'return to annotation' events triggered from problems.
# 1) There are annotationinput capa problems rendered on the page
# 2) Each one has an embedded return link (see annotation capa problem template).
# Since the capa problem injects HTML content via AJAX, the best we can do is
# is let the click events bubble up to the body and handle them there.
$('body').delegate @problemReturnSelector, 'click', @onClickReturn
initTips: () ->
# tooltips are used to display annotations for highlighted text spans
@$(@spanSelector).each (index, el) =>
$(el).qtip(@getSpanTipOptions el)
getSpanTipOptions: (el) ->
content:
title:
text: @makeTipTitle(el)
text: @makeTipContent(el)
position:
my: 'bottom center' # of tooltip
at: 'top center' # of target
target: $(el) # where the tooltip was triggered (i.e. the annotation span)
container: @$el
adjust:
y: -5
show:
event: 'click mouseenter'
solo: true
hide:
event: 'click mouseleave'
delay: 500,
fixed: true # don't hide the tooltip if it is moused over
style:
classes: 'ui-tooltip-annotatable'
events:
show: @onShowTip
onClickToggleAnnotations: (e) => @toggleAnnotations()
onClickToggleInstructions: (e) => @toggleInstructions()
onClickReply: (e) => @replyTo(e.currentTarget)
onClickReturn: (e) => @returnFrom(e.currentTarget)
onShowTip: (event, api) =>
event.preventDefault() if @annotationsHidden
getSpanForProblemReturn: (el) ->
problem_id = $(@problemReturnSelector).index(el)
@$(@spanSelector).filter("[data-problem-id='#{problem_id}']")
getProblem: (el) ->
problem_id = @getProblemId(el)
$(@problemSelector).has(@problemInputSelector).eq(problem_id)
getProblemId: (el) ->
$(el).data('problem-id')
toggleAnnotations: () ->
hide = (@annotationsHidden = not @annotationsHidden)
@toggleAnnotationButtonText hide
@toggleSpans hide
@toggleTips hide
toggleTips: (hide) ->
visible = @findVisibleTips()
@hideTips visible
toggleAnnotationButtonText: (hide) ->
buttonText = (if hide then 'Show' else 'Hide')+' Annotations'
@$(@toggleAnnotationsSelector).text(buttonText)
toggleInstructions: () ->
hide = (@instructionsHidden = not @instructionsHidden)
@toggleInstructionsButton hide
@toggleInstructionsText hide
toggleInstructionsButton: (hide) ->
txt = (if hide then 'Expand' else 'Collapse')+' Instructions'
cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded'])
@$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1])
toggleInstructionsText: (hide) ->
slideMethod = (if hide then 'slideUp' else 'slideDown')
@$(@instructionsSelector)[slideMethod]()
toggleSpans: (hide) ->
@$(@spanSelector).toggleClass 'hide', hide, 250
replyTo: (buttonEl) ->
offset = -20
el = @getProblem buttonEl
if el.length > 0
@scrollTo(el, @afterScrollToProblem, offset)
else
console.log('problem not found. event: ', e) if @_debug
returnFrom: (buttonEl) ->
offset = -200
el = @getSpanForProblemReturn buttonEl
if el.length > 0
@scrollTo(el, @afterScrollToSpan, offset)
else
console.log('span not found. event:', e) if @_debug
scrollTo: (el, after, offset = -20) ->
$('html,body').scrollTo(el, {
duration: 500
onAfter: @_once => after?.call this, el
offset: offset
}) if $(el).length > 0
afterScrollToProblem: (problem_el) ->
problem_el.effect 'highlight', {}, 500
afterScrollToSpan: (span_el) ->
span_el.addClass 'selected', 400, 'swing', ->
span_el.removeClass 'selected', 400, 'swing'
makeTipContent: (el) ->
(api) =>
text = $(el).data('comment-body')
comment = @createComment(text)
problem_id = @getProblemId(el)
reply = @createReplyLink(problem_id)
$(comment).add(reply)
makeTipTitle: (el) ->
(api) =>
title = $(el).data('comment-title')
(if title then title else 'Commentary')
createComment: (text) ->
$("<div class=\"annotatable-comment\">#{text}</div>")
createReplyLink: (problem_id) ->
$("<a class=\"annotatable-reply\" href=\"javascript:void(0);\" data-problem-id=\"#{problem_id}\">Reply to Annotation</a>")
findVisibleTips: () ->
visible = []
@$(@spanSelector).each (index, el) ->
api = $(el).qtip('api')
tip = $(api?.elements.tooltip)
if tip.is(':visible')
visible.push el
visible
hideTips: (elements) ->
$(elements).qtip('hide')
_once: (fn) ->
done = false
return =>
fn.call this unless done
done = true
---
metadata:
display_name: 'Annotation'
data: |
<annotatable>
<instructions>
<p>Enter your (optional) instructions for the exercise in HTML format.</p>
<p>Annotations are specified by an <code>&lt;annotation&gt;</code> tag which may may have the following attributes:</p>
<ul class="instructions-template">
<li><code>title</code> (optional). Title of the annotation. Defaults to <i>Commentary</i> if omitted.</li>
<li><code>body</code> (<b>required</b>). Text of the annotation.</li>
<li><code>problem</code> (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have <code>problem="0"</code>.</li>
<li><code>highlight</code> (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.</li>
</ul>
</instructions>
<p>Add your HTML with annotation spans here.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <annotation title="My title" body="My comment" highlight="yellow" problem="0">Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.</p>
<p>Nulla facilisi. <annotation body="Basic annotation example." problem="1">Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.</p>
</annotatable>
children: []
"""Module annotatable tests"""
import unittest
from lxml import etree
from mock import Mock
from xmodule.annotatable_module import AnnotatableModule
from xmodule.modulestore import Location
from . import test_system
class AnnotatableModuleTestCase(unittest.TestCase):
location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"])
sample_xml = '''
<annotatable display_name="Iliad">
<instructions>Read the text.</instructions>
<p>
<annotation body="first">Sing</annotation>,
<annotation title="goddess" body="second">O goddess</annotation>,
<annotation title="anger" body="third" highlight="blue">the anger of Achilles son of Peleus</annotation>,
that brought <i>countless</i> ills upon the Achaeans. Many a brave soul did it send
hurrying down to Hades, and many a hero did it yield a prey to dogs and
<div style="font-weight:bold"><annotation body="fourth" problem="4">vultures</annotation>, for so were the counsels
of Jove fulfilled from the day on which the son of Atreus, king of men, and great
Achilles, first fell out with one another.</div>
</p>
<annotation title="footnote" body="the end">The Iliad of Homer by Samuel Butler</annotation>
</annotatable>
'''
definition = { 'data': sample_xml }
descriptor = Mock()
instance_state = None
shared_state = None
def setUp(self):
self.annotatable = AnnotatableModule(test_system, self.location, self.definition, self.descriptor, self.instance_state, self.shared_state)
def test_annotation_data_attr(self):
el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
expected_attr = {
'data-comment-body': {'value': 'foo', '_delete': 'body' },
'data-comment-title': {'value': 'bar', '_delete': 'title'},
'data-problem-id': {'value': '0', '_delete': 'problem'}
}
actual_attr = self.annotatable._get_annotation_data_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_default(self):
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
el = etree.fromstring(xml)
expected_attr = { 'class': { 'value': 'annotatable-span highlight' } }
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_valid_highlight(self):
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for color in self.annotatable.highlight_colors:
el = etree.fromstring(xml.format(highlight=color))
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
expected_attr = { 'class': {
'value': value,
'_delete': 'highlight' }
}
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_invalid_highlight(self):
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
el = etree.fromstring(xml.format(highlight=invalid_color))
expected_attr = { 'class': {
'value': 'annotatable-span highlight',
'_delete': 'highlight' }
}
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_render_annotation(self):
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
expected_el = etree.fromstring(expected_html)
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
self.annotatable._render_annotation(0, actual_el)
self.assertEqual(expected_el.tag, actual_el.tag)
self.assertEqual(expected_el.text, actual_el.text)
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
def test_render_content(self):
content = self.annotatable._render_content()
el = etree.fromstring(content)
self.assertEqual('div', el.tag, 'root tag is a div')
expected_num_annotations = 5
actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])')
self.assertEqual(expected_num_annotations, actual_num_annotations, 'check number of annotations')
def test_get_html(self):
context = self.annotatable.get_html()
for key in ['display_name', 'element_id', 'content_html', 'instructions_html']:
self.assertIn(key, context)
def test_extract_instructions(self):
xmltree = etree.fromstring(self.sample_xml)
expected_xml = u"<div>Read the text.</div>"
actual_xml = self.annotatable._extract_instructions(xmltree)
self.assertIsNotNone(actual_xml)
self.assertEqual(expected_xml.strip(), actual_xml.strip())
xmltree = etree.fromstring('<annotatable>foo</annotatable>')
actual = self.annotatable._extract_instructions(xmltree)
self.assertIsNone(actual)
\ No newline at end of file
...@@ -88,7 +88,7 @@ if Backbone? ...@@ -88,7 +88,7 @@ if Backbone?
if @$('section.discussion').length if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion) @$('section.discussion').replaceWith($discussion)
else else
$(".discussion-module").append($discussion) @$el.append($discussion)
@newPostForm = $('.new-post-article') @newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) -> @threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread
......
(function () {
var debug = false;
var module = {
debug: debug,
inputSelector: '.annotation-input',
tagSelector: '.tag',
tagsSelector: '.tags',
commentSelector: 'textarea.comment',
valueSelector: 'input.value', // stash tag selections and comment here as a JSON string...
singleSelect: true,
init: function() {
var that = this;
if(this.debug) { console.log('annotation input loaded: '); }
$(this.inputSelector).each(function(index, el) {
if(!$(el).data('listening')) {
$(el).delegate(that.tagSelector, 'click', $.proxy(that.onClickTag, that));
$(el).delegate(that.commentSelector, 'change', $.proxy(that.onChangeComment, that));
$(el).data('listening', 'yes');
}
});
},
onChangeComment: function(e) {
var value_el = this.findValueEl(e.target);
var current_value = this.loadValue(value_el);
var target_value = $(e.target).val();
current_value.comment = target_value;
this.storeValue(value_el, current_value);
},
onClickTag: function(e) {
var target_el = e.target, target_value, target_index;
var value_el, current_value;
value_el = this.findValueEl(e.target);
current_value = this.loadValue(value_el);
target_value = $(e.target).data('id');
if(!$(target_el).hasClass('selected')) {
if(this.singleSelect) {
current_value.options = [target_value]
} else {
current_value.options.push(target_value);
}
} else {
if(this.singleSelect) {
current_value.options = []
} else {
target_index = current_value.options.indexOf(target_value);
if(target_index !== -1) {
current_value.options.splice(target_index, 1);
}
}
}
this.storeValue(value_el, current_value);
if(this.singleSelect) {
$(target_el).closest(this.tagsSelector)
.find(this.tagSelector)
.not(target_el)
.removeClass('selected')
}
$(target_el).toggleClass('selected');
},
findValueEl: function(target_el) {
var input_el = $(target_el).closest(this.inputSelector);
return $(this.valueSelector, input_el);
},
loadValue: function(value_el) {
var json = $(value_el).val();
var result = JSON.parse(json);
if(result === null) {
result = {};
}
if(!result.hasOwnProperty('options')) {
result.options = [];
}
if(!result.hasOwnProperty('comment')) {
result.comment = '';
}
return result;
},
storeValue: function(value_el, new_value) {
var json = JSON.stringify(new_value);
$(value_el).val(json);
}
}
module.init();
}).call(this);
<div class="annotatable-wrapper">
<div class="annotatable-header">
% if display_name is not UNDEFINED and display_name is not None:
<div class="annotatable-title">${display_name}</div>
% endif
</div>
% if instructions_html is not UNDEFINED and instructions_html is not None:
<div class="annotatable-section shaded">
<div class="annotatable-section-title">
Instructions
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
</div>
<div class="annotatable-section-body annotatable-instructions">
${instructions_html}
</div>
</div>
% endif
<div class="annotatable-section">
<div class="annotatable-section-title">
Guided Discussion
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
</div>
<div class="annotatable-section-body annotatable-content">
${content_html}
</div>
</div>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment