Commit 0e07b799 by Matjaz Gregoric

Move dropped item to different zone in assessment mode.

In standard mode, once you drop an item into the correct zone, you can
no longer move it until you reset the exercise.

In assessment mode, we want users to be able to freely move items
between zones until they explicitly submit their solution.

This commit implements the ability to move dropped items between zones
in assessment mode. It does not make any changes to standard mode.
parent 30173c47
...@@ -143,6 +143,11 @@ ...@@ -143,6 +143,11 @@
margin: 0; margin: 0;
transform: translate(-50%, -50%); /* These blocks are to be centered on their absolute x,y position */ transform: translate(-50%, -50%); /* These blocks are to be centered on their absolute x,y position */
} }
/* Dragging placed option to a different zone in assessment mode */
.xblock--drag-and-drop .drag-container .target .option.grabbed-with-mouse {
transform: none; /* The transform messes with jQuery UI Draggable positioning, */
/* so remove it while dragging with the mouse. */
}
/* Placed options in an aligned zone */ /* Placed options in an aligned zone */
.xblock--drag-and-drop .zone .item-wrapper { .xblock--drag-and-drop .zone .item-wrapper {
...@@ -172,21 +177,24 @@ ...@@ -172,21 +177,24 @@
} }
/* Focused option */ /* Focused option */
.xblock--drag-and-drop .drag-container .item-bank .option:focus, .xblock--drag-and-drop .drag-container .option[draggable='true']:focus,
.xblock--drag-and-drop .drag-container .item-bank .option:hover, .xblock--drag-and-drop .drag-container .option[draggable='true']:hover,
.xblock--drag-and-drop .drag-container .item-bank .option[aria-grabbed='true'] { .xblock--drag-and-drop .drag-container .option[draggable='true'][aria-grabbed='true'] {
outline-width: 2px; outline-width: 2px;
outline-style: solid; outline-style: solid;
outline-offset: -4px; outline-offset: -4px;
} }
.xblock--drag-and-drop .drag-container .ui-draggable-dragging.option { .xblock--drag-and-drop .drag-container .option.grabbed-with-mouse {
box-shadow: 0 16px 32px 0 rgba(0, 0, 0, 0.3); box-shadow: 0 16px 32px 0 rgba(0, 0, 0, 0.3);
border: 1px solid #ccc; border: 1px solid #ccc;
opacity: .65; opacity: .65;
z-index: 20 !important; z-index: 20 !important;
margin: 0; /* Allow the draggable to touch the edges of the target image */ margin: 0; /* Allow the draggable to touch the edges of the target image */
} }
.xblock--drag-and-drop .drag-container .item-align-center .option.grabbed-with-mouse {
margin: 0 auto; /* Revert to auto margin when dragging with mouse to not confuse jQuery UI draggable */
}
.xblock--drag-and-drop .drag-container .option img { .xblock--drag-and-drop .drag-container .option img {
display: inline-block; display: inline-block;
...@@ -254,8 +262,9 @@ ...@@ -254,8 +262,9 @@
} }
/* Focused zone */ /* Focused zone */
.xblock--drag-and-drop .item-bank:focus,
.xblock--drag-and-drop .zone:focus { .xblock--drag-and-drop .zone:focus {
border: 2px solid #a5a5a5; outline: 2px solid #a5a5a5;
} }
.xblock--drag-and-drop .drag-container .target .zone p { .xblock--drag-and-drop .drag-container .target .zone p {
......
...@@ -30,6 +30,29 @@ function DragAndDropTemplates(configuration) { ...@@ -30,6 +30,29 @@ function DragAndDropTemplates(configuration) {
} }
}; };
var bankItemWidthStyles = function(item, ctx) {
var style = {};
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 + "%";
}
return style;
};
var itemContentTemplate = function(item) {
var item_content_html = item.displayName;
if (item.imageURL) {
item_content_html = '<img src="' + item.imageURL + '" alt="' + item.imageDescription + '" />';
}
return h('div', { innerHTML: item_content_html, className: "item-content" });
};
var itemTemplate = function(item, ctx) { var itemTemplate = function(item, ctx) {
// Define properties // Define properties
var className = (item.class_name) ? item.class_name : ""; var className = (item.class_name) ? item.class_name : "";
...@@ -40,12 +63,15 @@ function DragAndDropTemplates(configuration) { ...@@ -40,12 +63,15 @@ function DragAndDropTemplates(configuration) {
if (item.widthPercent) { if (item.widthPercent) {
className += " specified-width"; // The author has specified a width for this item. className += " specified-width"; // The author has specified a width for this item.
} }
if (item.grabbed_with) {
className += " grabbed-with-" + item.grabbed_with;
}
var attributes = { var attributes = {
'role': 'button', 'role': 'button',
'draggable': !item.drag_disabled, 'draggable': !item.drag_disabled,
'aria-grabbed': item.grabbed, 'aria-grabbed': item.grabbed,
'data-value': item.value, 'data-value': item.value,
'data-drag-disabled': item.drag_disabled 'tabindex': item.focusable ? 0 : undefined
}; };
var style = {}; var style = {};
if (item.background_color) { if (item.background_color) {
...@@ -83,28 +109,13 @@ function DragAndDropTemplates(configuration) { ...@@ -83,28 +109,13 @@ function DragAndDropTemplates(configuration) {
// ^ Hack to detect image width at runtime and make webkit consistent with Firefox // ^ Hack to detect image width at runtime and make webkit consistent with Firefox
} }
} else { } else {
// If an item has not been placed it must be possible to move focus to it using the keyboard: $.extend(style, bankItemWidthStyles(item, ctx));
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 // Define children
var children = [ var children = [
itemSpinnerTemplate(item.xhr_active) itemSpinnerTemplate(item.xhr_active)
]; ];
var item_content_html = item.displayName; var item_content = itemContentTemplate(item);
if (item.imageURL) {
item_content_html = '<img src="' + item.imageURL + '" alt="' + item.imageDescription + '" />';
}
var item_content = h('div', { innerHTML: item_content_html, className: "item-content" });
if (item.is_placed) { if (item.is_placed) {
// Insert information about zone in which this item has been placed // Insert information about zone in which this item has been placed
var item_description_id = configuration.url_name + '-item-' + item.value + '-description'; var item_description_id = configuration.url_name + '-item-' + item.value + '-description';
...@@ -143,6 +154,35 @@ function DragAndDropTemplates(configuration) { ...@@ -143,6 +154,35 @@ function DragAndDropTemplates(configuration) {
); );
}; };
// When an item is dragged out of the bank, a hidden placeholder of the same width and height as
// the original item is rendered in the bank. The function of the placeholder is to take up the
// same amount of space as the original item so that the bank does not collapse when you've dragged
// all items out.
var itemPlaceholderTemplate = function(item, ctx) {
var className = "";
if (item.has_image) {
className += " " + "option-with-image";
}
if (item.widthPercent) {
className += " specified-width"; // The author has specified a width for this item.
}
var style = bankItemWidthStyles(item, ctx);
// Placeholder should never be visible.
style.visibility = 'hidden';
return (
h(
'div.option',
{
key: 'placeholder-' + item.value,
className: className,
attributes: {draggable: false},
style: style
},
itemContentTemplate(item)
)
);
};
var zoneTemplate = function(zone, ctx) { var zoneTemplate = function(zone, ctx) {
var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr'; var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr';
var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone'; var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone';
...@@ -161,6 +201,7 @@ function DragAndDropTemplates(configuration) { ...@@ -161,6 +201,7 @@ function DragAndDropTemplates(configuration) {
h( h(
selector, selector,
{ {
key: zone.prefixed_uid,
id: zone.prefixed_uid, id: zone.prefixed_uid,
attributes: { attributes: {
'tabindex': 0, 'tabindex': 0,
...@@ -246,6 +287,15 @@ function DragAndDropTemplates(configuration) { ...@@ -246,6 +287,15 @@ function DragAndDropTemplates(configuration) {
var items_in_bank = $.grep(ctx.items, is_item_placed, true); var items_in_bank = $.grep(ctx.items, is_item_placed, true);
var is_item_placed_unaligned = function(i) { return i.zone_align === 'none'; }; var is_item_placed_unaligned = function(i) { return i.zone_align === 'none'; };
var items_placed_unaligned = $.grep(items_placed, is_item_placed_unaligned); var items_placed_unaligned = $.grep(items_placed, is_item_placed_unaligned);
var item_bank_properties = {};
if (ctx.item_bank_focusable) {
item_bank_properties.attributes = {
'tabindex': 0,
'dropzone': 'move',
'aria-dropeffect': 'move',
'role': 'button'
};
}
return ( return (
h('section.themed-xblock.xblock--drag-and-drop', [ h('section.themed-xblock.xblock--drag-and-drop', [
problemTitle, problemTitle,
...@@ -254,10 +304,11 @@ function DragAndDropTemplates(configuration) { ...@@ -254,10 +304,11 @@ function DragAndDropTemplates(configuration) {
h('p', {innerHTML: ctx.problem_html}), h('p', {innerHTML: ctx.problem_html}),
]), ]),
h('section.drag-container', {}, [ h('section.drag-container', {}, [
h( h('div.item-bank', item_bank_properties, [
'div.item-bank', h('p', { className: 'zone-description sr' }, gettext('Item Bank')),
renderCollection(itemTemplate, items_in_bank, ctx) renderCollection(itemTemplate, items_in_bank, ctx),
), renderCollection(itemPlaceholderTemplate, items_placed, ctx)
]),
h('div.target', h('div.target',
{ {
attributes: { attributes: {
...@@ -281,10 +332,9 @@ function DragAndDropTemplates(configuration) { ...@@ -281,10 +332,9 @@ function DragAndDropTemplates(configuration) {
h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}), h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}),
] ]
), ),
renderCollection(zoneTemplate, ctx.zones, ctx), renderCollection(itemTemplate, items_placed_unaligned, ctx),
renderCollection(itemTemplate, items_placed_unaligned, ctx) renderCollection(zoneTemplate, ctx.zones, ctx)
] ]),
),
]), ]),
keyboardHelpTemplate(ctx), keyboardHelpTemplate(ctx),
feedbackTemplate(ctx), feedbackTemplate(ctx),
...@@ -327,7 +377,6 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -327,7 +377,6 @@ function DragAndDropBlock(runtime, element, configuration) {
var TAB = 9; var TAB = 9;
var M = 77; var M = 77;
var placementMode = false;
var $selectedItem; var $selectedItem;
var $focusedElement; var $focusedElement;
...@@ -579,20 +628,31 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -579,20 +628,31 @@ function DragAndDropBlock(runtime, element, configuration) {
return key === RET || key === SPC; return key === RET || key === SPC;
}; };
var isSpaceKey = function(evt) {
var key = evt.which;
return key === SPC;
};
var focusNextZone = function(evt, $currentZone) { var focusNextZone = function(evt, $currentZone) {
var zones = $root.find('.target .zone').toArray();
// In assessment mode, item bank is a valid drop zone
if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) {
zones.push($root.find('.item-bank')[0]);
}
var idx = zones.indexOf($currentZone[0]);
if (evt.shiftKey) { // Going backward if (evt.shiftKey) { // Going backward
var isFirstZone = $currentZone.prev('.zone').length === 0; idx--;
if (isFirstZone) { if (idx < 0) {
evt.preventDefault(); idx = zones.length - 1;
$root.find('.target .zone').last().focus();
} }
} else { // Going forward } else { // Going forward
var isLastZone = $currentZone.next('.zone').length === 0; idx++;
if (isLastZone) { if (idx > zones.length - 1) {
evt.preventDefault(); idx = 0;
$root.find('.target .zone').first().focus();
} }
} }
evt.preventDefault();
zones[idx].focus();
}; };
var placeItem = function($zone, $item) { var placeItem = function($zone, $item) {
...@@ -636,29 +696,39 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -636,29 +696,39 @@ function DragAndDropBlock(runtime, element, configuration) {
var initDroppable = function() { var initDroppable = function() {
// Set up zones for keyboard interaction // Set up zones for keyboard interaction
$root.find('.zone').each(function() { $root.find('.zone, .item-bank').each(function() {
var $zone = $(this); var $zone = $(this);
$zone.on('keydown', function(evt) { $zone.on('keydown', function(evt) {
if (placementMode) { if (state.keyboard_placement_mode) {
if (isCycleKey(evt)) { if (isCycleKey(evt)) {
focusNextZone(evt, $zone); focusNextZone(evt, $zone);
} else if (isCancelKey(evt)) { } else if (isCancelKey(evt)) {
evt.preventDefault(); evt.preventDefault();
placementMode = false; state.keyboard_placement_mode = false;
releaseItem($selectedItem); releaseItem($selectedItem);
} else if (isActionKey(evt)) { } else if (isActionKey(evt)) {
evt.preventDefault(); evt.preventDefault();
placementMode = false; state.keyboard_placement_mode = false;
placeItem($zone);
releaseItem($selectedItem); releaseItem($selectedItem);
if ($zone.is('.item-bank')) {
delete state.items[$selectedItem.data('value')];
applyState();
} else {
placeItem($zone);
}
} }
} else if (isSpaceKey(evt)) {
// Pressing the space bar moves the page down by default in most browsers.
// That can be distracting while moving items with the keyboard, so prevent
// the default scroll from happening while a zone is focused.
evt.preventDefault();
} }
}); });
}); });
// Make zone accept items that are dropped using the mouse // Make zones accept items that are dropped using the mouse
$root.find('.zone').droppable({ $root.find('.zone').droppable({
accept: '.item-bank .option', accept: '.drag-container .option',
tolerance: 'pointer', tolerance: 'pointer',
drop: function(evt, ui) { drop: function(evt, ui) {
var $zone = $(this); var $zone = $(this);
...@@ -666,18 +736,34 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -666,18 +736,34 @@ function DragAndDropBlock(runtime, element, configuration) {
placeItem($zone, $item); placeItem($zone, $item);
} }
}); });
if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) {
// Make item bank accept items that are returned to the bank using the mouse
$root.find('.item-bank').droppable({
accept: '.target .option',
tolerance: 'pointer',
drop: function(evt, ui) {
var $item = ui.helper;
var item_id = $item.data('value');
releaseItem($item);
delete state.items[item_id];
applyState();
}
});
}
}; };
var initDraggable = function() { var initDraggable = function() {
$root.find('.item-bank .option').not('[data-drag-disabled=true]').each(function() { $root.find('.drag-container .option[draggable=true]').each(function() {
var $item = $(this); var $item = $(this);
// Allow item to be "picked up" using the keyboard // Allow item to be "picked up" using the keyboard
$item.on('keydown', function(evt) { $item.on('keydown', function(evt) {
if (isActionKey(evt)) { if (isActionKey(evt)) {
evt.preventDefault(); evt.preventDefault();
placementMode = true; evt.stopPropagation();
grabItem($item); state.keyboard_placement_mode = true;
grabItem($item, 'keyboard');
$selectedItem = $item; $selectedItem = $item;
$root.find('.target .zone').first().focus(); $root.find('.target .zone').first().focus();
} }
...@@ -686,20 +772,32 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -686,20 +772,32 @@ function DragAndDropBlock(runtime, element, configuration) {
// Make item draggable using the mouse // Make item draggable using the mouse
try { try {
$item.draggable({ $item.draggable({
addClasses: false, // don't add ui-draggable-* classes as they don't play well with virtual DOM.
containment: $root.find('.drag-container'), containment: $root.find('.drag-container'),
cursor: 'move', cursor: 'move',
stack: $root.find('.item-bank .option'), stack: $root.find('.drag-container .option'),
revert: 'invalid', revert: 'invalid',
revertDuration: 150, revertDuration: 150,
start: function(evt, ui) { start: function(evt, ui) {
var $item = $(this); var $item = $(this);
grabItem($item); // Store initial position of dragged item to be able to revert back to it on cancelled drag
// (when user drops the item onto an area that is not a droppable zone).
// The jQuery UI draggable library usually knows how to revert correctly, but our dropped items
// have a translation transform that confuses jQuery UI draggable, so we "help" it do the right
// thing by manually storing the initial position and resetting it in the 'stop' handler below.
$item.data('initial-position', {
left: $item.css('left'),
top: $item.css('top')
});
grabItem($item, 'mouse');
publishEvent({ publishEvent({
event_type: 'edx.drag_and_drop_v2.item.picked_up', event_type: 'edx.drag_and_drop_v2.item.picked_up',
item_id: $item.data('value'), item_id: $item.data('value'),
}); });
}, },
stop: function(evt, ui) { stop: function(evt, ui) {
// Revert to original position.
$item.css($item.data('initial-position'));
releaseItem($(this)); releaseItem($(this));
} }
}); });
...@@ -710,9 +808,9 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -710,9 +808,9 @@ function DragAndDropBlock(runtime, element, configuration) {
}); });
}; };
var grabItem = function($item) { var grabItem = function($item, interaction_type) {
var item_id = $item.data('value'); var item_id = $item.data('value');
setGrabbedState(item_id, true); setGrabbedState(item_id, true, interaction_type);
updateDOM(); updateDOM();
}; };
...@@ -722,16 +820,22 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -722,16 +820,22 @@ function DragAndDropBlock(runtime, element, configuration) {
updateDOM(); updateDOM();
}; };
var setGrabbedState = function(item_id, grabbed) { var setGrabbedState = function(item_id, grabbed, interaction_type) {
for (var i = 0; i < configuration.items.length; i++) { configuration.items.forEach(function(item) {
if (configuration.items[i].id === item_id) { if (item.id === item_id) {
configuration.items[i].grabbed = grabbed; if (grabbed) {
item.grabbed = true;
item.grabbed_with = interaction_type;
} else {
item.grabbed = false;
delete item.grabbed_with;
}
} }
} });
}; };
var destroyDraggable = function() { var destroyDraggable = function() {
$root.find('.item-bank .option[data-drag-disabled=true]').each(function() { $root.find('.drag-container .option[draggable=false]').each(function() {
var $item = $(this); var $item = $(this);
$item.off(); $item.off();
...@@ -764,12 +868,10 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -764,12 +868,10 @@ function DragAndDropBlock(runtime, element, configuration) {
// In assessment mode we leave it in the chosen zone until explicit answer submission. // In assessment mode we leave it in the chosen zone until explicit answer submission.
if (configuration.mode === DragAndDropBlock.STANDARD_MODE) { if (configuration.mode === DragAndDropBlock.STANDARD_MODE) {
state.last_action_correct = data.correct; state.last_action_correct = data.correct;
if (data.correct) { state.feedback = data.feedback;
state.items[item_id].correct = true; if (!data.correct) {
} else {
delete state.items[item_id]; delete state.items[item_id];
} }
state.feedback = data.feedback;
if (data.finished) { if (data.finished) {
state.finished = true; state.finished = true;
state.overall_feedback = data.overall_feedback; state.overall_feedback = data.overall_feedback;
...@@ -832,22 +934,32 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -832,22 +934,32 @@ function DragAndDropBlock(runtime, element, configuration) {
if (item.grabbed !== undefined) { if (item.grabbed !== undefined) {
grabbed = item.grabbed; grabbed = item.grabbed;
} }
var placed = item_user_state && item_user_state.correct; var drag_disabled;
// In standard mode items placed in correct zone are no longer draggable.
// In assessment mode items are draggable and can be moved between zones
// until user explicitly submits the problem.
if (configuration.mode === DragAndDropBlock.STANDARD_MODE) {
drag_disabled = Boolean(state.finished || item_user_state);
} else {
drag_disabled = Boolean(state.finished);
}
var itemProperties = { var itemProperties = {
value: item.id, value: item.id,
drag_disabled: Boolean(item_user_state || state.finished), drag_disabled: drag_disabled,
class_name: placed || state.finished ? 'fade' : undefined, focusable: !drag_disabled,
class_name: drag_disabled ? 'fade' : undefined,
xhr_active: (item_user_state && item_user_state.submitting_location), xhr_active: (item_user_state && item_user_state.submitting_location),
displayName: item.displayName, displayName: item.displayName,
imageURL: item.expandedImageURL, imageURL: item.expandedImageURL,
imageDescription: item.imageDescription, imageDescription: item.imageDescription,
has_image: !!item.expandedImageURL, has_image: !!item.expandedImageURL,
grabbed: grabbed, grabbed: grabbed,
grabbed_with: item.grabbed_with,
is_placed: Boolean(item_user_state),
widthPercent: item.widthPercent, // widthPercent may be undefined (auto width) widthPercent: item.widthPercent, // widthPercent may be undefined (auto width)
imgNaturalWidth: item.imgNaturalWidth, imgNaturalWidth: item.imgNaturalWidth,
}; };
if (item_user_state) { if (item_user_state) {
itemProperties.is_placed = true;
itemProperties.zone = item_user_state.zone; itemProperties.zone = item_user_state.zone;
itemProperties.zone_align = item_user_state.zone_align; itemProperties.zone_align = item_user_state.zone_align;
itemProperties.x_percent = item_user_state.x_percent; itemProperties.x_percent = item_user_state.x_percent;
...@@ -862,6 +974,11 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -862,6 +974,11 @@ function DragAndDropBlock(runtime, element, configuration) {
return itemProperties; return itemProperties;
}); });
// In assessment mode, it is possible to move items back to the bank, so the bank should be able to
// gain focus while keyboard placement is in progress.
var item_bank_focusable = state.keyboard_placement_mode &&
configuration.mode === DragAndDropBlock.ASSESSMENT_MODE;
var context = { var context = {
// configuration - parts that never change: // configuration - parts that never change:
bg_image_width: bgImgNaturalWidth, // Not stored in configuration since it's unknown on the server side bg_image_width: bgImgNaturalWidth, // Not stored in configuration since it's unknown on the server side
...@@ -877,6 +994,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -877,6 +994,7 @@ function DragAndDropBlock(runtime, element, configuration) {
items: items, items: items,
// state - parts that can change: // state - parts that can change:
last_action_correct: state.last_action_correct, last_action_correct: state.last_action_correct,
item_bank_focusable: item_bank_focusable,
popup_html: state.feedback || '', popup_html: state.feedback || '',
feedback_html: $.trim(state.overall_feedback), feedback_html: $.trim(state.overall_feedback),
display_reset_button: Object.keys(state.items).length > 0, display_reset_button: Object.keys(state.items).length > 0,
......
...@@ -409,11 +409,15 @@ msgid "ok" ...@@ -409,11 +409,15 @@ msgid "ok"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Placed in: " msgid "Item Bank"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Correctly placed in: " msgid "Placed in: {zone_title}"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Correctly placed in: {zone_title}"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
......
...@@ -494,12 +494,16 @@ msgid "ok" ...@@ -494,12 +494,16 @@ msgid "ok"
msgstr "ök Ⱡ'σя#" msgstr "ök Ⱡ'σя#"
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Placed in: " msgid "Item Bank"
msgstr "Pläçéd ïn: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" msgstr "Ìtém Bänk Ⱡ'σяєм ιρѕυм ∂σł#"
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Correctly placed in: " msgid "Placed in: {zone_title}"
msgstr "Çörréçtlý pläçéd ïn: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" msgstr "Pläçéd ïn: {zone_title} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: public/js/drag_and_drop.js
msgid "Correctly placed in: {zone_title}"
msgstr "Çörréçtlý pläçéd ïn: {zone_title} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Reset problem" msgid "Reset problem"
......
...@@ -66,7 +66,7 @@ class InteractionTestBase(object): ...@@ -66,7 +66,7 @@ class InteractionTestBase(object):
return self._page.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] 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): def _get_unplaced_item_by_value(self, item_value):
items_container = self._page.find_element_by_css_selector('.item-bank') items_container = self._get_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): def _get_placed_item_by_value(self, item_value):
...@@ -85,11 +85,32 @@ class InteractionTestBase(object): ...@@ -85,11 +85,32 @@ class InteractionTestBase(object):
def _get_dialog_dismiss_button(self, dialog_modal): # pylint: disable=no-self-use def _get_dialog_dismiss_button(self, dialog_modal): # pylint: disable=no-self-use
return dialog_modal.find_element_by_css_selector('.modal-dismiss-button') return dialog_modal.find_element_by_css_selector('.modal-dismiss-button')
def _get_item_bank(self):
return self._page.find_element_by_css_selector('.item-bank')
def _get_zone_position(self, zone_id): def _get_zone_position(self, zone_id):
return self.browser.execute_script( return self.browser.execute_script(
'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id) 'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id)
) )
def _get_draggable_property(self, item_value):
"""
Returns the value of the 'draggable' property of item.
Selenium has the element.get_attribute method that looks up properties and attributes,
but for some reason it *always* returns "true" for the 'draggable' property, event though
both the HTML attribute and the DOM property are set to false.
We work around that selenium bug by using JavaScript to get the correct value of 'draggable'.
"""
script = "return $('div.option[data-value={}]').prop('draggable')".format(item_value)
return self.browser.execute_script(script)
def assertDraggable(self, item_value):
self.assertTrue(self._get_draggable_property(item_value))
def assertNotDraggable(self, item_value):
self.assertFalse(self._get_draggable_property(item_value))
@staticmethod @staticmethod
def wait_until_ondrop_xhr_finished(elem): def wait_until_ondrop_xhr_finished(elem):
""" """
...@@ -104,29 +125,55 @@ class InteractionTestBase(object): ...@@ -104,29 +125,55 @@ class InteractionTestBase(object):
) )
def place_item(self, item_value, zone_id, action_key=None): def place_item(self, item_value, zone_id, action_key=None):
"""
Place item with ID of item_value into zone with ID of zone_id.
zone_id=None means place item back to the item bank.
action_key=None means simulate mouse drag/drop instead of placing the item with keyboard.
"""
if action_key is None: if action_key is None:
self.drag_item_to_zone(item_value, zone_id) self.drag_item_to_zone(item_value, zone_id)
else: else:
self.move_item_to_zone(item_value, zone_id, action_key) self.move_item_to_zone(item_value, zone_id, action_key)
def drag_item_to_zone(self, item_value, zone_id): def drag_item_to_zone(self, item_value, zone_id):
element = self._get_unplaced_item_by_value(item_value) """
target = self._get_zone_by_id(zone_id) Drag item to desired zone using mouse interaction.
zone_id=None means drag item back to the item bank.
"""
element = self._get_item_by_value(item_value)
if zone_id is None:
target = self._get_item_bank()
else:
target = self._get_zone_by_id(zone_id)
action_chains = ActionChains(self.browser) action_chains = ActionChains(self.browser)
action_chains.drag_and_drop(element, target).perform() action_chains.drag_and_drop(element, target).perform()
def move_item_to_zone(self, item_value, zone_id, action_key): def move_item_to_zone(self, item_value, zone_id, action_key):
# Get zone position """
zone_position = self._get_zone_position(zone_id) Place item to descired zone using keybard interaction.
zone_id=None means place item back into the item bank.
"""
# Focus on the item: # Focus on the item:
item = self._get_unplaced_item_by_value(item_value) item = self._get_item_by_value(item_value)
ActionChains(self.browser).move_to_element(item).perform() ActionChains(self.browser).move_to_element(item).perform()
# Press the action key: # Press the action key:
item.send_keys(action_key) # Focus is on first *zone* now item.send_keys(action_key) # Focus is on first *zone* now
self.assert_grabbed_item(item) self.assert_grabbed_item(item)
for _ in range(zone_position): # Get desired zone and figure out how many times we have to press Tab to focus the zone.
if zone_id is None: # moving back to the bank
zone = self._get_item_bank()
# When switching focus between zones in keyboard placement mode,
# the item bank always gets focused last (after all regular zones),
# so we have to press Tab once for every regular zone to move focus to the item bank.
tab_press_count = len(self.all_zones)
else:
zone = self._get_zone_by_id(zone_id)
# The number of times we have to press Tab to focus the desired zone equals the zero-based
# position of the zone (zero presses for first zone, one press for second zone, etc).
tab_press_count = self._get_zone_position(zone_id)
for _ in range(tab_press_count):
self._page.send_keys(Keys.TAB) self._page.send_keys(Keys.TAB)
self._get_zone_by_id(zone_id).send_keys(action_key) zone.send_keys(action_key)
def assert_grabbed_item(self, item): def assert_grabbed_item(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'true') self.assertEqual(item.get_attribute('aria-grabbed'), 'true')
...@@ -134,32 +181,37 @@ class InteractionTestBase(object): ...@@ -134,32 +181,37 @@ class InteractionTestBase(object):
def assert_placed_item(self, item_value, zone_title, assessment_mode=False): def assert_placed_item(self, item_value, zone_title, assessment_mode=False):
item = self._get_placed_item_by_value(item_value) item = self._get_placed_item_by_value(item_value)
self.wait_until_visible(item) self.wait_until_visible(item)
self.wait_until_ondrop_xhr_finished(item)
item_content = item.find_element_by_css_selector('.item-content') item_content = item.find_element_by_css_selector('.item-content')
self.wait_until_visible(item_content) self.wait_until_visible(item_content)
item_description = item.find_element_by_css_selector('.sr') item_description = item.find_element_by_css_selector('.sr')
self.wait_until_visible(item_description) self.wait_until_visible(item_description)
item_description_id = '-item-{}-description'.format(item_value) item_description_id = '-item-{}-description'.format(item_value)
self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item.get_attribute('aria-grabbed'), 'false') self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('data-drag-disabled'), 'true')
self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id)
self.assertEqual(item_description.get_attribute('id'), item_description_id) self.assertEqual(item_description.get_attribute('id'), item_description_id)
if assessment_mode: if assessment_mode:
self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title))
else: else:
self.assertNotDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title))
def assert_reverted_item(self, item_value): def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value) item = self._get_item_by_value(item_value)
self.wait_until_visible(item) self.wait_until_visible(item)
self.wait_until_ondrop_xhr_finished(item)
item_content = item.find_element_by_css_selector('.item-content') item_content = item.find_element_by_css_selector('.item-content')
self.assertEqual(item.get_attribute('class'), 'option ui-draggable') self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0') self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item.get_attribute('draggable'), 'true')
self.assertEqual(item.get_attribute('aria-grabbed'), 'false') self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('data-drag-disabled'), 'false')
self.assertIsNone(item_content.get_attribute('aria-describedby')) self.assertIsNone(item_content.get_attribute('aria-describedby'))
try: try:
...@@ -182,10 +234,11 @@ class InteractionTestBase(object): ...@@ -182,10 +234,11 @@ class InteractionTestBase(object):
for item_key in decoy_items: for item_key in decoy_items:
item = self._get_item_by_value(item_key) item = self._get_item_by_value(item_key)
self.assertEqual(item.get_attribute('aria-grabbed'), 'false') self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('data-drag-disabled'), 'true')
if assessment_mode: if assessment_mode:
self.assertDraggable(item_key)
self.assertEqual(item.get_attribute('class'), 'option') self.assertEqual(item.get_attribute('class'), 'option')
else: else:
self.assertNotDraggable(item_key)
self.assertEqual(item.get_attribute('class'), 'option fade') self.assertEqual(item.get_attribute('class'), 'option fade')
def parameterized_item_positive_feedback_on_good_move( def parameterized_item_positive_feedback_on_good_move(
...@@ -242,6 +295,50 @@ class InteractionTestBase(object): ...@@ -242,6 +295,50 @@ class InteractionTestBase(object):
self.assertTrue(popup.is_displayed()) self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id) self.assert_reverted_item(definition.item_id)
def parameterized_move_items_between_zones(self, items_map, all_zones, scroll_down=100, action_key=None):
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
# Take each item, place it into first zone, then continue moving it until it has visited all zones.
for item_key in items_map.keys():
for zone_id, zone_title in all_zones:
self.place_item(item_key, zone_id, action_key)
self.assert_placed_item(item_key, zone_title, assessment_mode=True)
# Finally, move them all back to the bank.
self.place_item(item_key, None, action_key)
self.assert_reverted_item(item_key)
def parameterized_cannot_move_items_between_zones(self, items_map, all_zones, scroll_down=100, action_key=None):
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
# Take each item an assigned zone, place it into the correct zone, then ensure it cannot be moved to other.
# zones or back to the bank.
for item_key, definition in items_map.items():
if definition.zone_ids: # skip decoy items
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=False)
if action_key:
item = self._get_item_by_value(definition.item_id)
# When using the keyboard, ensure that dropped items cannot get "grabbed".
# Assert item has no tabindex.
self.assertIsNone(item.get_attribute('tabindex'))
# Focus on the item:
ActionChains(self.browser).move_to_element(item).perform()
# Press the action key:
item.send_keys(action_key)
# Assert item is not grabbed.
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
else:
# When using the mouse, try to drag items and observe it doesn't work.
for zone_id, _zone_title in all_zones:
if zone_id not in definition.zone_ids:
self.place_item(item_key, zone_id, action_key)
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=False)
# Finally, try to move item back to the bank.
self.place_item(item_key, None, action_key)
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=False)
def parameterized_final_feedback_and_reset( def parameterized_final_feedback_and_reset(
self, items_map, feedback, scroll_down=100, action_key=None, assessment_mode=False self, items_map, feedback, scroll_down=100, action_key=None, assessment_mode=False
): ):
...@@ -396,6 +493,12 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseInt ...@@ -396,6 +493,12 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseInt
self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones, action_key=action_key) self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones, action_key=action_key)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m') @data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_cannot_move_items_between_zones(self, action_key):
self.parameterized_cannot_move_items_between_zones(
self.items_map, self.all_zones, action_key=action_key
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset(self, action_key): def test_final_feedback_and_reset(self, action_key):
self.parameterized_final_feedback_and_reset(self.items_map, self.feedback, action_key=action_key) self.parameterized_final_feedback_and_reset(self.items_map, self.feedback, action_key=action_key)
...@@ -424,6 +527,12 @@ class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, InteractionTestB ...@@ -424,6 +527,12 @@ class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, InteractionTestB
) )
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m') @data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_move_items_between_zones(self, action_key):
self.parameterized_move_items_between_zones(
self.items_map, self.all_zones, action_key=action_key
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset(self, action_key): def test_final_feedback_and_reset(self, action_key):
self.parameterized_final_feedback_and_reset( self.parameterized_final_feedback_and_reset(
self.items_map, self.feedback, action_key=action_key, assessment_mode=True self.items_map, self.feedback, action_key=action_key, assessment_mode=True
...@@ -525,6 +634,34 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration ...@@ -525,6 +634,34 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration
) )
class PreventSpaceBarScrollTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
""""
Test that browser default page down action is prevented when pressing the space bar while
any zone is focused.
"""
def get_scroll(self):
return self.browser.execute_script('return $(window).scrollTop()')
def test_space_bar_scroll(self):
# Window should not be scrolled at first.
self.assertEqual(self.get_scroll(), 0)
# Pressing space bar while no zone is focused should scroll the window down (default browser action).
self._page.send_keys(Keys.SPACE)
# Window should be scrolled down a bit.
wait = WebDriverWait(self, 2)
# While the XHR is in progress, a spinner icon is shown inside the item.
# When the spinner disappears, we can assume that the XHR request has finished.
wait.until(lambda s: s.get_scroll() > 0)
# Scroll the window back.
self.scroll_down(pixels=0)
self.assertEqual(self.get_scroll(), 0)
# Now press Space while one of the zones is focused.
zone = self._get_zone_by_id(self.all_zones[0][0])
zone.send_keys(Keys.SPACE)
# No scrolling should occur.
self.assertEqual(self.get_scroll(), 0)
class CustomDataInteractionTest(StandardInteractionTest): class CustomDataInteractionTest(StandardInteractionTest):
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"),
......
...@@ -146,7 +146,6 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -146,7 +146,6 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(item.get_attribute('draggable'), 'true') self.assertEqual(item.get_attribute('draggable'), 'true')
self.assertEqual(item.get_attribute('aria-grabbed'), 'false') self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('data-value'), str(index)) self.assertEqual(item.get_attribute('data-value'), str(index))
self.assertIn('ui-draggable', self.get_element_classes(item))
self._test_item_style(item, color_settings) self._test_item_style(item, color_settings)
try: try:
background_image = item.find_element_by_css_selector('img') background_image = item.find_element_by_css_selector('img')
...@@ -160,8 +159,16 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -160,8 +159,16 @@ class TestDragAndDropRender(BaseIntegrationTest):
def test_drag_container(self): def test_drag_container(self):
self.load_scenario() self.load_scenario()
item_bank = self._page.find_element_by_css_selector('.drag-container') drag_container = self._page.find_element_by_css_selector('.drag-container')
self.assertIsNone(item_bank.get_attribute('role')) self.assertIsNone(drag_container.get_attribute('role'))
def test_item_bank(self):
self.load_scenario()
item_bank = self._page.find_element_by_css_selector('.item-bank')
description = item_bank.find_element_by_css_selector('p.zone-description')
self.assertEqual(description.text, 'Item Bank')
# Description should only be visible to screen readers:
self.assertEqual(description.get_attribute('class'), 'zone-description sr')
def test_zones(self): def test_zones(self):
self.load_scenario() self.load_scenario()
......
...@@ -187,6 +187,7 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest): ...@@ -187,6 +187,7 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest):
target_img_width = target_img.size["width"] target_img_width = target_img.size["width"]
item_bank = self._page.find_element_by_css_selector('.item-bank') item_bank = self._page.find_element_by_css_selector('.item-bank')
item_bank_width = item_bank.size["width"] item_bank_width = item_bank.size["width"]
item_bank_height = item_bank.size["height"]
if is_desktop: if is_desktop:
# If using a desktop-sized window, we can know the exact dimensions of various containers: # If using a desktop-sized window, we can know the exact dimensions of various containers:
...@@ -239,6 +240,10 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest): ...@@ -239,6 +240,10 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest):
*expect.img_pixel_size_exact *expect.img_pixel_size_exact
) )
# Test that the item bank maintains its original size.
self.assertEqual(item_bank.size["width"], item_bank_width)
self.assertEqual(item_bank.size["height"], item_bank_height)
class AlignedSizingTests(SizingTests): class AlignedSizingTests(SizingTests):
""" """
......
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