Commit 32f48eea by Braden MacDonald

Merge pull request #49 from open-craft/width-changes

Mobile support: Improve draggable width options for more flexible authoring
parents 6d42da9e 6b2155bb
......@@ -17,4 +17,4 @@ script:
notifications:
email: false
addons:
firefox: "36.0"
firefox: "43.0"
......@@ -111,6 +111,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" Translate text """
return self.runtime.service(self, "i18n").ugettext(text)
@XBlock.supports("multi_device") # Enable this block for use in the mobile app via webview
def student_view(self, context):
"""
Player view, displayed to the student
......
......@@ -57,16 +57,17 @@
position: relative;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 3px;
padding: 5px;
padding: 0; /* padding: 5px looks better but makes some blocks to change in size when dropped onto the target; */
}
.xblock--drag-and-drop .drag-container .option {
display: inline-block;
width: auto;
min-width: 4em;
max-width: calc(100% / 3 - 1% - 1% - 20px);
max-width: 30%;
border: 1px solid transparent;
border-radius: 3px;
box-sizing: border-box;
margin: 5px;
padding: 10px;
background-color: #1d5280;
......@@ -79,6 +80,10 @@
z-index: 10 !important;
}
.xblock--drag-and-drop .drag-container .option.specified-width img {
width: 100%; /* If the image is smaller than the specified width, make it larger */
}
.xblock--drag-and-drop .drag-container .option .spinner-wrapper {
margin-right: 3px;
float: left;
......@@ -122,15 +127,16 @@
outline-offset: -4px;
}
.xblock--drag-and-drop .drag-container .ui-draggable-dragging {
.xblock--drag-and-drop .drag-container .ui-draggable-dragging.option {
box-shadow: 0 16px 32px 0 rgba(0, 0, 0, 0.3);
border: 1px solid #ccc;
opacity: .65;
z-index: 20 !important;
margin: 0; /* Allow the draggable to touch the edges of the target image */
}
.xblock--drag-and-drop .drag-container .option img {
display: block;
display: inline-block;
max-width: 100%;
}
......
......@@ -185,9 +185,8 @@
margin-right: 1%;
}
.xblock--drag-and-drop--editor .items-form .item-width,
.xblock--drag-and-drop--editor .items-form .item-height {
width: 40px;
.xblock--drag-and-drop--editor .items-form .item-width {
width: 50px;
}
.xblock--drag-and-drop--editor .items-form .item-numerical-value,
......@@ -204,7 +203,13 @@
.xblock--drag-and-drop--editor .items-form .row {
margin-bottom: 20px;
}
.xblock--drag-and-drop--editor .items-form .row.advanced {
display: none;
}
.xblock--drag-and-drop--editor .items-form .row.advanced-link {
padding-left: 1em;
font-size: 80%;
}
/** Buttons **/
.xblock--drag-and-drop--editor .btn {
......
......@@ -10,6 +10,7 @@ function DragAndDropBlock(runtime, element, configuration) {
var root = $root[0];
var state = undefined;
var bgImgNaturalWidth = undefined; // pixel width of the background image (when not scaled)
var __vdom = virtualDom.h(); // blank virtual DOM
// Event string size limit.
......@@ -42,11 +43,11 @@ function DragAndDropBlock(runtime, element, configuration) {
computeZoneDimension(zone, bgImg.width, bgImg.height);
});
state = stateResult[0]; // stateResult is an array of [data, statusText, jqXHR]
migrateConfiguration(bgImg.width);
migrateState(bgImg.width, bgImg.height);
applyState();
bgImgNaturalWidth = bgImg.width;
// Set up event handlers
initDroppable();
// Set up event handlers:
$(document).on('keydown mousedown touchstart', closePopup);
$element.on('click', '.keyboard-help-button', showKeyboardHelp);
......@@ -59,6 +60,13 @@ function DragAndDropBlock(runtime, element, configuration) {
});
$element.on('click', '.submit-input', submitInput);
// For the next one, we need to use addEventListener with useCapture 'true' in order
// to watch for load events on any child element, since load events do not bubble.
element.addEventListener('load', webkitFix, true);
applyState();
initDroppable();
// Indicate that exercise is done loading
publishEvent({event_type: 'edx.drag_and_drop_v2.loaded'});
}).fail(function() {
......@@ -166,6 +174,38 @@ function DragAndDropBlock(runtime, element, configuration) {
}
};
/**
* webkitFix:
* When our draggables do not have a width specified by the author, we want them sized using
* the following algorithm: "be as wide as possible but never wider than ~30% of the
* background image width and never wider than the natural size of the text or image
* that is this draggable's content." (this works well for both desktop and mobile)
*
* The current CSS rules to achieve this work fine for draggables in the "tray" at the top,
* but when they are placed (position:absolute), there seems to be no way to achieve this
* that works consistently in both Webkit and firefox. (Using display: table works in Webkit
* but not Firefox; using the current CSS works in Firefox but not Webkit. This is due to
* the amiguous nature of 'max-width' when refering to a parent whose width is computed from
* the child (<div style='width: auto;'><img style='width:auto; max-width: x%;'></div>)
*
* This workaround simply detects the image width when any image loads, then sets the width
* on the [grand]parent element, resolving the ambiguity.
*/
var webkitFix = function(event) {
var $img = $(event.target);
var $option = $img.parent().parent();
if (!$option.is('.option')) {
return;
}
var itemId = $option.data('value');
configuration.items.forEach(function(item) {
if (item.id == itemId) {
item.imgNaturalWidth = event.target.naturalWidth;
}
});
setTimeout(applyState, 0); // Apply changes to the DOM after the event handling completes.
};
var previousFeedback = undefined;
/**
......@@ -540,6 +580,8 @@ function DragAndDropBlock(runtime, element, configuration) {
imageDescription: item.imageDescription,
has_image: !!imageURL,
grabbed: grabbed,
widthPercent: item.widthPercent, // widthPercent may be undefined (auto width)
imgNaturalWidth: item.imgNaturalWidth,
};
if (item_user_state) {
itemProperties.is_placed = true;
......@@ -558,6 +600,7 @@ function DragAndDropBlock(runtime, element, configuration) {
var context = {
// configuration - parts that never change:
bg_image_width: bgImgNaturalWidth, // Not stored in configuration since it's unknown on the server side
header_html: configuration.title,
show_title: configuration.show_title,
question_html: configuration.question_text,
......@@ -578,6 +621,21 @@ function DragAndDropBlock(runtime, element, configuration) {
};
/**
* migrateConfiguration: Apply any changes to support older versions of the configuration.
* We have to do this in JS, not python, since some migrations depend on the image size,
* which is not known in Python-land.
*/
var migrateConfiguration = function(bg_image_width) {
for (var i in configuration.items) {
var item = configuration.items[i];
// Convert from old-style pixel widths to new-style percentage widths:
if (item.widthPercent === undefined && item.size && parseInt(item.size.width) > 0) {
item.widthPercent = parseInt(item.size.width) / bg_image_width * 100;
}
}
}
/**
* migrateState: Apply any changes necessary to support the 'state' format used by older
* versions of this XBlock.
* We have to do this in JS, not python, since some migrations depend on the image size,
......
......@@ -7,6 +7,13 @@ function DragAndDropEditBlock(runtime, element, params) {
// Make gettext available in Handlebars templates
Handlebars.registerHelper('i18n', function(str) { return gettext(str); });
// Numeric rounding in Handlebars templates
Handlebars.registerHelper('singleDecimalFloat', function(value) {
if (value === "" || isNaN(Number(value))) {
return "";
}
return Number(value).toFixed(Number(value) == parseInt(value) ? 0 : 1);
});
var $element = $(element);
......@@ -154,7 +161,8 @@ function DragAndDropEditBlock(runtime, element, params) {
.on('click', '.add-item', function(e) {
_fn.build.form.item.add();
})
.on('click', '.remove-item', _fn.build.form.item.remove);
.on('click', '.remove-item', _fn.build.form.item.remove)
.on('click', '.advanced-link a', _fn.build.form.item.showAdvancedSettings);
},
form: {
zone: {
......@@ -319,32 +327,40 @@ function DragAndDropEditBlock(runtime, element, params) {
},
item: {
count: 0,
add: function(oldItem) {
add: function(itemData) {
var $form = _fn.build.$el.items.form,
tpl = _fn.tpl.itemInput,
ctx = {};
if (oldItem) ctx = oldItem;
ctx.dropdown = _fn.build.form.createDropdown(ctx.zone);
// Item width/height are ignored in new versions of the block, but
// preserve the data in case we change back to using those values.
if (oldItem && oldItem.size && oldItem.size.width != 'auto') {
ctx.width = oldItem.size.width.substr(0, oldItem.size.width.length - 2); // Remove 'px'
} else {
ctx.width = '0';
}
if (oldItem && oldItem.size && oldItem.size.height != 'auto') {
ctx.height = oldItem.size.height.substr(0, oldItem.size.height.length - 2); // Remove 'px'
} else {
ctx.height = '0';
if (itemData) {
ctx = itemData;
if (itemData.backgroundImage && !ctx.imageURL) {
ctx.imageURL = itemData.backgroundImage; // This field was renamed.
}
if (itemData.size && parseInt(itemData.size.width) > 0) {
// Convert old fixed pixel width setting values (hard to
// make mobile friendly) to new percentage format.
// Note itemData.size.width is a string like "380px" (it can
// also be "auto" but that's excluded by the if condition above)
var bgImgWidth = _fn.build.$el.targetImage[0].naturalWidth;
if (bgImgWidth > 0 && typeof ctx.widthPercent === "undefined") {
ctx.widthPercent = parseInt(itemData.size.width) / bgImgWidth * 100;
}
// Preserve the old-style data in case we need it again:
ctx.pixelWidth = itemData.size.width.substr(0, itemData.size.width.length - 2); // Remove 'px'
}
if (itemData.size && parseInt(itemData.size.height) > 0) {
// Item fixed pixel height is ignored in new versions of the
// block, but preserve the data in case we need it again:
ctx.pixelHeight = itemData.size.height.substr(0, itemData.size.height.length - 2); // Remove 'px'
}
if (itemData.inputOptions) {
ctx.numericalValue = itemData.inputOptions.value;
ctx.numericalMargin = itemData.inputOptions.margin;
}
}
if (oldItem && oldItem.inputOptions) {
ctx.numericalValue = oldItem.inputOptions.value;
ctx.numericalMargin = oldItem.inputOptions.margin;
}
ctx.dropdown = _fn.build.form.createDropdown(ctx.zone);
_fn.build.form.item.count++;
$form.append(tpl(ctx));
......@@ -370,7 +386,13 @@ function DragAndDropEditBlock(runtime, element, params) {
if (_fn.build.form.item.count === 1) {
_fn.build.$el.items.form.find('.remove-item').addClass('hidden');
}
}
},
showAdvancedSettings: function(e) {
e.preventDefault();
var $el = $(e.currentTarget).closest('.item');
$el.find('.row.advanced').show();
$el.find('.row.advanced-link').hide();
},
},
submit: function() {
var items = [],
......@@ -383,16 +405,6 @@ function DragAndDropEditBlock(runtime, element, params) {
imageDescription = $el.find('.item-image-description').val();
if (name.length > 0 || imageURL.length > 0) {
// Item width/height are ignored, but preserve the data:
var width = $el.find('.item-width').val(),
height = $el.find('.item-height').val();
if (height === '0') height = 'auto';
else height = height + 'px';
if (width === '0') width = 'auto';
else width = width + 'px';
var data = {
displayName: name,
zone: $el.find('.zone-select').val(),
......@@ -401,13 +413,12 @@ function DragAndDropEditBlock(runtime, element, params) {
correct: $el.find('.success-feedback').val(),
incorrect: $el.find('.error-feedback').val()
},
size: {
width: width,
height: height
},
imageURL: imageURL,
imageDescription: imageDescription,
};
// Optional preferred width as a percentage of the bg image's width:
var widthPercent = $el.find('.item-width').val();
if (widthPercent && +widthPercent > 0) { data.widthPercent = widthPercent; }
var numValue = parseFloat($el.find('.item-numerical-value').val());
var numMargin = parseFloat($el.find('.item-numerical-margin').val());
......
......@@ -52,12 +52,15 @@
);
};
var itemTemplate = function(item) {
var itemTemplate = function(item, ctx) {
// Define properties
var className = (item.class_name) ? item.class_name : "";
if (item.has_image) {
className += " " + "option-with-image";
}
if (item.widthPercent) {
className += " specified-width"; // The author has specified a width for this item.
}
var attributes = {
'draggable': !item.drag_disabled,
'aria-grabbed': item.grabbed,
......@@ -77,9 +80,26 @@
if (item.is_placed) {
style.left = item.x_percent + "%";
style.top = item.y_percent + "%";
if (item.widthPercent) {
style.width = item.widthPercent + "%";
style.maxWidth = item.widthPercent + "%"; // default maxWidth is ~33%
} else if (item.imgNaturalWidth) {
style.width = (item.imgNaturalWidth + 22) + "px"; // 22px is for 10px padding + 1px border each side
// ^ Hack to detect image width at runtime and make webkit consistent with Firefox
}
} else {
// If an item has not been placed it must be possible to move focus to it using the keyboard:
attributes.tabindex = 0;
if (item.widthPercent) {
// The item bank container is often wider than the background image, and the
// widthPercent is specified relative to the background image so we have to
// convert it to pixels. But if the browser window / mobile screen is not as
// wide as the image, then the background image will be scaled down and this
// pixel value would be too large, so we also specify it as a max-width
// percentage.
style.width = (item.widthPercent / 100 * ctx.bg_image_width) + "px";
style.maxWidth = item.widthPercent + "%";
}
}
// Define children
var children = [
......@@ -107,7 +127,9 @@
h(
'div.option',
{
key: item.value,
// Unique key for virtual dom change tracking. Key must be different for
// Placed vs Unplaced, or weird bugs can occur.
key: item.value + (item.is_placed ? "-p" : "-u"),
className: className,
attributes: attributes,
style: style
......
......@@ -92,27 +92,14 @@
<textarea id="item-{{id}}-error-feedback"
class="error-feedback">{{ feedback.incorrect }}</textarea>
</div>
<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 for="item-{{id}}-width">{{i18n "Width in pixels (0 for auto)"}}</label>
<input type="text"
id="item-{{id}}-width"
class="item-width"
value="{{ width }}" />
<label for="item-{{id}}-height">{{i18n "Height in pixels (0 for auto)"}}</label>
<input type="text"
id="item-{{id}}-height"
class="item-height"
value="{{ height }}" />
<div class="row advanced-link">
<a href="#">{{i18n "Show advanced settings" }}</a>
</div>
<div class="row">
<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>
<input type="number" id="item-{{id}}-width-percent" class="item-width" value="{{ singleDecimalFloat widthPercent }}" step="0.1" min="1" max="99" />%
</div>
<div class="row advanced">
<label for="item-{{id}}-numerical-value">
{{i18n "Optional numerical value (if you set this, students will be prompted for this value after dropping this item)"}}
</label>
......@@ -121,7 +108,7 @@
id="item-{{id}}-numerical-value"
class="item-numerical-value" value="{{ numericalValue }}" />
</div>
<div class="row">
<div class="row advanced">
<label for="item-{{id}}-numerical-margin">
{{i18n "Margin +/- (when a numerical value is required, values entered by students must not differ from the expected value by more than this margin; default is zero)"}}
</label>
......
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 200 200" width="200px" height="200px" xml:space="preserve">
<style type="text/css">
.st0{fill:#FBB03B;stroke:#000000;stroke-miterlimit:10;}
.st1{font-family:'Helvetica';}
.st2{font-size:24px;}
</style>
<rect class="st0" width="200" height="200"/>
<text transform="matrix(1 0 0 1 34.6152 104.5322)" class="st1 st2">200 x 200px</text>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 400 300" width="400px" height="300px" xml:space="preserve">
<style type="text/css">
.st0{fill:#8CC63F;stroke:#000000;stroke-miterlimit:10;}
.st1{font-family:'Helvetica';}
.st2{font-size:48px;}
</style>
<rect class="st0" width="400" height="300"/>
<text transform="matrix(1 0 0 1 71 160)" class="st1 st2">400 x 300px</text>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 60 60" width="60px" height="60px" xml:space="preserve">
<style type="text/css">
.st0{fill:#BBF03B;}
.st1{font-family:'Helvetica';}
.st2{font-size:12px;}
</style>
<rect class="st0" width="60" height="60"/>
<text transform="matrix(1 0 0 1 3 35)" class="st1 st2">60 x 60px</text>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 500 500" width="500px" height="500px" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:#999999;}
.st2{fill:#F2F2F2;}
.st3{font-family:'Helvetica-Bold';}
.st4{font-size:24px;}
.st5{letter-spacing:-1;}
</style>
<g id="Layer_2">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.127" y1="250" x2="500" y2="250">
<stop offset="0" style="stop-color:#CCE0F4"/>
<stop offset="1" style="stop-color:#005B97"/>
</linearGradient>
<rect x="0" class="st0" width="500" height="500"/>
</g>
<g id="Layer_1">
<rect x="0" y="200" class="st1" width="250" height="100"/>
<rect x="0" y="400" class="st1" width="375" height="100"/>
<rect x="0" class="st1" width="166.7" height="100"/>
<text transform="matrix(1 0 0 1 36 58)" class="st2 st3 st4">&lt;- 1/3 -&gt;</text>
<text transform="matrix(1 0 0 1 74 260)"><tspan x="0" y="0" class="st2 st3 st4">&lt;-</tspan><tspan x="22" y="0" class="st2 st3 st4"> </tspan><tspan x="29.2" y="0" class="st2 st3 st4">50% -</tspan><tspan x="91.9" y="0" class="st2 st3 st4 st5">&gt;</tspan></text>
<text transform="matrix(1 0 0 1 150 455)"><tspan x="0" y="0" class="st2 st3 st4">&lt;-</tspan><tspan x="22" y="0" class="st2 st3 st4"> </tspan><tspan x="29.2" y="0" class="st2 st3 st4">75% -</tspan><tspan x="91.9" y="0" class="st2 st3 st4 st5">&gt;</tspan></text>
</g>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1600 900" width="1600px" height="900px" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:#999999;}
.st2{fill:#F2F2F2;}
.st3{font-family:'Helvetica-Bold';}
.st4{font-size:60px;}
.st5{letter-spacing:1;}
.st6{letter-spacing:-3;}
</style>
<g id="Layer_2">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.127" y1="450" x2="1600" y2="450">
<stop offset="0" style="stop-color:#CCE0F4"/>
<stop offset="1" style="stop-color:#005B97"/>
</linearGradient>
<rect x="0" class="st0" width="1600" height="900"/>
</g>
<g id="Layer_1">
<rect y="350" class="st1" width="800" height="200"/>
<rect x="0" y="700" class="st1" width="1200" height="200"/>
<rect x="0" class="st1" width="533.33" height="200"/>
<text transform="matrix(1 0 0 1 145 115)" class="st2 st3 st4">&lt;- 1/3 -&gt;</text>
<text transform="matrix(1 0 0 1 288 462)"><tspan x="0" y="0" class="st2 st3 st4">&lt;-</tspan><tspan x="55" y="0" class="st2 st3 st4 st5"> </tspan><tspan x="72.9" y="0" class="st2 st3 st4">50% -</tspan><tspan x="229.6" y="0" class="st2 st3 st4 st6">&gt;</tspan></text>
<text transform="matrix(1 0 0 1 486 821)"><tspan x="0" y="0" class="st2 st3 st4">&lt;-</tspan><tspan x="55" y="0" class="st2 st3 st4 st5"> </tspan><tspan x="72.9" y="0" class="st2 st3 st4">75% -</tspan><tspan x="229.6" y="0" class="st2 st3 st4 st6">&gt;</tspan></text>
</g>
</svg>
{
"question_text": "Note: This is an example of data in the format used by prior versions of this block. Not in particular the old `size` data.",
"zones": [
{
"index": 1,
"width": 200,
"title": "Zone 1",
"height": 100,
"y": "200",
"x": "100",
"id": "zone-1"
},
{
"index": 2,
"width": 200,
"title": "Zone 2",
"height": 100,
"y": 0,
"x": 0,
"id": "zone-2"
}
],
"items": [
{
"displayName": "1",
"feedback": {
"incorrect": "No 1",
"correct": "Yes 1"
},
"zone": "Zone 1",
"backgroundImage": "",
"id": 0,
"size": {
"width": "190px",
"height": "auto"
}
},
{
"displayName": "2",
"feedback": {
"incorrect": "No 2",
"correct": "Yes 2"
},
"zone": "Zone 2",
"backgroundImage": "",
"id": 1,
"size": {
"width": "190px",
"height": "100px"
},
"inputOptions": {
"value": 100,
"margin": 5
}
},
{
"displayName": "Pic",
"feedback": {
"incorrect": "",
"correct": ""
},
"zone": "Zone 1",
"backgroundImage": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MDAiIGhlaWdodD0iNjAwIiBzdHlsZT0iYmFja2dyb3VuZDogI2VlZjsiPjwvc3ZnPg==",
"id": 2,
"size": {
"width": "100px",
"height": "auto"
}
}
],
"state": {
"items": {},
"finished": true
},
"feedback": {
"start": "Intro Feedback",
"finish": "Final Feedback"
},
"title": "Drag and Drop (Old-style data)"
}
\ No newline at end of file
{
{% if img == "wide" %}
"targetImg": "{{img_wide_url}}",
"zones": [
{"index": 1, "title": "Zone 1/3", "width": 533, "height": 200, "x": "0", "y": "0", "id": "zone-1"},
{"index": 2, "title": "Zone 50%", "width": 800, "height": 200, "x": "0", "y": "350", "id": "zone-2"},
{"index": 3, "title": "Zone 75%", "width": 1200, "height": 200, "x": "0", "y": "700", "id": "zone-3"}
],
{% else %}
"targetImg": "{{img_square_url}}",
"zones": [
{"index": 1, "title": "Zone 1/3", "width": 166, "height": 100, "x": "0", "y": "0", "id": "zone-1"},
{"index": 2, "title": "Zone 50%", "width": 250, "height": 100, "x": "0", "y": "200", "id": "zone-2"},
{"index": 3, "title": "Zone 75%", "width": 375, "height": 100, "x": "0", "y": "400", "id": "zone-3"}
],
{% endif %}
"displayBorders": true,
"items": [
{
"displayName": "Auto",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 1/3",
"imageURL": "",
"id": 0
},
{
"displayName": "Auto with long text that should wrap because draggables are given a maximum width",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 1/3",
"imageURL": "",
"id": 1
},
{
"displayName": "33.3%",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 1/3",
"imageURL": "",
"id": 2,
"widthPercent": 33.3
},
{
"displayName": "50%",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 50%",
"imageURL": "",
"id": 3,
"widthPercent": 50
},
{
"displayName": "75%",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 75%",
"imageURL": "",
"id": 4,
"widthPercent": 75
},
{
"displayName": "IMG 400x300",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 50%",
"imageURL": "{{img_400x300_url}}",
"id": 5,
},
{
"displayName": "IMG 200x200",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 50%",
"imageURL": "{{img_200x200_url}}",
"id": 6,
},
{
"displayName": "IMG 400x300",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 50%",
"imageURL": "{{img_400x300_url}}",
"id": 7,
"widthPercent": 50
},
{
"displayName": "IMG 200x200",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 50%",
"imageURL": "{{img_200x200_url}}",
"id": 8,
"widthPercent": 50
},
{
"displayName": "IMG 60x60",
"feedback": {"incorrect": "", "correct": ""},
"zone": "Zone 1/3",
"imageURL": "{{img_60x60_url}}",
"id": 9
}
],
"feedback": {"start": "Some Intro Feedback", "finish": "Some Final Feedback"}
}
......@@ -22,12 +22,6 @@ from .test_base import BaseIntegrationTest
loader = ResourceLoader(__name__)
ZONES_MAP = {
0: TOP_ZONE_TITLE,
1: MIDDLE_ZONE_TITLE,
2: BOTTOM_ZONE_TITLE,
}
# Classes ###########################################################
......@@ -67,16 +61,19 @@ class InteractionTestBase(object):
self.browser.set_window_size(1024, 800)
def _get_item_by_value(self, item_value):
return self._page.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0]
def _get_unplaced_item_by_value(self, item_value):
items_container = self._page.find_element_by_css_selector('.item-bank')
return items_container.find_elements_by_xpath("//div[@data-value='{item_id}']".format(item_id=item_value))[0]
return items_container.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0]
def _get_placed_item_by_value(self, item_value):
items_container = self._page.find_element_by_css_selector('.target')
return items_container.find_elements_by_xpath("//div[@data-value='{item_id}']".format(item_id=item_value))[0]
return items_container.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0]
def _get_zone_by_id(self, zone_id):
zones_container = self._page.find_element_by_css_selector('.target')
return zones_container.find_elements_by_xpath("//div[@data-zone='{zone_id}']".format(zone_id=zone_id))[0]
return zones_container.find_elements_by_xpath(".//div[@data-zone='{zone_id}']".format(zone_id=zone_id))[0]
def _get_input_div_by_value(self, item_value):
element = self._get_item_by_value(item_value)
......@@ -95,9 +92,6 @@ class InteractionTestBase(object):
'return $("div[data-zone=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id)
)
def _focus_item(self, item_position):
self.browser.execute_script("$('.option:nth-child({n})').focus()".format(n=item_position+1))
def place_item(self, item_value, zone_id, action_key=None):
if action_key is None:
self.drag_item_to_zone(item_value, zone_id)
......@@ -105,29 +99,23 @@ class InteractionTestBase(object):
self.move_item_to_zone(item_value, zone_id, action_key)
def drag_item_to_zone(self, item_value, zone_id):
element = self._get_item_by_value(item_value)
element = self._get_unplaced_item_by_value(item_value)
target = self._get_zone_by_id(zone_id)
action_chains = ActionChains(self.browser)
action_chains.drag_and_drop(element, target).perform()
def move_item_to_zone(self, item_value, zone_id, action_key):
# Get item position
item_position = item_value
# Get zone position
zone_position = self._get_zone_position(zone_id)
self._focus_item(0)
focused_item = self._get_item_by_value(0)
for i in range(item_position):
focused_item.send_keys(Keys.TAB)
focused_item = self._get_item_by_value(i+1)
focused_item.send_keys(action_key) # Focus is on first *zone* now
self.assert_grabbed_item(focused_item)
focused_zone = self._get_zone_by_id(ZONES_MAP[0])
for i in range(zone_position):
focused_zone.send_keys(Keys.TAB)
focused_zone = self._get_zone_by_id(ZONES_MAP[i+1])
focused_zone.send_keys(action_key)
# Focus on the item:
item = self._get_unplaced_item_by_value(item_value)
ActionChains(self.browser).move_to_element(item).perform()
# Press the action key:
item.send_keys(action_key) # Focus is on first *zone* now
self.assert_grabbed_item(item)
for _ in range(zone_position):
self._page.send_keys(Keys.TAB)
self._get_zone_by_id(zone_id).send_keys(action_key)
def send_input(self, item_value, value):
element = self._get_item_by_value(item_value)
......@@ -311,6 +299,11 @@ class InteractionTestBase(object):
self.assertFalse(dialog_modal_overlay.is_displayed())
self.assertFalse(dialog_modal.is_displayed())
def _switch_to_block(self, idx):
""" Only needed if ther eare multiple blocks on the page. """
self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx]
self.scroll_down(0)
class DefaultDataTestMixin(object):
"""
......@@ -534,10 +527,6 @@ class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest):
return "<vertical_demo>{dnd_blocks}</vertical_demo>".format(dnd_blocks=blocks_xml)
def _switch_to_block(self, idx):
self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx]
self.scroll_down(0)
def test_item_positive_feedback_on_good_move(self):
self._switch_to_block(0)
self.parameterized_item_positive_feedback_on_good_move(self.item_maps['block1'])
......
from __future__ import division
import base64
from collections import namedtuple
import os.path
from selenium.webdriver.common.keys import Keys
from xblockutils.resources import ResourceLoader
from .test_base import BaseIntegrationTest
from .test_interaction import InteractionTestBase
loader = ResourceLoader(__name__)
def _svg_to_data_uri(path):
""" Convert an SVG image (by path) to a data URI """
data_path = os.path.dirname(__file__) + "/data/"
with open(data_path + path, "rb") as svg_fh:
encoded = base64.b64encode(svg_fh.read())
return "data:image/svg+xml;base64,{}".format(encoded)
Expectation = namedtuple('Expectation', [
'item_id',
'zone_id',
'width_percent', # we expect this item to have this width relative to its container (item bank or image target)
'fixed_width_percent', # we expect this item to have this width (always relative to the target image)
'img_pixel_size_exact', # we expect the image inside the draggable to have the exact size [w, h] in pixels
])
Expectation.__new__.__defaults__ = (None,) * len(Expectation._fields) # pylint: disable=protected-access
ZONE_33 = "Zone 1/3" # Title of top zone in each image used in these tests (33% width)
ZONE_50 = "Zone 50%"
ZONE_75 = "Zone 75%"
AUTO_MAX_WIDTH = 30 # Maximum width (as % of the parent container) for items with automatic sizing
class SizingTests(InteractionTestBase, BaseIntegrationTest):
"""
Tests that cover features like draggable blocks with automatic sizes vs. specified sizes,
different background image ratios, and responsive behavior.
Tip: To see how these tests work, throw in an 'import time; time.sleep(200)' at the start of
one of the tests, so you can check it out in the selenium browser window that opens.
These tests intentionally do not use ddt in order to run faster. Instead, each test iterates
through data and uses verbose assertion messages to clearly indicate where failures occur.
"""
PAGE_TITLE = 'Drag and Drop v2 Sizing'
PAGE_ID = 'drag_and_drop_v2_sizing'
@staticmethod
def _get_scenario_xml():
"""
Set up the test scenario:
* An upper dndv2 xblock with a wide image (1600x900 SVG)
(on desktop and mobile, this background image will always fill the available width
and should have the same width as the item bank above)
* A lower dndv2 xblock with a small square image (500x500 SVG)
(on desktop, the square image is not as wide as the item bank, but on mobile it
may take up the whole width of the screen)
"""
params = {
"img": "wide",
"img_wide_url": _svg_to_data_uri('dnd-bg-wide.svg'),
"img_square_url": _svg_to_data_uri('dnd-bg-square.svg'),
"img_400x300_url": _svg_to_data_uri('400x300.svg'),
"img_200x200_url": _svg_to_data_uri('200x200.svg'),
"img_60x60_url": _svg_to_data_uri('60x60.svg'),
}
upper_block = "<drag-and-drop-v2 data='{data}'/>".format(
data=loader.render_django_template("data/test_sizing_template.json", params)
)
params["img"] = "square"
lower_block = "<drag-and-drop-v2 data='{data}'/>".format(
data=loader.render_django_template("data/test_sizing_template.json", params)
)
return "<vertical_demo>{}\n{}</vertical_demo>".format(upper_block, lower_block)
EXPECTATIONS = [
# The text 'Auto' with no fixed size specified should be 5-20% wide
Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, 20]),
# The long text with no fixed size specified should be wrapped at the maximum width
Expectation(item_id=1, zone_id=ZONE_33, width_percent=AUTO_MAX_WIDTH),
# The text items that specify specific widths as a percentage of the background image:
Expectation(item_id=2, zone_id=ZONE_33, fixed_width_percent=33.3),
Expectation(item_id=3, zone_id=ZONE_50, fixed_width_percent=50),
Expectation(item_id=4, zone_id=ZONE_75, fixed_width_percent=75),
# A 400x300 image with automatic sizing should be constrained to the maximum width
Expectation(item_id=5, zone_id=ZONE_50, width_percent=AUTO_MAX_WIDTH),
# A 200x200 image with automatic sizing
Expectation(item_id=6, zone_id=ZONE_50, width_percent=[25, 30]),
# A 400x300 image with a specified width of 50%
Expectation(item_id=7, zone_id=ZONE_50, fixed_width_percent=50),
# A 200x200 image with a specified width of 50%
Expectation(item_id=8, zone_id=ZONE_50, fixed_width_percent=50),
# A 60x60 auto-sized image should appear with pixel dimensions of 60x60 since it's
# too small to be shrunk be the default max-size.
Expectation(item_id=9, zone_id=ZONE_33, img_pixel_size_exact=[60, 60]),
]
def test_wide_image_desktop(self):
""" Test the upper, larger, wide image in a desktop-sized window """
self._check_sizes(0, self.EXPECTATIONS)
def test_square_image_desktop(self):
""" Test the lower, smaller, square image in a desktop-sized window """
self._check_sizes(1, self.EXPECTATIONS, expected_img_width=500)
def _size_for_mobile(self):
self.browser.set_window_size(375, 627) # iPhone 6 viewport size
def test_wide_image_mobile(self):
""" Test the upper, larger, wide image in a mobile-sized window """
self._size_for_mobile()
self._check_sizes(0, self.EXPECTATIONS, is_desktop=False)
def test_square_image_mobile(self):
""" Test the lower, smaller, square image in a mobile-sized window """
self._size_for_mobile()
self._check_sizes(1, self.EXPECTATIONS, expected_img_width=375, is_desktop=False)
def _check_width(self, item_description, item, container_width, expected_percent):
"""
Check that item 'item' has a width that is approximately the specified percentage
of container_width, or if expected_percent is a pair of numbers, that it is within
that range.
"""
width_percent = item.size["width"] / container_width * 100
if isinstance(expected_percent, (list, tuple)):
min_expected, max_expected = expected_percent
msg = "{} should have width of {}% - {}%. Actual: {:.2f}%".format(
item_description, min_expected, max_expected, width_percent
)
self.assertGreaterEqual(width_percent, min_expected, msg)
self.assertLessEqual(width_percent, max_expected, msg)
else:
self.assertAlmostEqual(
width_percent, expected_percent, delta=1,
msg="{} should have width of ~{}% (+/- 1%). Actual: {:.2f}%".format(
item_description, expected_percent, width_percent
)
)
if item.find_elements_by_css_selector("img"):
# This item contains an image. The image should always fill the width of the draggable.
image = item.find_element_by_css_selector("img")
image_width_expected = item.size["width"] - 22
self.assertAlmostEqual(
image.size["width"], image_width_expected, delta=1,
msg="{} image does not take up the full width of the draggable (width is {}px; expected {}px)".format(
item_description, image.size["width"], image_width_expected,
)
)
def _check_img_pixel_dimensions(self, item_description, item, expect_w, expect_h):
img_element = item.find_element_by_css_selector("img")
self.assertEqual(
img_element.size, {"width": expect_w, "height": expect_h},
msg="Expected {}'s image to have exact dimensions {}x{}px; found {}x{}px instead.".format(
item_description, expect_w, expect_h, img_element.size["width"], img_element.size["height"]
)
)
def _check_sizes(self, block_index, expectations, expected_img_width=755, is_desktop=True):
""" Test the actual dimensions that each draggable has, in the bank and when placed """
# Check assumptions - the container wrapping this XBlock should be 770px wide
self._switch_to_block(block_index)
target_img = self._page.find_element_by_css_selector('.target-img')
target_img_width = target_img.size["width"]
item_bank = self._page.find_element_by_css_selector('.item-bank')
item_bank_width = item_bank.size["width"]
if is_desktop:
# If using a desktop-sized window, we can know the exact dimensions of various containers:
self.assertEqual(self._page.size["width"], 770) # self._page is the .xblock--drag-and-drop div
self.assertEqual(target_img_width, expected_img_width)
self.assertEqual(item_bank_width, 755)
# Test each element, before it is placed (while it is in the item bank).
for expect in expectations:
if expect.width_percent is not None:
self._check_width(
item_description="Unplaced item {}".format(expect.item_id),
item=self._get_unplaced_item_by_value(expect.item_id),
container_width=item_bank_width,
expected_percent=expect.width_percent,
)
if expect.fixed_width_percent is not None:
self._check_width(
item_description="Unplaced item {} with fixed width".format(expect.item_id),
item=self._get_unplaced_item_by_value(expect.item_id),
container_width=target_img_width,
expected_percent=expect.fixed_width_percent,
)
if expect.img_pixel_size_exact is not None:
self._check_img_pixel_dimensions(
"Unplaced item {}".format(expect.item_id),
self._get_unplaced_item_by_value(expect.item_id),
*expect.img_pixel_size_exact
)
# Test each element, after it it placed.
for expect in expectations:
self.place_item(expect.item_id, expect.zone_id, action_key=Keys.RETURN)
expected_width_percent = expect.fixed_width_percent or expect.width_percent
if expected_width_percent is not None:
self._check_width(
item_description="Placed item {}".format(expect.item_id),
item=self._get_placed_item_by_value(expect.item_id),
container_width=target_img_width,
expected_percent=expected_width_percent,
)
if expect.img_pixel_size_exact is not None:
self._check_img_pixel_dimensions(
"Placed item {}".format(expect.item_id),
self._get_placed_item_by_value(expect.item_id),
*expect.img_pixel_size_exact
)
class SizingBackwardsCompatibilityTests(InteractionTestBase, BaseIntegrationTest):
"""
Test backwards compatibility with data generated in older versions of this block.
Older versions allowed authors to specify a fixed width and height for each draggable, in
pixels (new versions only have a configurable width, and it is a percent width).
"""
PAGE_TITLE = 'Drag and Drop v2 Sizing Backwards Compatibility'
PAGE_ID = 'drag_and_drop_v2_sizing_backwards_compatibility'
@staticmethod
def _get_scenario_xml():
"""
Set up the test scenario:
* One DndDv2 block using 'old_version_data.json'
"""
dnd_block = "<drag-and-drop-v2 data='{data}'/>".format(
data=loader.load_unicode("data/old_version_data.json")
)
return "<vertical_demo>{}</vertical_demo>".format(dnd_block)
def test_draggable_sizes(self):
""" Test the fixed pixel widths set in old versions of the block """
self._expect_width_px(item_id=0, width_px=190, zone_id="Zone 1")
self._expect_width_px(item_id=1, width_px=190, zone_id="Zone 2")
self._expect_width_px(item_id=2, width_px=100, zone_id="Zone 1")
def _expect_width_px(self, item_id, width_px, zone_id):
item = self._get_unplaced_item_by_value(item_id)
self.assertEqual(item.size["width"], width_px)
self.place_item(item_id, zone_id)
item = self._get_placed_item_by_value(item_id)
self.assertEqual(item.size["width"], width_px)
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