Commit 3849de48 by Jesse Shapiro Committed by GitHub

[SOL-1909] Multiple Drop Targets per Item (#83)

Allowing items to have more than one permitted target zone
parent 7fd8c2ed
...@@ -131,17 +131,16 @@ items, as well as the drag items themselves. A drag item can contain ...@@ -131,17 +131,16 @@ items, as well as the drag items themselves. A drag item can contain
either text or an image. You can define custom success and error either text or an image. You can define custom success and error
feedback for each item. The feedback text is displayed in a popup feedback for each item. The feedback text is displayed in a popup
after the learner drops the item on a zone - the success feedback is after the learner drops the item on a zone - the success feedback is
shown if the item is dropped on the correct zone, while the error shown if the item is dropped on a correct zone, while the error
feedback is shown when dropping the item on an incorrect drop zone. feedback is shown when dropping the item on an incorrect drop zone.
![Zone dropdown](/doc/img/edit-view-zone-dropdown.png) You can select any number of zones for an item to belong to using
the checkboxes; all zones defined in the previous step are available.
You can leave all of the checkboxes unchecked in order to create a
"decoy" item that doesn't belong to any zone.
The zone that an item belongs to is selected from a dropdown that You can define an arbitrary number of drag items, each of which may
includes all drop zones defined in the previous step and a `none` be attached to any number of zones.
option that can be used for "decoy" items - items that don't belong to
any zone.
You can define an arbitrary number of drag items.
Demo Course Demo Course
----------- -----------
......
doc/img/edit-view-items.png

37.4 KB | W: | H:

doc/img/edit-view-items.png

117 KB | W: | H:

doc/img/edit-view-items.png
doc/img/edit-view-items.png
doc/img/edit-view-items.png
doc/img/edit-view-items.png
  • 2-up
  • Swipe
  • Onion skin
...@@ -21,6 +21,7 @@ BOTTOM_ZONE_DESCRIPTION = _("Use this zone to associate an item with the bottom ...@@ -21,6 +21,7 @@ BOTTOM_ZONE_DESCRIPTION = _("Use this zone to associate an item with the bottom
ITEM_CORRECT_FEEDBACK = _("Correct! This one belongs to {zone}.") ITEM_CORRECT_FEEDBACK = _("Correct! This one belongs to {zone}.")
ITEM_INCORRECT_FEEDBACK = _("No, this item does not belong here. Try again.") ITEM_INCORRECT_FEEDBACK = _("No, this item does not belong here. Try again.")
ITEM_NO_ZONE_FEEDBACK = _("You silly, there are no zones for this one.") ITEM_NO_ZONE_FEEDBACK = _("You silly, there are no zones for this one.")
ITEM_ANY_ZONE_FEEDBACK = _("Of course it goes here! It goes anywhere!")
START_FEEDBACK = _("Drag the items onto the image above.") START_FEEDBACK = _("Drag the items onto the image above.")
FINISH_FEEDBACK = _("Good work! You have completed this drag and drop problem.") FINISH_FEEDBACK = _("Good work! You have completed this drag and drop problem.")
...@@ -63,7 +64,9 @@ DEFAULT_DATA = { ...@@ -63,7 +64,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK, "incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE) "correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE)
}, },
"zone": TOP_ZONE_ID, "zones": [
TOP_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 0, "id": 0,
}, },
...@@ -73,7 +76,9 @@ DEFAULT_DATA = { ...@@ -73,7 +76,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK, "incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE) "correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE)
}, },
"zone": MIDDLE_ZONE_ID, "zones": [
MIDDLE_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 1, "id": 1,
}, },
...@@ -83,19 +88,35 @@ DEFAULT_DATA = { ...@@ -83,19 +88,35 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK, "incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE) "correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE)
}, },
"zone": BOTTOM_ZONE_ID, "zones": [
BOTTOM_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 2, "id": 2,
}, },
{ {
"displayName": _("Goes anywhere"),
"feedback": {
"incorrect": "",
"correct": ITEM_ANY_ZONE_FEEDBACK
},
"zones": [
TOP_ZONE_ID,
BOTTOM_ZONE_ID,
MIDDLE_ZONE_ID
],
"imageURL": "",
"id": 3
},
{
"displayName": _("I don't belong anywhere"), "displayName": _("I don't belong anywhere"),
"feedback": { "feedback": {
"incorrect": ITEM_NO_ZONE_FEEDBACK, "incorrect": ITEM_NO_ZONE_FEEDBACK,
"correct": "" "correct": ""
}, },
"zone": "none", "zones": [],
"imageURL": "", "imageURL": "",
"id": 3, "id": 4,
}, },
], ],
"feedback": { "feedback": {
......
...@@ -166,7 +166,11 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -166,7 +166,11 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
items = copy.deepcopy(self.data.get('items', '')) items = copy.deepcopy(self.data.get('items', ''))
for item in items: for item in items:
del item['feedback'] del item['feedback']
del item['zone'] # Use item.pop to remove both `item['zone']` and `item['zones']`; we don't have
# a guarantee that either will be present, so we can't use `del`. Legacy instances
# will have `item['zone']`, while current versions will have `item['zones']`.
item.pop('zone', None)
item.pop('zones', None)
# Fall back on "backgroundImage" to be backward-compatible. # Fall back on "backgroundImage" to be backward-compatible.
image_url = item.get('imageURL') or item.get('backgroundImage') image_url = item.get('imageURL') or item.get('backgroundImage')
if image_url: if image_url:
...@@ -233,6 +237,19 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -233,6 +237,19 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
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))
# Do a bit of manipulation so we get the appearance of a list of zone options on
# items that still have just a single zone stored
items = self.data.get('items', [])
for item in items:
zones = self._get_item_zones(item['id'])
# Note that we appear to be mutating the state of the XBlock here, but because
# the change won't be committed, we're actually just affecting the data that
# we're going to send to the client, not what's saved in the backing store.
item['zones'] = zones
item.pop('zone', None)
fragment.initialize_js('DragAndDropEditBlock', { fragment.initialize_js('DragAndDropEditBlock', {
'data': self.data, 'data': self.data,
'target_img_expanded_url': self.target_img_expanded_url, 'target_img_expanded_url': self.target_img_expanded_url,
...@@ -267,7 +284,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -267,7 +284,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
overall_feedback = None overall_feedback = None
is_correct = False is_correct = False
if item['zone'] == attempt['zone']: # Student placed item in correct zone if self._is_attempt_correct(attempt): # Student placed item in a correct zone
is_correct = True is_correct = True
feedback = item['feedback']['correct'] feedback = item['feedback']['correct']
state = { state = {
...@@ -321,6 +338,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -321,6 +338,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
self.item_state = {} self.item_state = {}
return self._get_user_state() return self._get_user_state()
def _is_attempt_correct(self, attempt):
"""
Check if the item was placed correctly.
"""
correct_zones = self._get_item_zones(attempt['val'])
return attempt['zone'] in correct_zones
def _expand_static_url(self, url): def _expand_static_url(self, url):
""" """
This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the
...@@ -373,12 +397,19 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -373,12 +397,19 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" Get all user-specific data, and any applicable feedback """ """ 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 = self._get_item_definition(int(item_id))
# If information about zone is missing # If information about zone is missing
# (because problem was completed before a11y enhancements were implemented), # (because problem was completed before a11y enhancements were implemented),
# deduce zone in which item is placed from definition: # deduce zone in which item is placed from definition:
if item.get('zone') is None: if item.get('zone') is None:
item['zone'] = definition.get('zone', 'unknown') valid_zones = self._get_item_zones(int(item_id))
if valid_zones:
# If we get to this point, then the item was placed prior to support for
# multiple correct zones being added. As a result, it can only be correct
# on a single zone, and so we can trust that the item was placed on the
# zone with index 0.
item['zone'] = valid_zones[0]
else:
item['zone'] = 'unknown'
is_finished = self._is_finished() is_finished = self._is_finished()
return { return {
...@@ -408,6 +439,24 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -408,6 +439,24 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" """
return next(i for i in self.data['items'] if i['id'] == item_id) return next(i for i in self.data['items'] if i['id'] == item_id)
def _get_item_zones(self, item_id):
"""
Returns a list of the zones that are valid options for the item.
If the item is configured with a list of zones, return that list. If
the item is configured with a single zone, encapsulate that zone's
ID in a list and return the list. If the item is not configured with
any zones, or if it's configured explicitly with no zones, return an
empty list.
"""
item = self._get_item_definition(item_id)
if item.get('zones') is not None:
return item.get('zones')
elif item.get('zone') is not None and item.get('zone') != 'none':
return [item.get('zone')]
else:
return []
def _get_zones(self): def _get_zones(self):
""" """
Get drop zone data, defined by the author. Get drop zone data, defined by the author.
...@@ -432,39 +481,37 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -432,39 +481,37 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
if zone["uid"] == uid: if zone["uid"] == uid:
return zone return zone
def _get_grade(self): def _get_item_stats(self):
""" """
Returns the student's grade for this block. Returns a tuple representing the number of correctly-placed items,
and the total number of items that must be placed on the board (non-decoy items).
""" """
correct_count = 0 all_items = self.data['items']
total_count = 0
item_state = self._get_item_state() item_state = self._get_item_state()
for item in self.data['items']: required_items = [str(item['id']) for item in all_items if self._get_item_zones(item['id']) != []]
if item['zone'] != 'none': placed_items = [item for item in required_items if item in item_state]
total_count += 1 correct_items = [item for item in placed_items if item_state[item]['correct']]
item_id = str(item['id'])
if item_id in item_state: required_count = len(required_items)
correct_count += 1 correct_count = len(correct_items)
return correct_count / float(total_count) * self.weight return correct_count, required_count
def _get_grade(self):
"""
Returns the student's grade for this block.
"""
correct_count, required_count = self._get_item_stats()
return correct_count / float(required_count) * self.weight
def _is_finished(self): def _is_finished(self):
""" """
All items are at their correct place and a value has been All items are at their correct place and a value has been
submitted for each item that expects a value. submitted for each item that expects a value.
""" """
completed_count = 0 correct_count, required_count = self._get_item_stats()
total_count = 0 return correct_count == required_count
item_state = self._get_item_state()
for item in self.data['items']:
if item['zone'] != 'none':
total_count += 1
item_id = str(item['id'])
if item_id in item_state:
completed_count += 1
return completed_count == total_count
@XBlock.json_handler @XBlock.json_handler
def publish_event(self, data, suffix=''): def publish_event(self, data, suffix=''):
......
...@@ -178,17 +178,22 @@ ...@@ -178,17 +178,22 @@
margin: 15px 0; margin: 15px 0;
} }
.xblock--drag-and-drop--editor .items-form label { .xblock--drag-and-drop--editor .items-form label,
.xblock--drag-and-drop--editor .items-form .h3 {
margin: 0 1%; margin: 0 1%;
} }
.xblock--drag-and-drop--editor .items-form input,
.xblock--drag-and-drop--editor .items-form select { .xblock--drag-and-drop--editor .items-form select {
width: 35%; width: 35%;
} }
.xblock--drag-and-drop--editor .items-form .zone-checkbox {
width: initial;
}
.xblock--drag-and-drop--editor .items-form .item-text,
.xblock--drag-and-drop--editor .items-form .item-image-url { .xblock--drag-and-drop--editor .items-form .item-image-url {
width: 81%; width: 50%;
margin: 0 1%; margin: 0 1%;
} }
...@@ -201,6 +206,10 @@ ...@@ -201,6 +206,10 @@
margin: 0 1%; margin: 0 1%;
} }
.xblock--drag-and-drop--editor .items-form .zone-checkbox-label {
text-align: left;
}
.xblock--drag-and-drop--editor .items-form .row { .xblock--drag-and-drop--editor .items-form .row {
margin-bottom: 20px; margin-bottom: 20px;
} }
...@@ -211,6 +220,9 @@ ...@@ -211,6 +220,9 @@
padding-left: 1em; padding-left: 1em;
font-size: 80%; font-size: 80%;
} }
.xblock--drag-and-drop-editor .items-form .zone-checkbox-row {
margin-bottom: 0px;
}
/** Buttons **/ /** Buttons **/
.xblock--drag-and-drop--editor .btn { .xblock--drag-and-drop--editor .btn {
...@@ -243,8 +255,9 @@ ...@@ -243,8 +255,9 @@
} }
.xblock--drag-and-drop--editor .remove-item { .xblock--drag-and-drop--editor .remove-item {
float: right;
display: inline-block; display: inline-block;
margin-left: 95px; margin-right: 16px;
} }
.xblock--drag-and-drop--editor .icon { .xblock--drag-and-drop--editor .icon {
......
...@@ -31,7 +31,7 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -31,7 +31,7 @@ function DragAndDropEditBlock(runtime, element, params) {
_fn.tpl = { _fn.tpl = {
zoneInput: Handlebars.compile($("#zone-input-tpl", element).html()), zoneInput: Handlebars.compile($("#zone-input-tpl", element).html()),
zoneElement: Handlebars.compile($("#zone-element-tpl", element).html()), zoneElement: Handlebars.compile($("#zone-element-tpl", element).html()),
zoneDropdown: Handlebars.compile($("#zone-dropdown-tpl", element).html()), zoneCheckbox: Handlebars.compile($("#zone-checkbox-tpl", element).html()),
itemInput: Handlebars.compile($("#item-input-tpl", element).html()), itemInput: Handlebars.compile($("#item-input-tpl", element).html()),
}; };
} }
...@@ -357,26 +357,20 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -357,26 +357,20 @@ function DragAndDropEditBlock(runtime, element, params) {
_fn.build.form.zone.renderZonesPreview(); _fn.build.form.zone.renderZonesPreview();
}, },
}, },
createDropdown: function(selectedUID) { createCheckboxes: function(selectedZones) {
var template = _fn.tpl.zoneDropdown; var template = _fn.tpl.zoneCheckbox;
var dropdown = []; var checkboxes = [];
var zoneObjects = _fn.build.form.zone.zoneObjects; var zoneObjects = _fn.build.form.zone.zoneObjects;
zoneObjects.forEach(function(zoneObj) { zoneObjects.forEach(function(zoneObj) {
dropdown.push(template({ checkboxes.push(template({
uid: zoneObj.uid, zoneUid: zoneObj.uid,
title: zoneObj.title, title: zoneObj.title,
selected: (zoneObj.uid == selectedUID) ? 'selected' : '', checked: $.inArray(zoneObj.uid, selectedZones) !== -1 ? 'checked' : '',
})); }));
}); });
dropdown.push(template({ var html = checkboxes.join('');
uid: "none",
title: window.gettext("None"),
selected: (selectedUID === "none") ? 'selected' : '',
}));
var html = dropdown.join('');
return new Handlebars.SafeString(html); return new Handlebars.SafeString(html);
}, },
feedback: function($form) { feedback: function($form) {
...@@ -415,8 +409,7 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -415,8 +409,7 @@ function DragAndDropEditBlock(runtime, element, params) {
ctx.pixelHeight = itemData.size.height.substr(0, itemData.size.height.length - 2); // Remove 'px' ctx.pixelHeight = itemData.size.height.substr(0, itemData.size.height.length - 2); // Remove 'px'
} }
} }
ctx.checkboxes = _fn.build.form.createCheckboxes(ctx.zones);
ctx.dropdown = _fn.build.form.createDropdown(ctx.zone);
_fn.build.form.item.count++; _fn.build.form.item.count++;
$form.append(tpl(ctx)); $form.append(tpl(ctx));
...@@ -464,12 +457,15 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -464,12 +457,15 @@ function DragAndDropEditBlock(runtime, element, params) {
var $el = $(el), var $el = $(el),
name = $el.find('.item-text').val(), name = $el.find('.item-text').val(),
imageURL = $el.find('.item-image-url').val(), imageURL = $el.find('.item-image-url').val(),
imageDescription = $el.find('.item-image-description').val(); imageDescription = $el.find('.item-image-description').val(),
selectedZones = $el.find('.zone-checkbox:checked');
if (name.length > 0 || imageURL.length > 0) { if (name.length > 0 || imageURL.length > 0) {
var data = { var data = {
displayName: name, displayName: name,
zone: $el.find('.zone-select').val(), zones: $.map(selectedZones, function(checkbox){
return checkbox.value;
}),
id: i, id: i,
feedback: { feedback: {
correct: $el.find('.success-feedback').val(), correct: $el.find('.success-feedback').val(),
......
...@@ -82,45 +82,61 @@ ...@@ -82,45 +82,61 @@
</div> </div>
</script> </script>
<script id="zone-dropdown-tpl" type="text/html"> <script id="zone-checkbox-tpl" type="text/html">
<option value="{{ uid }}" {{ selected }}>{{ title }}</option> <div class="zone-checkbox-row">
<label>
<input type="checkbox"
value="{{ zoneUid }}"
class="zone-checkbox"
{{ checked }} />
{{ title }}
</label>
</div>
</script> </script>
<script id="item-input-tpl" type="text/html"> <script id="item-input-tpl" type="text/html">
<div class="item"> <div class="item">
<div class="row"> <div class="row">
<label for="item-{{id}}-text">{{i18n "Text"}}</label> <label class="h3">
{{i18n "Text"}}
<input type="text" <input type="text"
id="item-{{id}}-text" placeholder="{{i18n 'Use text that is clear and descriptive of the item to be placed'}}"
class="item-text" class="item-text"
value="{{ displayName }}" /> value="{{ displayName }}" />
<label for="item-{{id}}-zone">{{i18n "Zone"}}</label> </label>
<select id="item-{{id}}-zone"
class="zone-select">{{ dropdown }}</select>
<a href="#" class="remove-item hidden"> <a href="#" class="remove-item hidden">
<div class="icon remove"></div> <div class="icon remove"></div>
</a> </a>
</div> </div>
<div class="row"> <div class="row">
<label for="item-{{id}}-image-url">{{i18n "Image URL (alternative to the text)"}}</label> <fieldset>
<legend class="h3">
{{ i18n "Zones" }}
</legend>
{{ checkboxes }}
</fieldset>
</div>
<div class="row">
<label class="h3">
{{i18n "Image URL (alternative to the text)"}}
<input type="text" <input type="text"
id="item-{{id}}-image-url"
placeholder="{{i18n 'For example, http://example.com/image.png or /static/image.png'}}" placeholder="{{i18n 'For example, http://example.com/image.png or /static/image.png'}}"
class="item-image-url" class="item-image-url"
value="{{ imageURL }}" /> value="{{ imageURL }}" />
</label>
</div> </div>
<div class="row"> <div class="row">
<label for="item-{{id}}-image-description">{{i18n "Image description (should provide sufficient information to place the item even if the image did not load)"}}</label> <label class="h3" for="item-{{id}}-image-description">{{i18n "Image description (should provide sufficient information to place the item even if the image did not load)"}}</label>
<textarea id="item-{{id}}-image-description" {{#if imageURL}}required{{/if}} <textarea id="item-{{id}}-image-description" {{#if imageURL}}required{{/if}}
class="item-image-description">{{ imageDescription }}</textarea> class="item-image-description">{{ imageDescription }}</textarea>
</div> </div>
<div class="row"> <div class="row">
<label for="item-{{id}}-success-feedback">{{i18n "Success Feedback"}}</label> <label class="h3" for="item-{{id}}-success-feedback">{{i18n "Success Feedback"}}</label>
<textarea id="item-{{id}}-success-feedback" <textarea id="item-{{id}}-success-feedback"
class="success-feedback">{{ feedback.correct }}</textarea> class="success-feedback">{{ feedback.correct }}</textarea>
</div> </div>
<div class="row"> <div class="row">
<label for="item-{{id}}-error-feedback">{{i18n "Error Feedback"}}</label> <label class="h3" for="item-{{id}}-error-feedback">{{i18n "Error Feedback"}}</label>
<textarea id="item-{{id}}-error-feedback" <textarea id="item-{{id}}-error-feedback"
class="error-feedback">{{ feedback.incorrect }}</textarea> class="error-feedback">{{ feedback.incorrect }}</textarea>
</div> </div>
...@@ -128,8 +144,14 @@ ...@@ -128,8 +144,14 @@
<a href="#">{{i18n "Show advanced settings" }}</a> <a href="#">{{i18n "Show advanced settings" }}</a>
</div> </div>
<div class="row advanced"> <div class="row advanced">
<label for="item-{{id}}-width-percent">{{i18n "Preferred width as a percentage of the background image width (or blank for automatic width):"}}</label> <label>
<input type="number" id="item-{{id}}-width-percent" class="item-width" value="{{ singleDecimalFloat widthPercent }}" step="0.1" min="1" max="99" />% {{i18n "Preferred width as a percentage of the background image width (or blank for automatic width):"}}
<input type="number"
class="item-width"
value="{{ singleDecimalFloat widthPercent }}"
step="0.1"
min="1"
max="99" />%
</div> </div>
</div> </div>
</script> </script>
...@@ -19,6 +19,14 @@ msgid "" ...@@ -19,6 +19,14 @@ msgid ""
msgstr "" msgstr ""
#: default_data.py #: default_data.py
msgid "Of course it goes here! It goes anywhere!"
msgstr ""
#: default_data.py
msgid "Goes anywhere"
msgstr ""
#: default_data.py
msgid "The Top Zone" msgid "The Top Zone"
msgstr "" msgstr ""
...@@ -248,6 +256,14 @@ msgid "" ...@@ -248,6 +256,14 @@ msgid ""
"automatic width):" "automatic width):"
msgstr "" msgstr ""
#: templates/html/js_templates.html
msgid "Zones"
msgstr ""
#: templates/html/js_templates.html
msgid "Use text that is clear and descriptive of the item to be placed"
msgstr ""
#: templates/html/drag_and_drop.html #: templates/html/drag_and_drop.html
msgid "Loading drag and drop problem." msgid "Loading drag and drop problem."
msgstr "" msgstr ""
......
...@@ -104,6 +104,14 @@ msgstr "Göés tö thé ßöttöm Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α ...@@ -104,6 +104,14 @@ msgstr "Göés tö thé ßöttöm Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α
msgid "I don't belong anywhere" msgid "I don't belong anywhere"
msgstr "Ì dön't ßélöng änýwhéré Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#" msgstr "Ì dön't ßélöng änýwhéré Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
#: default_data.py
msgid "Of course it goes here! It goes anywhere!"
msgstr "Öf çöürsé ït göés héré! Ìt göés änýwhéré! Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: default_data.py
msgid "Goes anywhere"
msgstr "Göés änýwhéré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Title" msgid "Title"
msgstr "Tïtlé Ⱡ'σяєм ιρѕ#" msgstr "Tïtlé Ⱡ'σяєм ιρѕ#"
...@@ -304,6 +312,16 @@ msgstr "" ...@@ -304,6 +312,16 @@ msgstr ""
"Préférréd wïdth äs ä pérçéntägé öf thé ßäçkgröünd ïmägé wïdth (ör ßlänk för " "Préférréd wïdth äs ä pérçéntägé öf thé ßäçkgröünd ïmägé wïdth (ör ßlänk för "
"äütömätïç wïdth): Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#" "äütömätïç wïdth): Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
#: templates/html/js_templates.html
msgid "Zones"
msgstr "Zönés Ⱡ'σяєм ιρѕ#"
#: templates/html/js_templates.html
msgid "Use text that is clear and descriptive of the item to be placed"
msgstr ""
"Ûsé téxt thät ïs çléär änd désçrïptïvé öf thé ïtém tö ßé pläçéd Ⱡ'σяєм "
"ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: templates/html/drag_and_drop.html #: templates/html/drag_and_drop.html
msgid "Loading drag and drop problem." msgid "Loading drag and drop problem."
msgstr "Löädïng dräg änd dröp prößlém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" msgstr "Löädïng dräg änd dröp prößlém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
......
{
"zones": [
{
"width": 200,
"title": "Zone 1",
"height": 100,
"y": "200",
"x": "100",
"uid": "zone-1"
},
{
"width": 200,
"title": "Zone 2",
"height": 100,
"y": 0,
"x": 0,
"uid": "zone-2"
}
],
"items": [
{
"displayName": "1 here",
"feedback": {
"incorrect": "No 1",
"correct": "Yes 1"
},
"zones": [
"zone-1",
"zone-2"
],
"imageURL": "",
"id": 0
},
{
"displayName": "2 here",
"feedback": {
"incorrect": "No 2",
"correct": "Yes 2"
},
"zone": "zone-2",
"imageURL": "",
"id": 1
},
{
"displayName": "X",
"feedback": {
"incorrect": "No Zone for this",
"correct": ""
},
"zone": "none",
"imageURL": "",
"id": 2
}
],
"feedback": {
"start": "Some Intro Feed",
"finish": "Some Final Feed"
}
}
...@@ -14,7 +14,7 @@ from drag_and_drop_v2.default_data import ( ...@@ -14,7 +14,7 @@ from drag_and_drop_v2.default_data import (
TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE, TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK, ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK,
START_FEEDBACK, FINISH_FEEDBACK ITEM_ANY_ZONE_FEEDBACK, START_FEEDBACK, FINISH_FEEDBACK
) )
from .test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
...@@ -27,10 +27,10 @@ loader = ResourceLoader(__name__) ...@@ -27,10 +27,10 @@ loader = ResourceLoader(__name__)
# Classes ########################################################### # Classes ###########################################################
class ItemDefinition(object): class ItemDefinition(object):
def __init__(self, item_id, zone_id, zone_title, feedback_positive, feedback_negative): def __init__(self, item_id, zone_ids, zone_title, feedback_positive, feedback_negative):
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_ids = zone_ids
self.zone_title = zone_title self.zone_title = zone_title
self.item_id = item_id self.item_id = item_id
...@@ -40,14 +40,14 @@ class InteractionTestBase(object): ...@@ -40,14 +40,14 @@ class InteractionTestBase(object):
def _get_items_with_zone(cls, items_map): def _get_items_with_zone(cls, items_map):
return { return {
item_key: definition for item_key, definition in items_map.items() item_key: definition for item_key, definition in items_map.items()
if definition.zone_id is not None if definition.zone_ids != []
} }
@classmethod @classmethod
def _get_items_without_zone(cls, items_map): def _get_items_without_zone(cls, items_map):
return { return {
item_key: definition for item_key, definition in items_map.items() item_key: definition for item_key, definition in items_map.items()
if definition.zone_id is None if definition.zone_ids == []
} }
def setUp(self): def setUp(self):
...@@ -169,7 +169,7 @@ class InteractionTestBase(object): ...@@ -169,7 +169,7 @@ class InteractionTestBase(object):
self.scroll_down(pixels=scroll_down) self.scroll_down(pixels=scroll_down)
for definition in self._get_items_with_zone(items_map).values(): for definition in self._get_items_with_zone(items_map).values():
self.place_item(definition.item_id, definition.zone_id, action_key) self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.wait_until_html_in(definition.feedback_positive, feedback_popup_content) self.wait_until_html_in(definition.feedback_positive, feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup') self.assertEqual(popup.get_attribute('class'), 'popup')
self.assert_placed_item(definition.item_id, definition.zone_title) self.assert_placed_item(definition.item_id, definition.zone_title)
...@@ -183,7 +183,7 @@ class InteractionTestBase(object): ...@@ -183,7 +183,7 @@ class InteractionTestBase(object):
for definition in items_map.values(): for definition in items_map.values():
for zone in all_zones: for zone in all_zones:
if zone == definition.zone_id: if zone in definition.zone_ids:
continue continue
self.place_item(definition.item_id, zone, action_key) self.place_item(definition.item_id, zone, action_key)
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content) self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
...@@ -205,7 +205,7 @@ class InteractionTestBase(object): ...@@ -205,7 +205,7 @@ class InteractionTestBase(object):
self.scroll_down(pixels=scroll_down) self.scroll_down(pixels=scroll_down)
for item_key, definition in items.items(): for item_key, definition in items.items():
self.place_item(definition.item_id, definition.zone_id, action_key) self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.assert_placed_item(definition.item_id, definition.zone_title) self.assert_placed_item(definition.item_id, definition.zone_title)
self.wait_until_html_in(feedback['final'], self._get_feedback_message()) self.wait_until_html_in(feedback['final'], self._get_feedback_message())
...@@ -280,18 +280,22 @@ class DefaultDataTestMixin(object): ...@@ -280,18 +280,22 @@ class DefaultDataTestMixin(object):
items_map = { items_map = {
0: ItemDefinition( 0: ItemDefinition(
0, TOP_ZONE_ID, TOP_ZONE_TITLE, 0, [TOP_ZONE_ID], TOP_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
), ),
1: ItemDefinition( 1: ItemDefinition(
1, MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE, 1, [MIDDLE_ZONE_ID], MIDDLE_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
), ),
2: ItemDefinition( 2: ItemDefinition(
2, BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE, 2, [BOTTOM_ZONE_ID], BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
), ),
3: ItemDefinition(3, None, None, "", ITEM_NO_ZONE_FEEDBACK), 3: ItemDefinition(
3, [MIDDLE_ZONE_ID, TOP_ZONE_ID, BOTTOM_ZONE_ID], MIDDLE_ZONE_TITLE,
ITEM_ANY_ZONE_FEEDBACK, ITEM_INCORRECT_FEEDBACK
),
4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK),
} }
all_zones = [TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID] all_zones = [TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID]
...@@ -322,6 +326,30 @@ class BasicInteractionTest(DefaultDataTestMixin, InteractionTestBase): ...@@ -322,6 +326,30 @@ class BasicInteractionTest(DefaultDataTestMixin, InteractionTestBase):
self.interact_with_keyboard_help() self.interact_with_keyboard_help()
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
items_map = {
0: ItemDefinition(0, ['zone-1', 'zone-2'], ["Zone 1", "Zone 2"], ["Yes 1", "Yes 1"], ["No 1", "No 1"]),
}
def test_multiple_positive_feedback(self):
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
reset = self._get_reset_button()
self.scroll_down(pixels=100)
for item in self.items_map.values():
for i, zone in enumerate(item.zone_ids):
self.place_item(item.item_id, zone, None)
self.wait_until_html_in(item.feedback_positive[i], feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup')
self.assert_placed_item(item.item_id, item.zone_title[i])
reset.click()
def _get_scenario_xml(self):
return self._get_custom_scenario_xml("data/test_multiple_options_data.json")
@ddt @ddt
class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
""" """
...@@ -339,7 +367,7 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration ...@@ -339,7 +367,7 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration
}, },
{ {
'name': 'grade', 'name': 'grade',
'data': {'max_value': 1, 'value': (1.0 / 3)}, 'data': {'max_value': 1, 'value': (1.0 / 4)},
}, },
{ {
'name': 'edx.drag_and_drop_v2.item.dropped', 'name': 'edx.drag_and_drop_v2.item.dropped',
...@@ -409,9 +437,9 @@ class KeyboardInteractionTest(BasicInteractionTest, BaseIntegrationTest): ...@@ -409,9 +437,9 @@ class KeyboardInteractionTest(BasicInteractionTest, BaseIntegrationTest):
class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest): class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest):
items_map = { items_map = {
0: ItemDefinition(0, 'zone-1', "Zone 1", "Yes 1", "No 1"), 0: ItemDefinition(0, ['zone-1'], "Zone 1", "Yes 1", "No 1"),
1: ItemDefinition(1, 'zone-2', "Zone 2", "Yes 2", "No 2"), 1: ItemDefinition(1, ['zone-2'], "Zone 2", "Yes 2", "No 2"),
2: ItemDefinition(2, None, None, "", "No Zone for this") 2: ItemDefinition(2, [], None, "", "No Zone for this")
} }
all_zones = ['zone-1', 'zone-2'] all_zones = ['zone-1', 'zone-2']
...@@ -427,9 +455,9 @@ class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest): ...@@ -427,9 +455,9 @@ class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest):
class CustomHtmlDataInteractionTest(BasicInteractionTest, BaseIntegrationTest): class CustomHtmlDataInteractionTest(BasicInteractionTest, BaseIntegrationTest):
items_map = { items_map = {
0: ItemDefinition(0, 'zone-1', 'Zone <i>1</i>', "Yes <b>1</b>", "No <b>1</b>"), 0: ItemDefinition(0, ['zone-1'], 'Zone <i>1</i>', "Yes <b>1</b>", "No <b>1</b>"),
1: ItemDefinition(1, 'zone-2', 'Zone <b>2</b>', "Yes <i>2</i>", "No <i>2</i>"), 1: ItemDefinition(1, ['zone-2'], 'Zone <b>2</b>', "Yes <i>2</i>", "No <i>2</i>"),
2: ItemDefinition(2, None, None, "", "No Zone for <i>X</i>") 2: ItemDefinition(2, [], None, "", "No Zone for <i>X</i>")
} }
all_zones = ['zone-1', 'zone-2'] all_zones = ['zone-1', 'zone-2']
...@@ -452,14 +480,14 @@ class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest): ...@@ -452,14 +480,14 @@ class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest):
item_maps = { item_maps = {
'block1': { 'block1': {
0: ItemDefinition(0, 'zone-1', 'Zone 1', "Yes 1", "No 1"), 0: ItemDefinition(0, ['zone-1'], 'Zone 1', "Yes 1", "No 1"),
1: ItemDefinition(1, 'zone-2', 'Zone 2', "Yes 2", "No 2"), 1: ItemDefinition(1, ['zone-2'], 'Zone 2', "Yes 2", "No 2"),
2: ItemDefinition(2, None, None, "", "No Zone for this") 2: ItemDefinition(2, [], None, "", "No Zone for this")
}, },
'block2': { 'block2': {
10: ItemDefinition(10, 'zone-51', 'Zone 51', "Correct 1", "Incorrect 1"), 10: ItemDefinition(10, ['zone-51'], 'Zone 51', "Correct 1", "Incorrect 1"),
20: ItemDefinition(20, 'zone-52', 'Zone 52', "Correct 2", "Incorrect 2"), 20: ItemDefinition(20, ['zone-52'], 'Zone 52', "Correct 2", "Incorrect 2"),
30: ItemDefinition(30, None, None, "", "No Zone for this") 30: ItemDefinition(30, [], None, "", "No Zone for this")
}, },
} }
......
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
"incorrect": "No <b>1</b>", "incorrect": "No <b>1</b>",
"correct": "Yes <b>1</b>" "correct": "Yes <b>1</b>"
}, },
"zone": "Zone <i>1</i>", "zones": ["Zone <i>1</i>"],
"imageURL": "", "imageURL": "",
"id": 0 "id": 0
}, },
...@@ -37,7 +37,10 @@ ...@@ -37,7 +37,10 @@
"incorrect": "No <i>2</i>", "incorrect": "No <i>2</i>",
"correct": "Yes <i>2</i>" "correct": "Yes <i>2</i>"
}, },
"zone": "Zone <b>2</b>", "zones": [
"Zone <b>2</b>",
"Zone <i>1</i>"
],
"imageURL": "", "imageURL": "",
"id": 1 "id": 1
}, },
...@@ -47,7 +50,7 @@ ...@@ -47,7 +50,7 @@
"incorrect": "", "incorrect": "",
"correct": "" "correct": ""
}, },
"zone": "none", "zones": [],
"imageURL": "", "imageURL": "",
"id": 2 "id": 2
}, },
...@@ -57,7 +60,7 @@ ...@@ -57,7 +60,7 @@
"incorrect": "", "incorrect": "",
"correct": "" "correct": ""
}, },
"zone": "none", "zones": [],
"imageURL": "http://placehold.it/100x300", "imageURL": "http://placehold.it/100x300",
"id": 3 "id": 3
} }
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
"incorrect": "No 1", "incorrect": "No 1",
"correct": "Yes 1" "correct": "Yes 1"
}, },
"zone": "zone-1", "zones": ["zone-1"],
"imageURL": "", "imageURL": "",
"id": 0 "id": 0
}, },
...@@ -36,7 +36,10 @@ ...@@ -36,7 +36,10 @@
"incorrect": "No 2", "incorrect": "No 2",
"correct": "Yes 2" "correct": "Yes 2"
}, },
"zone": "zone-2", "zones": [
"zone-2",
"zone-1"
],
"imageURL": "", "imageURL": "",
"id": 1 "id": 1
}, },
...@@ -46,7 +49,7 @@ ...@@ -46,7 +49,7 @@
"incorrect": "", "incorrect": "",
"correct": "" "correct": ""
}, },
"zone": "none", "zones": [],
"imageURL": "/static/test_url_expansion", "imageURL": "/static/test_url_expansion",
"id": 2 "id": 2
}, },
...@@ -56,7 +59,7 @@ ...@@ -56,7 +59,7 @@
"incorrect": "", "incorrect": "",
"correct": "" "correct": ""
}, },
"zone": "none", "zones": [],
"imageURL": "http://placehold.it/200x100", "imageURL": "http://placehold.it/200x100",
"id": 3 "id": 3
} }
......
...@@ -48,7 +48,13 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -48,7 +48,13 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.assertEqual(items, [ self.assertEqual(items, [
{"id": i, "displayName": display_name, "imageURL": "", "expandedImageURL": ""} {"id": i, "displayName": display_name, "imageURL": "", "expandedImageURL": ""}
for i, display_name in enumerate( for i, display_name in enumerate(
["Goes to the top", "Goes to the middle", "Goes to the bottom", "I don't belong anywhere"] [
"Goes to the top",
"Goes to the middle",
"Goes to the bottom",
"Goes anywhere",
"I don't belong anywhere"
]
) )
]) ])
...@@ -72,6 +78,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -72,6 +78,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.call_handler('do_attempt', data) self.call_handler('do_attempt', data)
data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"} data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"}
self.call_handler('do_attempt', data) self.call_handler('do_attempt', data)
data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"}
self.call_handler('do_attempt', data)
# Check the result: # Check the result:
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
...@@ -79,12 +87,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -79,12 +87,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID}, '0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID}, '1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, '2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID},
}) })
self.assertEqual(self.call_handler('get_user_state'), { self.assertEqual(self.call_handler('get_user_state'), {
'items': { 'items': {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID}, '0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID}, '1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, '2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID},
}, },
'finished': True, 'finished': True,
'overall_feedback': FINISH_FEEDBACK, 'overall_feedback': FINISH_FEEDBACK,
......
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