Commit 6c1d71ca by Braden MacDonald

Merge pull request #38 from open-craft/drag-above-drop

Move draggable items above drop image area
parents 7b054467 140c261f
...@@ -10,7 +10,8 @@ install: ...@@ -10,7 +10,8 @@ install:
- "python setup.py sdist" - "python setup.py sdist"
- "pip install dist/xblock-drag-and-drop-v2-0.1.tar.gz" - "pip install dist/xblock-drag-and-drop-v2-0.1.tar.gz"
script: script:
- pep8 drag_and_drop_v2 --max-line-length=120 - pep8 drag_and_drop_v2 tests --max-line-length=120
- pylint drag_and_drop_v2 tests
- python run_tests.py - python run_tests.py
notifications: notifications:
email: false email: false
......
...@@ -4,21 +4,21 @@ DEFAULT_DATA = { ...@@ -4,21 +4,21 @@ DEFAULT_DATA = {
"zones": [ "zones": [
{ {
"index": 1, "index": 1,
"width": 200, "id": "zone-1",
"title": _("Zone 1"), "title": _("Zone 1"),
"height": 100, "x": 160,
"x": "120", "y": 30,
"y": "200", "width": 196,
"id": "zone-1" "height": 178,
}, },
{ {
"index": 2, "index": 2,
"width": 200, "id": "zone-2",
"title": _("Zone 2"), "title": _("Zone 2"),
"height": 100, "x": 86,
"x": "120", "y": 210,
"y": "360", "width": 340,
"id": "zone-2" "height": 140,
} }
], ],
"items": [ "items": [
...@@ -31,10 +31,6 @@ DEFAULT_DATA = { ...@@ -31,10 +31,6 @@ DEFAULT_DATA = {
"zone": "Zone 1", "zone": "Zone 1",
"backgroundImage": "", "backgroundImage": "",
"id": 0, "id": 0,
"size": {
"width": "190px",
"height": "auto"
}
}, },
{ {
"displayName": "2", "displayName": "2",
...@@ -45,10 +41,6 @@ DEFAULT_DATA = { ...@@ -45,10 +41,6 @@ DEFAULT_DATA = {
"zone": "Zone 2", "zone": "Zone 2",
"backgroundImage": "", "backgroundImage": "",
"id": 1, "id": 1,
"size": {
"width": "190px",
"height": "auto"
}
}, },
{ {
"displayName": "X", "displayName": "X",
...@@ -59,18 +51,10 @@ DEFAULT_DATA = { ...@@ -59,18 +51,10 @@ DEFAULT_DATA = {
"zone": "none", "zone": "none",
"backgroundImage": "", "backgroundImage": "",
"id": 2, "id": 2,
"size": {
"width": "100px",
"height": "100px"
}
}, },
], ],
"state": {
"items": {},
"finished": True
},
"feedback": { "feedback": {
"start": _("Intro Feed"), "start": _("Drag the items onto the image above."),
"finish": _("Final Feed") "finish": _("Good work! You have completed this drag and drop exercise.")
}, },
} }
...@@ -12,7 +12,7 @@ from xblock.core import XBlock ...@@ -12,7 +12,7 @@ from xblock.core import XBlock
from xblock.fields import Scope, String, Dict, Float, Boolean from xblock.fields import Scope, String, Dict, Float, Boolean
from xblock.fragment import Fragment from xblock.fragment import Fragment
from .utils import _, render_template, load_resource from .utils import _, render_template, load_resource # pylint: disable=unused-import
from .default_data import DEFAULT_DATA from .default_data import DEFAULT_DATA
...@@ -125,10 +125,40 @@ class DragAndDropBlock(XBlock): ...@@ -125,10 +125,40 @@ class DragAndDropBlock(XBlock):
for js_url in js_urls: for js_url in js_urls:
fragment.add_javascript_url(self.runtime.local_resource_url(self, js_url)) fragment.add_javascript_url(self.runtime.local_resource_url(self, js_url))
fragment.initialize_js('DragAndDropBlock') fragment.initialize_js('DragAndDropBlock', self.get_configuration())
return fragment return fragment
def get_configuration(self):
"""
Get the configuration data for the student_view.
The configuration is all the settings defined by the author, except for correct answers
and feedback.
"""
def items_without_answers():
items = copy.deepcopy(self.data.get('items', ''))
for item in items:
del item['feedback']
del item['zone']
item['inputOptions'] = 'inputOptions' in item
return items
return {
"zones": self.data.get('zones', []),
"display_zone_labels": self.data.get('displayLabels', False),
"items": items_without_answers(),
"title": self.display_name,
"show_title": self.show_title,
"question_text": self.question_text,
"show_question_header": self.show_question_header,
"target_img_expanded_url": self.target_img_expanded_url,
"item_background_color": self.item_background_color or None,
"item_text_color": self.item_text_color or None,
"initial_feedback": self.data['feedback']['start'],
# final feedback (data.feedback.finish) is not included - it may give away answers.
}
def studio_view(self, context): def studio_view(self, context):
""" """
Editing view in Studio Editing view in Studio
...@@ -164,7 +194,11 @@ class DragAndDropBlock(XBlock): ...@@ -164,7 +194,11 @@ class DragAndDropBlock(XBlock):
for js_url in js_urls: for js_url in js_urls:
fragment.add_javascript_url(self.runtime.local_resource_url(self, js_url)) fragment.add_javascript_url(self.runtime.local_resource_url(self, js_url))
fragment.initialize_js('DragAndDropEditBlock') fragment.initialize_js('DragAndDropEditBlock', {
'data': self.data,
'target_img_expanded_url': self.target_img_expanded_url,
'default_background_image_url': self.default_background_image_url,
})
return fragment return fragment
...@@ -183,18 +217,13 @@ class DragAndDropBlock(XBlock): ...@@ -183,18 +217,13 @@ class DragAndDropBlock(XBlock):
'result': 'success', 'result': 'success',
} }
@XBlock.handler
def get_data(self, request, suffix=''):
data = self._get_data()
return webob.Response(body=json.dumps(data), content_type='application/json')
@XBlock.json_handler @XBlock.json_handler
def do_attempt(self, attempt, suffix=''): def do_attempt(self, attempt, suffix=''):
item = next(i for i in self.data['items'] if i['id'] == attempt['val']) item = next(i for i in self.data['items'] if i['id'] == attempt['val'])
state = None state = None
feedback = item['feedback']['incorrect'] feedback = item['feedback']['incorrect']
final_feedback = None overall_feedback = None
is_correct = False is_correct = False
is_correct_location = False is_correct_location = False
...@@ -220,16 +249,15 @@ class DragAndDropBlock(XBlock): ...@@ -220,16 +249,15 @@ class DragAndDropBlock(XBlock):
is_correct = True is_correct = True
feedback = item['feedback']['correct'] feedback = item['feedback']['correct']
state = { state = {
'top': attempt['top'], 'x_percent': attempt['x_percent'],
'left': attempt['left'], 'y_percent': attempt['y_percent'],
'absolute': True # flag for backwards compatibility (values used to be relative)
} }
if state: if state:
self.item_state[str(item['id'])] = state self.item_state[str(item['id'])] = state
if self._is_finished(): if self._is_finished():
final_feedback = self.data['feedback']['finish'] overall_feedback = self.data['feedback']['finish']
# don't publish the grade if the student has already completed the exercise # don't publish the grade if the student has already completed the exercise
if not self.completed: if not self.completed:
...@@ -259,49 +287,72 @@ class DragAndDropBlock(XBlock): ...@@ -259,49 +287,72 @@ class DragAndDropBlock(XBlock):
'correct': is_correct, 'correct': is_correct,
'correct_location': is_correct_location, 'correct_location': is_correct_location,
'finished': self._is_finished(), 'finished': self._is_finished(),
'final_feedback': final_feedback, 'overall_feedback': overall_feedback,
'feedback': feedback 'feedback': feedback
} }
@XBlock.json_handler @XBlock.json_handler
def reset(self, data, suffix=''): def reset(self, data, suffix=''):
self.item_state = {} self.item_state = {}
return self._get_data() return self._get_user_state()
def _expand_static_url(self, url):
"""
This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the
only portable URL format for static files that works across export/import and reruns).
This method is unfortunately a bit hackish since XBlock does not provide a low-level API
for this.
"""
if hasattr(self.runtime, 'replace_urls'):
url = self.runtime.replace_urls('"{}"'.format(url))[1:-1]
elif hasattr(self.runtime, 'course_id'):
# edX Studio uses a different runtime for 'studio_view' than 'student_view',
# and the 'studio_view' runtime doesn't provide the replace_urls API.
try:
from static_replace import replace_static_urls # pylint: disable=import-error
url = replace_static_urls('"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1]
except ImportError:
pass
return url
def _get_data(self): @XBlock.json_handler
data = copy.deepcopy(self.data) def expand_static_url(self, url, suffix=''):
""" AJAX-accessible handler for expanding URLs to static [image] files """
return {'url': self._expand_static_url(url)}
@property
def target_img_expanded_url(self):
""" Get the expanded URL to the target image (the image items are dragged onto). """
if self.data.get("targetImg"):
return self._expand_static_url(self.data["targetImg"])
else:
return self.default_background_image_url
for item in data['items']: @property
# Strip answers def default_background_image_url(self):
del item['feedback'] """ The URL to the default background image, shown when no custom background is used """
del item['zone'] return self.runtime.local_resource_url(self, "public/img/triangle.png")
item['inputOptions'] = 'inputOptions' in item
if not self._is_finished(): @XBlock.handler
del data['feedback']['finish'] def get_user_state(self, request, suffix=''):
""" GET all user-specific data, and any applicable feedback """
data = self._get_user_state()
return webob.Response(body=json.dumps(data), content_type='application/json')
def _get_user_state(self):
""" Get all user-specific data, and any applicable feedback """
item_state = self._get_item_state() item_state = self._get_item_state()
for item_id, item in item_state.iteritems(): for item_id, item in item_state.iteritems():
definition = next(i for i in self.data['items'] if str(i['id']) == item_id) definition = next(i for i in self.data['items'] if str(i['id']) == item_id)
item['correct_input'] = self._is_correct_input(definition, item.get('input')) item['correct_input'] = self._is_correct_input(definition, item.get('input'))
data['state'] = { is_finished = self._is_finished()
return {
'items': item_state, 'items': item_state,
'finished': self._is_finished() 'finished': is_finished,
'overall_feedback': self.data['feedback']['finish' if is_finished else 'start'],
} }
data['title'] = self.display_name
data['show_title'] = self.show_title
data['question_text'] = self.question_text
data['show_question_header'] = self.show_question_header
if self.item_background_color:
data['item_background_color'] = self.item_background_color
if self.item_text_color:
data['item_text_color'] = self.item_text_color
return data
def _get_item_state(self): def _get_item_state(self):
""" """
Returns the user item state. Returns the user item state.
......
.xblock--drag-and-drop { .xblock--drag-and-drop {
width: 770px; width: auto;
max-width: 770px;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #fff;
} }
/* Header, instruction text, etc. */
.xblock--drag-and-drop .problem-header { .xblock--drag-and-drop .problem-header {
display: inline-block; display: inline-block;
margin: 0 0 15px 0; margin: 0 0 15px 0;
} }
.xblock--drag-and-drop .problem-progress {
display: inline-block;
padding-left: 5px;
color: #666;
font-weight: 100;
font-size: 1em;
}
.xblock--drag-and-drop .problem p { .xblock--drag-and-drop .problem p {
margin-bottom: 1.41575em; margin-bottom: 1.41575em;
} }
.xblock--drag-and-drop .drag-container { /* Shared styles used in header and footer */
width: 760px;
background: #ebf0f2;
position: relative;
}
.xblock--drag-and-drop .clear {
clear: both;
}
.xblock--drag-and-drop .title1 { .xblock--drag-and-drop .title1 {
color: rgb(85, 85, 85); color: rgb(85, 85, 85);
...@@ -41,26 +27,48 @@ ...@@ -41,26 +27,48 @@
margin-top: 20px; margin-top: 20px;
} }
/* drag-container holds the .items and the .target image */
.xblock--drag-and-drop .drag-container {
width: auto;
padding: 5px;
background: #ebf0f2;
}
/*.xblock--drag-and-drop .clear {
clear: both;
}*/
/** Draggable Items **/ /** Draggable Items **/
.xblock--drag-and-drop .drag-container .items { .xblock--drag-and-drop .item-bank {
width: 210px; display: -ms-flexbox;
margin: 10px; display: flex;
padding: 0 !important; /* LMS tries to override this */
-ms-flex-flow: row wrap;
flex-flow: row wrap;
-ms-justify-content: flex-start;
justify-content: flex-start;
-ms-flex-align: center;
align-items: center;
position: relative; position: relative;
display: inline; border: 1px solid rgba(0,0,0, 0.1);
float: left; border-radius: 3px;
list-style-type: none; padding: 5px;
} }
.xblock--drag-and-drop .drag-container .option { .xblock--drag-and-drop .drag-container .option {
position: relative;
display: inline-block; display: inline-block;
width: auto; width: auto;
min-width: 4em;
max-width: calc(100% / 3 - 1% - 1% - 20px);
border: 1px solid transparent;
border-radius: 3px; border-radius: 3px;
margin: 1%; margin: 5px;
padding: 10px; padding: 10px;
background: #2e83cd; background-color: #2e83cd;
font-size: 14px; font-size: 14px;
color: #fff; color: #fff;
opacity: 1; opacity: 1;
...@@ -68,13 +76,22 @@ ...@@ -68,13 +76,22 @@
z-index: 10 !important; z-index: 10 !important;
} }
/* Placed option */
.xblock--drag-and-drop .drag-container .target .option {
position: absolute;
margin: 0;
transform: translate(-50%, -50%); /* These blocks are to be centered on their absolute x,y position */
}
.xblock--drag-and-drop .drag-container .ui-draggable-dragging { .xblock--drag-and-drop .drag-container .ui-draggable-dragging {
box-shadow: 0 16px 32px 0 rgba(0,0,0,.3); box-shadow: 0 16px 32px 0 rgba(0,0,0,.3);
border: 1px solid #ccc; border: 1px solid #ccc;
opacity: .65; opacity: .65;
z-index: 20 !important;
} }
.xblock--drag-and-drop .drag-container .option img { .xblock--drag-and-drop .drag-container .option img {
display: block;
max-width: 100%; max-width: 100%;
} }
...@@ -91,11 +108,10 @@ ...@@ -91,11 +108,10 @@
} }
.xblock--drag-and-drop .drag-container .option .numerical-input .submit-input { .xblock--drag-and-drop .drag-container .option .numerical-input .submit-input {
box-sizing: border-box;
position: absolute; position: absolute;
left: 150px; left: 150px;
top: 4px; top: 4px;
height: 24px; white-space: nowrap; /* Fix cross-browser issue: Without this declaration, button text wraps in Chrome/Chromium */
} }
.xblock--drag-and-drop .drag-container .option .numerical-input.correct .input-submit, .xblock--drag-and-drop .drag-container .option .numerical-input.correct .input-submit,
...@@ -120,19 +136,27 @@ ...@@ -120,19 +136,27 @@
/*** Drop Target ***/ /*** Drop Target ***/
.xblock--drag-and-drop .target { .xblock--drag-and-drop .target {
width: 515px; display: table;
height: 510px; /* 'display: table' makes this have the smallest size that fits the .target-img
while still allowing the image to use 'max-width: 100%' and scale proportionally.
The end result is that this element has the same width and height as the image, so we
can use it as a 'position: relative' anchor for the placed elements. */
height: auto;
position: relative; position: relative;
display: inline; margin-top: 1%;
float: left;
margin: 10px 0 15px 5px;
background: #fff; background: #fff;
} }
.xblock--drag-and-drop .target-img-wrapper {
/* This element is required for Firefox due to https://bugzilla.mozilla.org/show_bug.cgi?id=975632 */
display: table-row;
}
.xblock--drag-and-drop .target-img { .xblock--drag-and-drop .target-img {
background: url('../img/triangle.png') no-repeat; display: table-cell;
width: 100%; width: 100%;
height: 100%; max-width: 100%;
height: auto;
} }
.xblock--drag-and-drop .zone { .xblock--drag-and-drop .zone {
......
/*** xBlock styles ***/ .xblock--drag-and-drop--editor {
.xblock--drag-and-drop { width: 100%;
width: 100%; height: 100%;
margin: 0;
padding: 0;
background: #fff;
}
.modal-window .editor-with-buttons.xblock--drag-and-drop {
/* Fix Studio edito height */
margin-bottom: 0;
height: 380px;
}
/** Draggable Items **/
.xblock--drag-and-drop .items {
width: 210px;
margin: 10px;
padding: 0;
font-size: 14px;
position: relative;
display: inline;
float: left;
list-style-type: none;
} }
.modal-window .drag-builder {
/*** Drop Target ***/
.xblock--drag-and-drop .target {
width: 515px;
height: 510px;
position: relative;
display: inline;
float: left;
margin: 10px 0 15px 5px;
background: #fff;
z-index: 1;
}
.xblock--drag-and-drop .target-img {
background: url('../img/triangle.png') no-repeat;
width: 100%; width: 100%;
height: 100%; height: calc(100% - 60px);
position: absolute;
overflow-y: scroll;
} }
.xblock--drag-and-drop .zone { /*** Drop Target ***/
.xblock--drag-and-drop--editor .zone {
position: absolute; position: absolute;
display: -webkit-box; display: -webkit-box;
...@@ -68,9 +35,11 @@ ...@@ -68,9 +35,11 @@
box-pack:center; box-pack:center;
box-align:center; box-align:center;
border: 1px dotted #666;
box-sizing: border-box;
} }
.xblock--drag-and-drop .zone p { .xblock--drag-and-drop--editor .zone p {
width: 100%; width: 100%;
font-family: Arial; font-family: Arial;
font-size: 16px; font-size: 16px;
...@@ -82,153 +51,164 @@ ...@@ -82,153 +51,164 @@
} }
/*** IE9 alignment fix ***/ /*** IE9 alignment fix ***/
.lt-ie10 .xblock--drag-and-drop .zone { .lt-ie10 .xblock--drag-and-drop--editor .zone {
display: table; display: table;
} }
.lt-ie10 .xblock--drag-and-drop .zone p { .lt-ie10 .xblock--drag-and-drop--editor .zone p {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
text-align: center; text-align: center;
} }
/** Builder **/ /** Builder **/
.xblock--drag-and-drop .hidden { .xblock--drag-and-drop--editor .hidden {
display: none !important; display: none !important;
} }
.xblock--drag-and-drop .drag-builder { .xblock--drag-and-drop--editor .tab {
height: 100%;
overflow: scroll;
}
.xblock--drag-and-drop .drag-builder .tab {
width: 100%; width: 100%;
background: #eee; background: #eee;
padding: 3px 0; padding: 3px 0;
position: relative; position: relative;
} }
.xblock--drag-and-drop .drag-builder .tab:after, .xblock--drag-and-drop--editor .tab::after,
.xblock--drag-and-drop .drag-builder .tab-footer:after, .xblock--drag-and-drop--editor .tab-footer::after {
.xblock--drag-and-drop .drag-builder .target:after {
content: ""; content: "";
display: table; display: table;
clear: both; clear: both;
} }
.xblock--drag-and-drop .drag-builder .tab h3 { .xblock--drag-and-drop--editor .tab h3 {
margin: 20px 0 8px 0; margin: 20px 0 8px 0;
} }
.xblock--drag-and-drop .drag-builder .tab-header, .xblock--drag-and-drop--editor .tab-header,
.xblock--drag-and-drop .drag-builder .tab-content, .xblock--drag-and-drop--editor .tab-content,
.xblock--drag-and-drop .drag-builder .tab-footer { .xblock--drag-and-drop--editor .tab-footer {
width: 96%; width: 96%;
margin: 2%; margin: 2%;
} }
.xblock--drag-and-drop .drag-builder .tab-footer { .xblock--drag-and-drop--editor .tab-footer {
height: 25px; height: 25px;
position: relative; position: relative;
display: block; display: block;
float: left; float: left;
} }
.xblock--drag-and-drop .drag-builder .items { .xblock--drag-and-drop--editor .items {
width: calc(100% - 515px); width: calc(100% - 515px);
margin: 10px 0 0 0; margin: 10px 0 0 0;
} }
.xblock--drag-and-drop .drag-builder .target { .xblock--drag-and-drop--editor .target-image-form input {
margin-left: 0; width: 50%;
} }
.xblock--drag-and-drop .drag-builder .target-image-form input { /* Zones Tab */
width: 50%; .xblock--drag-and-drop--editor .zones-tab .zone-editor {
position: relative;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-start;
justify-content: space-between;
}
.xblock--drag-and-drop--editor .zones-tab .tab-content .controls {
width: 40%;
max-width: 50%;
min-width: 330px;
margin-right: 15px;
} }
.xblock--drag-and-drop .zones-form .zone-row label { .xblock--drag-and-drop--editor .zones-tab .tab-content .target {
position: relative;
border: 1px solid #ccc;
overflow: hidden;
}
.xblock--drag-and-drop--editor .zones-tab .tab-content .target-img {
display: block;
width: auto;
height: auto;
max-width: 100%;
}
.xblock--drag-and-drop--editor .zones-form .zone-row label {
display: inline-block; display: inline-block;
width: 18%; width: 18%;
} }
.xblock--drag-and-drop .zones-form .zone-row .title { .xblock--drag-and-drop--editor .zones-form .zone-row .title {
width: 60%; width: 60%;
margin: 0 0 5px; margin: 0 0 5px;
} }
.xblock--drag-and-drop .zones-form .zone-row .layout { .xblock--drag-and-drop--editor .zones-form .zone-row .layout {
margin-bottom: 15px; margin-bottom: 15px;
} }
.xblock--drag-and-drop .zones-form .zone-row .layout .size, .xblock--drag-and-drop--editor .zones-form .zone-row .layout .size,
.xblock--drag-and-drop .zones-form .zone-row .layout .coord { .xblock--drag-and-drop--editor .zones-form .zone-row .layout .coord {
width: 15%; width: 15%;
margin: 0 19px 5px 0; margin: 0 19px 5px 0;
} }
.xblock--drag-and-drop .drag-builder .target { .xblock--drag-and-drop--editor .feedback-form textarea {
margin-bottom: 40px;
}
.xblock--drag-and-drop .drag-builder .zone {
border: 1px dotted #666;
}
.xblock--drag-and-drop .feedback-form textarea {
width: 99%; width: 99%;
height: 128px; height: 128px;
} }
.xblock--drag-and-drop .item-styles-form .item-styles-form-help { .xblock--drag-and-drop--editor .item-styles-form .item-styles-form-help {
margin-top: 5px; margin-top: 5px;
font-size: small; font-size: small;
} }
.xblock--drag-and-drop .item-styles-form, .xblock--drag-and-drop--editor .item-styles-form,
.xblock--drag-and-drop .items-form { .xblock--drag-and-drop--editor .items-form {
margin-bottom: 30px; margin-bottom: 30px;
} }
.xblock--drag-and-drop .items-form .item { .xblock--drag-and-drop--editor .items-form .item {
background: #73bde7; background: #73bde7;
padding: 10px 0 1px; padding: 10px 0 1px;
margin: 15px 0; margin: 15px 0;
} }
.xblock--drag-and-drop .items-form label { .xblock--drag-and-drop--editor .items-form label {
margin: 0 1%; margin: 0 1%;
} }
.xblock--drag-and-drop .items-form input, .xblock--drag-and-drop--editor .items-form input,
.xblock--drag-and-drop .items-form select { .xblock--drag-and-drop--editor .items-form select {
width: 35%; width: 35%;
} }
.xblock--drag-and-drop .items-form .item-width, .xblock--drag-and-drop--editor .items-form .item-width,
.xblock--drag-and-drop .items-form .item-height { .xblock--drag-and-drop--editor .items-form .item-height {
width: 40px; width: 40px;
} }
.xblock--drag-and-drop .items-form .item-numerical-value, .xblock--drag-and-drop--editor .items-form .item-numerical-value,
.xblock--drag-and-drop .items-form .item-numerical-margin { .xblock--drag-and-drop--editor .items-form .item-numerical-margin {
width: 60px; width: 60px;
} }
.xblock--drag-and-drop .items-form textarea { .xblock--drag-and-drop--editor .items-form textarea {
width: 97%; width: 97%;
margin: 0 1%; margin: 0 1%;
} }
.xblock--drag-and-drop .items-form .row { .xblock--drag-and-drop--editor .items-form .row {
margin-bottom: 20px; margin-bottom: 20px;
} }
/** Buttons **/ /** Buttons **/
.xblock--drag-and-drop .btn { .xblock--drag-and-drop--editor .btn {
background: #2e83cd; background: #2e83cd;
color: #fff; color: #fff;
border: 1px solid #156ab4; border: 1px solid #156ab4;
...@@ -236,33 +216,33 @@ ...@@ -236,33 +216,33 @@
padding: 5px 10px; padding: 5px 10px;
} }
.xblock--drag-and-drop .btn:hover { .xblock--drag-and-drop--editor .btn:hover {
opacity: 0.8; opacity: 0.8;
cursor: pointer; cursor: pointer;
} }
.xblock--drag-and-drop .btn:focus { .xblock--drag-and-drop--editor .btn:focus {
outline: none; outline: none;
opacity: 0.5; opacity: 0.5;
} }
.xblock--drag-and-drop .add-element { .xblock--drag-and-drop--editor .add-element {
text-decoration: none; text-decoration: none;
color: #2e83cd; color: #2e83cd;
} }
.xblock--drag-and-drop .remove-zone { .xblock--drag-and-drop--editor .remove-zone {
float: right; float: right;
margin-top: 2px; margin-top: 2px;
margin-right: 16px; margin-right: 16px;
} }
.xblock--drag-and-drop .remove-item { .xblock--drag-and-drop--editor .remove-item {
display: inline-block; display: inline-block;
margin-left: 95px; margin-left: 95px;
} }
.xblock--drag-and-drop .icon { .xblock--drag-and-drop--editor .icon {
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 7px; border-radius: 7px;
...@@ -272,14 +252,14 @@ ...@@ -272,14 +252,14 @@
margin: 0 5px 0 0; margin: 0 5px 0 0;
} }
.xblock--drag-and-drop .add-zone:hover, .xblock--drag-and-drop--editor .add-zone:hover,
.xblock--drag-and-drop .add-zone:hover .icon, .xblock--drag-and-drop--editor .add-zone:hover .icon,
.xblock--drag-and-drop .remove-zone:hover, .xblock--drag-and-drop--editor .remove-zone:hover,
.xblock--drag-and-drop .remove-zone:hover .icon { .xblock--drag-and-drop--editor .remove-zone:hover .icon {
opacity: 0.7; opacity: 0.7;
} }
.xblock--drag-and-drop .icon.add:before { .xblock--drag-and-drop--editor .icon.add:before {
content: ''; content: '';
height: 10px; height: 10px;
width: 2px; width: 2px;
...@@ -291,7 +271,7 @@ ...@@ -291,7 +271,7 @@
left: 6px; left: 6px;
} }
.xblock--drag-and-drop .icon.add:after { .xblock--drag-and-drop--editor .icon.add:after {
content: ''; content: '';
height: 2px; height: 2px;
width: 10px; width: 10px;
...@@ -303,7 +283,7 @@ ...@@ -303,7 +283,7 @@
left: 0; left: 0;
} }
.xblock--drag-and-drop .icon.remove:before { .xblock--drag-and-drop--editor .icon.remove:before {
content: ''; content: '';
height: 10px; height: 10px;
width: 2px; width: 2px;
...@@ -318,7 +298,7 @@ ...@@ -318,7 +298,7 @@
transform: rotate(45deg); transform: rotate(45deg);
} }
.xblock--drag-and-drop .icon.remove:after { .xblock--drag-and-drop--editor .icon.remove:after {
content: ''; content: '';
height: 2px; height: 2px;
width: 10px; width: 10px;
...@@ -333,10 +313,10 @@ ...@@ -333,10 +313,10 @@
transform: rotate(45deg); transform: rotate(45deg);
} }
.xblock--drag-and-drop .remove-item .icon.remove { .xblock--drag-and-drop--editor .remove-item .icon.remove {
background: #fff; background: #fff;
} }
.xblock--drag-and-drop .remove-item .icon.remove:before, .xblock--drag-and-drop--editor .remove-item .icon.remove:before,
.xblock--drag-and-drop .remove-item .icon.remove:after { .xblock--drag-and-drop--editor .remove-item .icon.remove:after {
background: #2e83cd; background: #2e83cd;
} }
function DragAndDropBlock(runtime, element) { function DragAndDropBlock(runtime, element, configuration) {
"use strict";
// Set up gettext in case it isn't available in the client runtime: // Set up a mock for gettext if it isn't available in the client runtime:
if (typeof gettext == "undefined") { if (!window.gettext) { window.gettext = function gettext_stub(string) { return string; }; }
window.gettext = function gettext_stub(string) { return string; };
}
var root = $(element).find('.xblock--drag-and-drop')[0]; var $element = $(element);
// root: root node managed by the virtual DOM
var $root = $element.find('.xblock--drag-and-drop');
var root = $root[0];
var __state; var state = undefined;
var __vdom = virtualDom.h(); // blank virtual DOM var __vdom = virtualDom.h(); // blank virtual DOM
var init = function() { var init = function() {
$.ajax(runtime.handlerUrl(element, 'get_data'), { // Load the current user state, and load the image, then render the block.
dataType: 'json' // We load the user state via AJAX rather than passing it in statically (like we do with
}).done(function(data){ // configuration) due to how the LMS handles unit tabs. If you click on a unit with this
setState(data); // block, make changes, click on the tab for another unit, then click back, this block
// would re-initialize with the old state. To avoid that, we always fetch the state
// using AJAX during initialization.
$.when(
$.ajax(runtime.handlerUrl(element, 'get_user_state'), {dataType: 'json'}),
loadBackgroundImage()
).done(function(stateResult, bgImg){
configuration.zones.forEach(function (zone) {
computeZoneDimension(zone, bgImg.width, bgImg.height);
});
state = stateResult[0]; // stateResult is an array of [data, statusText, jqXHR]
migrateState(bgImg.width, bgImg.height);
applyState();
initDroppable(); initDroppable();
});
$(document).on('mousedown touchstart', closePopup); $(document).on('mousedown touchstart', closePopup);
$(element).on('click', '.reset-button', resetExercise); $element.on('click', '.reset-button', resetExercise);
$(element).on('click', '.submit-input', submitInput); $element.on('click', '.submit-input', submitInput);
publishEvent({event_type: 'xblock.drag-and-drop-v2.loaded'}); publishEvent({event_type: 'xblock.drag-and-drop-v2.loaded'});
}).fail(function() {
$root.text(gettext("An error occurred. Unable to load drag and drop exercise."));
});
}; };
var getState = function() { /** Asynchronously load the main background image used for this block. */
return __state; var loadBackgroundImage = function() {
var promise = $.Deferred();
var img = new Image();
img.addEventListener("load", function() {
if (img.width > 0 && img.height > 0) {
promise.resolve(img);
} else {
promise.reject();
}
}, false);
img.addEventListener("error", function() { promise.reject() });
img.src = configuration.target_img_expanded_url;
return promise;
}
/** Zones are specified in the configuration via pixel values - convert to percentages */
var computeZoneDimension = function(zone, bg_image_width, bg_image_height) {
if (zone.x_percent === undefined) {
// We can assume that if 'x_percent' is not set, 'y_percent', 'width_percent', and
// 'height_percent' will also not be set.
zone.x_percent = (+zone.x) / bg_image_width * 100;
delete zone.x;
zone.y_percent = (+zone.y) / bg_image_height * 100;
delete zone.y;
zone.width_percent = (+zone.width) / bg_image_width * 100;
delete zone.width;
zone.height_percent = (+zone.height) / bg_image_height * 100;
delete zone.height;
}
}; };
var setState = function(new_state) {
if (new_state.state.feedback) { var previousFeedback = undefined;
if (new_state.state.feedback !== __state.state.feedback) { /**
* Update the DOM to reflect 'state'.
*/
var applyState = function() {
// Is there a change to the feedback popup?
if (state.feedback !== previousFeedback) {
if (state.feedback) {
if (previousFeedback) {
publishEvent({
event_type: 'xblock.drag-and-drop-v2.feedback.closed',
content: previousFeedback,
manually: false,
});
}
publishEvent({
event_type: 'xblock.drag-and-drop-v2.feedback.opened',
content: state.feedback,
});
} else {
publishEvent({ publishEvent({
event_type: 'xblock.drag-and-drop-v2.feedback.closed', event_type: 'xblock.drag-and-drop-v2.feedback.closed',
content: __state.state.feedback, content: state.feedback,
manually: false manually: true,
}); });
} }
publishEvent({ previousFeedback = state.feedback;
event_type: 'xblock.drag-and-drop-v2.feedback.opened',
content: new_state.state.feedback
});
} }
__state = new_state;
updateDOM(new_state); updateDOM();
destroyDraggable(); destroyDraggable();
if (!new_state.state.finished) { if (!state.finished) {
initDraggable(); initDraggable();
} }
}; };
...@@ -56,6 +114,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -56,6 +114,7 @@ function DragAndDropBlock(runtime, element) {
var new_vdom = render(state); var new_vdom = render(state);
var patches = virtualDom.diff(__vdom, new_vdom); var patches = virtualDom.diff(__vdom, new_vdom);
root = virtualDom.patch(root, patches); root = virtualDom.patch(root, patches);
$root = $(root);
__vdom = new_vdom; __vdom = new_vdom;
}; };
...@@ -68,38 +127,42 @@ function DragAndDropBlock(runtime, element) { ...@@ -68,38 +127,42 @@ function DragAndDropBlock(runtime, element) {
}; };
var initDroppable = function() { var initDroppable = function() {
$(root).find('.zone').droppable({ $root.find('.zone').droppable({
accept: '.xblock--drag-and-drop .items .option', accept: '.xblock--drag-and-drop .item-bank .option',
tolerance: 'pointer', tolerance: 'pointer',
drop: function(evt, ui) { drop: function(evt, ui) {
var item_id = ui.draggable.data('value'); var item_id = ui.draggable.data('value');
var zone = $(this).data('zone'); var zone = $(this).data('zone');
var position = ui.draggable.position(); var $target_img = $root.find('.target-img');
var top = position.top + 'px';
var left = position.left + 'px'; // Calculate the position of the center of the dropped element relative to
var state = getState(); // the image.
state.state.items[item_id] = { var x_pos = (ui.helper.offset().left + (ui.helper.outerWidth()/2) - $target_img.offset().left);
top: top, var x_pos_percent = x_pos / $target_img.width() * 100;
left: left, var y_pos = (ui.helper.offset().top + (ui.helper.outerHeight()/2) - $target_img.offset().top);
absolute: true, var y_pos_percent = y_pos / $target_img.height() * 100;
submitting_location: true
state.items[item_id] = {
x_percent: x_pos_percent,
y_percent: y_pos_percent,
submitting_location: true,
}; };
// Wrap in setTimeout to let the droppable event finish. // Wrap in setTimeout to let the droppable event finish.
setTimeout(function() { setTimeout(function() {
setState(state); applyState();
submitLocation(item_id, zone, top, left); submitLocation(item_id, zone, x_pos_percent, y_pos_percent);
}, 0); }, 0);
} }
}); });
}; };
var initDraggable = function() { var initDraggable = function() {
$(root).find('.items .option').not('[data-drag-disabled=true]').each(function() { $root.find('.item-bank .option').not('[data-drag-disabled=true]').each(function() {
try { try {
$(this).draggable({ $(this).draggable({
containment: '.xblock--drag-and-drop .drag-container', containment: '.xblock--drag-and-drop .drag-container',
cursor: 'move', cursor: 'move',
stack: '.xblock--drag-and-drop .items .option', stack: '.xblock--drag-and-drop .item-bank .option',
revert: 'invalid', revert: 'invalid',
revertDuration: 150, revertDuration: 150,
start: function(evt, ui) { start: function(evt, ui) {
...@@ -118,7 +181,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -118,7 +181,7 @@ function DragAndDropBlock(runtime, element) {
}; };
var destroyDraggable = function() { var destroyDraggable = function() {
$(root).find('.items .option[data-drag-disabled=true]').each(function() { $root.find('.item-bank .option[data-drag-disabled=true]').each(function() {
try { try {
$(this).draggable('destroy'); $(this).draggable('destroy');
} catch (e) { } catch (e) {
...@@ -128,26 +191,30 @@ function DragAndDropBlock(runtime, element) { ...@@ -128,26 +191,30 @@ function DragAndDropBlock(runtime, element) {
}); });
}; };
var submitLocation = function(item_id, zone, top, left) { var submitLocation = function(item_id, zone, x_percent, y_percent) {
if (!zone) { if (!zone) {
return; return;
} }
var url = runtime.handlerUrl(element, 'do_attempt'); var url = runtime.handlerUrl(element, 'do_attempt');
var data = {val: item_id, zone: zone, top: top, left: left}; var data = {
val: item_id,
zone: zone,
x_percent: x_percent,
y_percent: y_percent,
};
$.post(url, JSON.stringify(data), 'json').done(function(data){ $.post(url, JSON.stringify(data), 'json').done(function(data){
var state = getState();
if (data.correct_location) { if (data.correct_location) {
state.state.items[item_id].correct_input = Boolean(data.correct); state.items[item_id].correct_input = Boolean(data.correct);
state.state.items[item_id].submitting_location = false; state.items[item_id].submitting_location = false;
} else { } else {
delete state.state.items[item_id]; delete state.items[item_id];
} }
state.state.feedback = data.feedback; state.feedback = data.feedback;
if (data.finished) { if (data.finished) {
state.state.finished = true; state.finished = true;
state.feedback.finish = data.final_feedback; state.overall_feedback = data.overall_feedback;
} }
setState(state); applyState();
}); });
}; };
...@@ -163,35 +230,34 @@ function DragAndDropBlock(runtime, element) { ...@@ -163,35 +230,34 @@ function DragAndDropBlock(runtime, element) {
return; return;
} }
var state = getState(); state.items[item_id].input = input_value;
state.state.items[item_id].input = input_value; state.items[item_id].submitting_input = true;
state.state.items[item_id].submitting_input = true; applyState();
setState(state);
var url = runtime.handlerUrl(element, 'do_attempt'); var url = runtime.handlerUrl(element, 'do_attempt');
var data = {val: item_id, input: input_value}; var data = {val: item_id, input: input_value};
$.post(url, JSON.stringify(data), 'json').done(function(data) { $.post(url, JSON.stringify(data), 'json').done(function(data) {
state.state.items[item_id].submitting_input = false; state.items[item_id].submitting_input = false;
state.state.items[item_id].correct_input = data.correct; state.items[item_id].correct_input = data.correct;
state.state.feedback = data.feedback; state.feedback = data.feedback;
if (data.finished) { if (data.finished) {
state.state.finished = true; state.finished = true;
state.feedback.finish = data.final_feedback; state.overall_feedback = data.overall_feedback;
} }
setState(state); applyState();
}); });
}; };
var closePopup = function(evt) { var closePopup = function(evt) {
if (!state.feedback) {
return;
}
var target = $(evt.target); var target = $(evt.target);
var popup_box = '.xblock--drag-and-drop .popup'; var popup_box = '.xblock--drag-and-drop .popup';
var close_button = '.xblock--drag-and-drop .popup .close'; var close_button = '.xblock--drag-and-drop .popup .close';
var submit_input_button = '.xblock--drag-and-drop .submit-input'; var submit_input_button = '.xblock--drag-and-drop .submit-input';
var state = getState();
if (!state.state.feedback) {
return;
}
if (target.is(popup_box) || target.is(submit_input_button)) { if (target.is(popup_box) || target.is(submit_input_button)) {
return; return;
} }
...@@ -199,14 +265,8 @@ function DragAndDropBlock(runtime, element) { ...@@ -199,14 +265,8 @@ function DragAndDropBlock(runtime, element) {
return; return;
} }
publishEvent({ delete state.feedback;
event_type: 'xblock.drag-and-drop-v2.feedback.closed', applyState();
content: state.state.feedback,
manually: true
});
delete state.state.feedback;
setState(state);
}; };
var resetExercise = function() { var resetExercise = function() {
...@@ -214,63 +274,103 @@ function DragAndDropBlock(runtime, element) { ...@@ -214,63 +274,103 @@ function DragAndDropBlock(runtime, element) {
type: 'POST', type: 'POST',
url: runtime.handlerUrl(element, 'reset'), url: runtime.handlerUrl(element, 'reset'),
data: '{}', data: '{}',
success: setState
}); });
state = {
'items': [],
'finished': false,
'overall_feedback': configuration.initial_feedback,
};
applyState();
}; };
var render = function(state) { var render = function() {
var items = state.items.map(function(item) { var items = configuration.items.map(function(item) {
var item_state = state.state.items[item.id];
var position = item_state || {};
var input = null; var input = null;
var item_user_state = state.items[item.id];
if (item.inputOptions) { if (item.inputOptions) {
input = { input = {
is_visible: item_state && !item_state.submitting_location, is_visible: item_user_state && !item_user_state.submitting_location,
has_value: Boolean(item_state && 'input' in item_state), has_value: Boolean(item_user_state && 'input' in item_user_state),
value : (item_state && item_state.input) || '', value : (item_user_state && item_user_state.input) || '',
class_name: undefined class_name: undefined,
}; };
if (input.has_value && !item_state.submitting_input) { if (input.has_value && !item_user_state.submitting_input) {
input.class_name = item_state.correct_input ? 'correct' : 'incorrect'; input.class_name = item_user_state.correct_input ? 'correct' : 'incorrect';
} }
} }
var itemProperties = { var itemProperties = {
value: item.id, value: item.id,
drag_disabled: Boolean(item_state || state.state.finished), drag_disabled: Boolean(item_user_state || state.finished),
width: item.size.width, class_name: item_user_state && ('input' in item_user_state || item_user_state.correct_input) ? 'fade': undefined,
height: item.size.height,
top: position.top,
left: position.left,
position: position.absolute ? 'absolute' : 'relative',
class_name: item_state && ('input' in item_state || item_state.correct_input) ? 'fade': undefined,
input: input, input: input,
content_html: item.backgroundImage ? '<img src="' + item.backgroundImage + '"/>' : item.displayName content_html: item.backgroundImage ? '<img src="' + item.backgroundImage + '"/>' : item.displayName
}; };
if (state.item_background_color) { if (item_user_state) {
itemProperties.background_color = state.item_background_color; itemProperties.is_placed = true;
itemProperties.x_percent = item_user_state.x_percent;
itemProperties.y_percent = item_user_state.y_percent;
}
if (configuration.item_background_color) {
itemProperties.background_color = configuration.item_background_color;
} }
if (state.item_text_color) { if (configuration.item_text_color) {
itemProperties.color = state.item_text_color; itemProperties.color = configuration.item_text_color;
} }
return itemProperties; return itemProperties;
}); });
var context = { var context = {
header_html: state.title, // configuration - parts that never change:
show_title: state.show_title, header_html: configuration.title,
question_html: state.question_text, show_title: configuration.show_title,
show_question_header: state.show_question_header, question_html: configuration.question_text,
popup_html: state.state.feedback || '', show_question_header: configuration.show_question_header,
feedback_html: $.trim(state.state.finished ? state.feedback.finish : state.feedback.start), target_img_src: configuration.target_img_expanded_url,
target_img_src: state.targetImg, display_zone_labels: configuration.display_zone_labels,
display_zone_labels: state.displayLabels, zones: configuration.zones,
display_reset_button: state.state.finished, items: items,
zones: state.zones, // state - parts that can change:
items: items popup_html: state.feedback || '',
feedback_html: $.trim(state.overall_feedback),
display_reset_button: Object.keys(state.items).length > 0,
}; };
return DragAndDropBlock.renderView(context); return DragAndDropBlock.renderView(context);
}; };
/**
* migrateState: Apply any changes necessary to support the 'state' format used by older
* versions of this XBlock.
* We have to do this in JS, not python, since some migrations depend on the image size,
* which is not known in Python-land.
*/
var migrateState = function(bg_image_width, bg_image_height) {
Object.keys(state.items).forEach(function(item_id) {
var item = state.items[item_id];
if (item.x_percent === undefined) {
// Find the matching item in the configuration
var width = 190;
var height = 44;
for (var i in configuration.items) {
if (configuration.items[i].id === +item_id) {
var size = configuration.items[i].size;
// size is an object like '{width: "50px", height: "auto"}'
if (parseInt(size.width ) > 0) { width = parseInt(size.width); }
if (parseInt(size.height) > 0) { height = parseInt(size.height); }
break;
}
}
// Update the user's item state to use centered relative coordinates
var left_px = parseFloat(item.left) - 220; // 220 px for the items container that used to be on the left
var top_px = parseFloat(item.top);
item.x_percent = (left_px + width/2) / bg_image_width * 100;
item.y_percent = (top_px + height/2) / bg_image_height * 100;
delete item.left;
delete item.top;
delete item.absolute;
}
});
};
init(); init();
} }
function DragAndDropEditBlock(runtime, element) { function DragAndDropEditBlock(runtime, element, params) {
// Set up gettext in case it isn't available in the client runtime: // Set up gettext in case it isn't available in the client runtime:
if (typeof gettext == "undefined") { if (typeof gettext == "undefined") {
...@@ -12,10 +12,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -12,10 +12,7 @@ function DragAndDropEditBlock(runtime, element) {
var dragAndDrop = (function($) { var dragAndDrop = (function($) {
var _fn = { var _fn = {
// Templates
// DOM Elements
$target: $('.xblock--drag-and-drop .target-img', element),
tpl: { tpl: {
init: function() { init: function() {
_fn.tpl = { _fn.tpl = {
...@@ -30,27 +27,28 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -30,27 +27,28 @@ function DragAndDropEditBlock(runtime, element) {
build: { build: {
$el: { $el: {
feedback: { feedback: {
form: $('.xblock--drag-and-drop .drag-builder .feedback-form', element), form: $('.drag-builder .feedback-form', element),
tab: $('.xblock--drag-and-drop .drag-builder .feedback-tab', element) tab: $('.drag-builder .feedback-tab', element)
}, },
zones: { zones: {
form: $('.xblock--drag-and-drop .drag-builder .zones-form', element), form: $('.drag-builder .zones-form', element),
tab: $('.xblock--drag-and-drop .drag-builder .zones-tab', element) tab: $('.drag-builder .zones-tab', element)
}, },
items: { items: {
form: $('.xblock--drag-and-drop .drag-builder .items-form', element), form: $('.drag-builder .items-form', element),
tab: $('.xblock--drag-and-drop .drag-builder .items-tab', element) tab: $('.drag-builder .items-tab', element)
}, },
target: $('.xblock--drag-and-drop .drag-builder .target-img', element) targetImage: $('.drag-builder .target .target-img', element),
zonesPreview: $('.drag-builder .target .zones-preview', element),
}, },
init: function(data) { init: function() {
_fn.data = data; _fn.data = params.data;
// Compile templates // Compile templates
_fn.tpl.init(); _fn.tpl.init();
// Display target image // Display target image
_fn.$target.show(); _fn.build.$el.targetImage.show();
_fn.build.clickHandlers(); _fn.build.clickHandlers();
}, },
...@@ -71,9 +69,10 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -71,9 +69,10 @@ function DragAndDropEditBlock(runtime, element) {
_fn.build.form.zone.add(); _fn.build.form.zone.add();
} }
if (_fn.data.targetImg) { // Set the target image and bind its event handler:
_fn.$target.css('background', 'url(' + _fn.data.targetImg + ') no-repeat'); $('.target-image-form input', element).val(_fn.data.targetImg);
} _fn.build.$el.targetImage.load(_fn.build.form.zone.imageLoaded);
_fn.build.$el.targetImage.attr('src', params.target_img_expanded_url);
if (_fn.data.displayLabels) { if (_fn.data.displayLabels) {
$('.display-labels-form input', element).prop('checked', true); $('.display-labels-form input', element).prop('checked', true);
...@@ -88,7 +87,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -88,7 +87,7 @@ function DragAndDropEditBlock(runtime, element) {
$(this).one('click', function(e) { $(this).one('click', function(e) {
// $zoneTab -> $itemTab // $zoneTab -> $itemTab
e.preventDefault(); e.preventDefault();
_fn.build.form.zone.setAll();
for (var i = 0; i < _fn.data.items.length; i++) { for (var i = 0; i < _fn.data.items.length; i++) {
_fn.build.form.item.add(_fn.data.items[i]); _fn.build.form.item.add(_fn.data.items[i]);
} }
...@@ -119,11 +118,23 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -119,11 +118,23 @@ function DragAndDropEditBlock(runtime, element) {
_fn.build.form.zone.add(); _fn.build.form.zone.add();
}) })
.on('click', '.remove-zone', _fn.build.form.zone.remove) .on('click', '.remove-zone', _fn.build.form.zone.remove)
.on('input', '.zone-row input', _fn.build.form.zone.changedInputHandler)
.on('click', '.target-image-form button', function(e) { .on('click', '.target-image-form button', function(e) {
e.preventDefault(); e.preventDefault();
_fn.data.targetImg = $('.target-image-form input', element).val(); var new_img_url = $.trim($('.target-image-form input', element).val());
_fn.$target.css('background', 'url(' + _fn.data.targetImg + ') no-repeat'); if (new_img_url) {
// We may need to 'expand' the URL before it will be valid.
// e.g. '/static/blah.png' becomes '/asset-v1:course+id/blah.png'
var handlerUrl = runtime.handlerUrl(element, 'expand_static_url');
$.post(handlerUrl, JSON.stringify(new_img_url), function(result) {
_fn.build.$el.targetImage.attr('src', result.url);
});
} else {
new_img_url = params.default_background_image_url;
_fn.build.$el.targetImage.attr('src', new_img_url);
}
_fn.data.targetImg = new_img_url;
// Placeholder shim for IE9 // Placeholder shim for IE9
$.placeholder.shim(); $.placeholder.shim();
...@@ -142,21 +153,18 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -142,21 +153,18 @@ function DragAndDropEditBlock(runtime, element) {
zone: { zone: {
count: 0, count: 0,
formCount: 0, formCount: 0,
list: [], zoneObjects: [],
obj: [],
getObjByIndex: function(num) { getObjByIndex: function(num) {
for (var i = 0; i < _fn.build.form.zone.obj.length; i++) { for (var i = 0; i < _fn.build.form.zone.zoneObjects.length; i++) {
if (_fn.build.form.zone.obj[i].index == num) if (_fn.build.form.zone.zoneObjects[i].index == num)
return _fn.build.form.zone.obj[i]; return _fn.build.form.zone.zoneObjects[i];
} }
}, },
add: function(oldZone) { add: function(oldZone) {
var inputTemplate = _fn.tpl.zoneInput, var inputTemplate = _fn.tpl.zoneInput,
zoneTemplate = _fn.tpl.zoneElement,
name = 'zone-', name = 'zone-',
$elements = _fn.build.$el, $elements = _fn.build.$el,
num, num;
zoneObj;
if (!oldZone) oldZone = {}; if (!oldZone) oldZone = {};
...@@ -166,17 +174,17 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -166,17 +174,17 @@ function DragAndDropEditBlock(runtime, element) {
name += num; name += num;
// Update zone obj // Update zone obj
zoneObj = { var zoneObj = {
title: oldZone.title || 'Zone ' + num, title: oldZone.title || 'Zone ' + num,
id: name, id: name,
index: num, index: num,
width: oldZone.width || 200, width: oldZone.width || 200,
height: oldZone.height || 100, height: oldZone.height || 100,
x: oldZone.x || 0, x: oldZone.x || 0,
y: oldZone.y || 0 y: oldZone.y || 0,
}; };
_fn.build.form.zone.obj.push(zoneObj); _fn.build.form.zone.zoneObjects.push(zoneObj);
// Add fields to zone position form // Add fields to zone position form
$zoneNode = $(inputTemplate(zoneObj)); $zoneNode = $(inputTemplate(zoneObj));
...@@ -185,10 +193,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -185,10 +193,7 @@ function DragAndDropEditBlock(runtime, element) {
_fn.build.form.zone.enableDelete(); _fn.build.form.zone.enableDelete();
// Add zone div to target // Add zone div to target
$elements.target.append(zoneTemplate(zoneObj)); _fn.build.form.zone.renderZonesPreview();
// Listen to changes in form to update zone div
_fn.build.form.zone.clickHandler(num);
// Placeholder shim for IE9 // Placeholder shim for IE9
$.placeholder.shim(); $.placeholder.shim();
...@@ -202,14 +207,14 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -202,14 +207,14 @@ function DragAndDropEditBlock(runtime, element) {
e.preventDefault(); e.preventDefault();
$el.detach(); $el.detach();
$('#' + id, element).detach();
// Find the index of the zone in the array and remove it. // Find the index of the zone in the array and remove it.
for (array_index = 0; array_index < _fn.build.form.zone.obj.length; for (array_index = 0; array_index < _fn.build.form.zone.zoneObjects.length;
array_index++) { array_index++) {
if (_fn.build.form.zone.obj[array_index].index == index) break; if (_fn.build.form.zone.zoneObjects[array_index].index == index) break;
} }
_fn.build.form.zone.obj.splice(array_index, 1); _fn.build.form.zone.zoneObjects.splice(array_index, 1);
_fn.build.form.zone.renderZonesPreview();
_fn.build.form.zone.formCount--; _fn.build.form.zone.formCount--;
_fn.build.form.zone.disableDelete(); _fn.build.form.zone.disableDelete();
...@@ -227,80 +232,72 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -227,80 +232,72 @@ function DragAndDropEditBlock(runtime, element) {
_fn.build.$el.zones.form.find('.remove-zone').addClass('hidden'); _fn.build.$el.zones.form.find('.remove-zone').addClass('hidden');
} }
}, },
setAll: function() { renderZonesPreview: function() {
var zones = [], // Refresh the div which shows a preview of the zones over top of
$form = _fn.build.$el.zones.form.find('.title'); // the background image.
_fn.build.$el.zonesPreview.html('');
var imgWidth = _fn.build.$el.targetImage[0].naturalWidth;
var imgHeight = _fn.build.$el.targetImage[0].naturalHeight;
if (imgWidth == 0 || imgHeight == 0) {
// Set a non-zero value to avoid divide-by-zero:
imgWidth = imgHeight = 400;
}
this.zoneObjects.forEach(function(zoneObj) {
_fn.build.$el.zonesPreview.append(
_fn.tpl.zoneElement({
id: zoneObj.id,
title: zoneObj.title,
x_percent: (+zoneObj.x) / imgWidth * 100,
y_percent: (+zoneObj.y) / imgHeight * 100,
width_percent: (+zoneObj.width) / imgWidth * 100,
height_percent: (+zoneObj.height) / imgHeight * 100,
})
);
});
},
getZoneNames: function() {
var zoneNames = [];
var $form = _fn.build.$el.zones.form.find('.title');
$form.each(function(i, el) { $form.each(function(i, el) {
var val = $(el).val(); var val = $(el).val();
if (val.length > 0) { if (val.length > 0) {
zones.push(val); zoneNames.push(val);
} }
}); });
return zoneNames;
_fn.build.form.zone.list = zones;
},
clickHandler: function(num) {
var $div = $('#zone-' + num, element),
$form = _fn.build.$el.zones.form.find('.zone-row.zone-' + num);
// Listen to form changes and update zone div position
$form.on('keyup', '.title', function(e) {
var text = $(e.currentTarget).val(),
record = _fn.build.form.zone.getObjByIndex(num);
$div.find('p').html(text);
record.title = text;
}).on('keyup', '.width', function(e) {
var width = $(e.currentTarget).val(),
record = _fn.build.form.zone.getObjByIndex(num);
$div.css('width', width + 'px');
record.width = width;
}).on('keyup', '.height', function(e) {
var height = $(e.currentTarget).val(),
record = _fn.build.form.zone.getObjByIndex(num);
$div.css('height', height + 'px');
record.height = height;
}).on('keyup', '.x', function(e) {
var x = $(e.currentTarget).val(),
record = _fn.build.form.zone.getObjByIndex(num);
$div.css('left', x + 'px');
record.x = x;
}).on('keyup', '.y', function(e) {
var y = $(e.currentTarget).val(),
record = _fn.build.form.zone.getObjByIndex(num);
$div.css('top', y + 'px');
record.y = y;
});
}, },
cleanObject: function(arr) { changedInputHandler: function(ev) {
var clean = [], // Called when any of the inputs have changed.
i, var $changedInput = $(ev.currentTarget);
len = arr.length; var $row = $changedInput.closest('.zone-row');
var record = _fn.build.form.zone.getObjByIndex($row.data('index'));
for (i=0; i<len; i++) { if ($changedInput.hasClass('title')) {
clean.push(arr[i]); record.title = $changedInput.val();
} else if ($changedInput.hasClass('width')) {
record.width = $changedInput.val();
} else if ($changedInput.hasClass('height')) {
record.height = $changedInput.val();
} else if ($changedInput.hasClass('x')) {
record.x = $changedInput.val();
} else if ($changedInput.hasClass('y')) {
record.y = $changedInput.val();
} }
_fn.build.form.zone.renderZonesPreview();
return clean; },
} imageLoaded: function() {
// The target background image has loaded (or reloaded, if changed).
_fn.build.form.zone.renderZonesPreview();
},
}, },
createDropdown: function(selected) { createDropdown: function(selected) {
var tpl = _fn.tpl.zoneDropdown, var tpl = _fn.tpl.zoneDropdown,
i,
is_sel,
arr = _fn.build.form.zone.list,
dropdown = [], dropdown = [],
html, html,
dropdown_items = arr.concat('none'); dropdown_items = _fn.build.form.zone.getZoneNames().concat('none');
for (i=0; i<dropdown_items.length; i++) { for (var i=0; i<dropdown_items.length; i++) {
is_sel = (dropdown_items[i] == selected) ? 'selected' : ''; var is_sel = (dropdown_items[i] == selected) ? 'selected' : '';
dropdown.push(tpl({ value: dropdown_items[i], selected: is_sel })); dropdown.push(tpl({ value: dropdown_items[i], selected: is_sel }));
} }
...@@ -324,15 +321,18 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -324,15 +321,18 @@ function DragAndDropEditBlock(runtime, element) {
ctx.dropdown = _fn.build.form.createDropdown(ctx.zone); ctx.dropdown = _fn.build.form.createDropdown(ctx.zone);
if (!oldItem) ctx.width = '190'; // Item width/height are ignored in new versions of the block, but
else ctx.width = oldItem.size.width.substr(0, // preserve the data in case we change back to using those values.
oldItem.size.width.length - 2); if (oldItem && oldItem.size && oldItem.size.width != 'auto') {
if (ctx.width == 'au') ctx.width = '0'; ctx.width = oldItem.size.width.substr(0, oldItem.size.width.length - 2); // Remove 'px'
} else {
if (!oldItem) ctx.height = '0'; ctx.width = '0';
else ctx.height = oldItem.size.height.substr(0, }
oldItem.size.height.length - 2); if (oldItem && oldItem.size && oldItem.size.height != 'auto') {
if (ctx.height == 'au') ctx.height = '0'; ctx.height = oldItem.size.height.substr(0, oldItem.size.height.length - 2); // Remove 'px'
} else {
ctx.height = '0';
}
if (oldItem && oldItem.inputOptions) { if (oldItem && oldItem.inputOptions) {
ctx.numericalValue = oldItem.inputOptions.value; ctx.numericalValue = oldItem.inputOptions.value;
...@@ -379,6 +379,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -379,6 +379,7 @@ function DragAndDropEditBlock(runtime, element) {
backgroundImage = $el.find('.background-image').val(); backgroundImage = $el.find('.background-image').val();
if (name.length > 0 || backgroundImage.length > 0) { if (name.length > 0 || backgroundImage.length > 0) {
// Item width/height are ignored, but preserve the data:
var width = $el.find('.item-width').val(), var width = $el.find('.item-width').val(),
height = $el.find('.item-height').val(); height = $el.find('.item-height').val();
...@@ -417,7 +418,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -417,7 +418,7 @@ function DragAndDropEditBlock(runtime, element) {
}); });
_fn.data.items = items; _fn.data.items = items;
_fn.data.zones = _fn.build.form.zone.cleanObject(_fn.build.form.zone.obj); _fn.data.zones = _fn.build.form.zone.zoneObjects;
var data = { var data = {
'display_name': $element.find('.display-name').val(), 'display_name': $element.find('.display-name').val(),
...@@ -450,7 +451,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -450,7 +451,7 @@ function DragAndDropEditBlock(runtime, element) {
}; };
return { return {
builder: _fn.build.init init: _fn.build.init
}; };
})(jQuery); })(jQuery);
...@@ -458,5 +459,5 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -458,5 +459,5 @@ function DragAndDropEditBlock(runtime, element) {
runtime.notify('cancel', {}); runtime.notify('cancel', {});
}); });
dragAndDrop.builder(window.DragAndDropV2BlockPreviousData); dragAndDrop.init();
} }
(function(h) { (function(h) {
"use strict";
// Set up a mock for gettext if it isn't available in the client runtime:
if (!window.gettext) { window.gettext = function gettext_stub(string) { return string; }; }
var FocusHook = function() { var FocusHook = function() {
if (!(this instanceof FocusHook)) { if (!(this instanceof FocusHook)) {
...@@ -14,10 +18,6 @@ ...@@ -14,10 +18,6 @@
}, 0); }, 0);
}; };
var px = function(n) {
return n + 'px';
};
var renderCollection = function(template, collection, ctx) { var renderCollection = function(template, collection, ctx) {
return collection.map(function(item) { return collection.map(function(item) {
return template(item, ctx); return template(item, ctx);
...@@ -40,35 +40,46 @@ ...@@ -40,35 +40,46 @@
}; };
var itemTemplate = function(item) { var itemTemplate = function(item) {
var style = { var style = {};
width: item.width,
height: item.height,
top: item.top,
left: item.left,
position: item.position
};
if (item.background_color) { if (item.background_color) {
style['background-color'] = item.background_color; style['background-color'] = item.background_color;
} }
if (item.color) { if (item.color) {
style.color = item.color; style.color = item.color;
} }
if (item.is_placed) {
style.left = item.x_percent + "%";
style.top = item.y_percent + "%";
}
return ( return (
h('div.option', {className: item.class_name, h('div.option',
attributes: {'data-value': item.value, 'data-drag-disabled': item.drag_disabled}, {
style: style}, [ key: item.value,
h('div', {innerHTML: item.content_html}), className: item.class_name,
itemInputTemplate(item.input) attributes: {'data-value': item.value, 'data-drag-disabled': item.drag_disabled},
]) style: style
}, [
h('div', {innerHTML: item.content_html}),
itemInputTemplate(item.input)
]
)
); );
}; };
var zoneTemplate = function(zone, ctx) { var zoneTemplate = function(zone, ctx) {
return ( return (
h('div.zone', {id: zone.id, attributes: {'data-zone': zone.title}, h(
style: {top: px(zone.y), left: px(zone.x), 'div.zone',
width: px(zone.width), height: px(zone.height)}}, {
h('p', {style: {visibility: ctx.display_zone_labels ? 'visible': 'hidden'}}, zone.title)) id: zone.id,
attributes: {'data-zone': zone.title},
style: {
top: zone.y_percent + '%', left: zone.x_percent + "%",
width: zone.width_percent + '%', height: zone.height_percent + "%",
}
},
ctx.display_zone_labels ? h('p', zone.title) : null
)
); );
}; };
...@@ -88,28 +99,31 @@ ...@@ -88,28 +99,31 @@
var mainTemplate = function(ctx) { var mainTemplate = function(ctx) {
var problemHeader = ctx.show_title ? h('h2.problem-header', {innerHTML: ctx.header_html}) : null; var problemHeader = ctx.show_title ? h('h2.problem-header', {innerHTML: ctx.header_html}) : null;
var questionHeader = ctx.show_question_header ? h('h3.title1', gettext('Question')) : null; var questionHeader = ctx.show_question_header ? h('h3.title1', gettext('Question')) : null;
var is_item_placed = function(i) { return i.is_placed; };
var items_placed = $.grep(ctx.items, is_item_placed);
var items_in_bank = $.grep(ctx.items, is_item_placed, true);
return ( return (
h('section.xblock--drag-and-drop', [ h('section.xblock--drag-and-drop', [
problemHeader, problemHeader,
h('section.problem', {role: 'application'}, [ h('section.problem', {role: 'application'}, [
questionHeader, questionHeader,
h('p', {innerHTML: ctx.question_html}) h('p', {innerHTML: ctx.question_html}),
]), ]),
h('section.drag-container', [ h('section.drag-container', [
h('div.items', renderCollection(itemTemplate, ctx.items, ctx)), h('div.item-bank', renderCollection(itemTemplate, items_in_bank, ctx)),
h('div.target', [ h('div.target', [
h('div.popup', {style: {display: ctx.popup_html ? 'block' : 'none'}}, [ h('div.popup', {style: {display: ctx.popup_html ? 'block' : 'none'}}, [
h('div.close.icon-remove-sign.fa-times-circle'), h('div.close.icon-remove-sign.fa-times-circle'),
h('p.popup-content', {innerHTML: ctx.popup_html}) h('p.popup-content', {innerHTML: ctx.popup_html}),
]),
h('div.target-img-wrapper', [
h('img.target-img', {src: ctx.target_img_src, alt: "Image Description here"}),
]), ]),
h('div.target-img', {style: {backgroundImage: ctx.target_img_src ? renderCollection(zoneTemplate, ctx.zones, ctx),
'url(' + ctx.target_img_src + ')' : renderCollection(itemTemplate, items_placed, ctx),
undefined}},
renderCollection(zoneTemplate, ctx.zones, ctx))
]), ]),
h('div.clear')
]), ]),
feedbackTemplate(ctx) feedbackTemplate(ctx),
]) ])
); );
}; };
......
<section class="xblock--drag-and-drop"></section> {% load i18n %}
<section class="xblock--drag-and-drop">
{% trans "Loading drag and drop exercise." %}
</section>
{% load i18n %} {% load i18n %}
<div class="xblock--drag-and-drop editor-with-buttons"> <div class="xblock--drag-and-drop--editor editor-with-buttons">
{{ js_templates|safe }} {{ js_templates|safe }}
<script type="text/javascript">
var DragAndDropV2BlockPreviousData = JSON.parse(decodeURIComponent('{{ data|safe }}'));
</script>
<section class="drag-builder"> <section class="drag-builder">
<div class="tab feedback-tab"> <div class="tab feedback-tab">
<p class="tab-content"> <p class="tab-content">
...@@ -57,12 +53,16 @@ ...@@ -57,12 +53,16 @@
<label for="display-labels">{% trans "Display label names on the image" %}:</label> <label for="display-labels">{% trans "Display label names on the image" %}:</label>
<input name="display-labels" id="display-labels" type="checkbox" /> <input name="display-labels" id="display-labels" type="checkbox" />
</section> </section>
<div class="items"> <div class="zone-editor">
<form class="zones-form"></form> <div class="controls">
<a href="#" class="add-zone add-element"><div class="icon add"></div>{% trans "Add a zone" %}</a> <form class="zones-form"></form>
</div> <a href="#" class="add-zone add-element"><div class="icon add"></div>{% trans "Add a zone" %}</a>
<div class="target"> </div>
<div class="target-img"></div> <div class="target">
<img class="target-img">
<div class="zones-preview">
</div>
</div>
</div> </div>
</section> </section>
</div> </div>
......
<script id="zone-element-tpl" type="text/html"> <script id="zone-element-tpl" type="text/html">
<div id="{{ id }}" class="zone" data-zone="{{ title }}" style=" <div id="{{ id }}" class="zone" data-zone="{{ title }}" style="
top:{{ y }}px; top:{{ y_percent }}%;
left:{{ x }}px; left:{{ x_percent }}%;
width:{{ width }}px; width:{{ width_percent }}%;
height:{{ height }}px;"> height:{{ height_percent }}%;">
<p>{{{ title }}}</p> <p>{{{ title }}}</p>
</div> </div>
</script> </script>
<script id="zone-input-tpl" type="text/html"> <script id="zone-input-tpl" type="text/html">
<div class="zone-row {{ id }}"> <div class="zone-row {{ id }}" data-index="{{index}}">
<label>{{i18n "Text"}}</label> <label>{{i18n "Text"}}</label>
<input type="text" class="title" value="{{ title }}" /> <input type="text" class="title" value="{{ title }}" />
<a href="#" class="remove-zone hidden"> <a href="#" class="remove-zone hidden">
...@@ -56,7 +56,15 @@ ...@@ -56,7 +56,15 @@
<label>{{i18n "Error Feedback"}}</label> <label>{{i18n "Error Feedback"}}</label>
<textarea class="error-feedback">{{ feedback.incorrect }}</textarea> <textarea class="error-feedback">{{ feedback.incorrect }}</textarea>
</div> </div>
<div class="row"> <div class="row" style="display: none;">
<!--
width and height are no longer respected, so they are now hidden, but we are
keeping the HTML and JS code intact so that the existing data will be preserved
until we can be sure that removing the width/height is OK for all courses that use
this block.
If we do add them back in, we should only allow setting "width". Height will be
detected automatically based on font/image size requirements.
-->
<label>{{i18n "Width in pixels (0 for auto)"}}</label> <label>{{i18n "Width in pixels (0 for auto)"}}</label>
<input type="text" class="item-width" value="{{ width }}"></input> <input type="text" class="item-width" value="{{ width }}"></input>
<label>{{i18n "Height in pixels (0 for auto)"}}</label> <label>{{i18n "Height in pixels (0 for auto)"}}</label>
......
...@@ -6,11 +6,17 @@ max-line-length=120 ...@@ -6,11 +6,17 @@ max-line-length=120
[MESSAGES CONTROL] [MESSAGES CONTROL]
disable= disable=
locally-disabled, attribute-defined-outside-init,
missing-docstring, locally-disabled,
too-many-ancestors, missing-docstring,
too-many-public-methods, too-many-ancestors,
unused-argument too-many-arguments,
too-many-instance-attributes,
too-few-public-methods,
too-many-public-methods,
unused-argument,
invalid-name,
no-member
[SIMILARITIES] [SIMILARITIES]
min-similarity-lines=8 min-similarity-lines=8
...@@ -6,6 +6,7 @@ This script is required to run our selenium tests inside the xblock-sdk workbenc ...@@ -6,6 +6,7 @@ This script is required to run our selenium tests inside the xblock-sdk workbenc
because the workbench SDK's settings file is not inside any python module. because the workbench SDK's settings file is not inside any python module.
""" """
import logging
import os import os
import sys import sys
import workbench import workbench
...@@ -21,6 +22,9 @@ if __name__ == "__main__": ...@@ -21,6 +22,9 @@ if __name__ == "__main__":
# Configure a range of ports in case the default port of 8081 is in use # Configure a range of ports in case the default port of 8081 is in use
os.environ.setdefault("DJANGO_LIVE_TEST_SERVER_ADDRESS", "localhost:8081-8099") os.environ.setdefault("DJANGO_LIVE_TEST_SERVER_ADDRESS", "localhost:8081-8099")
# Silence too verbose Django logging
logging.disable(logging.DEBUG)
try: try:
os.mkdir('var') os.mkdir('var')
except OSError: except OSError:
......
...@@ -28,11 +28,7 @@ ...@@ -28,11 +28,7 @@
}, },
"zone": "Zone 1", "zone": "Zone 1",
"backgroundImage": "", "backgroundImage": "",
"id": 0, "id": 0
"size": {
"width": "190px",
"height": "auto"
}
}, },
{ {
"displayName": "2 here", "displayName": "2 here",
...@@ -43,10 +39,6 @@ ...@@ -43,10 +39,6 @@
"zone": "Zone 2", "zone": "Zone 2",
"backgroundImage": "", "backgroundImage": "",
"id": 1, "id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": { "inputOptions": {
"value": 100, "value": 100,
"margin": 5 "margin": 5
...@@ -60,17 +52,9 @@ ...@@ -60,17 +52,9 @@
}, },
"zone": "none", "zone": "none",
"backgroundImage": "", "backgroundImage": "",
"id": 2, "id": 2
"size": {
"width": "100px",
"height": "100px"
}
} }
], ],
"state": {
"items": {},
"finished": true
},
"feedback": { "feedback": {
"start": "Other Intro Feed", "start": "Other Intro Feed",
"finish": "Other Final Feed" "finish": "Other Final Feed"
......
...@@ -5,8 +5,8 @@ ...@@ -5,8 +5,8 @@
"width": 200, "width": 200,
"title": "Zone <i>1</i>", "title": "Zone <i>1</i>",
"height": 100, "height": 100,
"y": "200", "y": 200,
"x": "100", "x": 100,
"id": "zone-1" "id": "zone-1"
}, },
{ {
...@@ -28,11 +28,7 @@ ...@@ -28,11 +28,7 @@
}, },
"zone": "Zone <i>1</i>", "zone": "Zone <i>1</i>",
"backgroundImage": "", "backgroundImage": "",
"id": 0, "id": 0
"size": {
"width": "190px",
"height": "auto"
}
}, },
{ {
"displayName": "<i>2</i>", "displayName": "<i>2</i>",
...@@ -43,10 +39,6 @@ ...@@ -43,10 +39,6 @@
"zone": "Zone <b>2</b>", "zone": "Zone <b>2</b>",
"backgroundImage": "", "backgroundImage": "",
"id": 1, "id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": { "inputOptions": {
"value": 100, "value": 100,
"margin": 5 "margin": 5
...@@ -60,20 +52,12 @@ ...@@ -60,20 +52,12 @@
}, },
"zone": "none", "zone": "none",
"backgroundImage": "", "backgroundImage": "",
"id": 2, "id": 2
"size": {
"width": "100px",
"height": "100px"
}
} }
], ],
"state": {
"items": {},
"finished": true
},
"feedback": { "feedback": {
"start": "Intro <i>Feed</i>", "start": "Intro <i>Feed</i>",
"finish": "Final <b>Feed</b>" "finish": "Final <b>Feed</b>"
}, },
"targetImg": "https://www.edx.org/sites/default/files/theme/edx-logo-header.png" "targetImg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MDAiIGhlaWdodD0iNjAwIiBzdHlsZT0iYmFja2dyb3VuZDogI2VlZjsiPjwvc3ZnPg=="
} }
# Imports ########################################################### # Imports ###########################################################
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from tests.utils import load_resource from ..utils import load_resource
from workbench import scenarios from workbench import scenarios
...@@ -19,25 +19,26 @@ class BaseIntegrationTest(SeleniumBaseTest): ...@@ -19,25 +19,26 @@ class BaseIntegrationTest(SeleniumBaseTest):
"'": "&apos;" "'": "&apos;"
} }
def _make_scenario_xml(self, display_name, show_title, question_text, completed=False, show_question_header=True): @staticmethod
def _make_scenario_xml(display_name, show_title, question_text, completed=False, show_question_header=True):
return """ return """
<vertical_demo> <vertical_demo>
<drag-and-drop-v2 <drag-and-drop-v2
display_name='{display_name}' display_name='{display_name}'
show_title='{show_title}' show_title='{show_title}'
question_text='{question_text}' question_text='{question_text}'
show_question_header='{show_question_header}' show_question_header='{show_question_header}'
weight='1' weight='1'
completed='{completed}' completed='{completed}'
/> />
</vertical_demo> </vertical_demo>
""".format( """.format(
display_name=escape(display_name), display_name=escape(display_name),
show_title=show_title, show_title=show_title,
question_text=escape(question_text), question_text=escape(question_text),
show_question_header=show_question_header, show_question_header=show_question_header,
completed=completed, completed=completed,
) )
def _get_custom_scenario_xml(self, filename): def _get_custom_scenario_xml(self, filename):
data = load_resource(filename) data = load_resource(filename)
...@@ -50,7 +51,7 @@ class BaseIntegrationTest(SeleniumBaseTest): ...@@ -50,7 +51,7 @@ class BaseIntegrationTest(SeleniumBaseTest):
self.addCleanup(scenarios.remove_scenario, identifier) self.addCleanup(scenarios.remove_scenario, identifier)
def _get_items(self): def _get_items(self):
items_container = self._page.find_element_by_css_selector('.items') items_container = self._page.find_element_by_css_selector('.item-bank')
return items_container.find_elements_by_css_selector('.option') return items_container.find_elements_by_css_selector('.option')
def _get_zones(self): def _get_zones(self):
...@@ -62,10 +63,12 @@ class BaseIntegrationTest(SeleniumBaseTest): ...@@ -62,10 +63,12 @@ class BaseIntegrationTest(SeleniumBaseTest):
def scroll_down(self, pixels=50): def scroll_down(self, pixels=50):
self.browser.execute_script("$(window).scrollTop({})".format(pixels)) self.browser.execute_script("$(window).scrollTop({})".format(pixels))
def get_element_html(self, element): @staticmethod
def get_element_html(element):
return element.get_attribute('innerHTML').strip() return element.get_attribute('innerHTML').strip()
def get_element_classes(self, element): @staticmethod
def get_element_classes(element):
return element.get_attribute('class').split() return element.get_attribute('class').split()
def wait_until_html_in(self, html, elem): def wait_until_html_in(self, html, elem):
...@@ -73,7 +76,8 @@ class BaseIntegrationTest(SeleniumBaseTest): ...@@ -73,7 +76,8 @@ class BaseIntegrationTest(SeleniumBaseTest):
wait.until(lambda e: html in e.get_attribute('innerHTML'), wait.until(lambda e: html in e.get_attribute('innerHTML'),
u"{} should be in {}".format(html, elem.get_attribute('innerHTML'))) u"{} should be in {}".format(html, elem.get_attribute('innerHTML')))
def wait_until_has_class(self, class_name, elem): @staticmethod
def wait_until_has_class(class_name, elem):
wait = WebDriverWait(elem, 2) wait = WebDriverWait(elem, 2)
wait.until(lambda e: class_name in e.get_attribute('class').split(), wait.until(lambda e: class_name in e.get_attribute('class').split(),
u"Class name {} not in {}".format(class_name, elem.get_attribute('class'))) u"Class name {} not in {}".format(class_name, elem.get_attribute('class')))
from tests.integration.test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
class TestCustomDataDragAndDropRendering(BaseIntegrationTest): class TestCustomDataDragAndDropRendering(BaseIntegrationTest):
...@@ -26,6 +26,9 @@ class TestCustomDataDragAndDropRendering(BaseIntegrationTest): ...@@ -26,6 +26,9 @@ class TestCustomDataDragAndDropRendering(BaseIntegrationTest):
self.assertIn('<span style="color:red">X</span>', self.get_element_html(items[2])) self.assertIn('<span style="color:red">X</span>', self.get_element_html(items[2]))
def test_background_image(self): def test_background_image(self):
bg_image = self.browser.execute_script('return jQuery(".target-img").css("background-image")') bg_image = self.browser.find_element_by_css_selector(".xblock--drag-and-drop .target-img")
custom_image_url = 'https://www.edx.org/sites/default/files/theme/edx-logo-header.png' custom_image_url = (
self.assertEqual(bg_image, 'url("{}")'.format(custom_image_url)) "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciI"
"HdpZHRoPSI4MDAiIGhlaWdodD0iNjAwIiBzdHlsZT0iYmFja2dyb3VuZDogI2VlZjsiPjwvc3ZnPg=="
)
self.assertEqual(bg_image.get_attribute("src"), custom_image_url)
from selenium.webdriver import ActionChains from selenium.webdriver import ActionChains
from tests.integration.test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
class ItemDefinition(object): class ItemDefinition(object):
def __init__(self, item_id, zone_id, feedback_positive, feedback_negative, input=None): def __init__(self, item_id, zone_id, feedback_positive, feedback_negative, input_value=None):
self.feedback_negative = feedback_negative self.feedback_negative = feedback_negative
self.feedback_positive = feedback_positive self.feedback_positive = feedback_positive
self.zone_id = zone_id self.zone_id = zone_id
self.item_id = item_id self.item_id = item_id
self.input = input self.input = input_value
class InteractionTestFixture(BaseIntegrationTest): class InteractionTestFixture(object):
""" """
Verifying Drag and Drop XBlock rendering against default data - if default data changes this would probably broke Verifying Drag and Drop XBlock rendering against default data - if default data changes this would probably broke
""" """
...@@ -28,11 +28,11 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -28,11 +28,11 @@ class InteractionTestFixture(BaseIntegrationTest):
all_zones = ['Zone 1', 'Zone 2'] all_zones = ['Zone 1', 'Zone 2']
feedback = { feedback = {
"intro": "Intro Feed", "intro": "Drag the items onto the image above.",
"final": "Final Feed" "final": "Good work! You have completed this drag and drop exercise."
} }
def _get_scenario_xml(self): def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>" return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
@classmethod @classmethod
...@@ -54,7 +54,7 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -54,7 +54,7 @@ class InteractionTestFixture(BaseIntegrationTest):
self.browser.set_window_size(1024, 800) self.browser.set_window_size(1024, 800)
def _get_item_by_value(self, item_value): def _get_item_by_value(self, item_value):
items_container = self._page.find_element_by_css_selector('.items') items_container = self._page.find_element_by_css_selector('.item-bank')
return items_container.find_elements_by_xpath("//div[@data-value='{item_id}']".format(item_id=item_value))[0] return items_container.find_elements_by_xpath("//div[@data-value='{item_id}']".format(item_id=item_value))[0]
def _get_zone_by_id(self, zone_id): def _get_zone_by_id(self, zone_id):
...@@ -77,6 +77,9 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -77,6 +77,9 @@ class InteractionTestFixture(BaseIntegrationTest):
action_chains.drag_and_drop(element, target).perform() action_chains.drag_and_drop(element, target).perform()
def test_item_positive_feedback_on_good_move(self): def test_item_positive_feedback_on_good_move(self):
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=100)
for definition in self._get_correct_item_for_zone().values(): for definition in self._get_correct_item_for_zone().values():
if not definition.input: if not definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id) self.drag_item_to_zone(definition.item_id, definition.zone_id)
...@@ -96,6 +99,9 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -96,6 +99,9 @@ class InteractionTestFixture(BaseIntegrationTest):
def test_item_negative_feedback_on_bad_move(self): def test_item_negative_feedback_on_bad_move(self):
feedback_popup = self._page.find_element_by_css_selector(".popup-content") feedback_popup = self._page.find_element_by_css_selector(".popup-content")
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=100)
for definition in self.items_map.values(): for definition in self.items_map.values():
for zone in self.all_zones: for zone in self.all_zones:
if zone == definition.zone_id: if zone == definition.zone_id:
...@@ -105,6 +111,10 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -105,6 +111,10 @@ class InteractionTestFixture(BaseIntegrationTest):
def test_item_positive_feedback_on_bad_input(self): def test_item_positive_feedback_on_bad_input(self):
feedback_popup = self._page.find_element_by_css_selector(".popup-content") feedback_popup = self._page.find_element_by_css_selector(".popup-content")
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=100)
for definition in self._get_correct_item_for_zone().values(): for definition in self._get_correct_item_for_zone().values():
if definition.input: if definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id) self.drag_item_to_zone(definition.item_id, definition.zone_id)
...@@ -118,10 +128,15 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -118,10 +128,15 @@ class InteractionTestFixture(BaseIntegrationTest):
self.assertEqual(self.get_element_html(feedback_message), self.feedback['intro']) # precondition check self.assertEqual(self.get_element_html(feedback_message), self.feedback['intro']) # precondition check
items = self._get_correct_item_for_zone() items = self._get_correct_item_for_zone()
get_locations = lambda: {item_id: self._get_item_by_value(item_id).location for item_id in items.keys()}
def get_locations():
return {item_id: self._get_item_by_value(item_id).location for item_id in items.keys()}
initial_locations = get_locations() initial_locations = get_locations()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=100)
for item_key, definition in items.items(): for item_key, definition in items.items():
self.drag_item_to_zone(item_key, definition.zone_id) self.drag_item_to_zone(item_key, definition.zone_id)
if definition.input: if definition.input:
...@@ -132,7 +147,7 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -132,7 +147,7 @@ class InteractionTestFixture(BaseIntegrationTest):
self.wait_until_html_in(self.feedback['final'], self._get_feedback_message()) self.wait_until_html_in(self.feedback['final'], self._get_feedback_message())
# Scroll "Reset exercise" button into view to make sure Selenium can successfully click it # Scroll "Reset exercise" button into view to make sure Selenium can successfully click it
self.scroll_down() self.scroll_down(pixels=250)
reset = self._page.find_element_by_css_selector('.reset-button') reset = self._page.find_element_by_css_selector('.reset-button')
reset.click() reset.click()
...@@ -144,7 +159,7 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -144,7 +159,7 @@ class InteractionTestFixture(BaseIntegrationTest):
self.assertDictEqual(locations_after_reset[item_key], initial_locations[item_key]) self.assertDictEqual(locations_after_reset[item_key], initial_locations[item_key])
class CustomDataInteractionTest(InteractionTestFixture): class CustomDataInteractionTest(InteractionTestFixture, BaseIntegrationTest):
items_map = { items_map = {
0: ItemDefinition(0, 'Zone 1', "Yes 1", "No 1"), 0: ItemDefinition(0, 'Zone 1', "Yes 1", "No 1"),
1: ItemDefinition(1, 'Zone 2', "Yes 2", "No 2", "102"), 1: ItemDefinition(1, 'Zone 2', "Yes 2", "No 2", "102"),
...@@ -162,7 +177,7 @@ class CustomDataInteractionTest(InteractionTestFixture): ...@@ -162,7 +177,7 @@ class CustomDataInteractionTest(InteractionTestFixture):
return self._get_custom_scenario_xml("integration/data/test_data.json") return self._get_custom_scenario_xml("integration/data/test_data.json")
class CustomHtmlDataInteractionTest(InteractionTestFixture): class CustomHtmlDataInteractionTest(InteractionTestFixture, BaseIntegrationTest):
items_map = { items_map = {
0: ItemDefinition(0, 'Zone <i>1</i>', "Yes <b>1</b>", "No <b>1</b>"), 0: ItemDefinition(0, 'Zone <i>1</i>', "Yes <b>1</b>", "No <b>1</b>"),
1: ItemDefinition(1, 'Zone <b>2</b>', "Yes <i>2</i>", "No <i>2</i>", "95"), 1: ItemDefinition(1, 'Zone <b>2</b>', "Yes <i>2</i>", "No <i>2</i>", "95"),
......
from ddt import ddt, unpack, data from ddt import ddt, unpack, data
from tests.integration.test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
class Colors(object): class Colors(object):
...@@ -19,18 +19,16 @@ class Colors(object): ...@@ -19,18 +19,16 @@ class Colors(object):
elif color == cls.CORNFLOWERBLUE: elif color == cls.CORNFLOWERBLUE:
return 'rgb(100, 149, 237)' return 'rgb(100, 149, 237)'
@ddt @ddt
class TestDragAndDropRender(BaseIntegrationTest): class TestDragAndDropRender(BaseIntegrationTest):
""" """
Verifying Drag and Drop XBlock rendering against default data - if default data changes this would probably broke Verifying Drag and Drop XBlock rendering against default data - if default data changes this
will probably break.
""" """
PAGE_TITLE = 'Drag and Drop v2' PAGE_TITLE = 'Drag and Drop v2'
PAGE_ID = 'drag_and_drop_v2' PAGE_ID = 'drag_and_drop_v2'
ITEM_PROPERTIES = [ ITEM_PROPERTIES = [{'text': '1'}, {'text': '2'}, {'text': 'X'}, ]
{'text': '1', 'style_settings': {'width': '190px', 'height': 'auto'}},
{'text': '2', 'style_settings': {'width': '190px', 'height': 'auto'}},
{'text': 'X', 'style_settings': {'width': '100px', 'height': '100px'}},
]
def load_scenario(self, item_background_color="", item_text_color=""): def load_scenario(self, item_background_color="", item_text_color=""):
scenario_xml = """ scenario_xml = """
...@@ -43,22 +41,27 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -43,22 +41,27 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self._page = self.go_to_page(self.PAGE_TITLE) self._page = self.go_to_page(self.PAGE_TITLE)
def _get_style(self, selector, style): def _get_style(self, selector, style, computed=True):
return self.browser.execute_script( if computed:
'return getComputedStyle($("{selector}").get(0)).{style}'.format(selector=selector, style=style) query = 'return getComputedStyle($("{selector}").get(0)).{style}'
) else:
query = 'return $("{selector}").get(0).style.{style}'
def _test_style(self, element, style_settings, element_type): return self.browser.execute_script(query.format(selector=selector, style=style))
style = element.get_attribute('style')
for style_prop, expected_value in style_settings.items(): def _assert_box_percentages(self, selector, left, top, width, height):
if style_prop == 'color' or style_prop == 'background-color' and expected_value.startswith('#'): """ Assert that the element 'selector' has the specified position/size percentages """
expected_value = Colors.rgb(expected_value) values = {key: self._get_style(selector, key, False) for key in ['left', 'top', 'width', 'height']}
expected = u"{0}: {1}".format(style_prop, expected_value) for key in values:
self.assertIn(expected, style) self.assertTrue(values[key].endswith('%'))
if element_type == "item": values[key] = float(values[key][:-1])
self._test_item_style(element, style_settings, style) self.assertAlmostEqual(values['left'], left, places=2)
self.assertAlmostEqual(values['top'], top, places=2)
def _test_item_style(self, item, style_settings, style): self.assertAlmostEqual(values['width'], width, places=2)
self.assertAlmostEqual(values['height'], height, places=2)
def _test_item_style(self, item_element, style_settings):
item_val = item_element.get_attribute('data-value')
style = item_element.get_attribute('style')
# Check background color # Check background color
background_color_property = 'background-color' background_color_property = 'background-color'
if background_color_property not in style_settings: if background_color_property not in style_settings:
...@@ -66,18 +69,18 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -66,18 +69,18 @@ class TestDragAndDropRender(BaseIntegrationTest):
expected_background_color = Colors.BLUE expected_background_color = Colors.BLUE
else: else:
expected_background_color = Colors.rgb(style_settings['background-color']) expected_background_color = Colors.rgb(style_settings['background-color'])
background_color = self._get_style('.items .option', 'backgroundColor') background_color = self._get_style('.item-bank .option[data-value='+item_val+']', 'backgroundColor')
self.assertEquals(background_color, expected_background_color) self.assertEquals(background_color, expected_background_color)
# Check text color # Check text color
color_property = 'color' color_property = 'color'
if color_property not in style_settings: if color_property not in style_settings:
self.assertNotIn(' ' + color_property, style) # Leading space makes sure that # Leading space below ensures that test does not find "color" in "background-color"
# test does not find "color" in "background-color" self.assertNotIn(' ' + color_property, style)
expected_color = Colors.WHITE expected_color = Colors.WHITE
else: else:
expected_color = Colors.rgb(style_settings['color']) expected_color = Colors.rgb(style_settings['color'])
color = self._get_style('.items .option', 'color') color = self._get_style('.item-bank .option[data-value='+item_val+']', 'color')
self.assertEquals(color, expected_color) self.assertEquals(color, expected_color)
def test_items(self): def test_items(self):
...@@ -91,7 +94,7 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -91,7 +94,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(item.get_attribute('data-value'), str(index)) self.assertEqual(item.get_attribute('data-value'), str(index))
self.assertEqual(item.text, self.ITEM_PROPERTIES[index]['text']) self.assertEqual(item.text, self.ITEM_PROPERTIES[index]['text'])
self.assertIn('ui-draggable', self.get_element_classes(item)) self.assertIn('ui-draggable', self.get_element_classes(item))
self._test_style(item, self.ITEM_PROPERTIES[index]['style_settings'], element_type='item') self._test_item_style(item, {})
@unpack @unpack
@data( @data(
...@@ -116,9 +119,7 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -116,9 +119,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(item.get_attribute('data-value'), str(index)) self.assertEqual(item.get_attribute('data-value'), str(index))
self.assertEqual(item.text, self.ITEM_PROPERTIES[index]['text']) self.assertEqual(item.text, self.ITEM_PROPERTIES[index]['text'])
self.assertIn('ui-draggable', self.get_element_classes(item)) self.assertIn('ui-draggable', self.get_element_classes(item))
self._test_style( self._test_item_style(item, color_settings)
item, dict(self.ITEM_PROPERTIES[index]['style_settings'], **color_settings), element_type='item'
)
def test_zones(self): def test_zones(self):
self.load_scenario() self.load_scenario()
...@@ -129,26 +130,22 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -129,26 +130,22 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(zones[0].get_attribute('data-zone'), 'Zone 1') self.assertEqual(zones[0].get_attribute('data-zone'), 'Zone 1')
self.assertIn('ui-droppable', self.get_element_classes(zones[0])) self.assertIn('ui-droppable', self.get_element_classes(zones[0]))
self._test_style( self._assert_box_percentages('#zone-1', left=31.1284, top=6.17284, width=38.1323, height=36.6255)
zones[0], {'top': '200px', 'left': '120px', 'width': '200px', 'height': '100px'}, element_type='zone'
)
self.assertEqual(zones[1].get_attribute('data-zone'), 'Zone 2') self.assertEqual(zones[1].get_attribute('data-zone'), 'Zone 2')
self.assertIn('ui-droppable', self.get_element_classes(zones[1])) self.assertIn('ui-droppable', self.get_element_classes(zones[1]))
self._test_style( self._assert_box_percentages('#zone-2', left=16.7315, top=43.2099, width=66.1479, height=28.8066)
zones[1], {'top': '360px', 'left': '120px', 'width': '200px', 'height': '100px'}, element_type='zone'
)
def test_feedback(self): def test_feedback(self):
self.load_scenario() self.load_scenario()
feedback_message = self._get_feedback_message() feedback_message = self._get_feedback_message()
self.assertEqual(feedback_message.text, "Intro Feed") self.assertEqual(feedback_message.text, "Drag the items onto the image above.")
def test_background_image(self): def test_background_image(self):
self.load_scenario() self.load_scenario()
bg_image = self.browser.execute_script('return jQuery(".target-img").css("background-image")') bg_image = self.browser.find_element_by_css_selector(".xblock--drag-and-drop .target-img")
image_path = '/resource/drag-and-drop-v2/public/img/triangle.png' image_path = '/resource/drag-and-drop-v2/public/img/triangle.png'
self.assertEqual(bg_image, 'url("{0}{1}")'.format(self.live_server_url, image_path)) self.assertTrue(bg_image.get_attribute("src").endswith(image_path))
from ddt import ddt, unpack, data from ddt import ddt, unpack, data
from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import NoSuchElementException
from tests.integration.test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
from workbench import scenarios from workbench import scenarios
......
import logging
import json
import unittest
from webob import Request
from mock import Mock
from workbench.runtime import WorkbenchRuntime
from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData, DictKeyValueStore
from nose.tools import (
assert_equals, assert_true, assert_false,
assert_in
)
from tests.utils import load_resource
import drag_and_drop_v2
# Silence too verbose Django logging
logging.disable(logging.DEBUG)
def make_request(body, method='POST'):
request = Request.blank('/')
request.method = 'POST'
request.body = body.encode('utf-8')
request.method = method
return request
def make_block():
block_type = 'drag_and_drop_v2'
key_store = DictKeyValueStore()
field_data = KvsFieldData(key_store)
runtime = WorkbenchRuntime()
def_id = runtime.id_generator.create_definition(block_type)
usage_id = runtime.id_generator.create_usage(def_id)
scope_ids = ScopeIds('user', block_type, def_id, usage_id)
return drag_and_drop_v2.DragAndDropBlock(runtime, field_data, scope_ids=scope_ids)
def test_templates_contents():
block = make_block()
block.display_name = "Test Drag & Drop"
block.question_text = "Question Drag & Drop"
block.weight = 5
student_fragment = block.runtime.render(block, 'student_view', ['ingore'])# block.render('student_view', Mock())
assert_in('<section class="xblock--drag-and-drop">',
student_fragment.content)
def test_studio_submit():
block = make_block()
body = json.dumps({
'display_name': "Test Drag & Drop",
'show_title': False,
'question_text': "Question Drag & Drop",
'show_question_header': False,
'item_background_color': 'cornflowerblue',
'item_text_color': 'coral',
'weight': '5',
'data': {
'foo': 1
}
})
res = block.handle('studio_submit', make_request(body))
assert_equals(json.loads(res.body), {'result': 'success'})
assert_equals(block.show_title, False)
assert_equals(block.display_name, "Test Drag & Drop")
assert_equals(block.question_text, "Question Drag & Drop")
assert_equals(block.show_question_header, False)
assert_equals(block.item_background_color, "cornflowerblue")
assert_equals(block.item_text_color, "coral")
assert_equals(block.weight, 5)
assert_equals(block.data, {'foo': 1})
class BaseDragAndDropAjaxFixture(object):
_oldMaxDiff = None
ZONE_1 = None
ZONE_2 = None
FEEDBACK = {
0: {"correct": None, "incorrect": None},
1: {"correct": None, "incorrect": None},
2: {"correct": None, "incorrect": None}
}
FINAL_FEEDBACK = None
def __init__(self, *args, **kwargs):
self._initial_data = None
self._block = None
super(BaseDragAndDropAjaxFixture, self).__init__(*args, **kwargs)
@classmethod
def setUpClass(cls):
cls._oldMaxDiff = assert_equals.__self__.maxDiff
assert_equals.__self__.maxDiff = None
@classmethod
def tearDownClass(cls):
assert_equals.__self__.maxDiff = cls._oldMaxDiff
def setUp(self):
self._block = make_block()
self._initial_data = self.initial_data()
self._block.data = self._initial_data
def tearDown(self):
self._block = None
def initial_data(self):
raise NotImplementedError
def get_data_response(self):
raise NotImplementedError
def test_get_data_returns_expected_data(self):
expected_response = self.get_data_response()
get_data_response = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected_response, get_data_response)
def test_do_attempt_wrong_with_feedback(self):
item_id, zone_id = 0, self.ZONE_2
data = json.dumps({"val": item_id, "zone": zone_id, "top": "31px", "left": "216px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"final_feedback": None,
"finished": False,
"correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"]
})
def test_do_attempt_wrong_without_feedback(self):
item_id, zone_id = 2, self.ZONE_1
data = json.dumps({"val": item_id, "zone": zone_id, "top": "42px", "left": "100px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"final_feedback": None,
"finished": False,
"correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"]
})
def test_do_attempt_correct(self):
item_id, zone_id = 0, self.ZONE_1
data = json.dumps({"val": item_id, "zone": zone_id, "top": "11px", "left": "111px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"final_feedback": None,
"finished": False,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[item_id]["correct"]
})
def test_do_attempt_with_input(self):
data = json.dumps({"val": 1, "zone": self.ZONE_2, "top": "22px", "left": "222px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"finished": False,
"correct": False,
"correct_location": True,
"feedback": None,
"final_feedback": None
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"1": {"top": "22px", "left": "222px", "absolute": True, "correct_input": False}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
data = json.dumps({"val": 1, "input": "250"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"finished": False,
"correct": False,
"correct_location": True,
"feedback": self.FEEDBACK[1]['incorrect'],
"final_feedback": None
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"1": {"top": "22px", "left": "222px", "absolute": True,
"input": "250", "correct_input": False}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
data = json.dumps({"val": 1, "input": "103"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"finished": False,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]['correct'],
"final_feedback": None
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"1": {"top": "22px", "left": "222px", "absolute": True,
"input": "103", "correct_input": True}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
def test_grading(self):
published_grades = []
def mock_publish(self, event, params):
if event == 'grade':
published_grades.append(params)
self._block.runtime.publish = mock_publish
data = json.dumps({"val": 0, "zone": self.ZONE_1, "top": "11px", "left": "111px"})
self._block.handle('do_attempt', make_request(data))
assert_equals(1, len(published_grades))
assert_equals({'value': 0.5, 'max_value': 1}, published_grades[-1])
data = json.dumps({"val": 1, "zone": self.ZONE_2, "top": "22px", "left": "222px"})
json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(2, len(published_grades))
assert_equals({'value': 0.5, 'max_value': 1}, published_grades[-1])
data = json.dumps({"val": 1, "input": "99"})
json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(3, len(published_grades))
assert_equals({'value': 1, 'max_value': 1}, published_grades[-1])
def test_do_attempt_final(self):
data = json.dumps({"val": 0, "zone": self.ZONE_1, "top": "11px", "left": "111px"})
self._block.handle('do_attempt', make_request(data))
expected = self.get_data_response()
expected["state"] = {
"items": {
"0": {"top": "11px", "left": "111px", "absolute": True,
"correct_input": True}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
data = json.dumps({"val": 1, "zone": self.ZONE_2, "top": "22px", "left": "222px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
data = json.dumps({"val": 1, "input": "99"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"final_feedback": self.FINAL_FEEDBACK,
"finished": True,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]["correct"]
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"0": {"top": "11px", "left": "111px", "absolute": True, "correct_input": True},
"1": {"top": "22px", "left": "222px", "absolute": True, "input": "99",
"correct_input": True}
},
"finished": True
}
expected["feedback"]["finish"] = self.FINAL_FEEDBACK
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
class TestDragAndDropHtmlData(BaseDragAndDropAjaxFixture, unittest.TestCase):
ZONE_1 = "Zone <i>1</i>"
ZONE_2 = "Zone <b>2</b>"
FEEDBACK = {
0: {"correct": "Yes <b>1</b>", "incorrect": "No <b>1</b>"},
1: {"correct": "Yes <i>2</i>", "incorrect": "No <i>2</i>"},
2: {"correct": "", "incorrect": ""}
}
FINAL_FEEDBACK = "Final <b>Feed</b>"
def initial_data(self):
return json.loads(load_resource('data/test_html_data.json'))
def get_data_response(self):
return json.loads(load_resource('data/test_get_html_data.json'))
class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase):
ZONE_1 = "Zone 1"
ZONE_2 = "Zone 2"
FEEDBACK = {
0: {"correct": "Yes 1", "incorrect": "No 1"},
1: {"correct": "Yes 2", "incorrect": "No 2"},
2: {"correct": "", "incorrect": ""}
}
FINAL_FEEDBACK = "Final Feed"
def initial_data(self):
return json.loads(load_resource('data/test_data.json'))
def get_data_response(self):
return json.loads(load_resource('data/test_get_data.json'))
def test_ajax_solve_and_reset():
block = make_block()
assert_false(block.completed)
assert_equals(block.item_state, {})
data = json.dumps({"val":0,"zone":"Zone 1","top":"11px","left":"111px"})
block.handle('do_attempt', make_request(data))
data = json.dumps({"val":1,"zone":"Zone 2","top":"22px","left":"222px"})
block.handle('do_attempt', make_request(data))
assert_true(block.completed)
assert_equals(block.item_state, {'0': {"top": "11px", "left": "111px", "absolute": True},
'1': {"top": "22px", "left": "222px", "absolute": True}})
block.handle('reset', make_request("{}"))
assert_true(block.completed)
assert_equals(block.item_state, {})
{ {
"zones": [ "title": "DnDv2 XBlock with HTML instructions",
{ "show_title": false,
"index": 1, "question_text": "Solve this <strong>drag-and-drop</strong> problem.",
"width": 200, "show_question_header": false,
"title": "Zone <i>1</i>", "target_img_expanded_url": "/expanded/url/to/drag_and_drop_v2/public/img/triangle.png",
"height": 100, "item_background_color": "white",
"y": "200", "item_text_color": "#000080",
"x": "100", "initial_feedback": "HTML <strong>Intro</strong> Feed",
"id": "zone-1" "display_zone_labels": false,
},
{ "zones": [
"index": 2, {
"width": 200, "index": 1,
"title": "Zone <b>2</b>", "title": "Zone <i>1</i>",
"height": 100, "x": 100,
"y": 0, "y": 200,
"x": 0, "width": 200,
"id": "zone-2" "height": 100,
} "id": "zone-1"
], },
"items": [ {
{ "index": 2,
"displayName": "<b>1</b>", "title": "Zone <b>2</b>",
"backgroundImage": "", "x": 0,
"id": 0, "y": 0,
"size": { "width": 200,
"width": "190px", "height": 100,
"height": "auto" "id": "zone-2"
}, }
"inputOptions": false ],
},
{ "items": [
"displayName": "<i>2</i>", {
"backgroundImage": "", "displayName": "<b>1</b>",
"id": 1, "backgroundImage": "",
"size": { "id": 0,
"width": "190px", "inputOptions": false
"height": "auto" },
}, {
"inputOptions": true "displayName": "<i>2</i>",
}, "backgroundImage": "",
{ "id": 1,
"displayName": "X", "inputOptions": true
"backgroundImage": "", },
"id": 2, {
"size": { "displayName": "X",
"width": "100px", "backgroundImage": "",
"height": "100px" "id": 2,
}, "inputOptions": false
"inputOptions": false },
}, {
{ "displayName": "",
"displayName": "", "backgroundImage": "http://placehold.it/100x300",
"backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg", "id": 3,
"id": 3, "inputOptions": false
"size": { }
"width": "190px", ]
"height": "auto"
},
"inputOptions": false
}
],
"state": {
"items": {},
"finished": false
},
"feedback": {
"start": "Intro Feed"
},
"targetImg": "http://i0.kym-cdn.com/photos/images/newsfeed/000/030/404/1260585284155.png",
"title": "Drag and Drop",
"show_title": true,
"question_text": "",
"show_question_header": true
} }
...@@ -5,8 +5,8 @@ ...@@ -5,8 +5,8 @@
"width": 200, "width": 200,
"title": "Zone <i>1</i>", "title": "Zone <i>1</i>",
"height": 100, "height": 100,
"y": "200", "y": 200,
"x": "100", "x": 100,
"id": "zone-1" "id": "zone-1"
}, },
{ {
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
"id": "zone-2" "id": "zone-2"
} }
], ],
"items": [ "items": [
{ {
"displayName": "<b>1</b>", "displayName": "<b>1</b>",
...@@ -28,11 +29,7 @@ ...@@ -28,11 +29,7 @@
}, },
"zone": "Zone <i>1</i>", "zone": "Zone <i>1</i>",
"backgroundImage": "", "backgroundImage": "",
"id": 0, "id": 0
"size": {
"width": "190px",
"height": "auto"
}
}, },
{ {
"displayName": "<i>2</i>", "displayName": "<i>2</i>",
...@@ -43,10 +40,6 @@ ...@@ -43,10 +40,6 @@
"zone": "Zone <b>2</b>", "zone": "Zone <b>2</b>",
"backgroundImage": "", "backgroundImage": "",
"id": 1, "id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": { "inputOptions": {
"value": 100, "value": 100,
"margin": 5 "margin": 5
...@@ -60,11 +53,7 @@ ...@@ -60,11 +53,7 @@
}, },
"zone": "none", "zone": "none",
"backgroundImage": "", "backgroundImage": "",
"id": 2, "id": 2
"size": {
"width": "100px",
"height": "100px"
}
}, },
{ {
"displayName": "", "displayName": "",
...@@ -73,21 +62,12 @@ ...@@ -73,21 +62,12 @@
"correct": "" "correct": ""
}, },
"zone": "none", "zone": "none",
"backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg", "backgroundImage": "http://placehold.it/100x300",
"id": 3, "id": 3
"size": {
"width": "190px",
"height": "auto"
}
} }
], ],
"state": {
"items": {},
"finished": true
},
"feedback": { "feedback": {
"start": "Intro Feed", "start": "HTML <strong>Intro</strong> Feed",
"finish": "Final <b>Feed</b>" "finish": "Final <strong>feedback</strong>!"
}, }
"targetImg": "http://i0.kym-cdn.com/photos/images/newsfeed/000/030/404/1260585284155.png"
} }
{
"display_name": "DnDv2 XBlock with HTML instructions",
"show_title": false,
"question_text": "Solve this <strong>drag-and-drop</strong> problem.",
"show_question_header": false,
"weight": 1,
"item_background_color": "white",
"item_text_color": "#000080"
}
{ {
"zones": [ "title": "Drag and Drop",
{ "show_title": true,
"index": 1, "question_text": "",
"title": "Zone 1", "show_question_header": true,
"id": "zone-1", "target_img_expanded_url": "http://i0.kym-cdn.com/photos/images/newsfeed/000/030/404/1260585284155.png",
"height": 100, "item_background_color": null,
"y": "200", "item_text_color": null,
"x": "100", "initial_feedback": "Intro Feed",
"width": 200 "display_zone_labels": false,
},
{ "zones": [
"index": 2, {
"title": "Zone 2", "index": 1,
"id": "zone-2", "title": "Zone 1",
"height": 100, "x": "100",
"y": 0, "y": "200",
"x": 0, "width": 200,
"width": 200 "height": 100,
} "id": "zone-1"
], },
"items": [ {
{ "index": 2,
"displayName": "1", "title": "Zone 2",
"backgroundImage": "", "x": 0,
"id": 0, "y": 0,
"size": { "width": 200,
"width": "190px", "height": 100,
"height": "auto" "id": "zone-2"
}, }
"inputOptions": false ],
},
{ "items": [
"displayName": "2", {
"backgroundImage": "", "displayName": "1",
"id": 1, "backgroundImage": "",
"size": { "id": 0,
"width": "190px", "inputOptions": false,
"height": "auto" "size": {"height": "auto", "width": "190px"}
}, },
"inputOptions": true {
}, "displayName": "2",
{ "backgroundImage": "",
"displayName": "X", "id": 1,
"backgroundImage": "", "inputOptions": true,
"id": 2, "size": {"height": "auto", "width": "190px"}
"size": { },
"width": "100px", {
"height": "100px" "displayName": "X",
}, "backgroundImage": "",
"inputOptions": false "id": 2,
}, "inputOptions": false,
{ "size": {"height": "100px", "width": "100px"}
"displayName": "", },
"backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg", {
"id": 3, "displayName": "",
"size": { "backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg",
"width": "190px", "id": 3,
"height": "auto" "inputOptions": false,
}, "size": {"height": "auto", "width": "190px"}
"inputOptions": false }
} ]
],
"state": {
"items": {},
"finished": false
},
"feedback": {
"start": "Intro Feed"
},
"targetImg": "http://i0.kym-cdn.com/photos/images/newsfeed/000/030/404/1260585284155.png",
"title": "Drag and Drop",
"show_title": true,
"question_text": "",
"show_question_header": true
} }
{
"title": "DnDv2 XBlock with plain text instructions",
"show_title": true,
"question_text": "Can you solve this drag-and-drop problem?",
"show_question_header": true,
"target_img_expanded_url": "http://placehold.it/800x600",
"item_background_color": null,
"item_text_color": null,
"initial_feedback": "This is the initial feedback.",
"display_zone_labels": false,
"zones": [
{
"index": 1,
"title": "Zone 1",
"y": 123,
"x": 234,
"width": 345,
"height": 456,
"id": "zone-1"
},
{
"index": 2,
"title": "Zone 2",
"y": 20,
"x": 10,
"width": 30,
"height": 40,
"id": "zone-2"
}
],
"items": [
{
"displayName": "1",
"backgroundImage": "",
"id": 0,
"inputOptions": false
},
{
"displayName": "2",
"backgroundImage": "",
"id": 1,
"inputOptions": true
},
{
"displayName": "X",
"backgroundImage": "",
"id": 2,
"inputOptions": false
},
{
"displayName": "",
"backgroundImage": "http://placehold.it/200x100",
"id": 3,
"inputOptions": false
}
]
}
{
"zones": [
{
"index": 1,
"title": "Zone 1",
"y": 123,
"x": 234,
"width": 345,
"height": 456,
"id": "zone-1"
},
{
"index": 2,
"title": "Zone 2",
"y": 20,
"x": 10,
"width": 30,
"height": 40,
"id": "zone-2"
}
],
"items": [
{
"displayName": "1",
"feedback": {
"incorrect": "No 1",
"correct": "Yes 1"
},
"zone": "Zone 1",
"backgroundImage": "",
"id": 0
},
{
"displayName": "2",
"feedback": {
"incorrect": "No 2",
"correct": "Yes 2"
},
"zone": "Zone 2",
"backgroundImage": "",
"id": 1,
"inputOptions": {
"value": 100,
"margin": 5
}
},
{
"displayName": "X",
"feedback": {
"incorrect": "",
"correct": ""
},
"zone": "none",
"backgroundImage": "",
"id": 2
},
{
"displayName": "",
"feedback": {
"incorrect": "",
"correct": ""
},
"zone": "none",
"backgroundImage": "http://placehold.it/200x100",
"id": 3
}
],
"feedback": {
"start": "This is the initial feedback.",
"finish": "This is the final feedback."
},
"targetImg": "http://placehold.it/800x600",
"displayLabels": false
}
{
"display_name": "DnDv2 XBlock with plain text instructions",
"show_title": true,
"question_text": "Can you solve this drag-and-drop problem?",
"show_question_header": true,
"weight": 1,
"item_background_color": "",
"item_text_color": ""
}
import json
import unittest
from ..utils import (
make_block,
load_resource,
TestCaseMixin,
)
class BaseDragAndDropAjaxFixture(TestCaseMixin):
ZONE_1 = None
ZONE_2 = None
FEEDBACK = {
0: {"correct": None, "incorrect": None},
1: {"correct": None, "incorrect": None},
2: {"correct": None, "incorrect": None}
}
FINAL_FEEDBACK = None
FOLDER = None
def setUp(self):
self.patch_workbench()
self.block = make_block()
initial_settings = self.initial_settings()
for field in initial_settings:
setattr(self.block, field, initial_settings[field])
self.block.data = self.initial_data()
@classmethod
def initial_data(cls):
return json.loads(load_resource('unit/data/{}/data.json'.format(cls.FOLDER)))
@classmethod
def initial_settings(cls):
return json.loads(load_resource('unit/data/{}/settings.json'.format(cls.FOLDER)))
@classmethod
def expected_configuration(cls):
return json.loads(load_resource('unit/data/{}/config_out.json'.format(cls.FOLDER)))
@classmethod
def initial_feedback(cls):
""" The initial overall_feedback value """
return cls.expected_configuration()["initial_feedback"]
def test_get_configuration(self):
self.assertEqual(self.expected_configuration(), self.block.get_configuration())
def test_do_attempt_wrong_with_feedback(self):
item_id, zone_id = 0, self.ZONE_2
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"overall_feedback": None,
"finished": False,
"correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"]
})
def test_do_attempt_wrong_without_feedback(self):
item_id, zone_id = 2, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"overall_feedback": None,
"finished": False,
"correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"]
})
def test_do_attempt_correct(self):
item_id, zone_id = 0, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"overall_feedback": None,
"finished": False,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[item_id]["correct"]
})
def test_do_attempt_with_input(self):
data = {"val": 1, "zone": self.ZONE_2, "x_percent": "0%", "y_percent": "85%"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"finished": False,
"correct": False,
"correct_location": True,
"feedback": None,
"overall_feedback": None,
})
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "correct_input": False},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
data = {"val": 1, "input": "250"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"finished": False,
"correct": False,
"correct_location": True,
"feedback": self.FEEDBACK[1]['incorrect'],
"overall_feedback": None
})
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "input": "250", "correct_input": False},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
data = {"val": 1, "input": "103"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"finished": False,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]['correct'],
"overall_feedback": None,
})
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "input": "103", "correct_input": True},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
def test_grading(self):
published_grades = []
def mock_publish(self, event, params):
if event == 'grade':
published_grades.append(params)
self.block.runtime.publish = mock_publish
self.call_handler('do_attempt', {
"val": 0, "zone": self.ZONE_1, "y_percent": "11%", "x_percent": "33%"
})
self.assertEqual(1, len(published_grades))
self.assertEqual({'value': 0.5, 'max_value': 1}, published_grades[-1])
self.call_handler('do_attempt', {
"val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%"
})
self.assertEqual(2, len(published_grades))
self.assertEqual({'value': 0.5, 'max_value': 1}, published_grades[-1])
self.call_handler('do_attempt', {"val": 1, "input": "99"})
self.assertEqual(3, len(published_grades))
self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1])
def test_do_attempt_final(self):
data = {"val": 0, "zone": self.ZONE_1, "x_percent": "33%", "y_percent": "11%"}
self.call_handler('do_attempt', data)
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True}
},
"finished": False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
data = {"val": 1, "zone": self.ZONE_2, "x_percent": "22%", "y_percent": "22%"}
res = self.call_handler('do_attempt', data)
data = {"val": 1, "input": "99"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
"overall_feedback": self.FINAL_FEEDBACK,
"finished": True,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]["correct"]
})
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True},
"1": {"x_percent": "22%", "y_percent": "22%", "input": "99", "correct_input": True}
},
"finished": True,
'overall_feedback': self.FINAL_FEEDBACK,
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
class TestDragAndDropHtmlData(BaseDragAndDropAjaxFixture, unittest.TestCase):
FOLDER = "html"
ZONE_1 = "Zone <i>1</i>"
ZONE_2 = "Zone <b>2</b>"
FEEDBACK = {
0: {"correct": "Yes <b>1</b>", "incorrect": "No <b>1</b>"},
1: {"correct": "Yes <i>2</i>", "incorrect": "No <i>2</i>"},
2: {"correct": "", "incorrect": ""}
}
FINAL_FEEDBACK = "Final <strong>feedback</strong>!"
class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase):
FOLDER = "plain"
ZONE_1 = "Zone 1"
ZONE_2 = "Zone 2"
FEEDBACK = {
0: {"correct": "Yes 1", "incorrect": "No 1"},
1: {"correct": "Yes 2", "incorrect": "No 2"},
2: {"correct": "", "incorrect": ""}
}
FINAL_FEEDBACK = "This is the final feedback."
class TestOldDataFormat(TestDragAndDropPlainData):
"""
Make sure we can work with the slightly-older format for 'data' field values.
"""
FOLDER = "old"
FINAL_FEEDBACK = "Final Feed"
import unittest
from ..utils import (
DEFAULT_START_FEEDBACK,
DEFAULT_FINISH_FEEDBACK,
make_block,
TestCaseMixin,
)
class BasicTests(TestCaseMixin, unittest.TestCase):
""" Basic unit tests for the Drag and Drop block, using its default settings """
def setUp(self):
self.block = make_block()
self.patch_workbench()
def test_template_contents(self):
context = {}
student_fragment = self.block.runtime.render(self.block, 'student_view', context)
self.assertIn('<section class="xblock--drag-and-drop">', student_fragment.content)
self.assertIn('Loading drag and drop exercise.', student_fragment.content)
def test_get_configuration(self):
"""
Test the get_configuration() method.
The result of this method is passed to the block's JavaScript during initialization.
"""
config = self.block.get_configuration()
zones = config.pop("zones")
items = config.pop("items")
self.assertEqual(config, {
"display_zone_labels": False,
"title": "Drag and Drop",
"show_title": True,
"question_text": "",
"show_question_header": True,
"target_img_expanded_url": '/expanded/url/to/drag_and_drop_v2/public/img/triangle.png',
"item_background_color": None,
"item_text_color": None,
"initial_feedback": DEFAULT_START_FEEDBACK,
})
self.assertEqual(zones, [
{
"index": 1,
"title": "Zone 1",
"id": "zone-1",
"x": 160,
"y": 30,
"width": 196,
"height": 178,
},
{
"index": 2,
"title": "Zone 2",
"id": "zone-2",
"x": 86,
"y": 210,
"width": 340,
"height": 140,
}
])
# Items should contain no answer data:
self.assertEqual(items, [
{"id": 0, "displayName": "1", "backgroundImage": "", "inputOptions": False},
{"id": 1, "displayName": "2", "backgroundImage": "", "inputOptions": False},
{"id": 2, "displayName": "X", "backgroundImage": "", "inputOptions": False},
])
def test_ajax_solve_and_reset(self):
# Check assumptions / initial conditions:
self.assertFalse(self.block.completed)
def assert_user_state_empty():
self.assertEqual(self.block.item_state, {})
self.assertEqual(self.call_handler("get_user_state"), {
'items': {},
'finished': False,
'overall_feedback': DEFAULT_START_FEEDBACK,
})
assert_user_state_empty()
# Drag both items into the correct spot:
data = {"val": 0, "zone": "Zone 1", "x_percent": "33%", "y_percent": "11%"}
self.call_handler('do_attempt', data)
data = {"val": 1, "zone": "Zone 2", "x_percent": "67%", "y_percent": "80%"}
self.call_handler('do_attempt', data)
# Check the result:
self.assertTrue(self.block.completed)
self.assertEqual(self.block.item_state, {
'0': {'x_percent': '33%', 'y_percent': '11%'},
'1': {'x_percent': '67%', 'y_percent': '80%'},
})
self.assertEqual(self.call_handler('get_user_state'), {
'items': {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct_input': True},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct_input': True},
},
'finished': True,
'overall_feedback': DEFAULT_FINISH_FEEDBACK,
})
# Reset to initial conditions
self.call_handler('reset', {})
self.assertTrue(self.block.completed)
assert_user_state_empty()
def test_studio_submit(self):
body = {
'display_name': "Test Drag & Drop",
'show_title': False,
'question_text': "Question Drag & Drop",
'show_question_header': False,
'item_background_color': 'cornflowerblue',
'item_text_color': 'coral',
'weight': '5',
'data': {
'foo': 1
},
}
res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'})
self.assertEqual(self.block.show_title, False)
self.assertEqual(self.block.display_name, "Test Drag & Drop")
self.assertEqual(self.block.question_text, "Question Drag & Drop")
self.assertEqual(self.block.show_question_header, False)
self.assertEqual(self.block.item_background_color, "cornflowerblue")
self.assertEqual(self.block.item_text_color, "coral")
self.assertEqual(self.block.weight, 5)
self.assertEqual(self.block.data, {'foo': 1})
def test_expand_static_url(self):
""" Test the expand_static_url handler needed in Studio when changing the image """
res = self.call_handler('expand_static_url', '/static/blah.png')
self.assertEqual(res, {'url': '/course/test-course/assets/blah.png'})
def test_image_url(self):
""" Ensure that the default image and custom URLs are both expanded by the runtime """
self.assertEqual(self.block.data.get("targetImg"), None)
self.assertEqual(
self.block.get_configuration()["target_img_expanded_url"],
'/expanded/url/to/drag_and_drop_v2/public/img/triangle.png',
)
self.block.data["targetImg"] = "/static/foo.png"
self.assertEqual(
self.block.get_configuration()["target_img_expanded_url"],
'/course/test-course/assets/foo.png',
)
import json
import pkg_resources import pkg_resources
import re
from mock import patch
from webob import Request
from workbench.runtime import WorkbenchRuntime
from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData, DictKeyValueStore
import drag_and_drop_v2
DEFAULT_START_FEEDBACK = "Drag the items onto the image above."
DEFAULT_FINISH_FEEDBACK = "Good work! You have completed this drag and drop exercise."
def make_request(data, method='POST'):
""" Make a webob JSON Request """
request = Request.blank('/')
request.method = 'POST'
request.body = json.dumps(data).encode('utf-8') if data is not None else ""
request.method = method
return request
def make_block():
""" Instantiate a DragAndDropBlock XBlock inside a WorkbenchRuntime """
block_type = 'drag_and_drop_v2'
key_store = DictKeyValueStore()
field_data = KvsFieldData(key_store)
runtime = WorkbenchRuntime()
def_id = runtime.id_generator.create_definition(block_type)
usage_id = runtime.id_generator.create_usage(def_id)
scope_ids = ScopeIds('user', block_type, def_id, usage_id)
return drag_and_drop_v2.DragAndDropBlock(runtime, field_data, scope_ids=scope_ids)
def load_resource(resource_path): def load_resource(resource_path):
...@@ -6,4 +40,33 @@ def load_resource(resource_path): ...@@ -6,4 +40,33 @@ def load_resource(resource_path):
Gets the content of a resource Gets the content of a resource
""" """
resource_content = pkg_resources.resource_string(__name__, resource_path) resource_content = pkg_resources.resource_string(__name__, resource_path)
return unicode(resource_content) return unicode(resource_content)
\ No newline at end of file
class TestCaseMixin(object):
""" Helpful mixins for unittest TestCase subclasses """
maxDiff = None
def patch_workbench(self):
self.apply_patch(
'workbench.runtime.WorkbenchRuntime.local_resource_url',
lambda _, _block, path: '/expanded/url/to/drag_and_drop_v2/' + path
)
self.apply_patch(
'workbench.runtime.WorkbenchRuntime.replace_urls',
lambda _, html: re.sub(r'"/static/([^"]*)"', r'"/course/test-course/assets/\1"', html),
create=True,
)
def apply_patch(self, *args, **kwargs):
new_patch = patch(*args, **kwargs)
mock = new_patch.start()
self.addCleanup(new_patch.stop)
return mock
def call_handler(self, handler_name, data=None, expect_json=True, method='POST'):
response = self.block.handle(handler_name, make_request(data, method=method))
if expect_json:
self.assertEqual(response.status_code, 200)
return json.loads(response.body)
return response
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