Commit 140c261f by Braden MacDonald

Merge pull request #4 from open-craft/resizable-image

Make the image resize to fit the container, make item placement relative
parents 1ace6d32 541295cc
......@@ -10,7 +10,8 @@ install:
- "python setup.py sdist"
- "pip install dist/xblock-drag-and-drop-v2-0.1.tar.gz"
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
notifications:
email: false
......
......@@ -4,21 +4,21 @@ DEFAULT_DATA = {
"zones": [
{
"index": 1,
"width": 200,
"id": "zone-1",
"title": _("Zone 1"),
"height": 100,
"x": "120",
"y": "200",
"id": "zone-1"
"x": 160,
"y": 30,
"width": 196,
"height": 178,
},
{
"index": 2,
"width": 200,
"id": "zone-2",
"title": _("Zone 2"),
"height": 100,
"x": "120",
"y": "360",
"id": "zone-2"
"x": 86,
"y": 210,
"width": 340,
"height": 140,
}
],
"items": [
......@@ -31,10 +31,6 @@ DEFAULT_DATA = {
"zone": "Zone 1",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
}
},
{
"displayName": "2",
......@@ -45,10 +41,6 @@ DEFAULT_DATA = {
"zone": "Zone 2",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "auto"
}
},
{
"displayName": "X",
......@@ -59,18 +51,10 @@ DEFAULT_DATA = {
"zone": "none",
"backgroundImage": "",
"id": 2,
"size": {
"width": "100px",
"height": "100px"
}
},
],
"state": {
"items": {},
"finished": True
},
"feedback": {
"start": _("Intro Feed"),
"finish": _("Final Feed")
"start": _("Drag the items onto the image above."),
"finish": _("Good work! You have completed this drag and drop exercise.")
},
}
......@@ -12,7 +12,7 @@ from xblock.core import XBlock
from xblock.fields import Scope, String, Dict, Float, Boolean
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
......@@ -125,10 +125,40 @@ class DragAndDropBlock(XBlock):
for js_url in js_urls:
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
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):
"""
Editing view in Studio
......@@ -164,7 +194,11 @@ class DragAndDropBlock(XBlock):
for js_url in js_urls:
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
......@@ -183,18 +217,13 @@ class DragAndDropBlock(XBlock):
'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
def do_attempt(self, attempt, suffix=''):
item = next(i for i in self.data['items'] if i['id'] == attempt['val'])
state = None
feedback = item['feedback']['incorrect']
final_feedback = None
overall_feedback = None
is_correct = False
is_correct_location = False
......@@ -220,16 +249,15 @@ class DragAndDropBlock(XBlock):
is_correct = True
feedback = item['feedback']['correct']
state = {
'top': attempt['top'],
'left': attempt['left'],
'absolute': True # flag for backwards compatibility (values used to be relative)
'x_percent': attempt['x_percent'],
'y_percent': attempt['y_percent'],
}
if state:
self.item_state[str(item['id'])] = state
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
if not self.completed:
......@@ -259,49 +287,72 @@ class DragAndDropBlock(XBlock):
'correct': is_correct,
'correct_location': is_correct_location,
'finished': self._is_finished(),
'final_feedback': final_feedback,
'overall_feedback': overall_feedback,
'feedback': feedback
}
@XBlock.json_handler
def reset(self, data, suffix=''):
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):
data = copy.deepcopy(self.data)
@XBlock.json_handler
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']:
# Strip answers
del item['feedback']
del item['zone']
item['inputOptions'] = 'inputOptions' in item
@property
def default_background_image_url(self):
""" The URL to the default background image, shown when no custom background is used """
return self.runtime.local_resource_url(self, "public/img/triangle.png")
if not self._is_finished():
del data['feedback']['finish']
@XBlock.handler
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()
for item_id, item in item_state.iteritems():
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'))
data['state'] = {
is_finished = self._is_finished()
return {
'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):
"""
Returns the user item state.
......
.xblock--drag-and-drop {
width: 770px;
width: auto;
max-width: 770px;
margin: 0;
padding: 0;
background: #fff;
}
/* Header, instruction text, etc. */
.xblock--drag-and-drop .problem-header {
display: inline-block;
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 {
margin-bottom: 1.41575em;
}
.xblock--drag-and-drop .drag-container {
width: 515px;
padding: 1%;
background: #ebf0f2;
position: relative;
}
.xblock--drag-and-drop .clear {
clear: both;
}
/* Shared styles used in header and footer */
.xblock--drag-and-drop .title1 {
color: rgb(85, 85, 85);
......@@ -42,22 +27,48 @@
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 **/
.xblock--drag-and-drop .drag-container .items {
display: block;
padding: 0 !important; /* LMS tries to override this */
.xblock--drag-and-drop .item-bank {
display: -ms-flexbox;
display: flex;
-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;
border: 1px solid rgba(0,0,0, 0.1);
border-radius: 3px;
padding: 5px;
}
.xblock--drag-and-drop .drag-container .option {
position: relative;
display: inline-block;
width: auto;
min-width: 4em;
max-width: calc(100% / 3 - 1% - 1% - 20px);
border: 1px solid transparent;
border-radius: 3px;
margin: 1%;
margin: 5px;
padding: 10px;
background: #2e83cd;
background-color: #2e83cd;
font-size: 14px;
color: #fff;
opacity: 1;
......@@ -65,13 +76,22 @@
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 {
box-shadow: 0 16px 32px 0 rgba(0,0,0,.3);
border: 1px solid #ccc;
opacity: .65;
z-index: 20 !important;
}
.xblock--drag-and-drop .drag-container .option img {
display: block;
max-width: 100%;
}
......@@ -88,11 +108,10 @@
}
.xblock--drag-and-drop .drag-container .option .numerical-input .submit-input {
box-sizing: border-box;
position: absolute;
left: 150px;
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,
......@@ -117,18 +136,27 @@
/*** Drop Target ***/
.xblock--drag-and-drop .target {
display: block;
width: 515px;
height: 510px;
display: table;
/* '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;
margin-top: 1%;
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 {
background: url('../img/triangle.png') no-repeat;
display: table-cell;
width: 100%;
height: 100%;
max-width: 100%;
height: auto;
}
.xblock--drag-and-drop .zone {
......
/*** xBlock styles ***/
.xblock--drag-and-drop {
width: 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;
.xblock--drag-and-drop--editor {
width: 100%;
height: 100%;
}
/*** 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;
.modal-window .drag-builder {
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;
display: -webkit-box;
......@@ -68,9 +35,11 @@
box-pack: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%;
font-family: Arial;
font-size: 16px;
......@@ -82,153 +51,164 @@
}
/*** IE9 alignment fix ***/
.lt-ie10 .xblock--drag-and-drop .zone {
.lt-ie10 .xblock--drag-and-drop--editor .zone {
display: table;
}
.lt-ie10 .xblock--drag-and-drop .zone p {
.lt-ie10 .xblock--drag-and-drop--editor .zone p {
display: table-cell;
vertical-align: middle;
text-align: center;
}
/** Builder **/
.xblock--drag-and-drop .hidden {
.xblock--drag-and-drop--editor .hidden {
display: none !important;
}
.xblock--drag-and-drop .drag-builder {
height: 100%;
overflow: scroll;
}
.xblock--drag-and-drop .drag-builder .tab {
.xblock--drag-and-drop--editor .tab {
width: 100%;
background: #eee;
padding: 3px 0;
position: relative;
}
.xblock--drag-and-drop .drag-builder .tab:after,
.xblock--drag-and-drop .drag-builder .tab-footer:after,
.xblock--drag-and-drop .drag-builder .target:after {
.xblock--drag-and-drop--editor .tab::after,
.xblock--drag-and-drop--editor .tab-footer::after {
content: "";
display: table;
clear: both;
}
.xblock--drag-and-drop .drag-builder .tab h3 {
.xblock--drag-and-drop--editor .tab h3 {
margin: 20px 0 8px 0;
}
.xblock--drag-and-drop .drag-builder .tab-header,
.xblock--drag-and-drop .drag-builder .tab-content,
.xblock--drag-and-drop .drag-builder .tab-footer {
.xblock--drag-and-drop--editor .tab-header,
.xblock--drag-and-drop--editor .tab-content,
.xblock--drag-and-drop--editor .tab-footer {
width: 96%;
margin: 2%;
}
.xblock--drag-and-drop .drag-builder .tab-footer {
.xblock--drag-and-drop--editor .tab-footer {
height: 25px;
position: relative;
display: block;
float: left;
}
.xblock--drag-and-drop .drag-builder .items {
.xblock--drag-and-drop--editor .items {
width: calc(100% - 515px);
margin: 10px 0 0 0;
}
.xblock--drag-and-drop .drag-builder .target {
margin-left: 0;
.xblock--drag-and-drop--editor .target-image-form input {
width: 50%;
}
.xblock--drag-and-drop .drag-builder .target-image-form input {
width: 50%;
/* Zones Tab */
.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;
width: 18%;
}
.xblock--drag-and-drop .zones-form .zone-row .title {
.xblock--drag-and-drop--editor .zones-form .zone-row .title {
width: 60%;
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;
}
.xblock--drag-and-drop .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 .size,
.xblock--drag-and-drop--editor .zones-form .zone-row .layout .coord {
width: 15%;
margin: 0 19px 5px 0;
}
.xblock--drag-and-drop .drag-builder .target {
margin-bottom: 40px;
}
.xblock--drag-and-drop .drag-builder .zone {
border: 1px dotted #666;
}
.xblock--drag-and-drop .feedback-form textarea {
.xblock--drag-and-drop--editor .feedback-form textarea {
width: 99%;
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;
font-size: small;
}
.xblock--drag-and-drop .item-styles-form,
.xblock--drag-and-drop .items-form {
.xblock--drag-and-drop--editor .item-styles-form,
.xblock--drag-and-drop--editor .items-form {
margin-bottom: 30px;
}
.xblock--drag-and-drop .items-form .item {
.xblock--drag-and-drop--editor .items-form .item {
background: #73bde7;
padding: 10px 0 1px;
margin: 15px 0;
}
.xblock--drag-and-drop .items-form label {
.xblock--drag-and-drop--editor .items-form label {
margin: 0 1%;
}
.xblock--drag-and-drop .items-form input,
.xblock--drag-and-drop .items-form select {
.xblock--drag-and-drop--editor .items-form input,
.xblock--drag-and-drop--editor .items-form select {
width: 35%;
}
.xblock--drag-and-drop .items-form .item-width,
.xblock--drag-and-drop .items-form .item-height {
.xblock--drag-and-drop--editor .items-form .item-width,
.xblock--drag-and-drop--editor .items-form .item-height {
width: 40px;
}
.xblock--drag-and-drop .items-form .item-numerical-value,
.xblock--drag-and-drop .items-form .item-numerical-margin {
.xblock--drag-and-drop--editor .items-form .item-numerical-value,
.xblock--drag-and-drop--editor .items-form .item-numerical-margin {
width: 60px;
}
.xblock--drag-and-drop .items-form textarea {
.xblock--drag-and-drop--editor .items-form textarea {
width: 97%;
margin: 0 1%;
}
.xblock--drag-and-drop .items-form .row {
.xblock--drag-and-drop--editor .items-form .row {
margin-bottom: 20px;
}
/** Buttons **/
.xblock--drag-and-drop .btn {
.xblock--drag-and-drop--editor .btn {
background: #2e83cd;
color: #fff;
border: 1px solid #156ab4;
......@@ -236,33 +216,33 @@
padding: 5px 10px;
}
.xblock--drag-and-drop .btn:hover {
.xblock--drag-and-drop--editor .btn:hover {
opacity: 0.8;
cursor: pointer;
}
.xblock--drag-and-drop .btn:focus {
.xblock--drag-and-drop--editor .btn:focus {
outline: none;
opacity: 0.5;
}
.xblock--drag-and-drop .add-element {
.xblock--drag-and-drop--editor .add-element {
text-decoration: none;
color: #2e83cd;
}
.xblock--drag-and-drop .remove-zone {
.xblock--drag-and-drop--editor .remove-zone {
float: right;
margin-top: 2px;
margin-right: 16px;
}
.xblock--drag-and-drop .remove-item {
.xblock--drag-and-drop--editor .remove-item {
display: inline-block;
margin-left: 95px;
}
.xblock--drag-and-drop .icon {
.xblock--drag-and-drop--editor .icon {
width: 14px;
height: 14px;
border-radius: 7px;
......@@ -272,14 +252,14 @@
margin: 0 5px 0 0;
}
.xblock--drag-and-drop .add-zone:hover,
.xblock--drag-and-drop .add-zone:hover .icon,
.xblock--drag-and-drop .remove-zone:hover,
.xblock--drag-and-drop .remove-zone:hover .icon {
.xblock--drag-and-drop--editor .add-zone:hover,
.xblock--drag-and-drop--editor .add-zone:hover .icon,
.xblock--drag-and-drop--editor .remove-zone:hover,
.xblock--drag-and-drop--editor .remove-zone:hover .icon {
opacity: 0.7;
}
.xblock--drag-and-drop .icon.add:before {
.xblock--drag-and-drop--editor .icon.add:before {
content: '';
height: 10px;
width: 2px;
......@@ -291,7 +271,7 @@
left: 6px;
}
.xblock--drag-and-drop .icon.add:after {
.xblock--drag-and-drop--editor .icon.add:after {
content: '';
height: 2px;
width: 10px;
......@@ -303,7 +283,7 @@
left: 0;
}
.xblock--drag-and-drop .icon.remove:before {
.xblock--drag-and-drop--editor .icon.remove:before {
content: '';
height: 10px;
width: 2px;
......@@ -318,7 +298,7 @@
transform: rotate(45deg);
}
.xblock--drag-and-drop .icon.remove:after {
.xblock--drag-and-drop--editor .icon.remove:after {
content: '';
height: 2px;
width: 10px;
......@@ -333,10 +313,10 @@
transform: rotate(45deg);
}
.xblock--drag-and-drop .remove-item .icon.remove {
.xblock--drag-and-drop--editor .remove-item .icon.remove {
background: #fff;
}
.xblock--drag-and-drop .remove-item .icon.remove:before,
.xblock--drag-and-drop .remove-item .icon.remove:after {
.xblock--drag-and-drop--editor .remove-item .icon.remove:before,
.xblock--drag-and-drop--editor .remove-item .icon.remove:after {
background: #2e83cd;
}
(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() {
if (!(this instanceof FocusHook)) {
......@@ -14,10 +18,6 @@
}, 0);
};
var px = function(n) {
return n + 'px';
};
var renderCollection = function(template, collection, ctx) {
return collection.map(function(item) {
return template(item, ctx);
......@@ -40,35 +40,46 @@
};
var itemTemplate = function(item) {
var style = {
width: item.width,
height: item.height,
top: item.top,
left: item.left,
position: item.position
};
var style = {};
if (item.background_color) {
style['background-color'] = item.background_color;
}
if (item.color) {
style.color = item.color;
}
if (item.is_placed) {
style.left = item.x_percent + "%";
style.top = item.y_percent + "%";
}
return (
h('div.option', {className: item.class_name,
attributes: {'data-value': item.value, 'data-drag-disabled': item.drag_disabled},
style: style}, [
h('div', {innerHTML: item.content_html}),
itemInputTemplate(item.input)
])
h('div.option',
{
key: item.value,
className: item.class_name,
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) {
return (
h('div.zone', {id: zone.id, attributes: {'data-zone': zone.title},
style: {top: px(zone.y), left: px(zone.x),
width: px(zone.width), height: px(zone.height)}},
h('p', {style: {visibility: ctx.display_zone_labels ? 'visible': 'hidden'}}, zone.title))
h(
'div.zone',
{
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 @@
var mainTemplate = function(ctx) {
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 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 (
h('section.xblock--drag-and-drop', [
problemHeader,
h('section.problem', {role: 'application'}, [
questionHeader,
h('p', {innerHTML: ctx.question_html})
h('p', {innerHTML: ctx.question_html}),
]),
h('section.drag-container', [
h('div.items', {height: ctx.itemsHeight}, renderCollection(itemTemplate, ctx.items, ctx)),
h('div.item-bank', renderCollection(itemTemplate, items_in_bank, ctx)),
h('div.target', [
h('div.popup', {style: {display: ctx.popup_html ? 'block' : 'none'}}, [
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 ?
'url(' + ctx.target_img_src + ')' :
undefined}},
renderCollection(zoneTemplate, ctx.zones, ctx))
renderCollection(zoneTemplate, ctx.zones, ctx),
renderCollection(itemTemplate, items_placed, 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 %}
<div class="xblock--drag-and-drop editor-with-buttons">
<div class="xblock--drag-and-drop--editor editor-with-buttons">
{{ js_templates|safe }}
<script type="text/javascript">
var DragAndDropV2BlockPreviousData = JSON.parse(decodeURIComponent('{{ data|safe }}'));
</script>
<section class="drag-builder">
<div class="tab feedback-tab">
<p class="tab-content">
......@@ -57,12 +53,16 @@
<label for="display-labels">{% trans "Display label names on the image" %}:</label>
<input name="display-labels" id="display-labels" type="checkbox" />
</section>
<div class="items">
<form class="zones-form"></form>
<a href="#" class="add-zone add-element"><div class="icon add"></div>{% trans "Add a zone" %}</a>
</div>
<div class="target">
<div class="target-img"></div>
<div class="zone-editor">
<div class="controls">
<form class="zones-form"></form>
<a href="#" class="add-zone add-element"><div class="icon add"></div>{% trans "Add a zone" %}</a>
</div>
<div class="target">
<img class="target-img">
<div class="zones-preview">
</div>
</div>
</div>
</section>
</div>
......
<script id="zone-element-tpl" type="text/html">
<div id="{{ id }}" class="zone" data-zone="{{ title }}" style="
top:{{ y }}px;
left:{{ x }}px;
width:{{ width }}px;
height:{{ height }}px;">
top:{{ y_percent }}%;
left:{{ x_percent }}%;
width:{{ width_percent }}%;
height:{{ height_percent }}%;">
<p>{{{ title }}}</p>
</div>
</script>
<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>
<input type="text" class="title" value="{{ title }}" />
<a href="#" class="remove-zone hidden">
......@@ -56,7 +56,15 @@
<label>{{i18n "Error Feedback"}}</label>
<textarea class="error-feedback">{{ feedback.incorrect }}</textarea>
</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>
<input type="text" class="item-width" value="{{ width }}"></input>
<label>{{i18n "Height in pixels (0 for auto)"}}</label>
......
......@@ -6,11 +6,17 @@ max-line-length=120
[MESSAGES CONTROL]
disable=
locally-disabled,
missing-docstring,
too-many-ancestors,
too-many-public-methods,
unused-argument
attribute-defined-outside-init,
locally-disabled,
missing-docstring,
too-many-ancestors,
too-many-arguments,
too-many-instance-attributes,
too-few-public-methods,
too-many-public-methods,
unused-argument,
invalid-name,
no-member
[SIMILARITIES]
min-similarity-lines=8
......@@ -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.
"""
import logging
import os
import sys
import workbench
......@@ -21,6 +22,9 @@ if __name__ == "__main__":
# 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")
# Silence too verbose Django logging
logging.disable(logging.DEBUG)
try:
os.mkdir('var')
except OSError:
......
......@@ -28,11 +28,7 @@
},
"zone": "Zone 1",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
}
"id": 0
},
{
"displayName": "2 here",
......@@ -43,10 +39,6 @@
"zone": "Zone 2",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
......@@ -60,17 +52,9 @@
},
"zone": "none",
"backgroundImage": "",
"id": 2,
"size": {
"width": "100px",
"height": "100px"
}
"id": 2
}
],
"state": {
"items": {},
"finished": true
},
"feedback": {
"start": "Other Intro Feed",
"finish": "Other Final Feed"
......
......@@ -5,8 +5,8 @@
"width": 200,
"title": "Zone <i>1</i>",
"height": 100,
"y": "200",
"x": "100",
"y": 200,
"x": 100,
"id": "zone-1"
},
{
......@@ -28,11 +28,7 @@
},
"zone": "Zone <i>1</i>",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
}
"id": 0
},
{
"displayName": "<i>2</i>",
......@@ -43,10 +39,6 @@
"zone": "Zone <b>2</b>",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
......@@ -60,20 +52,12 @@
},
"zone": "none",
"backgroundImage": "",
"id": 2,
"size": {
"width": "100px",
"height": "100px"
}
"id": 2
}
],
"state": {
"items": {},
"finished": true
},
"feedback": {
"start": "Intro <i>Feed</i>",
"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 ###########################################################
from xml.sax.saxutils import escape
from selenium.webdriver.support.ui import WebDriverWait
from tests.utils import load_resource
from ..utils import load_resource
from workbench import scenarios
......@@ -19,25 +19,26 @@ class BaseIntegrationTest(SeleniumBaseTest):
"'": "&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 """
<vertical_demo>
<drag-and-drop-v2
display_name='{display_name}'
show_title='{show_title}'
question_text='{question_text}'
show_question_header='{show_question_header}'
weight='1'
completed='{completed}'
/>
</vertical_demo>
""".format(
display_name=escape(display_name),
show_title=show_title,
question_text=escape(question_text),
show_question_header=show_question_header,
completed=completed,
)
<vertical_demo>
<drag-and-drop-v2
display_name='{display_name}'
show_title='{show_title}'
question_text='{question_text}'
show_question_header='{show_question_header}'
weight='1'
completed='{completed}'
/>
</vertical_demo>
""".format(
display_name=escape(display_name),
show_title=show_title,
question_text=escape(question_text),
show_question_header=show_question_header,
completed=completed,
)
def _get_custom_scenario_xml(self, filename):
data = load_resource(filename)
......@@ -50,7 +51,7 @@ class BaseIntegrationTest(SeleniumBaseTest):
self.addCleanup(scenarios.remove_scenario, identifier)
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')
def _get_zones(self):
......@@ -62,10 +63,12 @@ class BaseIntegrationTest(SeleniumBaseTest):
def scroll_down(self, pixels=50):
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()
def get_element_classes(self, element):
@staticmethod
def get_element_classes(element):
return element.get_attribute('class').split()
def wait_until_html_in(self, html, elem):
......@@ -73,7 +76,8 @@ class BaseIntegrationTest(SeleniumBaseTest):
wait.until(lambda e: html in e.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.until(lambda e: class_name in e.get_attribute('class').split(),
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):
......@@ -26,6 +26,9 @@ class TestCustomDataDragAndDropRendering(BaseIntegrationTest):
self.assertIn('<span style="color:red">X</span>', self.get_element_html(items[2]))
def test_background_image(self):
bg_image = self.browser.execute_script('return jQuery(".target-img").css("background-image")')
custom_image_url = 'https://www.edx.org/sites/default/files/theme/edx-logo-header.png'
self.assertEqual(bg_image, 'url("{}")'.format(custom_image_url))
bg_image = self.browser.find_element_by_css_selector(".xblock--drag-and-drop .target-img")
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 tests.integration.test_base import BaseIntegrationTest
from .test_base import BaseIntegrationTest
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_positive = feedback_positive
self.zone_id = zone_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
"""
......@@ -28,11 +28,11 @@ class InteractionTestFixture(BaseIntegrationTest):
all_zones = ['Zone 1', 'Zone 2']
feedback = {
"intro": "Intro Feed",
"final": "Final Feed"
"intro": "Drag the items onto the image above.",
"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>"
@classmethod
......@@ -54,7 +54,7 @@ class InteractionTestFixture(BaseIntegrationTest):
self.browser.set_window_size(1024, 800)
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]
def _get_zone_by_id(self, zone_id):
......@@ -128,7 +128,9 @@ class InteractionTestFixture(BaseIntegrationTest):
self.assertEqual(self.get_element_html(feedback_message), self.feedback['intro']) # precondition check
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()
......@@ -157,7 +159,7 @@ class InteractionTestFixture(BaseIntegrationTest):
self.assertDictEqual(locations_after_reset[item_key], initial_locations[item_key])
class CustomDataInteractionTest(InteractionTestFixture):
class CustomDataInteractionTest(InteractionTestFixture, BaseIntegrationTest):
items_map = {
0: ItemDefinition(0, 'Zone 1', "Yes 1", "No 1"),
1: ItemDefinition(1, 'Zone 2', "Yes 2", "No 2", "102"),
......@@ -175,7 +177,7 @@ class CustomDataInteractionTest(InteractionTestFixture):
return self._get_custom_scenario_xml("integration/data/test_data.json")
class CustomHtmlDataInteractionTest(InteractionTestFixture):
class CustomHtmlDataInteractionTest(InteractionTestFixture, BaseIntegrationTest):
items_map = {
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"),
......
from ddt import ddt, unpack, data
from tests.integration.test_base import BaseIntegrationTest
from .test_base import BaseIntegrationTest
class Colors(object):
......@@ -19,18 +19,16 @@ class Colors(object):
elif color == cls.CORNFLOWERBLUE:
return 'rgb(100, 149, 237)'
@ddt
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_ID = 'drag_and_drop_v2'
ITEM_PROPERTIES = [
{'text': '1', 'style_settings': {'width': '190px', 'height': 'auto'}},
{'text': '2', 'style_settings': {'width': '190px', 'height': 'auto'}},
{'text': 'X', 'style_settings': {'width': '100px', 'height': '100px'}},
]
ITEM_PROPERTIES = [{'text': '1'}, {'text': '2'}, {'text': 'X'}, ]
def load_scenario(self, item_background_color="", item_text_color=""):
scenario_xml = """
......@@ -43,22 +41,27 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.browser.get(self.live_server_url)
self._page = self.go_to_page(self.PAGE_TITLE)
def _get_style(self, selector, style):
return self.browser.execute_script(
'return getComputedStyle($("{selector}").get(0)).{style}'.format(selector=selector, style=style)
)
def _test_style(self, element, style_settings, element_type):
style = element.get_attribute('style')
for style_prop, expected_value in style_settings.items():
if style_prop == 'color' or style_prop == 'background-color' and expected_value.startswith('#'):
expected_value = Colors.rgb(expected_value)
expected = u"{0}: {1}".format(style_prop, expected_value)
self.assertIn(expected, style)
if element_type == "item":
self._test_item_style(element, style_settings, style)
def _test_item_style(self, item, style_settings, style):
def _get_style(self, selector, style, computed=True):
if computed:
query = 'return getComputedStyle($("{selector}").get(0)).{style}'
else:
query = 'return $("{selector}").get(0).style.{style}'
return self.browser.execute_script(query.format(selector=selector, style=style))
def _assert_box_percentages(self, selector, left, top, width, height):
""" Assert that the element 'selector' has the specified position/size percentages """
values = {key: self._get_style(selector, key, False) for key in ['left', 'top', 'width', 'height']}
for key in values:
self.assertTrue(values[key].endswith('%'))
values[key] = float(values[key][:-1])
self.assertAlmostEqual(values['left'], left, places=2)
self.assertAlmostEqual(values['top'], top, places=2)
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
background_color_property = 'background-color'
if background_color_property not in style_settings:
......@@ -66,18 +69,18 @@ class TestDragAndDropRender(BaseIntegrationTest):
expected_background_color = Colors.BLUE
else:
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)
# Check text color
color_property = 'color'
if color_property not in style_settings:
self.assertNotIn(' ' + color_property, style) # Leading space makes sure that
# test does not find "color" in "background-color"
# Leading space below ensures that test does not find "color" in "background-color"
self.assertNotIn(' ' + color_property, style)
expected_color = Colors.WHITE
else:
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)
def test_items(self):
......@@ -91,7 +94,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(item.get_attribute('data-value'), str(index))
self.assertEqual(item.text, self.ITEM_PROPERTIES[index]['text'])
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
@data(
......@@ -116,9 +119,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(item.get_attribute('data-value'), str(index))
self.assertEqual(item.text, self.ITEM_PROPERTIES[index]['text'])
self.assertIn('ui-draggable', self.get_element_classes(item))
self._test_style(
item, dict(self.ITEM_PROPERTIES[index]['style_settings'], **color_settings), element_type='item'
)
self._test_item_style(item, color_settings)
def test_zones(self):
self.load_scenario()
......@@ -129,26 +130,22 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(zones[0].get_attribute('data-zone'), 'Zone 1')
self.assertIn('ui-droppable', self.get_element_classes(zones[0]))
self._test_style(
zones[0], {'top': '200px', 'left': '120px', 'width': '200px', 'height': '100px'}, element_type='zone'
)
self._assert_box_percentages('#zone-1', left=31.1284, top=6.17284, width=38.1323, height=36.6255)
self.assertEqual(zones[1].get_attribute('data-zone'), 'Zone 2')
self.assertIn('ui-droppable', self.get_element_classes(zones[1]))
self._test_style(
zones[1], {'top': '360px', 'left': '120px', 'width': '200px', 'height': '100px'}, element_type='zone'
)
self._assert_box_percentages('#zone-2', left=16.7315, top=43.2099, width=66.1479, height=28.8066)
def test_feedback(self):
self.load_scenario()
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):
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'
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 selenium.common.exceptions import NoSuchElementException
from tests.integration.test_base import BaseIntegrationTest
from .test_base import BaseIntegrationTest
from workbench import scenarios
......
{
"zones": [
{
"index": 1,
"width": 200,
"title": "Zone <i>1</i>",
"height": 100,
"y": "200",
"x": "100",
"id": "zone-1"
},
{
"index": 2,
"width": 200,
"title": "Zone <b>2</b>",
"height": 100,
"y": 0,
"x": 0,
"id": "zone-2"
}
],
"items": [
{
"displayName": "<b>1</b>",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": false
},
{
"displayName": "<i>2</i>",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": true
},
{
"displayName": "X",
"backgroundImage": "",
"id": 2,
"size": {
"width": "100px",
"height": "100px"
},
"inputOptions": false
},
{
"displayName": "",
"backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg",
"id": 3,
"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
"title": "DnDv2 XBlock with HTML instructions",
"show_title": false,
"question_text": "Solve this <strong>drag-and-drop</strong> problem.",
"show_question_header": false,
"target_img_expanded_url": "/expanded/url/to/drag_and_drop_v2/public/img/triangle.png",
"item_background_color": "white",
"item_text_color": "#000080",
"initial_feedback": "HTML <strong>Intro</strong> Feed",
"display_zone_labels": false,
"zones": [
{
"index": 1,
"title": "Zone <i>1</i>",
"x": 100,
"y": 200,
"width": 200,
"height": 100,
"id": "zone-1"
},
{
"index": 2,
"title": "Zone <b>2</b>",
"x": 0,
"y": 0,
"width": 200,
"height": 100,
"id": "zone-2"
}
],
"items": [
{
"displayName": "<b>1</b>",
"backgroundImage": "",
"id": 0,
"inputOptions": false
},
{
"displayName": "<i>2</i>",
"backgroundImage": "",
"id": 1,
"inputOptions": true
},
{
"displayName": "X",
"backgroundImage": "",
"id": 2,
"inputOptions": false
},
{
"displayName": "",
"backgroundImage": "http://placehold.it/100x300",
"id": 3,
"inputOptions": false
}
]
}
......@@ -5,8 +5,8 @@
"width": 200,
"title": "Zone <i>1</i>",
"height": 100,
"y": "200",
"x": "100",
"y": 200,
"x": 100,
"id": "zone-1"
},
{
......@@ -19,6 +19,7 @@
"id": "zone-2"
}
],
"items": [
{
"displayName": "<b>1</b>",
......@@ -28,11 +29,7 @@
},
"zone": "Zone <i>1</i>",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
}
"id": 0
},
{
"displayName": "<i>2</i>",
......@@ -43,10 +40,6 @@
"zone": "Zone <b>2</b>",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
......@@ -60,11 +53,7 @@
},
"zone": "none",
"backgroundImage": "",
"id": 2,
"size": {
"width": "100px",
"height": "100px"
}
"id": 2
},
{
"displayName": "",
......@@ -73,21 +62,12 @@
"correct": ""
},
"zone": "none",
"backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg",
"id": 3,
"size": {
"width": "190px",
"height": "auto"
}
"backgroundImage": "http://placehold.it/100x300",
"id": 3
}
],
"state": {
"items": {},
"finished": true
},
"feedback": {
"start": "Intro Feed",
"finish": "Final <b>Feed</b>"
},
"targetImg": "http://i0.kym-cdn.com/photos/images/newsfeed/000/030/404/1260585284155.png"
"start": "HTML <strong>Intro</strong> Feed",
"finish": "Final <strong>feedback</strong>!"
}
}
{
"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": [
{
"index": 1,
"title": "Zone 1",
"id": "zone-1",
"height": 100,
"y": "200",
"x": "100",
"width": 200
},
{
"index": 2,
"title": "Zone 2",
"id": "zone-2",
"height": 100,
"y": 0,
"x": 0,
"width": 200
}
],
"items": [
{
"displayName": "1",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": false
},
{
"displayName": "2",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": true
},
{
"displayName": "X",
"backgroundImage": "",
"id": 2,
"size": {
"width": "100px",
"height": "100px"
},
"inputOptions": false
},
{
"displayName": "",
"backgroundImage": "http://i1.kym-cdn.com/entries/icons/square/000/006/151/tumblr_lltzgnHi5F1qzib3wo1_400.jpg",
"id": 3,
"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
"title": "Drag and Drop",
"show_title": true,
"question_text": "",
"show_question_header": true,
"target_img_expanded_url": "http://i0.kym-cdn.com/photos/images/newsfeed/000/030/404/1260585284155.png",
"item_background_color": null,
"item_text_color": null,
"initial_feedback": "Intro Feed",
"display_zone_labels": false,
"zones": [
{
"index": 1,
"title": "Zone 1",
"x": "100",
"y": "200",
"width": 200,
"height": 100,
"id": "zone-1"
},
{
"index": 2,
"title": "Zone 2",
"x": 0,
"y": 0,
"width": 200,
"height": 100,
"id": "zone-2"
}
],
"items": [
{
"displayName": "1",
"backgroundImage": "",
"id": 0,
"inputOptions": false,
"size": {"height": "auto", "width": "190px"}
},
{
"displayName": "2",
"backgroundImage": "",
"id": 1,
"inputOptions": true,
"size": {"height": "auto", "width": "190px"}
},
{
"displayName": "X",
"backgroundImage": "",
"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,
"inputOptions": false,
"size": {"height": "auto", "width": "190px"}
}
]
}
{
"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 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):
......@@ -6,4 +40,33 @@ def load_resource(resource_path):
Gets the content of a resource
"""
resource_content = pkg_resources.resource_string(__name__, resource_path)
return unicode(resource_content)
\ No newline at end of file
return unicode(resource_content)
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